Match the Available Mults and Qs window to N1MM's screenshots

The summary table labels had no foreground set, so only the highlighted
numbers drew. The window now has the two-line Mults/Qs and Bands/Modes
buttons, a bold summary table, a list sorted on frequency, a ringed picked
row, a tick in the Mlt column and N1MM's empty-list message.

Bands & Modes opens a window with band groups and the CW, Phone, Digital and
Contest modes. The hover text uses SpotDescription like the bandmap, so
control characters and the cluster time are gone. Grid columns keep a
minimum width of their heading in the Available, Statistics and Network
status windows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 13:00:22 +00:00
co-authored by Claude Sonnet 5.5
parent fe70f9b058
commit 5e66778301
10 changed files with 395 additions and 134 deletions
+5 -3
View File
@@ -665,7 +665,8 @@ rather than guessing.
**View → Available Mults and Qs** lists what is spotted and not worked yet. The **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. 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 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. 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 row to show its multipliers and Qs. Click Mults, Qs or Total at the left to
show every band. show every band.
- The **Mults** / **Mults & Qs** button shows what the list holds now, and a - 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 click switches it. **Bands & Modes** opens a window with a tick for each band and for CW,
without touching the telnet filters. Both are kept between runs. 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 - The right-click menu also holds **Summary Panel Band Selection**, **Show
Summary Panel** and **Help**. With the list sorted on TS, newest first, 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 **QSY to top spot after logging QSO** tunes to the newest spot after each
@@ -0,0 +1,20 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.AvailableFilterDialog"
Title="Available Window Spot Display Filters" Width="500" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto" Margin="12">
<Border Grid.Row="0" Grid.Column="0" BorderThickness="1" BorderBrush="Gray" Padding="8" Margin="0,0,8,8">
<StackPanel Spacing="4">
<TextBlock Text="Bands" />
<StackPanel Name="BandGroups" Orientation="Horizontal" Spacing="12" />
</StackPanel>
</Border>
<Border Grid.Row="0" Grid.Column="1" BorderThickness="1" BorderBrush="Gray" Padding="8" Margin="0,0,0,8">
<StackPanel Name="ModeList" Spacing="4">
<TextBlock Text="Modes" />
</StackPanel>
</Border>
<Button Grid.Row="1" Grid.Column="1" Content="Close" HorizontalAlignment="Right" Click="OnClose" IsDefault="True" />
</Grid>
</Window>
@@ -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<string> bands;
private readonly HashSet<string> modes;
private readonly Action<IReadOnlyList<string>, IReadOnlyList<string>> changed;
private readonly Dictionary<string, CheckBox> bandBoxes = [];
private readonly Dictionary<string, CheckBox> modeBoxes = [];
public AvailableFilterDialog(
IReadOnlyList<string> bands,
IReadOnlyList<string> modes,
Action<IReadOnlyList<string>, IReadOnlyList<string>> changed)
{
this.bands = new HashSet<string>(bands, StringComparer.OrdinalIgnoreCase);
this.modes = new HashSet<string>(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<string> 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();
}
+13 -6
View File
@@ -2,6 +2,7 @@ using System.Globalization;
using Avalonia.Media; using Avalonia.Media;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Session; using Nonemm.Session;
using Nonemm.Spotting;
namespace Nonemm.App.Windows; 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 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; 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 int? Snr => Station.Spot.Snr;
public string Noise => Snr?.ToString(CultureInfo.InvariantCulture) ?? ""; public string Noise => Snr?.ToString(CultureInfo.InvariantCulture) ?? "";
@@ -91,15 +101,12 @@ public sealed class AvailableRow
public string Key => public string Key =>
$"{Call}|{Station.Band.Name}|{Frequency}|{Multipliers}|{Points}|{Spotter}|{Time}|{Sun}|{Star}|{IsTuned}"; $"{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 public string Tip
{ {
get get
{ {
List<string> lines = [$"{Call} spotted by {Station.Spot.Spotter}", $"{(int)(nowUtc - AtUtc).TotalMinutes} min ago"]; List<string> lines = [SpotDescription.Of(Station.Spot, nowUtc)];
if (Station.Spot.Comment.Length > 0)
{
lines.Add(Station.Spot.Comment);
}
if (Station.Spot.IsSplit) if (Station.Spot.IsSplit)
{ {
lines.Add($"listening on {Station.Spot.Qsx.Kilohertz:0.0}"); lines.Add($"listening on {Station.Spot.Qsx.Kilohertz:0.0}");
+57 -30
View File
@@ -3,35 +3,62 @@
xmlns:local="using:Nonemm.App.Windows" xmlns:local="using:Nonemm.App.Windows"
xmlns:theming="using:Nonemm.App.Theming" xmlns:theming="using:Nonemm.App.Theming"
x:Class="Nonemm.App.Windows.AvailableWindow" x:Class="Nonemm.App.Windows.AvailableWindow"
Title="Available Mults and Qs" Width="780" Height="440"> Title="Available Mults and Qs" Width="900" Height="440">
<DockPanel Margin="6"> <DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="0,0,0,6"> <Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" Margin="6,6,6,4">
<Button Name="ModeButton" MinWidth="90" ToolTip.Tip="Show only multipliers, or multipliers and Qs" /> <Button Name="ModeButton" Grid.Column="0" VerticalAlignment="Top" MinWidth="60"
<Button Name="FilterButton" Content="Bands &amp; Modes" /> HorizontalContentAlignment="Center" FontWeight="Bold"
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" /> ToolTip.Tip="Mults shows only multipliers. Mults and Qs shows the stations that are only points as well. Click to switch." />
<TextBlock Name="NoticeText" FontSize="11" VerticalAlignment="Center" /> <Grid Name="Summary" Grid.Column="1" HorizontalAlignment="Center" />
</StackPanel> <Button Name="FilterButton" Grid.Column="2" VerticalAlignment="Top" MinWidth="60"
<Grid Name="Summary" DockPanel.Dock="Top" Margin="0,0,0,6" HorizontalAlignment="Left" /> HorizontalContentAlignment="Center" FontWeight="Bold" Content="Bands&#10;Modes" />
<DataGrid Name="Spots" IsReadOnly="True" GridLinesVisibility="Horizontal" CanUserSortColumns="True" </Grid>
CanUserReorderColumns="True" CanUserResizeColumns="True" SelectionMode="Single" <Grid>
FontFamily="{x:Static theming:Fonts.Mono}"> <DataGrid Name="Spots" IsReadOnly="True" GridLinesVisibility="None" CanUserSortColumns="True"
<DataGrid.Styles> CanUserReorderColumns="True" CanUserResizeColumns="True" SelectionMode="Single"
<Style Selector="DataGridCell TextBlock"> FontFamily="{x:Static theming:Fonts.Mono}">
<Setter Property="FontFamily" Value="{x:Static theming:Fonts.Mono}" /> <DataGrid.Styles>
<Setter Property="FontSize" Value="{DynamicResource AvailableFontSize}" /> <Style Selector="DataGridCell TextBlock">
<Setter Property="Margin" Value="6,0" /> <Setter Property="FontFamily" Value="{x:Static theming:Fonts.Mono}" />
</Style> <Setter Property="FontSize" Value="{DynamicResource AvailableFontSize}" />
<Style Selector="DataGridRow"> <Setter Property="FontWeight" Value="Bold" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="Margin" Value="6,0" />
</Style> </Style>
<Style Selector="DataGridCell"> <Style Selector="DataGridRow">
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0,1" /> </Style>
</Style> <Style Selector="DataGridCell">
<Style Selector="DataGridColumnHeader"> <Setter Property="MinHeight" Value="0" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="Padding" Value="0,2" />
</Style> </Style>
</DataGrid.Styles> <Style Selector="DataGridColumnHeader">
</DataGrid> <Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="6,0" />
</Style>
<Style Selector="DataGridColumnHeader /template/ Path#SortIcon">
<Setter Property="Margin" Value="2,0,0,0" />
</Style>
<Style Selector="DataGridColumnHeader /template/ ContentPresenter">
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="FontSize" Value="{DynamicResource AvailableFontSize}" />
</Style>
<!-- the picked line is ringed, not filled, so the colour that says
what the station is worth stays readable -->
<Style Selector="DataGridRow:selected /template/ Rectangle#BackgroundRectangle">
<Setter Property="Fill" Value="Transparent" />
<Setter Property="Stroke" Value="#40B8B8" />
<Setter Property="StrokeThickness" Value="2" />
</Style>
<Style Selector="DataGridRow:pointerover /template/ Rectangle#BackgroundRectangle">
<Setter Property="Opacity" Value="0.4" />
</Style>
<Style Selector="DataGridCell:current /template/ Rectangle">
<Setter Property="IsVisible" Value="False" />
</Style>
</DataGrid.Styles>
</DataGrid>
<TextBlock Name="EmptyText" IsVisible="False" TextWrapping="Wrap" TextAlignment="Center"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="40" IsHitTestVisible="False" />
</Grid>
</DockPanel> </DockPanel>
</local:RefreshableWindow> </local:RefreshableWindow>
+104 -95
View File
@@ -10,6 +10,7 @@ using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Session; using Nonemm.Session;
using Nonemm.Spotting; using Nonemm.Spotting;
@@ -44,6 +45,7 @@ public sealed partial class AvailableWindow : RefreshableWindow
private AvailableRow? menuRow; private AvailableRow? menuRow;
private bool menuOnHeader; private bool menuOnHeader;
private string shownKey = ""; private string shownKey = "";
private string heading = "Available Mults and Qs";
private string summaryKey = ""; private string summaryKey = "";
private DateTime pausedUntil = DateTime.MinValue; private DateTime pausedUntil = DateTime.MinValue;
private bool refreshPending; private bool refreshPending;
@@ -57,10 +59,14 @@ public sealed partial class AvailableWindow : RefreshableWindow
BuildColumns(); BuildColumns();
ApplyColumns(); ApplyColumns();
ApplyFontSize(); 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 => _ = new FontZoom(this, new Settings().AvailableFontSize, () => session.Settings.AvailableFontSize, size =>
{ {
session.Save(session.Settings with { AvailableFontSize = size }); session.Save(session.Settings with { AvailableFontSize = size });
ApplyFontSize(); ApplyFontSize();
ColumnWidths.KeepHeadersReadable(Spots);
Refresh(); Refresh();
}); });
ModeButton.Click += (_, _) => SetMultsOnly(!session.Settings.AvailableMultsOnly); ModeButton.Click += (_, _) => SetMultsOnly(!session.Settings.AvailableMultsOnly);
@@ -107,10 +113,9 @@ public sealed partial class AvailableWindow : RefreshableWindow
} }
} }
private void ShowNotice() => private void ShowNotice() => Title = IsPaused
NoticeText.Text = IsPaused ? $"{heading} Scrolling Pausing {Math.Max(1, (int)Math.Ceiling((pausedUntil - DateTime.UtcNow).TotalSeconds))} secs"
? $"Scrolling Pausing {Math.Max(1, (int)Math.Ceiling((pausedUntil - DateTime.UtcNow).TotalSeconds))} secs" : heading;
: "";
private void OnTick() private void OnTick()
{ {
@@ -129,28 +134,28 @@ public sealed partial class AvailableWindow : RefreshableWindow
private void BuildColumns() private void BuildColumns()
{ {
Column("Call", nameof(AvailableRow.Call), r => r.Call, 110); Column("Call", nameof(AvailableRow.Call), r => r.Call);
Column("Freq", nameof(AvailableRow.Frequency), r => r.Kilohertz, 90); Column("Freq", nameof(AvailableRow.Frequency), r => r.Kilohertz);
Column("Mode", nameof(AvailableRow.Mode), r => r.Mode, 55); Column("Mode", nameof(AvailableRow.Mode), r => r.Mode);
Column("Dir", nameof(AvailableRow.Direction), r => r.Heading ?? -1, 50); Column("Dir", nameof(AvailableRow.Direction), r => r.Heading ?? -1);
Column("Mlt", nameof(AvailableRow.Multipliers), r => r.Multipliers, 50); Column("Mlt", nameof(AvailableRow.Mark), r => r.Multipliers);
Column("SN", nameof(AvailableRow.Noise), r => r.Snr ?? int.MinValue, 45); Column("SN", nameof(AvailableRow.Noise), r => r.Snr ?? int.MinValue);
Column("TS", nameof(AvailableRow.Time), r => r.AtUtc, 75); Column("TS", nameof(AvailableRow.Time), r => r.AtUtc);
Column("Spotter", nameof(AvailableRow.Spotter), r => r.Spotter, 110); Column("Spotter", nameof(AvailableRow.Spotter), r => r.Spotter);
Column("Grid", nameof(AvailableRow.Grid), r => r.Grid, 65); Column("Grid", nameof(AvailableRow.Grid), r => r.Grid);
Column("Sun", nameof(AvailableRow.Sun), r => r.SunMinutes, 60); Column("Sun", nameof(AvailableRow.Sun), r => r.SunMinutes);
Column("Pts", nameof(AvailableRow.Points), r => r.Points, 50); Column("Pts", nameof(AvailableRow.Points), r => r.Points);
Column("★", nameof(AvailableRow.Star), r => r.IsPreferred ? 1 : 0, 35); Column("★", nameof(AvailableRow.Star), r => r.IsPreferred ? 1 : 0);
} }
/// The grid sorts on the typed value behind the cell, not on its text. /// The grid sorts on the typed value behind the cell, not on its text.
private void Column<T>(string header, string path, Func<AvailableRow, T> key, double width) private void Column<T>(string header, string path, Func<AvailableRow, T> key)
where T : IComparable<T> where T : IComparable<T>
{ {
Spots.Columns.Add(new DataGridTextColumn Spots.Columns.Add(new DataGridTextColumn
{ {
Header = header, Header = header,
Width = new DataGridLength(width), Width = DataGridLength.Auto,
Binding = new Binding(path) { Mode = BindingMode.OneWay }, Binding = new Binding(path) { Mode = BindingMode.OneWay },
CustomSortComparer = Comparer<AvailableRow>.Create((left, right) => key(left).CompareTo(key(right))), CustomSortComparer = Comparer<AvailableRow>.Create((left, right) => key(left).CompareTo(key(right))),
}); });
@@ -246,11 +251,16 @@ public sealed partial class AvailableWindow : RefreshableWindow
{ {
Summary.IsVisible = session.Settings.AvailableShowsSummary; Summary.IsVisible = session.Settings.AvailableShowsSummary;
bool multsOnly = session.Settings.AvailableMultsOnly; bool multsOnly = session.Settings.AvailableMultsOnly;
ModeButton.Content = multsOnly ? "Mults" : "Mults & Qs"; ModeButton.Content = multsOnly ? "Mults" : "Mults\nQs";
ModeButton.Foreground = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth; ModeButton.Background = multsOnly ? Verdicts.NewMultiplier : Verdicts.Worth;
ModeButton.Foreground = Brushes.White;
if (session.Available is not { } available) 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<AvailableRow>(); Spots.ItemsSource = Array.Empty<AvailableRow>();
shownKey = ""; shownKey = "";
summaryKey = ""; summaryKey = "";
@@ -265,10 +275,11 @@ public sealed partial class AvailableWindow : RefreshableWindow
Bandmap bandmap = session.Bandmap; Bandmap bandmap = session.Bandmap;
HashSet<Band> picked = [.. clicked]; HashSet<Band> picked = [.. clicked];
Frequency? tuned = session.Position?.Frequency; Frequency? tuned = session.Position?.Frequency;
IReadOnlyList<ModeCategory> contestModes = session.Position?.Contest.Modes ?? [];
bool summaryShown = Summary.IsVisible; bool summaryShown = Summary.IsVisible;
bandmap.DropOlderThan(now); bandmap.DropOlderThan(now);
IReadOnlyList<AvailableStation> all = await available.AllAsync(); IReadOnlyList<AvailableStation> 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) if (again)
{ {
return; return;
@@ -278,10 +289,12 @@ public sealed partial class AvailableWindow : RefreshableWindow
{ {
BuildSummary(summary, settings, picked); BuildSummary(summary, settings, picked);
} }
Title = $"Available Mults and Qs — {computed.Total - computed.Mults} Qs · {computed.Mults} Mults · {computed.Total} total"; heading = $"Available Mults and Qs - {computed.Mults} Mults {computed.Total - computed.Mults} Qs of {computed.Total} total spots";
StatusText.Text = computed.Total == 0 ShowNotice();
? "No workable spots: check the contest, the bands and the cluster filters" EmptyText.Text = "No workable local nor Telnet spots available to display. "
: $"{computed.Mults} mults · {computed.Total - computed.Mults} Qs · {computed.Total} total"; + "Check contest, cluster filters, Bands & Modes button, and Telnet window filters.";
EmptyText.Foreground = Verdicts.Worth;
EmptyText.IsVisible = computed.Rows.Count == 0;
if (IsPaused) if (IsPaused)
{ {
refreshPending = true; refreshPending = true;
@@ -298,12 +311,13 @@ public sealed partial class AvailableWindow : RefreshableWindow
Bandmap bandmap, Bandmap bandmap,
HashSet<Band> picked, HashSet<Band> picked,
Frequency? tuned, Frequency? tuned,
IReadOnlyList<ModeCategory> contestModes,
bool summaryShown) bool summaryShown)
{ {
int mults = all.Count(s => s.IsMultiplier); int mults = all.Count(s => s.IsMultiplier);
IReadOnlyList<string> preferred = settings.SpotFilter.PreferredSpotters; IReadOnlyList<string> preferred = settings.SpotFilter.PreferredSpotters;
List<AvailableRow> rows = all List<AvailableRow> 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( .Select(s => new AvailableRow(
s, s,
ModeName(plan, s.Spot.Frequency), ModeName(plan, s.Spot.Frequency),
@@ -320,14 +334,29 @@ public sealed partial class AvailableWindow : RefreshableWindow
string.Join(';', rows.Select(r => r.Key))); string.Join(';', rows.Select(r => r.Key)));
} }
private static bool IsShown(AvailableStation station, Settings settings, BandPlan plan, HashSet<Band> picked) private static bool IsShown(
AvailableStation station,
Settings settings,
BandPlan plan,
HashSet<Band> picked,
IReadOnlyList<ModeCategory> contestModes)
{ {
IReadOnlyList<string> bands = settings.AvailableBands; IReadOnlyList<string> bands = settings.AvailableBands;
IReadOnlyList<string> modes = settings.AvailableModes;
ModeCategory? category = plan.ModeAt(station.Spot.Frequency); ModeCategory? category = plan.ModeAt(station.Spot.Frequency);
return (picked.Count == 0 || picked.Contains(station.Band)) return (picked.Count == 0 || picked.Contains(station.Band))
&& (bands.Count == 0 || bands.Contains(station.Band.Name, StringComparer.OrdinalIgnoreCase)) && (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<string> modes, ModeCategory category, IReadOnlyList<ModeCategory> contestModes)
{
bool contest = modes.Contains(AvailableFilterDialog.ContestMode, StringComparer.OrdinalIgnoreCase);
List<string> 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 private static string ModeKey(ModeCategory category) => category switch
@@ -354,8 +383,17 @@ public sealed partial class AvailableWindow : RefreshableWindow
view.SortDescriptions.Add(sort); 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; 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.ItemsSource = view;
Spots.SelectedItem = null;
if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } same) if (picked is not null && rows.FirstOrDefault(r => r.Call == picked) is { } same)
{ {
Spots.SelectedItem = same; Spots.SelectedItem = same;
@@ -434,40 +472,42 @@ public sealed partial class AvailableWindow : RefreshableWindow
Summary.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); Summary.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
} }
Label(0, 0, "", null); Label(1, 0, "Mults", true, false, e => ShowBands([], null, e));
Label(1, 0, "Mults", e => ShowBands([], null, e)); Label(2, 0, "Qs", true, false, e => ShowBands([], null, e));
Label(2, 0, "Qs", e => ShowBands([], null, e)); Label(3, 0, "Total", true, false, e => ShowBands([], null, e));
Label(3, 0, "Total", e => ShowBands([], null, e));
for (int at = 0; at < columns.Count; at++) for (int at = 0; at < columns.Count; at++)
{ {
Band band = columns[at]; Band band = columns[at];
int column = at + 1; int column = at + 1;
Label(0, column, band.Name.TrimEnd('M', 'm'), e => ShowBands([band], null, e), picked.Contains(band)); Label(0, column, BandLabel(band), true, picked.Contains(band), e => ShowBands([band], null, e));
Label(1, column, Count(mults[band]), e => ShowBands([band], true, e), mults[band] > 0 && mults[band] == mostMults); Label(1, column, mults[band].ToString(), false, mults[band] > 0 && mults[band] == mostMults, e => ShowBands([band], true, e));
Label(2, column, Count(qs[band]), e => ShowBands([band], false, e), qs[band] > 0 && qs[band] == mostQs); Label(2, column, qs[band].ToString(), false, qs[band] > 0 && qs[band] == mostQs, e => ShowBands([band], false, e));
Label(3, column, Count(mults[band] + qs[band]), e => ShowBands([band], null, 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<PointerPressedEventArgs>? click, bool highlight = false) private void Label(int row, int column, string text, bool bold, bool highlight, Action<PointerPressedEventArgs> click)
{ {
TextBlock block = new() TextBlock block = new()
{ {
Text = text, Text = text,
FontFamily = Theming.Fonts.Mono,
FontSize = session.Settings.AvailableFontSize, FontSize = session.Settings.AvailableFontSize,
Margin = new Thickness(6, 0), Margin = new Thickness(column == 0 ? 0 : 9, 0),
HorizontalAlignment = column == 0 ? HorizontalAlignment.Left : HorizontalAlignment.Right, MinWidth = column == 0 ? 0 : 22,
Foreground = highlight ? Verdicts.NewMultiplier : null, TextAlignment = column == 0 ? TextAlignment.Left : TextAlignment.Center,
FontWeight = highlight ? FontWeight.Bold : FontWeight.Normal, 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.Foreground = Verdicts.NewMultiplier;
block.PointerPressed += (_, e) => click(e);
} }
block.PointerPressed += (_, e) => click(e);
Grid.SetRow(block, row); Grid.SetRow(block, row);
Grid.SetColumn(block, column); Grid.SetColumn(block, column);
Summary.Children.Add(block); Summary.Children.Add(block);
@@ -617,8 +657,8 @@ public sealed partial class AvailableWindow : RefreshableWindow
{ {
AvailableRow? row = menuRow; AvailableRow? row = menuRow;
MenuItem delete = Action("Delete Spot", () => Delete(row!)); MenuItem delete = Action("Delete Spot", () => Delete(row!));
MenuItem callBlacklist = Action("Blacklist callsign", () => Blacklist(row!, spotter: false)); MenuItem callBlacklist = Action("Blacklist Callsign", () => Blacklist(row!, spotter: false));
MenuItem spotterBlacklist = Action("Blacklist spotter", () => Blacklist(row!, spotter: true)); MenuItem spotterBlacklist = Action("Blacklist Spotter", () => Blacklist(row!, spotter: true));
delete.IsEnabled = callBlacklist.IsEnabled = spotterBlacklist.IsEnabled = row is not null; delete.IsEnabled = callBlacklist.IsEnabled = spotterBlacklist.IsEnabled = row is not null;
MenuItem qs = Check("Show Qs && Mults", !session.Settings.AvailableMultsOnly, () => SetMultsOnly(!session.Settings.AvailableMultsOnly)); MenuItem qs = Check("Show Qs && Mults", !session.Settings.AvailableMultsOnly, () => SetMultsOnly(!session.Settings.AvailableMultsOnly));
@@ -638,17 +678,19 @@ public sealed partial class AvailableWindow : RefreshableWindow
return return
[ [
delete, delete,
new Separator(),
callBlacklist, callBlacklist,
spotterBlacklist, spotterBlacklist,
new Separator(), new Separator(),
Action("Set Band && Mode Filter Options", OpenFilterMenu), Action("Set Band && Mode Filter Options....", OpenFilterMenu),
new Separator(),
qs, qs,
new Separator(),
qsyAfter, qsyAfter,
new Separator(), new Separator(),
SummaryBandMenu(), SummaryBandMenu(),
Action("Auto Resize Columns", AutoResizeColumns), Action("Auto Resize Columns", AutoResizeColumns),
summaryShown, summaryShown,
new Separator(),
Action("Help", OpenHelp), 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 /// The bands and modes the list is limited to. It does not touch the
/// telnet window's filters. /// telnet window's filters.
private void OpenFilterMenu() private void OpenFilterMenu() =>
{ _ = new AvailableFilterDialog(
List<Control> items = []; session.Settings.AvailableBands,
foreach (Band band in Bands.ContestWithWarc) session.Settings.AvailableModes,
{ (bands, modes) =>
items.Add(FilterItem(band.Name, session.Settings.AvailableBands.Contains(band.Name), on => {
session.Settings with { AvailableBands = Toggled(session.Settings.AvailableBands, band.Name, on) })); session.Save(session.Settings with { AvailableBands = bands, AvailableModes = modes });
} pausedUntil = DateTime.MinValue;
items.Add(new Separator()); Refresh();
foreach ((string label, string key) in new[] { ("CW", "CW"), ("Phone", "PHONE"), ("Digital", "DIGITAL") }) }).ShowDialog(this);
{
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<bool, Settings> 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<string> Toggled(IReadOnlyList<string> names, string name, bool on) =>
[.. names.Where(n => n != name).Concat(on ? [name] : [])];
private void OpenHelp() => _ = TopLevel.GetTopLevel(this)?.Launcher.LaunchUriAsync(new Uri(HelpUrl)); private void OpenHelp() => _ = TopLevel.GetTopLevel(this)?.Launcher.LaunchUriAsync(new Uri(HelpUrl));
+36
View File
@@ -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);
}
}
}
}
@@ -41,6 +41,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
{ {
this.session = session; this.session = session;
InitializeComponent(); InitializeComponent();
ColumnWidths.KeepHeadersReadable(Rows);
Rows.LoadingRow += (_, e) => Rows.LoadingRow += (_, e) =>
{ {
if (e.Row.DataContext is StationRow row) if (e.Row.DataContext is StationRow row)
@@ -189,6 +189,7 @@ public sealed partial class StatisticsWindow : RefreshableWindow
Binding = new Binding($"[{at}]"), Binding = new Binding($"[{at}]"),
}); });
} }
ColumnWidths.KeepHeadersReadable(Table);
Table.ItemsSource = laid.Rows Table.ItemsSource = laid.Rows
.Select((cells, at) => new StatisticsRow(cells, isTotal: at == laid.Rows.Count - 1)) .Select((cells, at) => new StatisticsRow(cells, isTotal: at == laid.Rows.Count - 1))
.ToList(); .ToList();
@@ -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);
}
}