Edit the function key messages as their file

The messages were twelve boxes in a dialog, which meant no labels, no
search-and-pounce set, and no way to read a file somebody published. They are
now the file itself: right-click any function key button under the entry
window, or the two buttons in Config ▸ Keyer and messages, and the text of an
N1MM .mc file opens in a plain editor with Import, Export and Back to the
defaults.

MessageFile reads that format by N1MM's rules. A line starting with # is a
comment; every other line is a label, a comma and the message, with && standing
for one & in a label. Which key a line belongs to is decided by where it is and
nothing else: the first twelve lines are F1 to F12 while running, the next
twelve the same keys while searching, and a file that stops part way through the
second twelve leaves the rest sending what they send while running. A blank line
in the middle is a key with nothing in it, which is what the manual says; the
newline that ends the last line is not, which it does not say, but no .mc file
in the world means its final newline as a message.

Two things fall out of this. The buttons now say what the file says, and they
change when the operator moves between running and searching, so the labels are
the documentation N1MM's manual suggests writing them as. And the search set is
real: ESM's F2 while searching can be a different message from the one it sends
while running, which is how the file was always meant to be used.

Settings keep the file text and carry the twelve stored messages into it on
first read, with the labels this program used then, so nobody loses what they
had typed.

The editor is deliberately a text box rather than a grid of fields: importing,
exporting and editing are then the same thing, and the comments an operator
writes in the file survive being edited here. The telnet window's buttons can
use the same editor later.

Running it caught two: Save as the default button ate the Enter key that a
multi-line editor needs, and the button labels did not follow the move between
run and search until they were redrawn rather than only rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 08:13:24 +00:00
parent 0c440ed865
commit cf0166f1b2
14 changed files with 498 additions and 129 deletions

View File

@@ -91,8 +91,20 @@ mode stay where they were last typed.
### CW
**Config → Keyer and messages** picks `cwdaemon` (a UDP port, usually 6789) or a
WinKeyer (a serial port), sets the speed, and edits the twelve function key
messages for CW and for phone. Escape stops sending.
WinKeyer (a serial port) and sets the speed. Escape stops sending.
The messages are edited as their file, in a plain text editor: right-click any
of the function key buttons under the entry window, or use the two buttons in
Config → Keyer and messages. One line per key — the button label, a comma, then
the message — and a line starting with `#` is a comment. Which key a line
belongs to is decided by where it is in the file: the first twelve lines are F1
to F12 while running, and twelve more give the same keys while searching, with
the ones left out keeping their running message. The buttons say what the file
says, and they change as you move between running and searching.
This is N1MM's `.mc` format. **Import…** reads a file somebody published for
N1MM, **Export…** writes one out, and **Back to the defaults** puts the built-in
messages back.
The macros are N1MM's, spelled the way N1MM's function-key documentation spells
them, so a `.mc` file written for either program says the same thing. The text

View File

