Start the transmit pane again on every new message

The macro buttons went wrong at the end of a transmission, both faults in
DigitalEngineSender:

- The engine reports the transmitter drop on its own thread.
  WhenTransmitChanged took the state lock, decided the message had ended,
  released the lock, and only then called Buffer.Ended(), which clears
  what has gone to the engine. A macro pressed on the last character got
  through StartAsync in that gap and had already flushed its own text into
  Sent, so Ended() wiped the new text off the pane while the engine
  transmitted it. Ended() is now called inside the same lock.

- The pane only started again when the engine reported a drop. Two macros
  in a row keep the transmitter up, so that report never came and Sent
  grew with every press. Everything in Sent is locked, because it is in
  the engine and cannot be taken back, so the whole pane became
  read-only. TypeAhead.Started() drops the last message's sent text and
  keeps what was typed ahead, and StartAsync calls it whenever it keys a
  new transmission.

The rest of this commit is the digital transmit work these fixes sit on:
the pane as one coloured box, the sender's three keying states, the
type-ahead feeder paced by the clock with the engine's count as a brake,
{RX} flushing what is left in one piece, and the entry window's function
keys reading the digital macros on a digital mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
2026-09-03 14:40:30 +00:00
parent 7ae60be4a0
commit f49a8c10fd
20 changed files with 2604 additions and 390 deletions

View File

@@ -329,7 +329,8 @@ public sealed class AppSession : IDisposable
WineBridgeChannel channel = new(
Settings.DigitalBridgePath,
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine");
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine",
Paths.Diagnostics);
MmttyEngine started = new(channel, options);
await started.StartAsync();
digital = started;

View File

@@ -0,0 +1,40 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Nonemm.App.Controls">
<Style Selector="controls|TransmitPane">
<Setter Property="Background" Value="{DynamicResource FieldBackground}" />
<Setter Property="Foreground" Value="{DynamicResource FieldForeground}" />
<Setter Property="CaretBrush" Value="{DynamicResource FieldForeground}" />
<Setter Property="SelectionBrush" Value="#3399FF" />
<Setter Property="SelectionForegroundBrush" Value="#FFFFFF" />
<Setter Property="Padding" Value="3,2" />
<Setter Property="Template">
<!-- a TextBox subclass gets no theme of its own, so the pane carries
its own template. It is the Fluent one cut down to what the pane
uses: no watermark, no clear button, no border of its own -->
<ControlTemplate>
<ScrollViewer Name="PART_ScrollViewer"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<controls:TransmitPresenter Name="PART_TextPresenter"
Text="{TemplateBinding Text}"
CaretIndex="{TemplateBinding CaretIndex}"
SelectionStart="{TemplateBinding SelectionStart}"
SelectionEnd="{TemplateBinding SelectionEnd}"
SelectionBrush="{TemplateBinding SelectionBrush}"
SelectionForegroundBrush="{TemplateBinding SelectionForegroundBrush}"
CaretBrush="{TemplateBinding CaretBrush}"
TextAlignment="{TemplateBinding TextAlignment}"
TextWrapping="{TemplateBinding TextWrapping}"
LineHeight="{TemplateBinding LineHeight}"
LetterSpacing="{TemplateBinding LetterSpacing}"
SentLength="{TemplateBinding SentLength}"
SentBrush="{TemplateBinding SentBrush}"
VerticalAlignment="Top" />
</ScrollViewer>
</ControlTemplate>
</Setter>
</Style>
</Styles>

View File

@@ -0,0 +1,37 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace Nonemm.App.Controls;
/// The digital transmit pane: one editable box in which the first `SentLength`
/// characters — what has already gone to the engine — are drawn in `SentBrush`.
///
/// Avalonia's TextBox draws all of its text in one brush, so this was a label
/// beside a box before. That took width from the box as the label grew and the
/// label did not wrap. A TextBox builds its text through a TextPresenter, and
/// the TextLayout under it does take a brush per run, so the pane is a TextBox
/// with `TransmitPresenter` in place of the plain presenter. The template in
/// `DigitalWindow.axaml` is what puts it there.
public class TransmitPane : TextBox
{
public static readonly StyledProperty<int> SentLengthProperty =
AvaloniaProperty.Register<TransmitPane, int>(nameof(SentLength));
public static readonly StyledProperty<IBrush?> SentBrushProperty =
AvaloniaProperty.Register<TransmitPane, IBrush?>(nameof(SentBrush));
/// How many characters at the front of the text have gone out.
public int SentLength
{
get => GetValue(SentLengthProperty);
set => SetValue(SentLengthProperty, value);
}
/// What those characters are drawn in.
public IBrush? SentBrush
{
get => GetValue(SentBrushProperty);
set => SetValue(SentBrushProperty, value);
}
}

