Give each operator his windows back

The program stored an operator's window positions but never used them at
startup: the entry window was not placed and no window was reopened. It
now restores the whole screen both at startup and on a change of
operator: the windows that operator had open, where he had them, and the
ones he did not have closed.

The second radio's entry window no longer saves the layout. It has none
of the other windows open, so saving from it dropped every window the
first one had.

Ctrl+O gets six buttons for the operators last at the radio, newest
first. One click changes to that operator.

The recordings folder is no longer asked for. It is `Wav/<callsign>`
under the data folder, which is where N1MM plays them from, so
`StoredOperator.WavPath` is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
This commit is contained in:
2026-09-10 13:58:12 +00:00
co-authored by Claude Opus 5
parent 66e9a0ab65
commit f2d9e331b4
7 changed files with 114 additions and 53 deletions
+6
View File
@@ -253,6 +253,12 @@ public sealed record Settings
/// Whoever is at the radio now, or empty at a station with one operator. /// Whoever is at the radio now, or empty at a station with one operator.
public string CurrentOperator { get; init; } = ""; public string CurrentOperator { get; init; } = "";
/// The operators who were last at the radio, newest first. `Operators` is
/// kept in the order they took it, so the end of the list is the most
/// recent.
public IEnumerable<StoredOperator> RecentOperators(int count) =>
Operators.Reverse().Take(count);
/// The digital window's twenty-four macro buttons, in the same /// The digital window's twenty-four macro buttons, in the same
/// `label,message` lines as a function key file. Empty means the built-in /// `label,message` lines as a function key file. Empty means the built-in
/// set. /// set.
@@ -12,10 +12,5 @@ public sealed record StoredOperator
/// until he changes them. /// until he changes them.
public string Theme { get; init; } = ""; public string Theme { get; init; } = "";
/// The folder this operator's recordings are in. A voice keyer plays the
/// recordings of whoever is at the microphone, so the path is the
/// operator's rather than the station's.
public string WavPath { get; init; } = "";
public IReadOnlyList<StoredWindow> Windows { get; init; } = []; public IReadOnlyList<StoredWindow> Windows { get; init; } = [];
} }
+2 -7
View File
@@ -20,19 +20,14 @@
Click="OnCancel" IsCancel="True" /> Click="OnCancel" IsCancel="True" />
</StackPanel> </StackPanel>
<StackPanel Spacing="2"> <StackPanel Spacing="2">
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75" <TextBlock Name="RecentLabel" Classes="label" Text="Recent" />
Text="Whoever is at the radio. The callsign goes against every contact logged from now on, and the windows, colours and recordings are this operator's own." /> <UniformGrid Name="RecentPanel" Columns="3" />
<TextBlock Classes="label" Text="Operator callsign" /> <TextBlock Classes="label" Text="Operator callsign" />
<Grid ColumnDefinitions="*,6,150"> <Grid ColumnDefinitions="*,6,150">
<TextBox Name="CallBox" /> <TextBox Name="CallBox" />
<ComboBox Name="KnownBox" Grid.Column="2" HorizontalAlignment="Stretch" <ComboBox Name="KnownBox" Grid.Column="2" HorizontalAlignment="Stretch"
SelectionChanged="OnPickedKnown" PlaceholderText="known" /> SelectionChanged="OnPickedKnown" PlaceholderText="known" />
</Grid> </Grid>
<TextBlock Classes="label" Text="Recordings folder (the voice keyer's wav files)" />
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Name="WavBox" />
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseWav" />
</Grid>
<TextBlock Name="AboutText" FontSize="11" Opacity="0.75" Margin="0,10,0,0" <TextBlock Name="AboutText" FontSize="11" Opacity="0.75" Margin="0,10,0,0"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</StackPanel> </StackPanel>
+33 -19
View File
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Layout;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
namespace Nonemm.App.Dialogs; namespace Nonemm.App.Dialogs;
@@ -14,15 +15,19 @@ public sealed record OperatorChoice(StoredOperator Operator, bool IsRemoval);
/// callsign is not in the list yet is made by typing it. /// callsign is not in the list yet is made by typing it.
public sealed partial class OperatorDialog : Window public sealed partial class OperatorDialog : Window
{ {
private readonly IReadOnlyList<StoredOperator> known; /// How many of the operators who were last at the radio get a button. Six
/// fits two rows of three, which covers a weekend's shift roster.
private const int RecentCount = 6;
private readonly Settings settings;
public OperatorDialog(Settings settings) public OperatorDialog(Settings settings)
{ {
known = settings.Operators; this.settings = settings;
InitializeComponent(); InitializeComponent();
KnownBox.ItemsSource = known.Select(o => o.Callsign).ToList(); KnownBox.ItemsSource = settings.Operators.Select(o => o.Callsign).ToList();
CallBox.Text = settings.CurrentOperator; CallBox.Text = settings.CurrentOperator;
WavBox.Text = Find(settings.CurrentOperator)?.WavPath ?? ""; ShowRecent();
ShowWhatIsKept(); ShowWhatIsKept();
// the callsign is all most operators type here, so the cursor starts in // the callsign is all most operators type here, so the cursor starts in
// it and Enter closes the dialog // it and Enter closes the dialog
@@ -35,6 +40,26 @@ public sealed partial class OperatorDialog : Window
}; };
} }
/// A button for each of the last few operators at the radio, newest first.
/// One click changes to that operator.
private void ShowRecent()
{
StoredOperator[] recent = [.. settings.RecentOperators(RecentCount)];
RecentLabel.IsVisible = recent.Length > 0;
foreach (StoredOperator stored in recent)
{
Button button = new()
{
Content = stored.Callsign,
Margin = new Thickness(0, 2, 4, 2),
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Center,
};
button.Click += (_, _) => Close(new OperatorChoice(stored, IsRemoval: false));
RecentPanel.Children.Add(button);
}
}
/// The operator as the dialog leaves him: the callsign typed, keeping /// The operator as the dialog leaves him: the callsign typed, keeping
/// whatever was stored for him before. /// whatever was stored for him before.
private StoredOperator Picked private StoredOperator Picked
@@ -43,12 +68,12 @@ public sealed partial class OperatorDialog : Window
{ {
string callsign = (CallBox.Text ?? "").Trim().ToUpperInvariant(); string callsign = (CallBox.Text ?? "").Trim().ToUpperInvariant();
StoredOperator stored = Find(callsign) ?? new StoredOperator(); StoredOperator stored = Find(callsign) ?? new StoredOperator();
return stored with { Callsign = callsign, WavPath = (WavBox.Text ?? "").Trim() }; return stored with { Callsign = callsign };
} }
} }
private StoredOperator? Find(string callsign) => private StoredOperator? Find(string callsign) =>
known.FirstOrDefault(o => string.Equals(o.Callsign, callsign.Trim(), StringComparison.OrdinalIgnoreCase)); settings.Operators.FirstOrDefault(o => string.Equals(o.Callsign, callsign.Trim(), StringComparison.OrdinalIgnoreCase));
private void OnPickedKnown(object? sender, SelectionChangedEventArgs e) private void OnPickedKnown(object? sender, SelectionChangedEventArgs e)
{ {
@@ -57,7 +82,6 @@ public sealed partial class OperatorDialog : Window
return; return;
} }
CallBox.Text = callsign; CallBox.Text = callsign;
WavBox.Text = Find(callsign)?.WavPath ?? "";
ShowWhatIsKept(); ShowWhatIsKept();
} }
@@ -65,21 +89,11 @@ public sealed partial class OperatorDialog : Window
{ {
StoredOperator? stored = Find(CallBox.Text ?? ""); StoredOperator? stored = Find(CallBox.Text ?? "");
AboutText.Text = stored is null AboutText.Text = stored is null
? "A new operator: the windows and colours on the screen now become his." ? "new operator"
: $"{stored.Windows.Count} window positions kept" : $"{stored.Windows.Count} window positions kept"
+ (stored.Theme.Length > 0 ? $", {stored.Theme} colours" : ", the colours as they are"); + (stored.Theme.Length > 0 ? $", {stored.Theme} colours" : ", the colours as they are");
} }
private async void OnBrowseWav(object? sender, RoutedEventArgs e)
{
IReadOnlyList<IStorageFolder> picked = await StorageProvider.OpenFolderPickerAsync(
new FolderPickerOpenOptions { Title = "The operator's recordings", AllowMultiple = false });
if (picked.Count > 0)
{
WavBox.Text = picked[0].Path.LocalPath;
}
}
private void OnOk(object? sender, RoutedEventArgs e) private void OnOk(object? sender, RoutedEventArgs e)
{ {
if (Picked.Callsign.Length > 0) if (Picked.Callsign.Length > 0)
+56 -20
View File
@@ -37,11 +37,10 @@ public sealed partial class EntryWindow
private void ChangeTo(Settings settings, StoredOperator wanted) private void ChangeTo(Settings settings, StoredOperator wanted)
{ {
StoredOperator stored = settings.Operators.FirstOrDefault(o => Is(o, wanted.Callsign)) is { } known StoredOperator stored = settings.Operators.FirstOrDefault(o => Is(o, wanted.Callsign))
? known with { WavPath = wanted.WavPath }
// a new operator starts with the windows and colours on the screen // a new operator starts with the windows and colours on the screen
: wanted with { Theme = settings.Theme, Windows = Layout() }; ?? wanted with { Theme = settings.Theme, Windows = Layout() };
stored = stored with { WavPath = WavFolder(stored) }; MakeWavFolder(stored.Callsign);
session.Save(settings with session.Save(settings with
{ {
Operators = [.. settings.Operators.Where(o => !Is(o, stored.Callsign)), stored], Operators = [.. settings.Operators.Where(o => !Is(o, stored.Callsign)), stored],
@@ -56,35 +55,70 @@ public sealed partial class EntryWindow
{ {
logging.Operator = stored.Callsign; logging.Operator = stored.Callsign;
} }
foreach (Window window in Placed()) Restore(stored);
{
Place(window, stored);
}
Status($"operator {stored.Callsign}"); Status($"operator {stored.Callsign}");
} }
/// The operator's recordings folder: the one he named, or `Wav/<callsign>` /// Makes `Wav/<callsign>` beside the rest of the files, which is where N1MM
/// beside the rest of the files, which is where N1MM keeps them. The folder /// plays an operator's recordings from, so there is somewhere to put them.
/// is made, so there is somewhere to put the files rather than a path that private void MakeWavFolder(string callsign)
/// is not there.
private string WavFolder(StoredOperator stored)
{ {
string folder = stored.WavPath.Trim().Length > 0 string folder = session.Paths.WavFor(callsign);
? stored.WavPath.Trim()
: session.Paths.WavFor(stored.Callsign);
try try
{ {
Directory.CreateDirectory(folder); Directory.CreateDirectory(folder);
} }
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException) catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
{ {
// a folder on a drive that is not there is still worth keeping: it
// is the operator's own setting, and he is the one who fixes it
Status($"{folder} could not be made: {failure.Message}"); Status($"{folder} could not be made: {failure.Message}");
} }
return folder;
} }
/// Puts the screen back the way this operator left it: the windows he had
/// open, where he had them, and the ones he did not have closed. The second
/// radio's window is another view of the same screen, so it restores
/// nothing.
private void Restore(StoredOperator? stored)
{
if (radioNumber > 1 || stored is null)
{
return;
}
HashSet<string> wanted = [.. stored.Windows.Select(w => w.Name)];
foreach ((string name, Action open) in Openable())
{
if (wanted.Contains(name))
{
open();
}
}
foreach (Window window in openWindows.Values.Where(w => !wanted.Contains(w.GetType().Name)).ToList())
{
window.Close();
}
foreach (Window window in Placed())
{
Place(window, stored);
}
}
/// The windows an operator can have open, under the names they are stored
/// as. The call stack window is not one of them: the program opens and
/// closes that itself as calls go on and off the stack.
private (string Name, Action Open)[] Openable() =>
[
(nameof(LogWindow), () => _ = Show(() => new LogWindow(session), activate: false)),
(nameof(CheckWindow), () => _ = Show(() => new CheckWindow(session), activate: false)),
(nameof(BandmapWindow), () => _ = Show(() => new BandmapWindow(session, Tune), activate: false)),
(nameof(AvailableWindow), () => _ = Show(() => new AvailableWindow(session, Tune), activate: false)),
(nameof(ScoreWindow), () => _ = Show(() => new ScoreWindow(session), activate: false)),
(nameof(StatisticsWindow), () => _ = Show(() => new StatisticsWindow(session), activate: false)),
(nameof(NetworkStatusWindow), () => _ = Show(() => new NetworkStatusWindow(session), activate: false)),
(nameof(GraylineWindow), () => _ = Show(() => new GraylineWindow(session), activate: false)),
(nameof(TelnetWindow), () => _ = Show(() => new TelnetWindow(session, Tune), activate: false)),
(nameof(DigitalWindow), () => _ = Show(() => new DigitalWindow(session, this, radioNumber), activate: false)),
];
/// Puts a window where this operator last had it. Windows are opened one at /// Puts a window where this operator last had it. Windows are opened one at
/// a time, so each one is placed as it appears rather than all at once. /// a time, so each one is placed as it appears rather than all at once.
private void Place(Window window, StoredOperator? stored = null) private void Place(Window window, StoredOperator? stored = null)
@@ -108,7 +142,9 @@ public sealed partial class EntryWindow
/// at the radio. With nobody named there is nowhere to keep them. /// at the radio. With nobody named there is nowhere to keep them.
private Settings WithLayout(Settings settings) private Settings WithLayout(Settings settings)
{ {
if (Current() is not { } stored) // the second radio's window has none of the others open, so saving from
// it would drop every window the first one has
if (radioNumber > 1 || Current() is not { } stored)
{ {
return settings; return settings;
} }
@@ -126,6 +126,7 @@ public sealed partial class EntryWindow : Window
StartConnections(); StartConnections();
BuildEntryBoxes(); BuildEntryBoxes();
ShowSecondRadio(); ShowSecondRadio();
Restore(Current());
} }
/// A second radio gets its own entry window: the two share one log and one /// A second radio gets its own entry window: the two share one log and one
+16 -2
View File
@@ -107,7 +107,6 @@ public class SettingsTests : IDisposable
{ {
Callsign = "OM3KFF", Callsign = "OM3KFF",
Theme = "dark", Theme = "dark",
WavPath = "/home/erik/wav/om3kff",
Windows = [new StoredWindow { Name = "BandmapWindow", X = 10, Y = 20, Width = 300, Height = 400 }], Windows = [new StoredWindow { Name = "BandmapWindow", X = 10, Y = 20, Width = 300, Height = 400 }],
}, },
], ],
@@ -119,11 +118,26 @@ public class SettingsTests : IDisposable
StoredOperator stored = Assert.Single(read.Operators); StoredOperator stored = Assert.Single(read.Operators);
Assert.Equal("OM3KFF", read.CurrentOperator); Assert.Equal("OM3KFF", read.CurrentOperator);
Assert.Equal("dark", stored.Theme); Assert.Equal("dark", stored.Theme);
Assert.Equal("/home/erik/wav/om3kff", stored.WavPath);
StoredWindow window = Assert.Single(stored.Windows); StoredWindow window = Assert.Single(stored.Windows);
Assert.Equal(("BandmapWindow", 10, 20), (window.Name, window.X, window.Y)); Assert.Equal(("BandmapWindow", 10, 20), (window.Name, window.X, window.Y));
} }
/// The operator list is kept in the order operators took the radio, so the
/// dialog's recent buttons read it backwards and stop at the count asked
/// for.
[Fact]
public void RecentOperatorsAreNewestFirst()
{
Settings settings = new()
{
Operators = [.. new[] { "OM1AB", "OM2CD", "OM3EF", "OM4GH" }
.Select(call => new StoredOperator { Callsign = call })],
};
Assert.Equal(["OM4GH", "OM3EF", "OM2CD"],
settings.RecentOperators(3).Select(o => o.Callsign));
}
[Fact] [Fact]
public void AFileFromBeforeOperatorsHasNone() public void AFileFromBeforeOperatorsHasNone()
{ {