Keep an operator's windows, colours and recordings

Ctrl+O asks who is at the radio, as it does in N1MM, and the callsign goes on
every contact logged from then on. A multi-operator station changes hands every
few hours, so each operator also keeps what he wants the program to look like:
where the windows sit, the theme, and the folder his recordings are in.

The windows on the screen are stored against the operator leaving, before the
dialog opens, and the operator taking over gets his own back. A window he opens
later is placed as it appears. An operator who is new to the list starts with
whatever is on the screen.

Nothing plays the recordings yet, so the folder is stored and waits on voice
keying. {OPERATOR} in a message sends the callsign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-31 22:28:18 +00:00
parent 53c15d4061
commit 654adda080
15 changed files with 404 additions and 2 deletions

View File

@@ -400,6 +400,29 @@ to a tenth of a degree: `src/Nonemm.App/Assets/coastline.txt`. The sun's
position is the standard low-precision solar formula, good to about a hundredth
of a degree.
### Operators
**Ctrl+O**, or **Config → Change Operator…**, asks who is at the radio. The
callsign goes on every contact logged from then on, in the log's Op column and
in the ADIF `OPERATOR` field, and `{OPERATOR}` in a message sends it.
Each operator keeps three things of his own, which is what makes the key worth
pressing at a multi-operator station:
| Kept | What happens on the way in and out |
|---|---|
| Window positions | where every open window sits is stored against the operator leaving, and the operator taking over gets his own back. A window opened later is placed as it appears |
| Colours | the theme he last had |
| Recordings folder | the folder his wav files are in, since a voice keyer plays the recordings of whoever is at the microphone |
An operator whose callsign is not in the list is made by typing it, and starts
with the windows and colours on the screen. **Remove** drops one from the list;
the contacts he logged keep his callsign, because those are in the log rather
than in the settings.
Nothing plays the recordings yet — there is no voice keyer — so the folder is
stored and passed on rather than used.
### The log window
The pane under the log lists what has already been worked with the call being

View File

@@ -35,7 +35,8 @@ The other interfaces N1MM offers — fldigi, MMVARI, the hardware TNCs — are n
here either, though `DigitalEngine` is the place to add them.
**Voice keying.** `MessageSender` was written to cover a voice keyer playing a
recording, and nothing implements it. No DVK support either, so the second radio
recording, and nothing implements it. Each operator's recordings folder is
stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio
of an SO2R station cannot call CQ by voice. Alternating CQ therefore works on CW
only, though nothing in it is CW-specific: a voice keyer that reports when the
recording has finished would drive it as it stands.

View File

@@ -196,7 +196,12 @@ public sealed class AppSession : IDisposable
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
SaveDefinition(contest);
Logging = new ContestSession(
Store, contest, instance, Settings.Station.ToStationInfo(), Countries, History);
Store, contest, instance, Settings.Station.ToStationInfo(), Countries, History)
{
// whoever was at the radio when the last contest was open is still
// there, and every contact he logs carries his callsign
Operator = Settings.CurrentOperator,
};
positions.Clear();
for (int number = 1; number <= PositionCount; number++)
{

View File

@@ -186,6 +186,14 @@ public sealed record Settings
public IReadOnlyList<string> PhoneMessages { get; init; } = [];
/// The operators at this station, and their windows and colours. N1MM
/// keeps an operator's callsign against every contact he logs; this keeps
/// what he wants the program to look like as well.
public IReadOnlyList<StoredOperator> Operators { get; init; } = [];
/// Whoever is at the radio now, or empty at a station with one operator.
public string CurrentOperator { get; init; } = "";
/// The digital window's twenty-four macro buttons, in the same
/// `label,message` lines as a function key file. Empty means the built-in
/// set.

View File

@@ -0,0 +1,21 @@
namespace Nonemm.App.Configuration;
/// One operator's own settings. A multi-operator station changes hands every
/// few hours, and each operator wants the windows where he left them and the
/// colours he can read; N1MM asks for the operator's callsign the same way, and
/// records it against every contact he logs.
public sealed record StoredOperator
{
public string Callsign { get; init; } = "";
/// Empty leaves the colours as they are, which is what a new operator gets
/// until he changes them.
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; } = [];
}