View File

@@ -0,0 +1,133 @@
using Avalonia;
using Avalonia.Controls.Presenters;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Nonemm.App.Controls;
/// The presenter behind `TransmitPane`: the same text layout Avalonia builds
/// for a TextBox, with the first `SentLength` characters given `SentBrush`.
///
/// TextPresenter builds its layout in `CreateTextLayout` and already passes
/// per-run overrides for the selection, so this adds one more run to that list.
/// The selection has to keep its own colour on top, which is why the coloured
/// run is cut around it rather than laid over it: overlapping runs are not
/// defined.
public class TransmitPresenter : TextPresenter
{
public static readonly StyledProperty<int> SentLengthProperty =
AvaloniaProperty.Register<TransmitPresenter, int>(nameof(SentLength));
public static readonly StyledProperty<IBrush?> SentBrushProperty =
AvaloniaProperty.Register<TransmitPresenter, IBrush?>(nameof(SentBrush));
/// The width the layout is built to. `TextPresenter` keeps its own copy and
/// does not hand it out, so it is read off the measure pass here.
private Size constraint;
public int SentLength
{
get => GetValue(SentLengthProperty);
set => SetValue(SentLengthProperty, value);
}
public IBrush? SentBrush
{
get => GetValue(SentBrushProperty);
set => SetValue(SentBrushProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
constraint = availableSize;
return base.MeasureOverride(availableSize);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == SentLengthProperty || change.Property == SentBrushProperty)
{
InvalidateTextLayout();
}
}
protected override TextLayout CreateTextLayout()
{
string text = Text ?? "";
int sent = Math.Clamp(SentLength, 0, text.Length);
// nothing to colour, or a case the base class handles on its own: the
// password character replaces the text, and a preedit run is the input
// method's, not ours
if (sent == 0 || SentBrush is null || PasswordChar != '\0'
|| !string.IsNullOrEmpty(PreeditText))
{
return base.CreateTextLayout();
}
Typeface typeface = new(FontFamily, FontStyle, FontWeight, FontStretch);
// a zero constraint is a measure with no bound, which is infinity to
// the layout
double width = constraint.Width > 0 ? constraint.Width : double.PositiveInfinity;
double height = constraint.Height > 0 ? constraint.Height : double.PositiveInfinity;
return new TextLayout(
text,
typeface,
FontSize,
Foreground,
TextAlignment,
TextWrapping,
null,
null,
FlowDirection,
width,
height,
LineHeight,
LetterSpacing,
0,
FontFeatures,
Runs(typeface, sent));
}
/// The coloured runs, in order and not overlapping. The selection, when
/// there is one with a colour of its own, cuts the coloured run in two.
private List<ValueSpan<TextRunProperties>> Runs(Typeface typeface, int sent)
{
int from = Math.Min(SelectionStart, SelectionEnd);
int to = Math.Max(SelectionStart, SelectionEnd);
bool selected = ShowSelectionHighlight && to > from && SelectionForegroundBrush is not null;
List<ValueSpan<TextRunProperties>> runs = [];
Add(runs, 0, selected ? Math.Min(sent, from) : sent, SentBrush, typeface);
if (selected)
{
Add(runs, from, to, SelectionForegroundBrush, typeface);
Add(runs, to, sent, SentBrush, typeface);
}
return runs;
}
private void Add(
List<ValueSpan<TextRunProperties>> runs,
int start,
int end,
IBrush? brush,
Typeface typeface)
{
if (end <= start)
{
return;
}
runs.Add(new ValueSpan<TextRunProperties>(
start,
end - start,
new GenericTextRunProperties(
typeface,
FontSize,
null,
brush,
null,
BaselineAlignment.Baseline,
null,
FontFeatures)));
}
}