@@ -361,10 +361,10 @@ public sealed class AppSession : IDisposable
{
throw new InvalidOperationException("there is no keyer");
}
string template = Messages.For(
position.Mode.Category,
Settings.CwMessages,
Settings.PhoneMessages)[0];
string template = Messages
.For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile)
.Keys(position.IsRunning)[Esm.CallCq]
.Message;
if (template.Length == 0)
{
throw new InvalidOperationException("F1 has no message — Config ▸ Keyer and messages");

View File

@@ -140,7 +140,15 @@ public sealed record Settings
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
/// The twelve function key messages, keyed F1 to F12.
/// The function key messages as the text of an N1MM `.mc` file, which is
/// what the editor edits and what import and export read and write. Empty
/// means the built-in messages.
public string CwMessageFile { get; init; } = "";
public string PhoneMessageFile { get; init; } = "";
/// Written by versions that kept twelve messages and no labels. Read once,
/// to fill the file text in, and never written again.
public IReadOnlyList<string> CwMessages { get; init; } = [];
public IReadOnlyList<string> PhoneMessages { get; init; } = [];
@@ -232,11 +240,31 @@ public sealed record Settings
}
}
/// Carries the single radio an older settings file holds into the list.
private static Settings Migrated(Settings settings) =>
settings.Radios.Count > 0 || settings.RigctldHost.Length == 0
? settings
: settings with
/// The twelve messages an older settings file holds, written out as the
/// text of a function key file with the labels this program used then.
private static string FileFrom(IReadOnlyList<string> messages)
{
IReadOnlyList<(string Key, string Label)> keys =
[
("F1", "CQ"), ("F2", "Exch"), ("F3", "TU"), ("F4", "MyCall"),
("F5", "HisCall"), ("F6", "QSO B4"), ("F7", "?"), ("F8", "Agn"),
("F9", "Nr?"), ("F10", "Call?"), ("F11", "Spot"), ("F12", "Wipe"),
];
return string.Join(
'\n',
messages.Select((message, at) => at < keys.Count
? $"{keys[at].Key} {keys[at].Label},{message}"
: $",{message}"));
}
/// Carries what older settings files hold into the shape this one uses: the
/// single radio into the list of radios, and the twelve messages into the
/// text of a function key file.
private static Settings Migrated(Settings settings)
{
if (settings.RigctldHost.Length > 0 && settings.Radios.Count == 0)
{
settings = settings with
{
Radios = [new StoredRadio
{
@@ -248,6 +276,21 @@ public sealed record Settings
RigctldPort = 0,
RadioEnabled = false,
};
}
if (settings.CwMessages.Count > 0 && settings.CwMessageFile.Length == 0)
{
settings = settings with { CwMessageFile = FileFrom(settings.CwMessages), CwMessages = [] };
}
if (settings.PhoneMessages.Count > 0 && settings.PhoneMessageFile.Length == 0)
{
settings = settings with
{
PhoneMessageFile = FileFrom(settings.PhoneMessages),
PhoneMessages = [],
};
}
return settings;
}
}
/// One band's sub-band boundaries as they are stored. Kilohertz, because that

View File

@@ -1,7 +1,7 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.KeyerDialog"
Title="Keyer and messages" Width="560" Height="560"
Title="Keyer and messages" Width="560" SizeToContent="Height"
WindowStartupLocation="CenterOwner">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Top" Spacing="6">
@@ -23,9 +23,11 @@
Text="Ctrl+B starts calling CQ on one radio and then the other. Needs two radios." />
</Grid>
<TextBlock Text="Messages" FontSize="11" Opacity="0.7" Margin="0,10,0,1" />
<StackPanel Orientation="Horizontal" Spacing="6">
<RadioButton Name="CwButton" Content="CW" GroupName="mode" IsChecked="True" />
<RadioButton Name="PhoneButton" Content="Phone" GroupName="mode" />
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="The function keys are edited as their file, the same .mc file N1MM reads and writes. Right-clicking the buttons under the entry window opens the same editor." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,6,0,0">
<Button Content="Edit CW messages…" Click="OnEditCw" />
<Button Content="Edit phone messages…" Click="OnEditPhone" />
</StackPanel>
</StackPanel>
@@ -35,8 +37,6 @@
<Button Content="Save" Click="OnSave" IsDefault="True" />
</StackPanel>
<ScrollViewer Margin="0,6,0,0">
<Grid Name="MessageGrid" ColumnDefinitions="60,*" />
</ScrollViewer>
<Panel />
</DockPanel>
</Window>

View File

@@ -1,32 +1,30 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
using Nonemm.Core;
namespace Nonemm.App.Dialogs;
/// Which keyer to use, and what the function keys send.
/// Which keyer to use. The messages themselves are edited as their file, in
/// `MessagesDialog`, which is what the buttons here open.
public sealed partial class KeyerDialog : Window
{
private readonly Settings settings;
private readonly List<TextBox> messageBoxes = [];
private List<string> cwMessages;
private List<string> phoneMessages;
private string cwFile;
private string phoneFile;
public KeyerDialog(Settings settings)
{
this.settings = settings;
cwMessages = [.. Messages.For(Core.ModeCategory.Cw, settings.CwMessages, settings.PhoneMessages)];
phoneMessages = [.. Messages.For(Core.ModeCategory.Phone, settings.CwMessages, settings.PhoneMessages)];
cwFile = settings.CwMessageFile;
phoneFile = settings.PhoneMessageFile;
InitializeComponent();
KindBox.ItemsSource = new[] { "none", "cwdaemon", "winkeyer" };
KindBox.SelectedItem = settings.KeyerKind;
KindBox.SelectionChanged += (_, _) => ShowTarget();
SpeedBox.Text = settings.KeyerSpeed.ToString();
AlternatingGapBox.Text = settings.AlternatingCqGapMs.ToString();
CwButton.IsCheckedChanged += (_, _) => ShowMessages();
ShowTarget();
BuildMessageBoxes();
ShowMessages();
}
private void ShowTarget()
@@ -38,49 +36,17 @@ public sealed partial class KeyerDialog : Window
: $"{settings.KeyerHost}:{settings.KeyerPort}";
}
private void BuildMessageBoxes()
{
for (int at = 0; at < Messages.Keys.Count; at++)
{
MessageGrid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
TextBlock label = new()
{
Text = Messages.Keys[at].Key,
FontSize = 12,
Margin = new Avalonia.Thickness(0, 6, 6, 0),
};
Grid.SetRow(label, at);
MessageGrid.Children.Add(label);
private async void OnEditCw(object? sender, RoutedEventArgs e) =>
cwFile = await Edit(ModeCategory.Cw, cwFile);
TextBox box = new() { Margin = new Avalonia.Thickness(0, 2, 0, 2) };
Grid.SetRow(box, at);
Grid.SetColumn(box, 1);
MessageGrid.Children.Add(box);
messageBoxes.Add(box);
}
}
private async void OnEditPhone(object? sender, RoutedEventArgs e) =>
phoneFile = await Edit(ModeCategory.Phone, phoneFile);
private void ShowMessages()
{
List<string> shown = CwButton.IsChecked == true ? cwMessages : phoneMessages;
for (int at = 0; at < messageBoxes.Count; at++)
{
messageBoxes[at].Text = shown[at];
}
}
private void KeepMessages()
{
List<string> target = CwButton.IsChecked == true ? cwMessages : phoneMessages;
for (int at = 0; at < messageBoxes.Count; at++)
{
target[at] = messageBoxes[at].Text ?? "";
}
}
private async Task<string> Edit(ModeCategory mode, string text) =>
await new MessagesDialog(mode, text).ShowDialog<string?>(this) ?? text;
private void OnSave(object? sender, RoutedEventArgs e)
{
KeepMessages();
bool winkeyer = (KindBox.SelectedItem as string) == "winkeyer";
string[] target = (TargetBox.Text ?? "").Split(':');
Close(settings with
@@ -96,8 +62,8 @@ public sealed partial class KeyerDialog : Window
AlternatingCqGapMs = int.TryParse(AlternatingGapBox.Text, out int gap)
? Math.Max(100, gap)
: settings.AlternatingCqGapMs,
CwMessages = cwMessages,
PhoneMessages = phoneMessages,
CwMessageFile = cwFile,
PhoneMessageFile = phoneFile,
});
}

