Add the name, comment and lights from N1MM's entry window

Three more things off the screenshot of N1MM's entry window.

A Name box and a Comment box beside the exchange. Neither is part of the
exchange, so an empty one does not hold up a contact and ESM does not wait for
it; both fill the log columns of those names, and a contest that exchanges a
name keeps what was exchanged. Tab now walks the callsign, the exchange, the
name and the comment before coming round again, which is what the boxes being
there implies. The name is filled from the call history file, and what that file
says in its UserText column is written under the entry boxes, where N1MM writes
it.

Two lights beside the run switch: green while a radio is answering, red while
something is going out on the air. The transmit light follows the keyer, so with
cwdaemon it goes out when the message has actually finished rather than when the
text was handed over.

The window is wider to fit the two new boxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 09:13:58 +00:00
parent f8e07471a6
commit 7c2a2ed768
6 changed files with 215 additions and 13 deletions

View File

@@ -173,6 +173,18 @@ in again while you are running, and `EsmSendsCorrectedCall` sends the call again
in front of the last message when you have corrected it — copy `SM3AB`, fix it in front of the last message when you have corrected it — copy `SM3AB`, fix it
to `SM3ABC`, and the key sends `SM3ABC TU DL1ABC`. to `SM3ABC`, and the key sends `SM3ABC TU DL1ABC`.
### Name, comment, and the two lights
Beside the exchange are a Name box and a Comment box, as N1MM has them. Neither
is part of the exchange, so neither holds up a contact: they fill the log's Name
and Comment columns, and a contest that exchanges a name uses that instead. Tab
walks the callsign, the exchange, the name and the comment, and round again. The
name is filled from the call history file when there is one, and what the call
history says in its UserText column appears under the entry boxes.
Two lights sit beside the run switch: the left one is green while a radio is
answering, the right one red while something is going out on the air.
### The buttons and the title bar ### The buttons and the title bar
Under the function keys is the row N1MM has: Esc: Stop, Wipe, Log It, Edit, Under the function keys is the row N1MM has: Esc: Stop, Wipe, Log It, Edit,

View File