View File

@@ -127,17 +127,26 @@ public sealed partial class DigitalWindow
}
}
/// What is typed in the transmit pane goes into the type-ahead buffer,
/// which feeds the engine. A carriage return is what the engine takes as a
/// new line, so Enter puts one in rather than the newline the box would.
/// What is typed in the transmit pane goes into the type-ahead buffer.
/// Nothing goes on the air until the transmitter is keyed, which is the TX
/// button, Ctrl+Enter or Alt+T; from then on what is typed goes out as it
/// is typed. Enter is a new line in the message, which the engine takes as
/// a carriage return.
///
/// Escape stops now: what has not gone to the engine is dropped and the
/// engine drops what it holds.
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.Control)
{
e.Handled = true;
StartTransmit();
return;
}
if (e.Key == Key.Enter)
{
e.Handled = true;
int at = Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
TransmitBox.Text = (TransmitBox.Text ?? "").Insert(at, "\r");
TransmitBox.CaretIndex = at + 1;
Type("\r");
return;
}
if (e.Key == Key.Escape)
@@ -148,37 +157,61 @@ public sealed partial class DigitalWindow
}
}
/// The operator rewrote what has not gone out yet. Only the box holds it;
/// what is already in the engine is in the label beside it and cannot be
/// reached from here.
/// Puts text in at the caret, never before the text that has gone out.
private void Type(string text)
{
string was = TransmitBox.Text ?? "";
int at = Math.Clamp(TransmitBox.CaretIndex, locked, was.Length);
TransmitBox.Text = was.Insert(at, text);
TransmitBox.CaretIndex = at + text.Length;
}
/// The operator rewrote the pane. What has already gone to the engine
/// cannot be taken back, so an edit that reaches into it is undone; the
/// rest goes to the buffer, which sends it if the transmitter is up and
/// holds it if it is not.
private void OnTransmitTextChanged(object? sender, TextChangedEventArgs e)
{
if (showingBuffer || session.DigitalKeyer is not { } keyer)
{
return;
}
keyer.Buffer.Rewrite(TransmitBox.Text ?? "", CursorInBox());
}
/// The cursor holds the pump back: nothing behind it goes out, so the
/// engine idles rather than transmitting text the operator is still
/// typing. With the box out of focus there is no cursor to hold anything.
private void OnTransmitFocus(object? sender, RoutedEventArgs e) => ShowCursor();
private void ShowCursor()
{
if (session.DigitalKeyer is { } keyer)
string now = TransmitBox.Text ?? "";
if (FirstDifference(pane, now) < locked)
{
keyer.Buffer.Cursor = TransmitBox.IsFocused ? CursorInBox() : TypeAhead.NoCursor;
showingBuffer = true;
try
{
TransmitBox.Text = pane;
TransmitBox.CaretIndex = locked;
}
finally
{
showingBuffer = false;
}
return;
}
pane = now;
keyer.Buffer.Edit(now);
}
private int CursorInBox() =>
Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
/// Where two versions of the pane first differ, which is the length of both
/// when one is the other with text added or taken off the end.
private static int FirstDifference(string was, string now)
{
int most = Math.Min(was.Length, now.Length);
int at = 0;
while (at < most && was[at] == now[at])
{
at++;
}
return at;
}
/// Draws the buffer: what has gone out in the label, what is still to go in
/// the box. The pump takes characters off the front, so the cursor moves
/// back with them and the operator can go on typing while it does.
/// Draws the buffer: what has gone out and what is still to go, as one
/// text, with the length of the first half telling the pane how much of it
/// to colour. A character moving from one half to the other leaves the text
/// the same, so the caret and what the operator is typing do not move.
private void ShowBuffer()
{
if (session.DigitalKeyer is not { } keyer)
@@ -188,20 +221,29 @@ public sealed partial class DigitalWindow
showingBuffer = true;
try
{
SentText.Text = keyer.Buffer.Sent;
string pending = keyer.Buffer.Pending;
string was = TransmitBox.Text ?? "";
if (was == pending)
string sent = keyer.Buffer.Sent;
string now = sent + keyer.Buffer.Pending;
locked = sent.Length;
// only what the engine has transmitted is coloured. What it is
// still holding cannot be taken back either, but the operator has
// not heard it go yet, and marking it as gone turned every
// character red as it was typed once the transmission caught up
TransmitBox.SentLength = keyer.Buffer.OnAir;
if ((TransmitBox.Text ?? "") == now)
{
pane = now;
return;
}
int taken = was.Length > pending.Length
&& was.EndsWith(pending, StringComparison.Ordinal)
? was.Length - pending.Length
: 0;
int caret = TransmitBox.CaretIndex;
TransmitBox.Text = pending;
TransmitBox.CaretIndex = Math.Clamp(caret - taken, 0, pending.Length);
// the end of a message drops what has gone out off the front of
// the pane, so the caret moves back with the text it is in
string was = TransmitBox.Text ?? "";
int dropped = was.Length > now.Length && was.EndsWith(now, StringComparison.Ordinal)
? was.Length - now.Length
: 0;
int caret = TransmitBox.CaretIndex - dropped;
TransmitBox.Text = now;
TransmitBox.CaretIndex = Math.Clamp(caret, locked, now.Length);
pane = now;
}
finally
{
@@ -211,7 +253,8 @@ public sealed partial class DigitalWindow
/// N1MM's keys for the digital window: Alt+T turns the transmitter on and
/// puts the cursor where what is typed goes out, Ctrl+K does the same, and
/// Alt+G takes the next call off the grab list.
/// Alt+G takes the next call off the grab list. Escape stops now, wherever
/// the focus is.
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.T)
@@ -235,14 +278,15 @@ public sealed partial class DigitalWindow
if (e.Key == Key.Escape)
{
e.Handled = true;
_ = session.DigitalKeyer?.AbortAsync();
_ = Running()?.AbortAsync();
return;
}
base.OnKeyDown(e);
}
/// Alt+T: on to transmit with the cursor in the transmit pane, off back to
/// receive.
/// Alt+T: on to transmit with the cursor in the transmit pane, off to drop
/// the transmitter at the end of what is waiting.
private void ToggleTransmit()
{
if (Running() is not { } running)
@@ -251,26 +295,51 @@ public sealed partial class DigitalWindow
}
if (running.IsTransmitting)
{
_ = session.DigitalKeyer?.AbortAsync();
_ = running.ReturnToReceiveAsync();
ReturnToReceive();
return;
}
_ = running.SetPttAsync(true);
StartTransmit();
}
private void OnTransmit(object? sender, RoutedEventArgs e) => StartTransmit();
/// Keys the transmitter and opens the gate, so what is in the pane goes out
/// and so does whatever is typed into it after this. The keyer does the
/// keying, so the engine is never fed before it is keyed.
private void StartTransmit()
{
if (Running() is not { } running)
{
return;
}
if (session.DigitalKeyer is { } keyer)
{
keyer.Transmit();
}
else
{
_ = running.SetPttAsync(true);
}
TransmitBox.Focus();
}
private void OnTransmit(object? sender, RoutedEventArgs e)
{
_ = Running()?.SetPttAsync(true);
TransmitBox.Focus();
}
/// The RX button is the `{RX}` macro by hand: the transmitter drops at the
/// end of what is waiting rather than in the middle of it. Escape is what
/// stops now.
private void OnReceive(object? sender, RoutedEventArgs e) => ReturnToReceive();
/// The RX button stops where it is: what has not gone to the engine is
/// dropped, and the engine drops what it holds.
private void OnReceive(object? sender, RoutedEventArgs e)
private void ReturnToReceive()
{
_ = session.DigitalKeyer?.AbortAsync();
_ = Running()?.AbortAsync();
if (Running() is not { } running)
{
return;
}
if (session.DigitalKeyer is { } keyer)
{
keyer.ReturnToReceiveWhenSent();
return;
}
_ = running.ReturnToReceiveAsync();
}
private void OnClearTransmit(object? sender, RoutedEventArgs e)