View File

@@ -0,0 +1,24 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.MessagesDialog"
Title="Function key messages" Width="620" Height="520"
WindowStartupLocation="CenterOwner">
<DockPanel Margin="14">
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="One line per key: the button label, a comma, then the message. A line starting with # is a comment. The first twelve lines are F1 to F12 while running; twelve more give the keys while searching, and the ones left out keep their running message. This is N1MM's .mc format, so a file from either program works in the other." />
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="Auto,*,Auto" Margin="0,10,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="Import…" Click="OnImport" />
<Button Content="Export…" Click="OnExport" />
<Button Content="Back to the defaults" Click="OnDefaults" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" />
</StackPanel>
</Grid>
<TextBox Name="TextArea" AcceptsReturn="True" AcceptsTab="False" FontFamily="monospace"
FontSize="12" Margin="0,8,0,0" TextWrapping="NoWrap"
VerticalContentAlignment="Top" />
</DockPanel>
</Window>

View File

@@ -0,0 +1,82 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.Core;
namespace Nonemm.App.Dialogs;
/// The function key messages as the text of an N1MM `.mc` file. Editing them as
/// the file rather than as twelve boxes is what makes import and export the
/// same thing as what is on the screen.
///
/// It closes with the text, or with null when nothing is to be changed.
public sealed partial class MessagesDialog : Window
{
private static readonly FilePickerFileType MessageFiles = new("Function key files")
{
Patterns = ["*.mc"],
};
private readonly ModeCategory mode;
public MessagesDialog(ModeCategory mode, string text, string what = "Function key messages")
{
this.mode = mode;
InitializeComponent();
Title = $"{what} — {(mode == ModeCategory.Phone ? "phone" : "CW")}";
TextArea.Text = text.Trim().Length > 0 ? text : Messages.DefaultFor(mode);
}
private async void OnImport(object? sender, RoutedEventArgs e)
{
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(
new FilePickerOpenOptions
{
Title = "Import a function key file",
AllowMultiple = false,
FileTypeFilter = [MessageFiles],
});
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
{
return;
}
try
{
TextArea.Text = await File.ReadAllTextAsync(path);
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
TextArea.Text = $"# {path} could not be read: {error.Message}\n{TextArea.Text}";
}
}
private async void OnExport(object? sender, RoutedEventArgs e)
{
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export the function key file",
SuggestedFileName = mode == ModeCategory.Phone ? "phone.mc" : "cw.mc",
DefaultExtension = "mc",
FileTypeChoices = [MessageFiles],
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
try
{
await File.WriteAllTextAsync(path, TextArea.Text ?? "");
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
TextArea.Text = $"# {path} could not be written: {error.Message}\n{TextArea.Text}";
}
}
private void OnDefaults(object? sender, RoutedEventArgs e) =>
TextArea.Text = Messages.DefaultFor(mode);
private void OnSave(object? sender, RoutedEventArgs e) => Close(TextArea.Text ?? "");
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}