@@ -91,24 +91,27 @@ public sealed partial class EntryWindow
{ {
try try
{ {
sending = true;
Refresh();
await keyer.SendAsync(text); await keyer.SendAsync(text);
} }
catch (InvalidOperationException e) catch (InvalidOperationException e)
{ {
sending = false;
Status(e.Message); Status(e.Message);
return; return;
} }
if (after.Count > 0) _ = RunWhenSentAsync(keyer, after);
{
_ = RunWhenSentAsync(keyer, after);
}
} }
/// Waits for the keyer, turns the transmit light off, and runs whatever
/// stood after `{END}`.
private async Task RunWhenSentAsync(MessageSender keyer, IReadOnlyList<MessageAction> after) private async Task RunWhenSentAsync(MessageSender keyer, IReadOnlyList<MessageAction> after)
{ {
await WhenSentAsync(keyer); await WhenSentAsync(keyer);
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
sending = false;
foreach (MessageAction action in after) foreach (MessageAction action in after)
{ {
Run(action); Run(action);
@@ -188,6 +191,7 @@ public sealed partial class EntryWindow
Step(-session.Settings.PageStepHertz); Step(-session.Settings.PageStepHertz);
break; break;
case MessageCommand.StopSending: case MessageCommand.StopSending:
sending = false;
_ = session.Keyer?.AbortAsync(); _ = session.Keyer?.AbortAsync();
break; break;
case MessageCommand.Telnet: case MessageCommand.Telnet:

View File

@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Windows.EntryWindow" x:Class="Nonemm.App.Windows.EntryWindow"
Title="Nonemm" Title="Nonemm"
Width="620" SizeToContent="Height" Width="820" SizeToContent="Height"
CanResize="True" CanResize="True"
FontFamily="Inter, sans-serif"> FontFamily="Inter, sans-serif">
<Window.Styles> <Window.Styles>
@@ -113,10 +113,17 @@
<TextBlock Name="FrequencyText" FontFamily="monospace" FontSize="20" Text="14025.00" /> <TextBlock Name="FrequencyText" FontFamily="monospace" FontSize="20" Text="14025.00" />
<TextBlock Grid.Column="1" Name="ModeText" FontFamily="monospace" FontSize="20" <TextBlock Grid.Column="1" Name="ModeText" FontFamily="monospace" FontSize="20"
Margin="10,0,0,0" Text="CW" /> Margin="10,0,0,0" Text="CW" />
<Border Grid.Column="2" Name="RunBorder" Margin="10,2" Padding="6,1" CornerRadius="3" <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="10,2,0,2"
Background="#22808080"> VerticalAlignment="Center">
<TextBlock Name="RunText" FontSize="12" Text="S&amp;P" /> <Ellipse Name="RadioLight" Width="9" Height="9" Fill="#33808080"
</Border> VerticalAlignment="Center" />
<Ellipse Name="TransmitLight" Width="9" Height="9" Fill="#33808080"
VerticalAlignment="Center" />
<Border Name="RunBorder" Padding="6,1" CornerRadius="3" Background="#22808080"
Margin="4,0,0,0">
<TextBlock Name="RunText" FontSize="12" Text="S&amp;P" />
</Border>
</StackPanel>
<TextBlock Grid.Column="3" Name="ContestText" FontSize="12" Opacity="0.75" <TextBlock Grid.Column="3" Name="ContestText" FontSize="12" Opacity="0.75"
VerticalAlignment="Center" Margin="12,0,12,0" Text="no contest" VerticalAlignment="Center" Margin="12,0,12,0" Text="no contest"
TextTrimming="CharacterEllipsis" ClipToBounds="True" /> TextTrimming="CharacterEllipsis" ClipToBounds="True" />
@@ -134,6 +141,9 @@
<TextBlock Name="PathText" FontSize="11" Opacity="0.75" Margin="2,4,0,0" Text="" <TextBlock Name="PathText" FontSize="11" Opacity="0.75" Margin="2,4,0,0" Text=""
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
<TextBlock Name="UserTextText" FontSize="11" Margin="2,2,0,0" Text=""
Foreground="#2980B9" TextTrimming="CharacterEllipsis" />
<TextBlock Name="StatusText" FontSize="11" Opacity="0.7" Margin="2,4,0,0" Text="" /> <TextBlock Name="StatusText" FontSize="11" Opacity="0.7" Margin="2,4,0,0" Text="" />
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>

View File

@@ -20,6 +20,11 @@ public sealed partial class EntryWindow : Window
private EntryWindow? secondRadio; private EntryWindow? secondRadio;
private readonly List<TextBox> boxes = []; private readonly List<TextBox> boxes = [];
private readonly List<Button> functionButtons = []; private readonly List<Button> functionButtons = [];
private static readonly IBrush Dark = new SolidColorBrush(Color.FromArgb(0x33, 0x80, 0x80, 0x80));
private bool sending;
private TextBox? nameBox;
private TextBox? commentBox;
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) }; private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly Dictionary<Type, Window> openWindows = []; private readonly Dictionary<Type, Window> openWindows = [];
private bool updating; private bool updating;
@@ -179,10 +184,55 @@ public sealed partial class EntryWindow : Window
ExchangeField field = Logging.Entry.Exchange[at]; ExchangeField field = Logging.Entry.Exchange[at];
AddBox(field.Label, field.Width, at + 1); AddBox(field.Label, field.Width, at + 1);
} }
AddNotes();
boxes[0].Focus(); boxes[0].Focus();
Refresh(); Refresh();
} }
/// N1MM keeps a name and a comment beside the exchange, whatever the
/// contest is. Neither is part of the exchange: they fill the log columns
/// of the same names, and a contest that exchanges a name already has one.
private void AddNotes()
{
nameBox = Logging!.Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Name)
? null
: AddNote("Name", 8, EntryGrid.ColumnDefinitions.Count);
commentBox = AddNote("Comment", 12, EntryGrid.ColumnDefinitions.Count);
}
private TextBox AddNote(string label, int width, int index)
{
EntryGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
TextBlock caption = new() { Text = label };
caption.Classes.Add("label");
Grid.SetColumn(caption, index);
Grid.SetRow(caption, 0);
EntryGrid.Children.Add(caption);
TextBox box = new()
{
Width = (width * 13) + 20,
Margin = new Avalonia.Thickness(0, 0, 6, 0),
HorizontalAlignment = HorizontalAlignment.Left,
};
box.Classes.Add("entry");
box.TextChanged += (_, _) => OnNoteChanged();
Grid.SetColumn(box, index);
Grid.SetRow(box, 1);
EntryGrid.Children.Add(box);
return box;
}
private void OnNoteChanged()
{
if (updating || Logging is null)
{
return;
}
Logging.OtherName = nameBox?.Text ?? "";
Logging.Comment = commentBox?.Text ?? "";
}
private void AddBox(string label, int width, int index) private void AddBox(string label, int width, int index)
{ {
EntryGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); EntryGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
@@ -239,6 +289,7 @@ public sealed partial class EntryWindow : Window
break; break;
case Key.Escape: case Key.Escape:
e.Handled = true; e.Handled = true;
sending = false;
session.Alternating?.Stop(); session.Alternating?.Stop();
_ = session.Keyer?.AbortAsync(); _ = session.Keyer?.AbortAsync();
Logging.Wipe(); Logging.Wipe();
@@ -260,7 +311,7 @@ public sealed partial class EntryWindow : Window
e.Handled = true; e.Handled = true;
session.SwapRadio(); session.SwapRadio();
break; break;
case Key.Tab when FocusedIsEntryBox(): case Key.Tab when FocusedIsEntryBox() || FocusedIsNoteBox():
e.Handled = true; e.Handled = true;
MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift)); MoveFocus(forward: !e.KeyModifiers.HasFlag(KeyModifiers.Shift));
break; break;
@@ -343,19 +394,45 @@ public sealed partial class EntryWindow : Window
boxes[0].Focus(); boxes[0].Focus();
} }
/// Tab and space walk the callsign box, the exchange boxes, then the name
/// and comment boxes, and round again.
private void MoveFocus(bool forward) private void MoveFocus(bool forward)
{ {
if (Logging is null || boxes.Count == 0) if (Logging is null || boxes.Count == 0)
{ {
return; return;
} }
List<TextBox> notes = [.. new[] { nameBox, commentBox }.OfType<TextBox>()];
int inNotes = notes.FindIndex(box => box.IsFocused);
if (inNotes >= 0)
{
int next = inNotes + (forward ? 1 : -1);
if (next >= 0 && next < notes.Count)
{
notes[next].Focus();
return;
}
Logging.Entry.FocusOn(forward ? 0 : Logging.Entry.FieldCount - 1);
FocusEntryBox();
return;
}
if (forward && notes.Count > 0 && Logging.Entry.Focus == Logging.Entry.FieldCount - 1)
{
notes[0].Focus();
return;
}
if (forward) if (forward)
{ {
// leaving the callsign box is the moment the call is settled, which // leaving the callsign box is the moment the call is settled, which
// is when the call history can say what the exchange will be // is when the call history can say what the exchange will be
if (Logging.Entry.Focus == 0 && Logging.FillFromHistory()) if (Logging.Entry.Focus == 0)
{ {
SyncBoxes(); bool filled = Logging.FillFromHistory();
filled |= FillNameFromHistory();
if (filled)
{
SyncBoxes();
}
} }
Logging.Entry.Advance(); Logging.Entry.Advance();
} }
@@ -363,16 +440,45 @@ public sealed partial class EntryWindow : Window
{ {
Logging.Entry.Retreat(); Logging.Entry.Retreat();
} }
FocusEntryBox();
}
private void FocusEntryBox()
{
if (Logging is null)
{
return;
}
boxes[Logging.Entry.Focus].Focus(); boxes[Logging.Entry.Focus].Focus();
boxes[Logging.Entry.Focus].CaretIndex = boxes[Logging.Entry.Focus].Text?.Length ?? 0; boxes[Logging.Entry.Focus].CaretIndex = boxes[Logging.Entry.Focus].Text?.Length ?? 0;
} }
/// The call history knows the names in a lot of contests, and the name box
/// is where N1MM shows it when the contest does not exchange one.
private bool FillNameFromHistory()
{
if (Logging is null || nameBox is null || Logging.OtherName.Trim().Length > 0)
{
return false;
}
string known = Logging.Session.History.Find(Logging.Entry.Call)?.Name ?? "";
if (known.Length == 0)
{
return false;
}
Logging.OtherName = known;
return true;
}
private bool FocusedIsEntryBox() => boxes.Any(b => b.IsFocused); private bool FocusedIsEntryBox() => boxes.Any(b => b.IsFocused);
private bool FocusedIsNoteBox() => nameBox?.IsFocused == true || commentBox?.IsFocused == true;
/// The buttons N1MM puts under its function keys. Each one does what its /// The buttons N1MM puts under its function keys. Each one does what its
/// key does, so there is one place deciding what happens. /// key does, so there is one place deciding what happens.
private void OnStopSending(object? sender, RoutedEventArgs e) private void OnStopSending(object? sender, RoutedEventArgs e)
{ {
sending = false;
session.Alternating?.Stop(); session.Alternating?.Stop();
_ = session.Keyer?.AbortAsync(); _ = session.Keyer?.AbortAsync();
Status("stopped sending"); Status("stopped sending");
@@ -435,6 +541,14 @@ public sealed partial class EntryWindow : Window
{ {
boxes[at].Text = Logging.Entry[at]; boxes[at].Text = Logging.Entry[at];
} }
if (nameBox is not null)
{
nameBox.Text = Logging.OtherName;
}
if (commentBox is not null)
{
commentBox.Text = Logging.Comment;
}
updating = false; updating = false;
} }
@@ -457,6 +571,8 @@ public sealed partial class EntryWindow : Window
Title = TitleLine(); Title = TitleLine();
PathText.Text = PathLine(); PathText.Text = PathLine();
UserTextText.Text = Logging.Session.History.Find(Logging.Entry.Call)?.UserText ?? "";
ShowLights();
Verdict? verdict = Logging.Verdict(); Verdict? verdict = Logging.Verdict();
VerdictBorder.Background = Verdicts.Colour(verdict); VerdictBorder.Background = Verdicts.Colour(verdict);
@@ -483,6 +599,15 @@ public sealed partial class EntryWindow : Window
$"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}{radio}"; $"{Logging.Log.Tally.Points} pts · {mults} · {Logging.Log.TotalScore:N0}{radio}";
} }
/// The two lights N1MM has beside the run switch: the left one says a radio
/// is answering, the right one that something is going out on the air.
private void ShowLights()
{
bool radio = session.Radios.Any(r => r.Number == radioNumber && r.IsConnected);
RadioLight.Fill = radio ? Verdicts.NewMultiplier : Dark;
TransmitLight.Fill = sending ? Verdicts.Dupe : Dark;
}
/// What N1MM writes in its title bar: the frequency, the mode, whether the /// What N1MM writes in its title bar: the frequency, the mode, whether the
/// frequency came from a radio or was typed, and which radio this window /// frequency came from a radio or was typed, and which radio this window
/// is. /// is.