View File

@@ -1,6 +1,7 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
xmlns:controls="using:Nonemm.App.Controls"
x:Class="Nonemm.App.Windows.DigitalWindow"
Title="Digital Interface" Width="900" Height="640">
<Window.Styles>
@@ -33,6 +34,7 @@
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="4,2" />
</Style>
<StyleInclude Source="avares://Nonemm.App/Controls/TransmitPane.axaml" />
</Window.Styles>
<DockPanel>
@@ -166,17 +168,14 @@
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
<Border BorderThickness="1" BorderBrush="#40808080">
<!-- what has gone out, then what is still to go: the first is a
label so it cannot be edited, the second the box the operator
types in -->
<Grid ColumnDefinitions="Auto,*">
<TextBlock Name="SentText" FontFamily="monospace" Margin="3,3,0,0"
VerticalAlignment="Top" />
<TextBox Grid.Column="1" Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
FontFamily="monospace" BorderThickness="0"
KeyDown="OnTransmitKeyDown" TextChanged="OnTransmitTextChanged"
GotFocus="OnTransmitFocus" LostFocus="OnTransmitFocus" />
</Grid>
<!-- one box for the whole message: what has gone out is coloured and
cannot be edited, what is still to go is typed into the same
text, so the two wrap together and neither takes width from the
other -->
<controls:TransmitPane Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
FontFamily="monospace"
KeyDown="OnTransmitKeyDown"
TextChanged="OnTransmitTextChanged" />
</Border>
<Grid Grid.Column="2" RowDefinitions="*,Auto">
<Border BorderThickness="1" BorderBrush="#40808080">