View File

@@ -1,58 +1,59 @@
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App;
/// The function key messages. The text uses N1MM's macro names so a message
/// written for either program says the same thing.
/// The function key messages, held as the text of an N1MM `.mc` file so the
/// same file works in either program. The macro names are N1MM's too.
public static class Messages
{
public static readonly IReadOnlyList<(string Key, string Label)> Keys =
[
("F1", "CQ"), ("F2", "Exch"), ("F3", "TU"), ("F4", "MyCall"),
("F5", "HisCall"), ("F6", "QSO B4"), ("F7", "?"), ("F8", "Agn"),
("F9", "Nr?"), ("F10", "Call?"), ("F11", "Spot"), ("F12", "Wipe"),
];
public static readonly IReadOnlyList<string> DefaultCw =
[
"CQ TEST {MYCALL} {MYCALL}",
"{SENTRST} {EXCH}",
"TU {MYCALL}",
"{MYCALL}",
"!",
"QSO B4",
"?",
"AGN",
"NR?",
"CALL?",
"",
"",
];
/// What an operator gets before touching anything. Twelve lines, so the
/// same messages are sent running and searching until the operator writes
/// the second twelve.
public const string DefaultCw =
"""
# CW function keys. The first twelve lines are F1 to F12 while running;
# add twelve more for the keys while searching.
F1 CQ,CQ TEST {MYCALL} {MYCALL}
F2 Exch,{SENTRST} {EXCH}
F3 TU,TU {MYCALL}
F4 MyCall,{MYCALL}
F5 HisCall,!
F6 QSO B4,QSO B4
F7 ?,?
F8 Agn,AGN
F9 Nr?,NR?
F10 Call?,CALL?
F11 Spot,
F12 Wipe,
""";
/// Phone messages name a recording to play; the text is what would be said.
public static readonly IReadOnlyList<string> DefaultPhone =
[
"CQ CONTEST {MYCALL}",
"{EXCH}",
"THANK YOU {MYCALL}",
"{MYCALL}",
"!",
"QSO BEFORE",
"PLEASE REPEAT",
"AGAIN",
"NUMBER PLEASE",
"YOUR CALL PLEASE",
"",
"",
];
public const string DefaultPhone =
"""
# Phone function keys.
F1 CQ,CQ CONTEST {MYCALL}
F2 Exch,{EXCH}
F3 TU,THANK YOU {MYCALL}
F4 MyCall,{MYCALL}
F5 HisCall,!
F6 QSO B4,QSO BEFORE
F7 ?,PLEASE REPEAT
F8 Agn,AGAIN
F9 Nr?,NUMBER PLEASE
F10 Call?,YOUR CALL PLEASE
F11 Spot,
F12 Wipe,
""";
public static IReadOnlyList<string> For(
ModeCategory mode,
IReadOnlyList<string> cw,
IReadOnlyList<string> phone)
public static string DefaultFor(ModeCategory mode) =>
mode == ModeCategory.Phone ? DefaultPhone : DefaultCw;
/// The stored file for that mode, or the built-in one while the operator
/// has not written their own.
public static MessageFile For(ModeCategory mode, string cw, string phone)
{
IReadOnlyList<string> stored = mode == ModeCategory.Phone ? phone : cw;
IReadOnlyList<string> fallback = mode == ModeCategory.Phone ? DefaultPhone : DefaultCw;
return stored.Count == Keys.Count ? stored : fallback;
string stored = mode == ModeCategory.Phone ? phone : cw;
return MessageFile.Parse(stored.Trim().Length > 0 ? stored : DefaultFor(mode));
}
}