View File

@@ -37,6 +37,13 @@ public sealed class RadioPosition
/// searching. Each radio runs or searches on its own. /// searching. Each radio runs or searches on its own.
public bool IsRunning { get; set; } public bool IsRunning { get; set; }
/// The other operator's name and a note about the contact. Neither is part
/// of any exchange — N1MM keeps both beside the exchange boxes and writes
/// them into the log — so neither counts towards a complete exchange.
public string OtherName { get; set; } = "";
public string Comment { get; set; } = "";
public Contest Contest => Session.Contest; public Contest Contest => Session.Contest;
public ContestInstance Instance => Session.Instance; public ContestInstance Instance => Session.Instance;
@@ -83,8 +90,10 @@ public sealed class RadioPosition
{ {
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete"); throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
} }
Qso stored = Session.Add(BuildQso(Entry.Call.Trim())); Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
Entry.Clear(); Entry.Clear();
OtherName = "";
Comment = "";
return stored; return stored;
} }
@@ -119,9 +128,20 @@ public sealed class RadioPosition
public void Wipe() public void Wipe()
{ {
Entry.Clear(); Entry.Clear();
OtherName = "";
Comment = "";
Session.NotifyChanged(); Session.NotifyChanged();
} }
/// The name and comment boxes fill the log columns of the same names. A
/// contest that exchanges a name has already filled that column, and what
/// was exchanged wins.
private Qso Noted(Qso qso) => qso with
{
Name = qso.Name.Length > 0 ? qso.Name : OtherName.Trim(),
Comment = qso.Comment.Length > 0 ? qso.Comment : Comment.Trim(),
};
/// The report an operator sends without thinking about it: 599 on CW and /// The report an operator sends without thinking about it: 599 on CW and
/// digital modes, 59 on phone. /// digital modes, 59 on phone.
public string DefaultReport() => Mode.Category switch public string DefaultReport() => Mode.Category switch

View File

@@ -382,4 +382,35 @@ public class RadioPositionTests
Assert.Equal("CT", session.Entry.ValueOf(ExchangeSlot.Section)); Assert.Equal("CT", session.Entry.ValueOf(ExchangeSlot.Section));
} }
[Fact]
public void TheNameAndCommentBoxesReachTheLog()
{
RadioPosition session = Session();
session.Entry.Call = "JA1XYZ";
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
session.Entry.Set(ExchangeSlot.Zone, "25");
session.OtherName = "Ken";
session.Comment = "up 2";
Qso logged = session.LogContact();
Assert.Equal("Ken", logged.Name);
Assert.Equal("up 2", logged.Comment);
Assert.Equal("", session.OtherName);
Assert.Equal("", session.Comment);
}
[Fact]
public void WipingClearsTheNameAndTheComment()
{
RadioPosition session = Session();
session.OtherName = "Ken";
session.Comment = "up 2";
session.Wipe();
Assert.Equal("", session.OtherName);
Assert.Equal("", session.Comment);
}
} }