View File

@@ -59,6 +59,15 @@ public sealed partial class DigitalWindow : RefreshableWindow
/// redraw is not read back as an edit by the operator.
private bool showingBuffer;
/// The transmit pane as it was last drawn, so an edit can be told from a
/// redraw and the changed character found.
private string pane = "";
/// How much of the pane is in the engine's hands and cannot be edited. It
/// is more than what is coloured, which is only what has gone out over the
/// air.
private int locked;
/// The buffer this window is drawing, or null while no engine is running.
private TypeAhead? buffer;
@@ -82,9 +91,6 @@ public sealed partial class DigitalWindow : RefreshableWindow
entry.Activated += WhenEntryActivated;
BuildMacros();
ApplySettings();
// the cursor holds the pump back, so it has to follow the caret as it
// moves, not only as the text changes
TransmitBox.GetObservable(TextBox.CaretIndexProperty).Subscribe(new Watcher(ShowCursor));
Attach(session.Digital);
Refresh();
Closed += (_, _) =>
@@ -104,8 +110,8 @@ public sealed partial class DigitalWindow : RefreshableWindow
{
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
SentText.Foreground = Transmitted;
SentText.FontSize = Settings.DigitalFontSize;
TransmitBox.Background = Themes.Brush(Themes.Current.FieldBackground);
TransmitBox.SentBrush = Transmitted;
TransmitBox.FontSize = Settings.DigitalFontSize;
if (buffer is not null)
{
@@ -188,21 +194,16 @@ public sealed partial class DigitalWindow : RefreshableWindow
private void WhenBufferChanged(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(ShowBuffer);
/// The pane starts empty for the next message: the transmitter dropping
/// with nothing left to send is the end of this one. An engine that keys
/// itself drops between two characters as well, and that text is left
/// where it is.
/// The transmitter dropping ends the message. The buffer clears what has
/// gone out and keeps what the operator typed ahead, so the pane is left
/// with the next message in it rather than empty.
private void WhenTransmitChanged(object? sender, bool transmitting) =>
Dispatcher.UIThread.Post(() =>
{
TransmitDot.Background = transmitting
? Themes.Brush(Themes.Current.TransmitLight)
: Brushes.Transparent;
if (!transmitting && buffer is { IsSending: false })
{
buffer.Clear();
ShowBuffer();
}
ShowBuffer();
});
private void WhenConnectionChanged(object? sender, bool connected) =>
@@ -568,18 +569,3 @@ public sealed partial class DigitalWindow : RefreshableWindow
_ => StackOrder.Disabled,
};
}
/// Watches one property. Avalonia hands out observables and this program has no
/// other use for Rx, so a handler that takes no value is enough.
internal sealed class Watcher(Action changed) : IObserver<int>
{
public void OnCompleted()
{
}
public void OnError(Exception error)
{
}
public void OnNext(int value) => changed();
}

View File

@@ -35,13 +35,33 @@ public sealed partial class EntryWindow
/// The twelve keys for this radio, which are a different twelve while
/// running and while searching, as N1MM's file holds them.
///
/// On a digital mode they come from the digital macros instead, which is
/// where N1MM reads them from as well: its entry window loads the RTTYBTN
/// set there rather than the CW file. Without this the keys held CW text
/// with no `{TX}` in it, so pressing one fed the engine without keying the
/// transmitter and nothing went on the air.
private IReadOnlyList<FunctionKey> Keys() =>
Messages
.For(
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
session.Settings.CwMessageFile,
session.Settings.PhoneMessageFile)
.Keys(Logging?.IsRunning ?? false);
Logging?.Mode.Category == ModeCategory.Digital
? DigitalKeys()
: Messages
.For(
Logging?.Mode.Category ?? Core.ModeCategory.Cw,
session.Settings.CwMessageFile,
session.Settings.PhoneMessageFile)
.Keys(Logging?.IsRunning ?? false);
/// The first ten digital macros, then Spot and Wipe. F11 and F12 are what
/// they are in every other mode, so the labels say what the buttons do;
/// the macros past the tenth are on the digital window's own buttons.
private IReadOnlyList<FunctionKey> DigitalKeys()
{
IReadOnlyList<FunctionKey> macros = Messages.Digital(session.Settings.DigitalMessageFile).Buttons;
List<FunctionKey> keys = [.. macros.Take(MessageFile.KeyCount - 2)];
keys.Add(new FunctionKey("Spot", ""));
keys.Add(new FunctionKey("Wipe", ""));
return keys;
}
/// Right-clicking a function key button opens the messages for the mode the
/// radio is in, which is where import and export live too.
@@ -140,7 +160,12 @@ public sealed partial class EntryWindow
}
if (plan.Text.Length == 0)
{
// a button that only acts: {WIPE}, {LOG}, {RUN}
// a button that only acts: {WIPE}, {LOG}, {RUN}, or an {RX} on its
// own, which has nothing to wait for
foreach (MessageAction action in plan.After)
{
Run(action);
}
return true;
}
if ((through ?? Sender) is not { } keyer)
@@ -170,7 +195,22 @@ public sealed partial class EntryWindow
Status(e.Message);
return;
}
_ = RunWhenSentAsync(keyer, after);
// `{RX}` goes to the keyer as soon as the message is in it, which is
// N1MM's ending: the text in one piece, the stop 400 ms behind it, and
// the engine unkeys itself at the last character. Waiting for the
// message to go out first leaves the engine empty, and a stop that
// arrives there does nothing. Everything else after `{END}` still
// waits.
foreach (MessageAction action in after)
{
if (action.Command == MessageCommand.ReturnToReceive)
{
Run(action);
}
}
_ = RunWhenSentAsync(
keyer,
[.. after.Where(action => action.Command != MessageCommand.ReturnToReceive)]);
}
/// Waits for the keyer, turns the transmit light off, and runs whatever
@@ -292,14 +332,30 @@ public sealed partial class EntryWindow
Logging.Stack.Clear();
ShowCallStack();
break;
// the digital engine keys itself while it has text to send, so
// {TX} and {RX} only matter when a macro wants the transmitter
// held open around what it sends
// {TX} keys the transmitter and {RX} drops it. {RX} waits until
// everything waiting has gone out, so it ends the transmission
// rather than cutting it off
case MessageCommand.StartTransmit:
_ = session.Digital?.SetPttAsync(true);
// through the keyer, which keys the engine before it feeds it
// the message
if (session.DigitalKeyer is { } keying)
{
keying.Transmit();
}
else
{
_ = session.Digital?.SetPttAsync(true);
}
break;
case MessageCommand.ReturnToReceive:
_ = session.Digital?.ReturnToReceiveAsync();
if (session.DigitalKeyer is { } digital)
{
digital.ReturnToReceiveWhenSent();
}
else
{
_ = session.Digital?.ReturnToReceiveAsync();
}
break;
}
}