diff --git a/README.md b/README.md
index d13deeb..71b0fe1 100644
--- a/README.md
+++ b/README.md
@@ -665,7 +665,8 @@ rather than guessing.
**View → Available Mults and Qs** lists what is spotted and not worked yet. The
top shows a table with the multipliers, the Qs and the total on each band.
-Under it is one line per station. Click a header to sort on that column, and
+Under it is one line per station, sorted on frequency until you click another
+header. Click a header to sort on that column, and
shift-click a second header to add a second sort key. The line is coloured the
same as in the entry window, and the one the radio is on is drawn in reverse.
@@ -698,8 +699,9 @@ contents. Drag a header to move a column.
row to show its multipliers and Qs. Click Mults, Qs or Total at the left to
show every band.
- The **Mults** / **Mults & Qs** button shows what the list holds now, and a
- click switches it. **Bands & Modes** limits the list to some bands and modes
- without touching the telnet filters. Both are kept between runs.
+ click switches it. **Bands & Modes** opens a window with a tick for each band and for CW,
+ Phone, Digital and Contest modes; it limits the list without touching the
+ telnet filters. Both are kept between runs.
- The right-click menu also holds **Summary Panel Band Selection**, **Show
Summary Panel** and **Help**. With the list sorted on TS, newest first,
**QSY to top spot after logging QSO** tunes to the newest spot after each
diff --git a/src/Nonemm.App/Dialogs/AvailableFilterDialog.axaml b/src/Nonemm.App/Dialogs/AvailableFilterDialog.axaml
new file mode 100644
index 0000000..8e3c2cd
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/AvailableFilterDialog.axaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/AvailableFilterDialog.axaml.cs b/src/Nonemm.App/Dialogs/AvailableFilterDialog.axaml.cs
new file mode 100644
index 0000000..e10b4e0
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/AvailableFilterDialog.axaml.cs
@@ -0,0 +1,119 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Nonemm.Core;
+
+namespace Nonemm.App.Dialogs;
+
+/// The bands and modes the available window lists, as N1MM's "Spot Display
+/// Filters" window has them. Each tick takes effect at once, through `changed`,
+/// and nothing is ticked for every band or mode.
+public sealed partial class AvailableFilterDialog : Window
+{
+ public const string ContestMode = "CONTEST";
+
+ private static readonly (string Heading, string[] Bands)[] Groups =
+ [
+ ("HF", ["160M", "80M", "60M", "40M", "30M", "20M", "17M", "15M", "12M", "10M"]),
+ ("VHF", ["6M", "4M", "2M", "1.25M"]),
+ ("UHF", ["70CM", "33CM", "23CM", "13CM"]),
+ ("Mw", ["9CM", "6CM", "3CM", "1.25CM", "6MM", "4MM", "2.5MM", "2MM", "1MM"]),
+ ];
+
+ private static readonly (string Label, string Key)[] ModeChoices =
+ [
+ ("CW", "CW"),
+ ("Phone", "PHONE"),
+ ("Digital", "DIGITAL"),
+ ("Contest", ContestMode),
+ ];
+
+ private readonly HashSet bands;
+ private readonly HashSet modes;
+ private readonly Action, IReadOnlyList> changed;
+ private readonly Dictionary bandBoxes = [];
+ private readonly Dictionary modeBoxes = [];
+
+ public AvailableFilterDialog(
+ IReadOnlyList bands,
+ IReadOnlyList modes,
+ Action, IReadOnlyList> changed)
+ {
+ this.bands = new HashSet(bands, StringComparer.OrdinalIgnoreCase);
+ this.modes = new HashSet(modes, StringComparer.OrdinalIgnoreCase);
+ this.changed = changed;
+ InitializeComponent();
+ foreach ((string heading, string[] names) in Groups)
+ {
+ BandGroups.Children.Add(Group(heading, names));
+ }
+ ModeList.Children.Add(AllModes());
+ foreach ((string label, string key) in ModeChoices)
+ {
+ CheckBox box = new() { Content = label, IsChecked = this.modes.Contains(key) };
+ box.IsCheckedChanged += (_, _) => Toggle(modes: true, key, box.IsChecked == true);
+ modeBoxes[key] = box;
+ ModeList.Children.Add(box);
+ }
+ }
+
+ private Control Group(string heading, string[] names)
+ {
+ StackPanel panel = new() { Spacing = 2 };
+ Button all = new() { Content = heading, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left };
+ all.Click += (_, _) =>
+ {
+ bool everyOne = names.All(bands.Contains);
+ foreach (string name in names)
+ {
+ bandBoxes[name].IsChecked = !everyOne;
+ }
+ };
+ panel.Children.Add(all);
+ foreach (string name in names)
+ {
+ Band band = Bands.Named(name)!;
+ CheckBox box = new() { Content = Label(band), IsChecked = bands.Contains(name) };
+ box.IsCheckedChanged += (_, _) => Toggle(modes: false, name, box.IsChecked == true);
+ bandBoxes[name] = box;
+ panel.Children.Add(box);
+ }
+ return panel;
+ }
+
+ private Control AllModes()
+ {
+ Button all = new() { Content = "All", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left };
+ all.Click += (_, _) =>
+ {
+ foreach (CheckBox box in modeBoxes.Values)
+ {
+ box.IsChecked = false;
+ }
+ };
+ return all;
+ }
+
+ /// The megahertz for the bands up to 2.3 GHz, as N1MM labels them, and the
+ /// wavelength for the ones above.
+ private static string Label(Band band) =>
+ band.MegahertzLabel < 3_000
+ ? band.MegahertzLabel.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)
+ : band.Name.ToLowerInvariant();
+
+ private void Toggle(bool modes, string name, bool on)
+ {
+ HashSet set = modes ? this.modes : bands;
+ if (on)
+ {
+ set.Add(name);
+ }
+ else
+ {
+ set.Remove(name);
+ }
+ changed([.. bands], [.. this.modes]);
+ }
+
+ private void OnClose(object? sender, RoutedEventArgs e) => Close();
+}
diff --git a/src/Nonemm.App/Windows/AvailableRow.cs b/src/Nonemm.App/Windows/AvailableRow.cs
index a318e60..60116ea 100644
--- a/src/Nonemm.App/Windows/AvailableRow.cs
+++ b/src/Nonemm.App/Windows/AvailableRow.cs
@@ -2,6 +2,7 @@ using System.Globalization;
using Avalonia.Media;
using Nonemm.Core;
using Nonemm.Session;
+using Nonemm.Spotting;
namespace Nonemm.App.Windows;
@@ -55,10 +56,19 @@ public sealed class AvailableRow
public int? Heading => Station.Heading is { } heading ? (int)Math.Round(heading) % 360 : null;
- public string Direction => Heading?.ToString(CultureInfo.InvariantCulture) ?? "";
+ public string Direction => Heading is { } heading ? $"{heading}°" : "";
public int Multipliers => Station.NewMultipliers.Count;
+ /// A tick for a station that brings one multiplier and the number for one
+ /// that brings more, which is what N1MM's Mlt column shows.
+ public string Mark => Multipliers switch
+ {
+ 0 => "",
+ 1 => "✔",
+ _ => Multipliers.ToString(CultureInfo.InvariantCulture),
+ };
+
public int? Snr => Station.Spot.Snr;
public string Noise => Snr?.ToString(CultureInfo.InvariantCulture) ?? "";
@@ -91,15 +101,12 @@ public sealed class AvailableRow
public string Key =>
$"{Call}|{Station.Band.Name}|{Frequency}|{Multipliers}|{Points}|{Spotter}|{Time}|{Sun}|{Star}|{IsTuned}";
+ /// The same hover text as the bandmap, with the split and the sun added.
public string Tip
{
get
{
- List lines = [$"{Call} spotted by {Station.Spot.Spotter}", $"{(int)(nowUtc - AtUtc).TotalMinutes} min ago"];
- if (Station.Spot.Comment.Length > 0)
- {
- lines.Add(Station.Spot.Comment);
- }
+ List lines = [SpotDescription.Of(Station.Spot, nowUtc)];
if (Station.Spot.IsSplit)
{
lines.Add($"listening on {Station.Spot.Qsx.Kilohertz:0.0}");
diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml b/src/Nonemm.App/Windows/AvailableWindow.axaml
index 27a93b1..7192cdb 100644
--- a/src/Nonemm.App/Windows/AvailableWindow.axaml
+++ b/src/Nonemm.App/Windows/AvailableWindow.axaml
@@ -3,35 +3,62 @@
xmlns:local="using:Nonemm.App.Windows"
xmlns:theming="using:Nonemm.App.Theming"
x:Class="Nonemm.App.Windows.AvailableWindow"
- Title="Available Mults and Qs" Width="780" Height="440">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ Title="Available Mults and Qs" Width="900" Height="440">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs
index bd8f374..eb683f8 100644
--- a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs
@@ -10,6 +10,7 @@ using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Nonemm.App.Configuration;
+using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Session;
using Nonemm.Spotting;
@@ -44,6 +45,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
private AvailableRow? menuRow;
private bool menuOnHeader;
private string shownKey = "";
+ private string heading = "Available Mults and Qs";
private string summaryKey = "";
private DateTime pausedUntil = DateTime.MinValue;
private bool refreshPending;
@@ -57,10 +59,14 @@ public sealed partial class AvailableWindow : RefreshableWindow
BuildColumns();
ApplyColumns();
ApplyFontSize();
+ ColumnWidths.KeepHeadersReadable(Spots);
+ DataGridColumn mlt = Spots.Columns.First(c => Header(c) == "Mlt");
+ mlt.Width = new DataGridLength(mlt.MinWidth);
_ = new FontZoom(this, new Settings().AvailableFontSize, () => session.Settings.AvailableFontSize, size =>
{
session.Save(session.Settings with { AvailableFontSize = size });
ApplyFontSize();
+ ColumnWidths.KeepHeadersReadable(Spots);
Refresh();
});
ModeButton.Click += (_, _) => SetMultsOnly(!session.Settings.AvailableMultsOnly);
@@ -107,10 +113,9 @@ public sealed partial class AvailableWindow : RefreshableWindow
}
}
- private void ShowNotice() =>
- NoticeText.Text = IsPaused
- ? $"Scrolling Pausing {Math.Max(1, (int)Math.Ceiling((pausedUntil - DateTime.UtcNow).TotalSeconds))} secs"
- : "";
+ private void ShowNotice() => Title = IsPaused
+ ? $"{heading} Scrolling Pausing {Math.Max(1, (int)Math.Ceiling((pausedUntil - DateTime.UtcNow).TotalSeconds))} secs"
+ : heading;
private void OnTick()
{
@@ -129,28 +134,28 @@ public sealed partial class AvailableWindow : RefreshableWindow
private void BuildColumns()
{
- Column("Call", nameof(AvailableRow.Call), r => r.Call, 110);
- Column("Freq", nameof(AvailableRow.Frequency), r => r.Kilohertz, 90);
- Column("Mode", nameof(AvailableRow.Mode), r => r.Mode, 55);
- Column("Dir", nameof(AvailableRow.Direction), r => r.Heading ?? -1, 50);
- Column("Mlt", nameof(AvailableRow.Multipliers), r => r.Multipliers, 50);
- Column("SN", nameof(AvailableRow.Noise), r => r.Snr ?? int.MinValue, 45);
- Column("TS", nameof(AvailableRow.Time), r => r.AtUtc, 75);
- Column("Spotter", nameof(AvailableRow.Spotter), r => r.Spotter, 110);
- Column("Grid", nameof(AvailableRow.Grid), r => r.Grid, 65);
- Column("Sun", nameof(AvailableRow.Sun), r => r.SunMinutes, 60);
- Column("Pts", nameof(AvailableRow.Points), r => r.Points, 50);
- Column("★", nameof(AvailableRow.Star), r => r.IsPreferred ? 1 : 0, 35);
+ Column("Call", nameof(AvailableRow.Call), r => r.Call);
+ Column("Freq", nameof(AvailableRow.Frequency), r => r.Kilohertz);
+ Column("Mode", nameof(AvailableRow.Mode), r => r.Mode);
+ Column("Dir", nameof(AvailableRow.Direction), r => r.Heading ?? -1);
+ Column("Mlt", nameof(AvailableRow.Mark), r => r.Multipliers);
+ Column("SN", nameof(AvailableRow.Noise), r => r.Snr ?? int.MinValue);
+ Column("TS", nameof(AvailableRow.Time), r => r.AtUtc);
+ Column("Spotter", nameof(AvailableRow.Spotter), r => r.Spotter);
+ Column("Grid", nameof(AvailableRow.Grid), r => r.Grid);
+ Column("Sun", nameof(AvailableRow.Sun), r => r.SunMinutes);
+ Column("Pts", nameof(AvailableRow.Points), r => r.Points);
+ Column("★", nameof(AvailableRow.Star), r => r.IsPreferred ? 1 : 0);
}
/// The grid sorts on the typed value behind the cell, not on its text.
- private void Column(string header, string path, Func key, double width)
+ private void Column(string header, string path, Func key)
where T : IComparable
{
Spots.Columns.Add(new DataGridTextColumn
{
Header = header,
- Width = new DataGridLength(width),
+ Width = DataGridLength.Auto,
Binding = new Binding(path) { Mode = BindingMode.OneWay },
CustomSortComparer = Comparer.Create((left, right) => key(left).CompareTo(key(right))),
});
@@ -246,11 +251,16 @@ public sealed partial class AvailableWindow : RefreshableWindow
{
Summary.IsVisible = session.Settings.AvailableShowsSummary;
bool multsOnly = session.Settings.AvailableMultsOnly;
- ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs";
- ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
+ ModeButton.Content = multsOnly ? "Mults" : "Mults\nQs";
+ ModeButton.Background = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
+ ModeButton.Foreground = Brushes.White;
if (session.Available is not { } available)
{
- StatusText.Text = "no contest is open";
+ heading = "Available Mults and Qs";
+ ShowNotice();
+ EmptyText.Text = "No contest is open.";
+ EmptyText.Foreground = Verdicts.Worth;
+ EmptyText.IsVisible = true;
Spots.ItemsSource = Array.Empty();
shownKey = "";
summaryKey = "";
@@ -265,10 +275,11 @@ public sealed partial class AvailableWindow : RefreshableWindow
Bandmap bandmap = session.Bandmap;
HashSet picked = [.. clicked];
Frequency? tuned = session.Position?.Frequency;
+ IReadOnlyList contestModes = session.Position?.Contest.Modes ?? [];
bool summaryShown = Summary.IsVisible;
bandmap.DropOlderThan(now);
IReadOnlyList all = await available.AllAsync();
- Computed computed = await Task.Run(() => Compute(all, now, settings, plan, bandmap, picked, tuned, summaryShown));
+ Computed computed = await Task.Run(() => Compute(all, now, settings, plan, bandmap, picked, tuned, contestModes, summaryShown));
if (again)
{
return;
@@ -278,10 +289,12 @@ public sealed partial class AvailableWindow : RefreshableWindow
{
BuildSummary(summary, settings, picked);
}
- Title = $"Available Mults and Qs — {computed.Total - computed.Mults} Qs · {computed.Mults} Mults · {computed.Total} total";
- StatusText.Text = computed.Total == 0
- ? "No workable spots: check the contest, the bands and the cluster filters"
- : $"{computed.Mults} mults · {computed.Total - computed.Mults} Qs · {computed.Total} total";
+ heading = $"Available Mults and Qs - {computed.Mults} Mults {computed.Total - computed.Mults} Qs of {computed.Total} total spots";
+ ShowNotice();
+ EmptyText.Text = "No workable local nor Telnet spots available to display. "
+ + "Check contest, cluster filters, Bands & Modes button, and Telnet window filters.";
+ EmptyText.Foreground = Verdicts.Worth;
+ EmptyText.IsVisible = computed.Rows.Count == 0;
if (IsPaused)
{
refreshPending = true;
@@ -298,12 +311,13 @@ public sealed partial class AvailableWindow : RefreshableWindow
Bandmap bandmap,
HashSet picked,
Frequency? tuned,
+ IReadOnlyList contestModes,
bool summaryShown)
{
int mults = all.Count(s => s.IsMultiplier);
IReadOnlyList preferred = settings.SpotFilter.PreferredSpotters;
List rows = all
- .Where(s => (!settings.AvailableMultsOnly || s.IsMultiplier) && IsShown(s, settings, plan, picked))
+ .Where(s => (!settings.AvailableMultsOnly || s.IsMultiplier) && IsShown(s, settings, plan, picked, contestModes))
.Select(s => new AvailableRow(
s,
ModeName(plan, s.Spot.Frequency),
@@ -320,14 +334,29 @@ public sealed partial class AvailableWindow : RefreshableWindow
string.Join(';', rows.Select(r => r.Key)));
}
- private static bool IsShown(AvailableStation station, Settings settings, BandPlan plan, HashSet picked)
+ private static bool IsShown(
+ AvailableStation station,
+ Settings settings,
+ BandPlan plan,
+ HashSet picked,
+ IReadOnlyList contestModes)
{
IReadOnlyList bands = settings.AvailableBands;
- IReadOnlyList modes = settings.AvailableModes;
ModeCategory? category = plan.ModeAt(station.Spot.Frequency);
return (picked.Count == 0 || picked.Contains(station.Band))
&& (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase))
- && (modes.Count == 0 || category is null || modes.Contains(ModeKey(category.Value), StringComparer.OrdinalIgnoreCase));
+ && (category is null || ModeAllowed(settings.AvailableModes, category.Value, contestModes));
+ }
+
+ /// Modes ticked in the filter window. Contest alone keeps the modes the
+ /// contest runs; with others ticked a spot must be in one of them as well.
+ private static bool ModeAllowed(IReadOnlyList modes, ModeCategory category, IReadOnlyList contestModes)
+ {
+ bool contest = modes.Contains(AvailableFilterDialog.ContestMode, StringComparer.OrdinalIgnoreCase);
+ List named = [.. modes.Where(m => !m.Equals(AvailableFilterDialog.ContestMode, StringComparison.OrdinalIgnoreCase))];
+ bool inContest = !contest || contestModes.Count == 0 || contestModes.Contains(category);
+ bool inNamed = named.Count == 0 || named.Contains(ModeKey(category), StringComparer.OrdinalIgnoreCase);
+ return inContest && inNamed;
}
private static string ModeKey(ModeCategory category) => category switch
@@ -354,8 +383,17 @@ public sealed partial class AvailableWindow : RefreshableWindow
view.SortDescriptions.Add(sort);
}
}
+ else
+ {
+ // N1MM opens sorted on frequency, lowest first
+ DataGridColumn freq = Spots.Columns.First(c => Header(c) == "Freq");
+ view.SortDescriptions.Add(DataGridSortDescription.FromComparer(freq.CustomSortComparer!, ListSortDirection.Ascending));
+ }
string? picked = (Spots.SelectedItem as AvailableRow)?.Call;
+ // the grid picks the first line of a new view unless told not to
+ view.MoveCurrentToPosition(-1);
Spots.ItemsSource = view;
+ Spots.SelectedItem = null;
if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } same)
{
Spots.SelectedItem = same;
@@ -434,40 +472,42 @@ public sealed partial class AvailableWindow : RefreshableWindow
Summary.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
}
- Label(0, 0, "", null);
- Label(1, 0, "Mults", e => ShowBands([], null, e));
- Label(2, 0, "Qs", e => ShowBands([], null, e));
- Label(3, 0, "Total", e => ShowBands([], null, e));
+ Label(1, 0, "Mults", true, false, e => ShowBands([], null, e));
+ Label(2, 0, "Qs", true, false, e => ShowBands([], null, e));
+ Label(3, 0, "Total", true, false, e => ShowBands([], null, e));
for (int at = 0; at < columns.Count; at++)
{
Band band = columns[at];
int column = at + 1;
- Label(0, column, band.Name.TrimEnd('M', 'm'), e => ShowBands([band], null, e), picked.Contains(band));
- Label(1, column, Count(mults[band]), e => ShowBands([band], true, e), mults[band] > 0 && mults[band] == mostMults);
- Label(2, column, Count(qs[band]), e => ShowBands([band], false, e), qs[band] > 0 && qs[band] == mostQs);
- Label(3, column, Count(mults[band] + qs[band]), e => ShowBands([band], null, e));
+ Label(0, column, BandLabel(band), true, picked.Contains(band), e => ShowBands([band], null, e));
+ Label(1, column, mults[band].ToString(), false, mults[band] > 0 && mults[band] == mostMults, e => ShowBands([band], true, e));
+ Label(2, column, qs[band].ToString(), false, qs[band] > 0 && qs[band] == mostQs, e => ShowBands([band], false, e));
+ Label(3, column, (mults[band] + qs[band]).ToString(), false, false, e => ShowBands([band], null, e));
}
}
- private static string Count(int count) => count == 0 ? "·" : count.ToString();
+ /// The metres for the HF bands and the band's own name above that: 160, 80
+ /// … 10, 6m, 2m, 70cm.
+ private static string BandLabel(Band band) =>
+ band.MegahertzLabel < 30 ? band.Name[..^1] : band.Name.ToLowerInvariant();
- private void Label(int row, int column, string text, Action? click, bool highlight = false)
+ private void Label(int row, int column, string text, bool bold, bool highlight, Action click)
{
TextBlock block = new()
{
Text = text,
- FontFamily = Theming.Fonts.Mono,
FontSize = session.Settings.AvailableFontSize,
- Margin = new Thickness(6, 0),
- HorizontalAlignment = column == 0 ? HorizontalAlignment.Left : HorizontalAlignment.Right,
- Foreground = highlight ? Verdicts.NewMultiplier : null,
- FontWeight = highlight ? FontWeight.Bold : FontWeight.Normal,
+ Margin = new Thickness(column == 0 ? 0 : 9, 0),
+ MinWidth = column == 0 ? 0 : 22,
+ TextAlignment = column == 0 ? TextAlignment.Left : TextAlignment.Center,
+ FontWeight = bold || highlight ? FontWeight.Bold : FontWeight.Normal,
+ Cursor = new Cursor(StandardCursorType.Hand),
};
- if (click is not null)
+ if (highlight)
{
- block.Cursor = new Cursor(StandardCursorType.Hand);
- block.PointerPressed += (_, e) => click(e);
+ block.Foreground = Verdicts.NewMultiplier;
}
+ block.PointerPressed += (_, e) => click(e);
Grid.SetRow(block, row);
Grid.SetColumn(block, column);
Summary.Children.Add(block);
@@ -617,8 +657,8 @@ public sealed partial class AvailableWindow : RefreshableWindow
{
AvailableRow? row = menuRow;
MenuItem delete = Action("Delete Spot", () => Delete(row!));
- MenuItem callBlacklist = Action("Blacklist callsign", () => Blacklist(row!, spotter: false));
- MenuItem spotterBlacklist = Action("Blacklist spotter", () => Blacklist(row!, spotter: true));
+ MenuItem callBlacklist = Action("Blacklist Callsign", () => Blacklist(row!, spotter: false));
+ MenuItem spotterBlacklist = Action("Blacklist Spotter", () => Blacklist(row!, spotter: true));
delete.IsEnabled = callBlacklist.IsEnabled = spotterBlacklist.IsEnabled = row is not null;
MenuItem qs = Check("Show Qs && Mults", !session.Settings.AvailableMultsOnly, () => SetMultsOnly(!session.Settings.AvailableMultsOnly));
@@ -638,17 +678,19 @@ public sealed partial class AvailableWindow : RefreshableWindow
return
[
delete,
+ new Separator(),
callBlacklist,
spotterBlacklist,
new Separator(),
- Action("Set Band && Mode Filter Options", OpenFilterMenu),
+ Action("Set Band && Mode Filter Options....", OpenFilterMenu),
+ new Separator(),
qs,
+ new Separator(),
qsyAfter,
new Separator(),
SummaryBandMenu(),
Action("Auto Resize Columns", AutoResizeColumns),
summaryShown,
- new Separator(),
Action("Help", OpenHelp),
];
}
@@ -700,49 +742,16 @@ public sealed partial class AvailableWindow : RefreshableWindow
/// The bands and modes the list is limited to. It does not touch the
/// telnet window's filters.
- private void OpenFilterMenu()
- {
- List items = [];
- foreach (Band band in Bands.ContestWithWarc)
- {
- items.Add(FilterItem(band.Name, session.Settings.AvailableBands.Contains(band.Name), on =>
- session.Settings with { AvailableBands = Toggled(session.Settings.AvailableBands, band.Name, on) }));
- }
- items.Add(new Separator());
- foreach ((string label, string key) in new[] { ("CW", "CW"), ("Phone", "PHONE"), ("Digital", "DIGITAL") })
- {
- items.Add(FilterItem(label, session.Settings.AvailableModes.Contains(key), on =>
- session.Settings with { AvailableModes = Toggled(session.Settings.AvailableModes, key, on) }));
- }
- items.Add(new Separator());
- items.Add(Action("All bands and modes", () =>
- {
- session.Save(session.Settings with { AvailableBands = [], AvailableModes = [] });
- Refresh();
- }));
- new ContextMenu { ItemsSource = items }.Open(FilterButton);
- }
-
- private MenuItem FilterItem(string header, bool isChecked, Func change)
- {
- MenuItem item = new()
- {
- Header = header,
- ToggleType = MenuItemToggleType.CheckBox,
- IsChecked = isChecked,
- StaysOpenOnClick = true,
- };
- item.Click += (_, _) =>
- {
- session.Save(change(item.IsChecked));
- pausedUntil = DateTime.MinValue;
- Refresh();
- };
- return item;
- }
-
- private static IReadOnlyList Toggled(IReadOnlyList names, string name, bool on) =>
- [.. names.Where(n => n != name).Concat(on ? [name] : [])];
+ private void OpenFilterMenu() =>
+ _ = new AvailableFilterDialog(
+ session.Settings.AvailableBands,
+ session.Settings.AvailableModes,
+ (bands, modes) =>
+ {
+ session.Save(session.Settings with { AvailableBands = bands, AvailableModes = modes });
+ pausedUntil = DateTime.MinValue;
+ Refresh();
+ }).ShowDialog(this);
private void OpenHelp() => _ = TopLevel.GetTopLevel(this)?.Launcher.LaunchUriAsync(new Uri(HelpUrl));
diff --git a/src/Nonemm.App/Windows/ColumnWidths.cs b/src/Nonemm.App/Windows/ColumnWidths.cs
new file mode 100644
index 0000000..e7d895a
--- /dev/null
+++ b/src/Nonemm.App/Windows/ColumnWidths.cs
@@ -0,0 +1,36 @@
+using System.Globalization;
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace Nonemm.App.Windows;
+
+/// Stops a grid column being dragged, or sized to its cells, narrower than its
+/// own heading.
+public static class ColumnWidths
+{
+ /// What a header takes besides its text: its padding and the room the theme
+ /// keeps for the sort arrow.
+ private const double HeaderPadding = 40;
+
+ /// The width of the heading in the grid's font, with the room the arrow
+ /// needs.
+ public static double ForHeader(DataGrid grid, string heading) =>
+ new FormattedText(
+ heading,
+ CultureInfo.InvariantCulture,
+ FlowDirection.LeftToRight,
+ new Typeface(grid.FontFamily, FontStyle.Normal, FontWeight.Bold),
+ grid.FontSize,
+ null).Width + HeaderPadding;
+
+ public static void KeepHeadersReadable(DataGrid grid)
+ {
+ foreach (DataGridColumn column in grid.Columns)
+ {
+ if (column.Header is string heading)
+ {
+ column.MinWidth = ForHeader(grid, heading);
+ }
+ }
+ }
+}
diff --git a/src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs b/src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs
index 5e04829..787031a 100644
--- a/src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs
@@ -41,6 +41,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
{
this.session = session;
InitializeComponent();
+ ColumnWidths.KeepHeadersReadable(Rows);
Rows.LoadingRow += (_, e) =>
{
if (e.Row.DataContext is StationRow row)
diff --git a/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs b/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs
index 4fe23fb..6ff671f 100644
--- a/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/StatisticsWindow.axaml.cs
@@ -189,6 +189,7 @@ public sealed partial class StatisticsWindow : RefreshableWindow
Binding = new Binding($"[{at}]"),
});
}
+ ColumnWidths.KeepHeadersReadable(Table);
Table.ItemsSource = laid.Rows
.Select((cells, at) => new StatisticsRow(cells, isTotal: at == laid.Rows.Count - 1))
.ToList();
diff --git a/tests/Nonemm.App.Tests/AvailableRowTests.cs b/tests/Nonemm.App.Tests/AvailableRowTests.cs
new file mode 100644
index 0000000..3f46c30
--- /dev/null
+++ b/tests/Nonemm.App.Tests/AvailableRowTests.cs
@@ -0,0 +1,39 @@
+using Nonemm.App.Windows;
+using Nonemm.Contests;
+using Nonemm.Core;
+using Nonemm.Session;
+using Nonemm.Spotting;
+
+namespace Nonemm.App.Tests;
+
+public class AvailableRowTests
+{
+ private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
+
+ private static AvailableRow Row(string comment)
+ {
+ Spot spot = new(
+ Callsign.Parse("DL1ABC"),
+ Frequency.FromKilohertz(14_025),
+ Now.AddMinutes(-5),
+ SpotSource.Cluster,
+ "OK1TEST",
+ comment);
+ return new AvailableRow(new AvailableStation(spot, Bands.Band20M, Verdict.Dupe), "CW", Now, null, false, false);
+ }
+
+ [Fact]
+ public void TheHoverTextHasNoControlCharactersOtherThanLineBreaks()
+ {
+ string tip = Row("CW 24 dB\r\a 1234Z\r\n").Tip;
+
+ Assert.DoesNotContain(tip, c => char.IsControl(c) && c != '\n');
+ }
+
+ [Fact]
+ public void TheHoverTextLeavesOutTheClusterTimeAndTheSideband()
+ {
+ Assert.DoesNotContain("1234Z", Row("CW 24 dB 1234Z").Tip);
+ Assert.Equal("14025.0 by OK1TEST, 5m ago", Row("USB").Tip);
+ }
+}