View File

@@ -0,0 +1,17 @@
namespace Nonemm.App.Configuration;
/// Where one window sits, in screen pixels. `Name` is the window's class name,
/// which is what tells the entry window from the bandmap when a layout is put
/// back.
public sealed record StoredWindow
{
public string Name { get; init; } = "";
public int X { get; init; }
public int Y { get; init; }
public double Width { get; init; }
public double Height { get; init; }
}

View File

@@ -0,0 +1,40 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.OperatorDialog"
Title="Operator" Width="420" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<Window.Styles>
<Style Selector="TextBlock.label">
<Setter Property="FontSize" Value="11" />
<Setter Property="Opacity" Value="0.7" />
<Setter Property="Margin" Value="0,8,0,1" />
</Style>
</Window.Styles>
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Center"
Spacing="20" Margin="0,14,0,0">
<Button Content="Ok" Width="85" HorizontalContentAlignment="Center"
Click="OnOk" IsDefault="True" />
<Button Content="Remove" Width="85" HorizontalContentAlignment="Center" Click="OnRemove" />
<Button Content="Cancel" Width="85" HorizontalContentAlignment="Center"
Click="OnCancel" IsCancel="True" />
</StackPanel>
<StackPanel Spacing="2">
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
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." />
<TextBlock Classes="label" Text="Operator callsign" />
<Grid ColumnDefinitions="*,6,150">
<TextBox Name="CallBox" />
<ComboBox Name="KnownBox" Grid.Column="2" HorizontalAlignment="Stretch"
SelectionChanged="OnPickedKnown" PlaceholderText="known" />
</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"
TextWrapping="Wrap" />
</StackPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,102 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
namespace Nonemm.App.Dialogs;
/// What the operator dialog closed with: the operator to change to, or the one
/// to drop from the list.
public sealed record OperatorChoice(StoredOperator Operator, bool IsRemoval);
/// N1MM's Ctrl+O: who is at the radio now. It closes with the operator to
/// change to, or with null when nothing is to change. An operator whose
/// callsign is not in the list yet is made by typing it.
public sealed partial class OperatorDialog : Window
{
private readonly IReadOnlyList<StoredOperator> known;
public OperatorDialog(Settings settings)
{
known = settings.Operators;
InitializeComponent();
KnownBox.ItemsSource = known.Select(o => o.Callsign).ToList();
CallBox.Text = settings.CurrentOperator;
WavBox.Text = Find(settings.CurrentOperator)?.WavPath ?? "";
ShowWhatIsKept();
// the callsign is all most operators type here, so the cursor starts in
// it and Enter closes the dialog
Opened += (_, _) =>
{
CallBox.Focus();
// the operator taking over types his own callsign over the one
// leaving rather than clearing the box first
CallBox.SelectAll();
};
}
/// The operator as the dialog leaves him: the callsign typed, keeping
/// whatever was stored for him before.
private StoredOperator Picked
{
get
{
string callsign = (CallBox.Text ?? "").Trim().ToUpperInvariant();
StoredOperator stored = Find(callsign) ?? new StoredOperator();
return stored with { Callsign = callsign, WavPath = (WavBox.Text ?? "").Trim() };
}
}
private StoredOperator? Find(string callsign) =>
known.FirstOrDefault(o => string.Equals(o.Callsign, callsign.Trim(), StringComparison.OrdinalIgnoreCase));
private void OnPickedKnown(object? sender, SelectionChangedEventArgs e)
{
if (KnownBox.SelectedItem is not string callsign)
{
return;
}
CallBox.Text = callsign;
WavBox.Text = Find(callsign)?.WavPath ?? "";
ShowWhatIsKept();
}
private void ShowWhatIsKept()
{
StoredOperator? stored = Find(CallBox.Text ?? "");
AboutText.Text = stored is null
? "A new operator: the windows and colours on the screen now become his."
: $"{stored.Windows.Count} window positions kept"
+ (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)
{
if (Picked.Callsign.Length > 0)
{
Close(new OperatorChoice(Picked, IsRemoval: false));
}
}
/// Drops the operator from the list. The contacts he logged keep his
/// callsign: they are in the log, not in the settings.
private void OnRemove(object? sender, RoutedEventArgs e)
{
if (Find(CallBox.Text ?? "") is { } stored)
{
Close(new OperatorChoice(stored, IsRemoval: true));
}
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}

View File

@@ -674,6 +674,7 @@ public sealed partial class EntryWindow
return (T)existing;
}
T window = create();
Place(window);
openWindows[typeof(T)] = window;
window.Closed += (_, _) => openWindows.Remove(typeof(T));
window.Show(this);

View File

@@ -0,0 +1,117 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
using Nonemm.App.Theming;
namespace Nonemm.App.Windows;
/// Who is at the radio. A multi-operator station changes hands every few hours,
/// and each operator gets his own windows, colours and recordings back. N1MM
/// asks the same question with the same key, and writes the callsign against
/// every contact logged from then on.
public sealed partial class EntryWindow
{
private async void OnChangeOperator(object? sender, RoutedEventArgs e)
{
// what is on the screen now belongs to whoever is leaving
Settings settings = WithLayout(session.Settings);
if (await new OperatorDialog(settings).ShowDialog<OperatorChoice?>(this) is not { } choice)
{
session.Save(settings);
return;
}
if (choice.IsRemoval)
{
session.Save(settings with
{
Operators = [.. settings.Operators.Where(o => !Is(o, choice.Operator.Callsign))],
CurrentOperator = Is(choice.Operator, settings.CurrentOperator) ? "" : settings.CurrentOperator,
});
Status($"{choice.Operator.Callsign} dropped");
return;
}
ChangeTo(settings, choice.Operator);
}
private void ChangeTo(Settings settings, StoredOperator wanted)
{
StoredOperator stored = settings.Operators.FirstOrDefault(o => Is(o, wanted.Callsign)) is { } known
? known with { WavPath = wanted.WavPath }
// a new operator starts with the windows and colours on the screen
: wanted with { Theme = settings.Theme, Windows = Layout() };
session.Save(settings with
{
Operators = [.. settings.Operators.Where(o => !Is(o, stored.Callsign)), stored],
CurrentOperator = stored.Callsign,
Theme = stored.Theme.Length > 0 ? stored.Theme : settings.Theme,
});
if (stored.Theme.Length > 0)
{
Themes.Use(stored.Theme);
}
if (session.Logging is { } logging)
{
logging.Operator = stored.Callsign;
}
foreach (Window window in Placed())
{
Place(window, stored);
}
Status($"operator {stored.Callsign}");
}
/// 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.
private void Place(Window window, StoredOperator? stored = null)
{
StoredOperator? whose = stored ?? Current();
if (whose?.Windows.FirstOrDefault(w => w.Name == window.GetType().Name) is not { } where)
{
return;
}
window.Position = new PixelPoint(where.X, where.Y);
// a window that sizes itself to what is in it is left the size it wants
if (window.CanResize && window.SizeToContent == SizeToContent.Manual
&& where.Width > 0 && where.Height > 0)
{
window.Width = where.Width;
window.Height = where.Height;
}
}
/// The settings with the windows on the screen stored against the operator
/// at the radio. With nobody named there is nowhere to keep them.
private Settings WithLayout(Settings settings)
{
if (Current() is not { } stored)
{
return settings;
}
StoredOperator updated = stored with { Theme = settings.Theme, Windows = Layout() };
return settings with
{
Operators = [.. settings.Operators.Select(o => Is(o, updated.Callsign) ? updated : o)],
};
}
private StoredOperator? Current() =>
session.Settings.Operators.FirstOrDefault(o => Is(o, session.Settings.CurrentOperator));
private IReadOnlyList<StoredWindow> Layout() =>
[.. Placed().Select(w => new StoredWindow
{
Name = w.GetType().Name,
X = w.Position.X,
Y = w.Position.Y,
Width = w.Bounds.Width,
Height = w.Bounds.Height,
})];
/// This window and every window it has opened.
private IEnumerable<Window> Placed() => new Window[] { this }.Concat(openWindows.Values);
private static bool Is(StoredOperator stored, string callsign) =>
string.Equals(stored.Callsign, callsign.Trim(), StringComparison.OrdinalIgnoreCase);
}

View File

@@ -97,6 +97,7 @@
<MenuItem Header="Configure Radios…" Click="OnRadioSettings" />
<MenuItem Header="Configure Cluster…" Click="OnClusterSettings" />
<MenuItem Header="Change Your Station Data…" Click="OnStationSettings" />
<MenuItem Header="Change Operator…" Click="OnChangeOperator" InputGesture="Ctrl+O" />
<Separator />
<MenuItem Name="EsmItem" Header="Enter Sends Message (current mode)" ToggleType="CheckBox"
Click="OnToggleEsm" InputGesture="Ctrl+M" />

View File

@@ -56,6 +56,9 @@ public sealed partial class EntryWindow : Window
});
Themes.Changed += OnThemeChanged;
Closed += (_, _) => Themes.Changed -= OnThemeChanged;
// the windows are where this operator left them, and that is what he
// gets back next time he takes the radio
Closing += (_, _) => session.Save(WithLayout(session.Settings));
clock.Tick += (_, _) => UpdateClock();
clock.Start();
session.ActiveRadioChanged += (_, number) =>
@@ -423,6 +426,10 @@ public sealed partial class EntryWindow : Window
e.Handled = true;
OnEditLastContact(this, new RoutedEventArgs());
break;
case Key.O when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
OnChangeOperator(this, new RoutedEventArgs());
break;
case Key.P when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
e.Handled = true;
OnSpotIt(this, new RoutedEventArgs());

