diff --git a/README.md b/README.md index 3f01947..d27eaf0 100644 --- a/README.md +++ b/README.md @@ -214,12 +214,21 @@ The title bar says what N1MM's says: the frequency, the mode, whether the frequency came from a radio or was typed, and which radio the window belongs to when there are two. -### The band buttons +### The band panel -Down the left of the entry window are the bands, CW in one column and phone in -the other, as N1MM has them. A button puts the radio where you left that band -and mode, or at the start of that part of the band the first time. The button -for where the radio is now is marked. +Down the left of the entry window is N1MM's band panel: a row per band, a +column per mode the contest runs, and every cell painted with what the callsign +being typed would be worth on that band in that mode — red for one new +multiplier, green for more than one, blue for points, grey for a station already +worked there. So the panel says where the station is still needed without +working the bands one at a time. With nothing typed every cell is grey, as N1MM +leaves it. + +The bands are the six contest bands, with 30, 17 and 12 metres added for general +logging, which is the only entry N1MM shows them for. Clicking a cell puts the +radio where you left that band and mode, or at the start of that part of the +band the first time. The band and mode the radio is on is ringed rather than +filled, so the ring does not hide what the cell is saying. ### The call frame diff --git a/src/Nonemm.App/Theming/Themes.cs b/src/Nonemm.App/Theming/Themes.cs index 8b314c2..0ff90a6 100644 --- a/src/Nonemm.App/Theming/Themes.cs +++ b/src/Nonemm.App/Theming/Themes.cs @@ -32,6 +32,14 @@ public static class Themes Changed?.Invoke(null, EventArgs.Empty); } + /// Black on a light colour and white on a dark one, which is how N1MM picks + /// the text for a cell it has filled: light is the three channels adding up + /// to more than 384. + public static IBrush TextOn(Color background) => + background.R + background.G + background.B > 384 + ? Brush(Colors.Black) + : Brush(Colors.White); + /// One brush per colour, because a brush is created for every row of every /// window that is painted. public static IBrush Brush(Color colour) diff --git a/src/Nonemm.App/Verdicts.cs b/src/Nonemm.App/Verdicts.cs index cd6e978..299e605 100644 --- a/src/Nonemm.App/Verdicts.cs +++ b/src/Nonemm.App/Verdicts.cs @@ -32,13 +32,17 @@ public static class Verdicts : Worth; /// The same four states as backgrounds, which is what N1MM paints behind a - /// spot. - public static IBrush Background(Verdict? verdict) => - verdict is null ? Themes.Brush(Themes.Current.WorthBackground) - : verdict.IsDupe ? Themes.Brush(Themes.Current.DupeBackground) - : verdict.NewMultipliers.Count > 1 ? Themes.Brush(Themes.Current.MultipliersBackground) - : verdict.NewMultipliers.Count == 1 ? Themes.Brush(Themes.Current.MultiplierBackground) - : Themes.Brush(Themes.Current.WorthBackground); + /// spot and in the band panel. + public static IBrush Background(Verdict? verdict) => Themes.Brush(BackgroundColour(verdict)); + + /// The same as a colour, for a caller that has to pick readable text to go + /// on top of it. + public static Color BackgroundColour(Verdict? verdict) => + verdict is null ? Themes.Current.WorthBackground + : verdict.IsDupe ? Themes.Current.DupeBackground + : verdict.NewMultipliers.Count > 1 ? Themes.Current.MultipliersBackground + : verdict.NewMultipliers.Count == 1 ? Themes.Current.MultiplierBackground + : Themes.Current.WorthBackground; public static string Describe(Verdict? verdict) { diff --git a/src/Nonemm.App/Windows/EntryWindow.Bands.cs b/src/Nonemm.App/Windows/EntryWindow.Bands.cs index 14bfe69..a30cc52 100644 --- a/src/Nonemm.App/Windows/EntryWindow.Bands.cs +++ b/src/Nonemm.App/Windows/EntryWindow.Bands.cs @@ -1,62 +1,122 @@ +using Avalonia; using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Nonemm.App.Theming; +using Nonemm.Contests; using Nonemm.Core; +using Nonemm.Session; namespace Nonemm.App.Windows; -/// The band buttons down the left of the window, CW in one column and phone in -/// the other, as N1MM has them. A button puts the radio where the operator left -/// that band, or at the start of that part of it. +/// The band panel down the left of the window, laid out as N1MM lays it out: a +/// column per mode the contest runs, a row per band, and each cell painted with +/// what the callsign being typed would be worth there. Clicking a cell puts the +/// radio where the operator left that band, or at the start of that part of it. public sealed partial class EntryWindow { - private static readonly IReadOnlyList BandColumn = - [ - Bands.Band160M, Bands.Band80M, Bands.Band40M, Bands.Band30M, Bands.Band20M, - Bands.Band17M, Bands.Band15M, Bands.Band12M, Bands.Band10M, Bands.Band6M, - ]; + /// From here up a band is named with its unit — 6m, 2m, 70cm — and below it + /// with the metres alone. + private const double VhfFromMegahertz = 50; - private readonly List<(Band Band, ModeCategory Mode, Button Button)> bandButtons = []; + private readonly List bandCells = []; + private string bandsBuiltFor = ""; - private void BuildBandButtons() + private sealed record BandCell(Band Band, ModeCategory Mode, Border Fill, Border Ring, TextBlock Label); + + private void BuildBandCells(BandPanel panel) { + string wanted = string.Join( + '|', + [.. panel.Bands.Select(b => b.Name), .. panel.Modes.Select(m => m.ToString())]); + if (wanted == bandsBuiltFor) + { + return; + } + bandsBuiltFor = wanted; BandButtons.Children.Clear(); BandButtons.RowDefinitions.Clear(); - bandButtons.Clear(); + BandButtons.ColumnDefinitions.Clear(); + bandCells.Clear(); + foreach (ModeCategory _ in panel.Modes) + { + BandButtons.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + } BandButtons.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - Heading("CW", 0); - Heading("PH", 1); - for (int at = 0; at < BandColumn.Count; at++) + for (int column = 0; column < panel.Modes.Count; column++) + { + AddHeading(HeadingFor(panel.Modes[column]), column); + } + for (int row = 0; row < panel.Bands.Count; row++) { BandButtons.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - Add(BandColumn[at], ModeCategory.Cw, at + 1, column: 0); - Add(BandColumn[at], ModeCategory.Phone, at + 1, column: 1); + for (int column = 0; column < panel.Modes.Count; column++) + { + AddCell(panel.Bands[row], panel.Modes[column], row + 1, column); + } } } - private void Heading(string text, int column) + private void AddHeading(string text, int column) { TextBlock heading = new() { Text = text, FontSize = 10, - Opacity = 0.7, - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, - Margin = new Avalonia.Thickness(0, 0, 2, 2), + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 0, 2, 2), }; Grid.SetColumn(heading, column); BandButtons.Children.Add(heading); } - private void Add(Band band, ModeCategory mode, int row, int column) + private void AddCell(Band band, ModeCategory mode, int row, int column) { - Button button = new() { Content = band.MegahertzLabel.ToString("0.###") }; - button.Classes.Add("band"); - button.Click += (_, _) => MoveToBand(band, mode); - Grid.SetRow(button, row); - Grid.SetColumn(button, column); - BandButtons.Children.Add(button); - bandButtons.Add((band, mode, button)); + TextBlock label = new() + { + Text = LabelFor(band), + FontSize = 11, + HorizontalAlignment = HorizontalAlignment.Center, + }; + Border ring = new() + { + CornerRadius = new CornerRadius(10), + BorderThickness = new Thickness(1), + BorderBrush = Brushes.Transparent, + Padding = new Thickness(6, 1), + Margin = new Thickness(2), + Child = label, + }; + Border fill = new() + { + Margin = new Thickness(0, 0, 2, 0), + MinWidth = 34, + Cursor = new Cursor(StandardCursorType.Hand), + Child = ring, + }; + fill.PointerPressed += (_, _) => MoveToBand(band, mode); + Grid.SetRow(fill, row); + Grid.SetColumn(fill, column); + BandButtons.Children.Add(fill); + bandCells.Add(new BandCell(band, mode, fill, ring, label)); } + /// N1MM names the HF bands with the metres alone and the VHF bands with the + /// unit: 160, 80, 40 … then 6m, 2m, 70cm. + private static string LabelFor(Band band) => + band.MegahertzLabel < VhfFromMegahertz + ? band.Name[..^1] + : band.Name.ToLowerInvariant(); + + private static string HeadingFor(ModeCategory mode) => mode switch + { + ModeCategory.Phone => "PH", + ModeCategory.Digital => "DIG", + _ => "CW", + }; + private void MoveToBand(Band band, ModeCategory mode) { if (Logging is null) @@ -64,15 +124,22 @@ public sealed partial class EntryWindow return; } Frequency where = session.BandMemory.Where(band, mode, session.BandPlan); - Mode wanted = mode == ModeCategory.Phone ? Modes.ForSideband(where) : Modes.Cw; + Mode wanted = mode switch + { + ModeCategory.Phone => Modes.ForSideband(where), + ModeCategory.Digital => Modes.Rtty, + _ => Modes.Cw, + }; Logging.Tune(where, wanted); _ = session.Radio?.TuneAsync(where); boxes[0].Focus(); Refresh(); } - /// The button for the band and mode the radio is on is marked, and the - /// frequency is kept so the button goes back to it. + /// Each cell says what the call being typed would be worth on that band and + /// mode. With nothing typed N1MM paints every cell as a station already + /// worked. The band the radio is on is ringed rather than filled, so the + /// ring does not hide what the cell is saying. private void ShowBandButtons() { if (Logging is null) @@ -80,10 +147,21 @@ public sealed partial class EntryWindow return; } session.BandMemory.Remember(Logging.Frequency, Logging.Mode); + BandPanel panel = new(Logging); + BuildBandCells(panel); Band? here = Bands.ForFrequency(Logging.Frequency); - foreach ((Band band, ModeCategory mode, Button button) in bandButtons) + foreach (BandCell cell in bandCells) { - button.Classes.Set("here", band == here && mode == Logging.Mode.Category); + Verdict? worth = panel.Worth(cell.Band, cell.Mode); + Color background = worth is null + ? Themes.Current.DupeBackground + : Verdicts.BackgroundColour(worth); + IBrush text = Themes.TextOn(background); + cell.Fill.Background = Themes.Brush(background); + cell.Label.Foreground = text; + cell.Ring.BorderBrush = cell.Band == here && cell.Mode == Logging.Mode.Category + ? text + : Brushes.Transparent; } } } diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml index a6e9a08..5d948c0 100644 --- a/src/Nonemm.App/Windows/EntryWindow.axaml +++ b/src/Nonemm.App/Windows/EntryWindow.axaml @@ -32,20 +32,6 @@ - - -