View File

@@ -120,13 +120,18 @@ public sealed partial class EntryWindow
_ = SendKeysAsync(lastKeys);
}
private void ShowEsmKeys()
/// The buttons say what the file says, which is a different twelve labels
/// while running and while searching, and the ones ESM would send next are
/// highlighted.
private void ShowFunctionKeys()
{
EsmItem.IsChecked = IsEsmOn;
IReadOnlyList<int> keys = IsEsmOn ? NextEsmAction().Keys : [];
IReadOnlyList<int> next = IsEsmOn ? NextEsmAction().Keys : [];
IReadOnlyList<FunctionKey> keys = Keys();
for (int at = 0; at < functionButtons.Count; at++)
{
functionButtons[at].Classes.Set("esm", keys.Contains(at));
functionButtons[at].Content = keys[at].Label.Length > 0 ? keys[at].Label : $"F{at + 1}";
functionButtons[at].Classes.Set("esm", next.Contains(at));
}
}
}

View File

@@ -1,4 +1,5 @@
using Avalonia.Threading;
using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Keying;
using Nonemm.Session;
@@ -15,6 +16,35 @@ public sealed partial class EntryWindow
/// actions after `{END}` still run.
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
/// The twelve keys for this radio, which are a different twelve while
/// running and while searching, as N1MM's file holds them.
private IReadOnlyList<FunctionKey> Keys() =>
Messages
.For(
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
session.Settings.CwMessageFile,
session.Settings.PhoneMessageFile)
.Keys(Logging?.IsRunning ?? false);
/// Right-clicking a function key button opens the messages for the mode the
/// radio is in, which is where import and export live too.
private async Task EditMessages()
{
ModeCategory mode = Logging?.Mode.Category ?? ModeCategory.Cw;
string stored = mode == ModeCategory.Phone
? session.Settings.PhoneMessageFile
: session.Settings.CwMessageFile;
if (await new MessagesDialog(mode, stored).ShowDialog<string?>(this) is not { } edited)
{
return;
}
session.Save(mode == ModeCategory.Phone
? session.Settings with { PhoneMessageFile = edited }
: session.Settings with { CwMessageFile = edited });
BuildFunctionKeys();
Refresh();
}
private void SendMessage(int index) => _ = SendKeyAsync(index);
/// Sends one function key's message. The task finishes when the text has
@@ -27,10 +57,7 @@ public sealed partial class EntryWindow
{
return;
}
string template = Messages.For(
Logging.Mode.Category,
session.Settings.CwMessages,
session.Settings.PhoneMessages)[index];
string template = Keys()[index].Message;
if (template.Length == 0)
{
return;
@@ -238,10 +265,10 @@ public sealed partial class EntryWindow
{
return;
}
string template = Messages.For(
other.Mode.Category,
session.Settings.CwMessages,
session.Settings.PhoneMessages)[number - 1];
string template = Messages
.For(other.Mode.Category, session.Settings.CwMessageFile, session.Settings.PhoneMessageFile)
.Keys(other.IsRunning)[number - 1]
.Message;
if (template.Length == 0)
{
return;

View File

@@ -395,7 +395,7 @@ public sealed partial class EntryWindow : Window
private void Refresh()
{
ShowEsmKeys();
ShowFunctionKeys();
if (Logging is null)
{
VerdictText.Text = "";
@@ -450,17 +450,26 @@ public sealed partial class EntryWindow : Window
private void Status(string text) => StatusText.Text = text;
/// The labels come from the message file, so what the buttons say is what
/// the operator wrote. Right-clicking one opens the editor, as it does on
/// the telnet window's buttons.
private void BuildFunctionKeys()
{
FunctionKeys.Children.Clear();
functionButtons.Clear();
for (int at = 0; at < Messages.Keys.Count; at++)
for (int at = 0; at < MessageFile.KeyCount; at++)
{
int index = at;
(string key, string label) = Messages.Keys[at];
Button button = new() { Content = $"{key} {label}" };
Button button = new();
button.Classes.Add("fkey");
button.Click += (_, _) => RunFunctionKey(index);
button.PointerReleased += (_, e) =>
{
if (e.InitialPressMouseButton == MouseButton.Right)
{
_ = EditMessages();
}
};
FunctionKeys.Children.Add(button);
functionButtons.Add(button);
}

View File

@@ -0,0 +1,77 @@
namespace Nonemm.Session;
/// One function key: what the button says and what the key sends.
public sealed record FunctionKey(string Label, string Message)
{
public static readonly FunctionKey Empty = new("", "");
}
/// An N1MM function key file, the `.mc` files operators pass around.
///
/// The rules are N1MM's. A line starting with `#` is a comment. Every other
/// line is a label, a comma, and the message; `&&` in a label stands for one
/// `&`. Which key a line belongs to is decided by where it is in the file and
/// nothing else: the first twelve lines are F1 to F12 while running, the next
/// twelve are the same keys while searching. A file with twelve lines or fewer
/// sends the same messages either way, and one that stops part way through the
/// second twelve falls back to the running message for the keys it does not
/// reach.
public sealed class MessageFile
{
public const int KeyCount = 12;
private readonly IReadOnlyList<FunctionKey> running;
private readonly IReadOnlyList<FunctionKey> searching;
private MessageFile(IReadOnlyList<FunctionKey> running, IReadOnlyList<FunctionKey> searching)
{
this.running = running;
this.searching = searching;
}
public static MessageFile Parse(string text)
{
List<FunctionKey> lines = [];
// the newline that ends the last line is not a blank line; one written
// in the middle of the file is, and N1MM counts it as a key
string body = text.EndsWith('\n') ? text[..^1] : text;
foreach (string line in body.Split('\n'))
{
string cleaned = line.TrimEnd('\r');
if (cleaned.StartsWith('#'))
{
continue;
}
lines.Add(Read(cleaned));
}
List<FunctionKey> running = [];
List<FunctionKey> searching = [];
for (int at = 0; at < KeyCount; at++)
{
running.Add(At(lines, at) ?? FunctionKey.Empty);
}
for (int at = 0; at < KeyCount; at++)
{
searching.Add(At(lines, KeyCount + at) ?? running[at]);
}
return new MessageFile(running, searching);
}
private static FunctionKey? At(IReadOnlyList<FunctionKey> lines, int at) =>
at < lines.Count ? lines[at] : null;
private static FunctionKey Read(string line)
{
int comma = line.IndexOf(',');
if (comma < 0)
{
// a line with no comma is all message, which is what an operator
// who leaves the label out meant
return new FunctionKey("", line.Trim());
}
return new FunctionKey(line[..comma].Replace("&&", "&").Trim(), line[(comma + 1)..].Trim());
}
/// The twelve keys for the mode the operator is in.
public IReadOnlyList<FunctionKey> Keys(bool isRunning) => isRunning ? running : searching;
}

View File

@@ -64,4 +64,32 @@ public class SettingsTests : IDisposable
Assert.Equal("dxc.example.net", settings.ClusterHost);
Assert.Equal(7300, settings.ClusterPort);
}
[Fact]
public void TheTwelveMessagesAnOlderFileHoldsBecomeAFunctionKeyFile()
{
Settings settings = Load(
"""
{
"CwMessages": [ "CQ TEST {MYCALL}", "5NN {EXCH}" ]
}
""");
Assert.Empty(settings.CwMessages);
Assert.Equal("F1 CQ,CQ TEST {MYCALL}\nF2 Exch,5NN {EXCH}", settings.CwMessageFile);
}
[Fact]
public void AFileTextThatIsAlreadyThereIsLeftAlone()
{
Settings settings = Load(
"""
{
"CwMessageFile": "F1 CQ,CQ",
"CwMessages": [ "old" ]
}
""");
Assert.Equal("F1 CQ,CQ", settings.CwMessageFile);
}
}

View File

@@ -0,0 +1,95 @@
namespace Nonemm.Session.Tests;
public class MessageFileTests
{
private static string Lines(int count, string what) =>
string.Join('\n', Enumerable.Range(1, count).Select(at => $"F{at} {what},{what} {at}"));
[Fact]
public void ALabelAndAMessageAreSplitOnTheFirstComma()
{
MessageFile file = MessageFile.Parse("F1 CQ, CQ TEST {MYCALL}, AGAIN");
FunctionKey key = file.Keys(isRunning: true)[0];
Assert.Equal("F1 CQ", key.Label);
Assert.Equal("CQ TEST {MYCALL}, AGAIN", key.Message);
}
[Fact]
public void CommentLinesAreNotKeys()
{
MessageFile file = MessageFile.Parse("# CW messages\nF1 CQ,CQ TEST\n#and another\nF2 Exch,5NN");
Assert.Equal("CQ TEST", file.Keys(isRunning: true)[0].Message);
Assert.Equal("5NN", file.Keys(isRunning: true)[1].Message);
}
[Fact]
public void TwoAmpersandsInALabelStandForOne()
{
Assert.Equal("S&P", MessageFile.Parse("S&&P,TEST").Keys(isRunning: true)[0].Label);
}
[Fact]
public void ALineWithNoCommaIsAllMessage()
{
FunctionKey key = MessageFile.Parse("CQ TEST").Keys(isRunning: true)[0];
Assert.Equal("", key.Label);
Assert.Equal("CQ TEST", key.Message);
}
[Fact]
public void AFileOfTwelveLinesSendsTheSameMessagesEitherWay()
{
MessageFile file = MessageFile.Parse(Lines(12, "RUN"));
Assert.Equal("RUN 1", file.Keys(isRunning: true)[0].Message);
Assert.Equal("RUN 1", file.Keys(isRunning: false)[0].Message);
}
[Fact]
public void TheSecondTwelveLinesAreTheSearchingKeys()
{
MessageFile file = MessageFile.Parse(Lines(12, "RUN") + "\n" + Lines(12, "SNP"));
Assert.Equal("RUN 2", file.Keys(isRunning: true)[1].Message);
Assert.Equal("SNP 2", file.Keys(isRunning: false)[1].Message);
}
[Fact]
public void AFileThatStopsPartWayThroughFallsBackToTheRunningMessage()
{
MessageFile file = MessageFile.Parse(Lines(12, "RUN") + "\n" + Lines(3, "SNP"));
Assert.Equal("SNP 3", file.Keys(isRunning: false)[2].Message);
Assert.Equal("RUN 4", file.Keys(isRunning: false)[3].Message);
}
[Fact]
public void AShortFileLeavesTheRestOfTheKeysEmpty()
{
MessageFile file = MessageFile.Parse("F1 CQ,CQ TEST");
Assert.Equal("", file.Keys(isRunning: true)[1].Message);
Assert.Equal(12, file.Keys(isRunning: true).Count);
}
[Fact]
public void TheNewlineThatEndsTheFileIsNotABlankKey()
{
MessageFile file = MessageFile.Parse(Lines(12, "RUN") + "\nF1 SNP,SNP CQ\n");
Assert.Equal("SNP CQ", file.Keys(isRunning: false)[0].Message);
Assert.Equal("RUN 2", file.Keys(isRunning: false)[1].Message);
}
[Fact]
public void ABlankLineInTheMiddleIsAKeyWithNothingInIt()
{
MessageFile file = MessageFile.Parse("F1 CQ,CQ TEST\n\nF3 TU,TU");
Assert.Equal("", file.Keys(isRunning: true)[1].Message);
Assert.Equal("TU", file.Keys(isRunning: true)[2].Message);
}
}