View File

@@ -77,6 +77,11 @@ public static class MessageExpander
name.ToUpperInvariant() switch
{
"MYCALL" => session.Me.Callsign,
// whoever is at the radio, or the station callsign at a station
// where nobody has said
"OPERATOR" => session.Session.Operator.Length > 0
? session.Session.Operator
: session.Me.Callsign,
"CALL" => TheirCall(session),
"LASTCALL" => LastLogged(session)?.Call.Text ?? "",
"PREVNR" => LastLogged(session) is { } last

View File

@@ -92,4 +92,44 @@ public class SettingsTests : IDisposable
Assert.Equal("F1 CQ,CQ", settings.CwMessageFile);
}
/// An operator's windows and colours survive a trip through the file, and a
/// file written before operators existed reads as a station with none.
[Fact]
public void OperatorsAreStoredAndReadBack()
{
Settings settings = new()
{
CurrentOperator = "OM3KFF",
Operators =
[
new StoredOperator
{
Callsign = "OM3KFF",
Theme = "dark",
WavPath = "/home/erik/wav/om3kff",
Windows = [new StoredWindow { Name = "BandmapWindow", X = 10, Y = 20, Width = 300, Height = 400 }],
},
],
};
settings.Save(path);
Settings read = Settings.Load(path);
StoredOperator stored = Assert.Single(read.Operators);
Assert.Equal("OM3KFF", read.CurrentOperator);
Assert.Equal("dark", stored.Theme);
Assert.Equal("/home/erik/wav/om3kff", stored.WavPath);
StoredWindow window = Assert.Single(stored.Windows);
Assert.Equal(("BandmapWindow", 10, 20), (window.Name, window.X, window.Y));
}
[Fact]
public void AFileFromBeforeOperatorsHasNone()
{
Settings read = Load("""{ "Theme": "light" }""");
Assert.Empty(read.Operators);
Assert.Equal("", read.CurrentOperator);
}
}

View File

@@ -117,4 +117,18 @@ public class MessageExpanderTests
Assert.Equal("JN88 PM95", MessageExpander.Expand("{GRID} {GRIDSQUARE}", session));
}
/// N1MM's {OPERATOR}: who is at the radio, which is not always whose
/// callsign the station is on the air with.
[Fact]
public void TheOperatorIsWhoeverIsAtTheRadio()
{
OperatingPosition session = Session();
Assert.Equal("DL1ABC", MessageExpander.Expand("{OPERATOR}", session));
session.Session.Operator = "OM3KFF";
Assert.Equal("OM3KFF", MessageExpander.Expand("{OPERATOR}", session));
}
}