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:
@@ -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();
|
||||
}
|
||||
@@ -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<string> 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<string> lines = [SpotDescription.Of(Station.Spot, nowUtc)];
|
||||
if (Station.Spot.IsSplit)
|
||||
{
|
||||
lines.Add($"listening on {Station.Spot.Qsx.Kilohertz:0.0}");
|
||||
|
||||
@@ -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">
|
||||
<DockPanel Margin="6">
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="0,0,0,6">
|
||||
<Button Name="ModeButton" MinWidth="90" ToolTip.Tip="Show only multipliers, or multipliers and Qs" />
|
||||
<Button Name="FilterButton" Content="Bands & Modes" />
|
||||
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" />
|
||||
<TextBlock Name="NoticeText" FontSize="11" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<Grid Name="Summary" DockPanel.Dock="Top" Margin="0,0,0,6" HorizontalAlignment="Left" />
|
||||
<DataGrid Name="Spots" IsReadOnly="True" GridLinesVisibility="Horizontal" CanUserSortColumns="True"
|
||||
CanUserReorderColumns="True" CanUserResizeColumns="True" SelectionMode="Single"
|
||||
FontFamily="{x:Static theming:Fonts.Mono}">
|
||||
<DataGrid.Styles>
|
||||
<Style Selector="DataGridCell TextBlock">
|
||||
<Setter Property="FontFamily" Value="{x:Static theming:Fonts.Mono}" />
|
||||
<Setter Property="FontSize" Value="{DynamicResource AvailableFontSize}" />
|
||||
<Setter Property="Margin" Value="6,0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridCell">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="0,1" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
</DataGrid.Styles>
|
||||
</DataGrid>
|
||||
Title="Available Mults and Qs" Width="900" Height="440">
|
||||
<DockPanel>
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" Margin="6,6,6,4">
|
||||
<Button Name="ModeButton" Grid.Column="0" VerticalAlignment="Top" MinWidth="60"
|
||||
HorizontalContentAlignment="Center" FontWeight="Bold"
|
||||
ToolTip.Tip="Mults shows only multipliers. Mults and Qs shows the stations that are only points as well. Click to switch." />
|
||||
<Grid Name="Summary" Grid.Column="1" HorizontalAlignment="Center" />
|
||||
<Button Name="FilterButton" Grid.Column="2" VerticalAlignment="Top" MinWidth="60"
|
||||
HorizontalContentAlignment="Center" FontWeight="Bold" Content="Bands Modes" />
|
||||
</Grid>
|
||||
<Grid>
|
||||
<DataGrid Name="Spots" IsReadOnly="True" GridLinesVisibility="None" CanUserSortColumns="True"
|
||||
CanUserReorderColumns="True" CanUserResizeColumns="True" SelectionMode="Single"
|
||||
FontFamily="{x:Static theming:Fonts.Mono}">
|
||||
<DataGrid.Styles>
|
||||
<Style Selector="DataGridCell TextBlock">
|
||||
<Setter Property="FontFamily" Value="{x:Static theming:Fonts.Mono}" />
|
||||
<Setter Property="FontSize" Value="{DynamicResource AvailableFontSize}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="Margin" Value="6,0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridRow">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="DataGridCell">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="0,2" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<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>
|
||||
</local:RefreshableWindow>
|
||||
|
||||
@@ -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<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>
|
||||
{
|
||||
Spots.Columns.Add(new DataGridTextColumn
|
||||
{
|
||||
Header = header,
|
||||
Width = new DataGridLength(width),
|
||||
Width = DataGridLength.Auto,
|
||||
Binding = new Binding(path) { Mode = BindingMode.OneWay },
|
||||
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;
|
||||
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<AvailableRow>();
|
||||
shownKey = "";
|
||||
summaryKey = "";
|
||||
@@ -265,10 +275,11 @@ public sealed partial class AvailableWindow : RefreshableWindow
|
||||
Bandmap bandmap = session.Bandmap;
|
||||
HashSet<Band> picked = [.. clicked];
|
||||
Frequency? tuned = session.Position?.Frequency;
|
||||
IReadOnlyList<ModeCategory> contestModes = session.Position?.Contest.Modes ?? [];
|
||||
bool summaryShown = Summary.IsVisible;
|
||||
bandmap.DropOlderThan(now);
|
||||
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)
|
||||
{
|
||||
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<Band> picked,
|
||||
Frequency? tuned,
|
||||
IReadOnlyList<ModeCategory> contestModes,
|
||||
bool summaryShown)
|
||||
{
|
||||
int mults = all.Count(s => s.IsMultiplier);
|
||||
IReadOnlyList<string> preferred = settings.SpotFilter.PreferredSpotters;
|
||||
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(
|
||||
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<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> 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<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
|
||||
@@ -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<PointerPressedEventArgs>? click, bool highlight = false)
|
||||
private void Label(int row, int column, string text, bool bold, bool highlight, Action<PointerPressedEventArgs> 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<Control> 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<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 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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
InitializeComponent();
|
||||
ColumnWidths.KeepHeadersReadable(Rows);
|
||||
Rows.LoadingRow += (_, e) =>
|
||||
{
|
||||
if (e.Row.DataContext is StationRow row)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user