Show what has gone out in the transmit pane

The transmit pane is now the type-ahead buffer. What has gone to the engine is
in red beside the box and cannot be reached; what is still to go is in the box
and can be typed over, added to or deleted while the engine works through it.
A macro fills the pane rather than the engine, so the operator can correct a
call the message is still sending.

The cursor holds the pump: nothing behind it goes out, so an engine that
catches up with the operator idles instead of transmitting half a word. With
the box out of focus there is no cursor and everything goes.

Escape and the RX button drop what has not gone out and stop the engine where
it is. Enter puts in the carriage return the engine takes as a new line. The
pane starts empty for the next message.

The baud rate the pump counts by is in the digital setup window, beside the
alignment frequency. It has to match the engine: 45.45 unless the contest says
otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-09-01 05:25:49 +00:00
parent 65f1051bc1
commit f69c630ba1
7 changed files with 169 additions and 22 deletions

View File

@@ -132,7 +132,7 @@ public sealed class AppSession : IDisposable
public MmttyEngine? Digital => digital;
/// The same engine as something a macro can be sent through.
public MessageSender? DigitalKeyer => digitalSender;
public DigitalEngineSender? DigitalKeyer => digitalSender;
/// Alternating CQ, or null while there is no keyer. It needs two radios to
/// do anything, and a keyer that reports when a message has gone out.
@@ -333,7 +333,7 @@ public sealed class AppSession : IDisposable
MmttyEngine started = new(channel, options);
await started.StartAsync();
digital = started;
digitalSender = new DigitalEngineSender(started);
digitalSender = new DigitalEngineSender(started, Settings.DigitalBaud);
Changed?.Invoke(this, EventArgs.Empty);
return started;
}

View File

@@ -255,6 +255,11 @@ public sealed record Settings
/// fills the report in, the way typing one does.
public bool DigitalGrabSendsSpace { get; init; } = true;
/// The speed the engine transmits at. The type-ahead buffer is paced by
/// it, so a station running 75 baud sets it here as well as in the engine.
/// MMTTY's own default, and what nearly every RTTY contest runs at.
public double DigitalBaud { get; init; } = 45.45;
/// N1MM caps the receive pane's font at 14 points.
public int DigitalFontSize { get; init; } = 12;

View File

@@ -103,13 +103,17 @@
</Border>
</HeaderedContentControl>
<HeaderedContentControl Header="Alignment Frequency">
<HeaderedContentControl Header="Alignment Frequency and Speed">
<Border BorderThickness="1" BorderBrush="#40808080" Padding="8">
<StackPanel>
<TextBlock Classes="label" Text="MMTTY mark frequency (Hz)" Margin="0,0,0,1" />
<TextBox Name="MarkBox" Width="120" HorizontalAlignment="Left" />
<TextBlock Classes="label" TextWrapping="Wrap"
Text="The Align button retunes the radio so the signal that was clicked in the waterfall sits on this tone." />
<TextBlock Classes="label" Text="Baud rate" Margin="0,8,0,1" />
<TextBox Name="BaudBox" Width="120" HorizontalAlignment="Left" />
<TextBlock Classes="label" TextWrapping="Wrap"
Text="The speed the engine transmits at, which paces the transmit pane. Set it to the same speed as the engine: 45.45 baud unless the contest says otherwise." />
</StackPanel>
</Border>
</HeaderedContentControl>

View File

@@ -88,6 +88,7 @@ public sealed partial class DigitalDialog : Window
ForegroundHighlightBox.IsChecked = !settings.DigitalHighlightBackground;
BackgroundHighlightBox.IsChecked = settings.DigitalHighlightBackground;
MarkBox.Text = settings.DigitalMarkHertz.ToString(CultureInfo.InvariantCulture);
BaudBox.Text = settings.DigitalBaud.ToString(CultureInfo.InvariantCulture);
WindowBox.ItemsSource = WindowSizes;
WindowBox.SelectedItem =
WindowSizes.FirstOrDefault(s => s == settings.DigitalEngineWindow) ?? WindowSizes[0];
@@ -300,6 +301,10 @@ public sealed partial class DigitalDialog : Window
DigitalWineCommand = WineBox.Text?.Trim() is { Length: > 0 } wine ? wine : "wine",
DigitalPttPort = PttBox.Text?.Trim() ?? "",
DigitalMarkHertz = int.TryParse(MarkBox.Text, out int mark) ? mark : settings.DigitalMarkHertz,
DigitalBaud = double.TryParse(BaudBox.Text, NumberStyles.Float, CultureInfo.InvariantCulture,
out double baud) && baud > 0
? baud
: settings.DigitalBaud,
DigitalEngineWindow = WindowBox.SelectedItem as string ?? "Normal",
DigitalEngineOnTop = OnTopBox.IsChecked == true,
DigitalEnabled = (EngineBox.Text?.Trim().Length ?? 0) > 0,

View File

@@ -119,9 +119,7 @@ public sealed partial class DigitalWindow
}
try
{
string text = await File.ReadAllTextAsync(picked[0].Path.LocalPath);
TransmitBox.Text += text;
await keyer.SendAsync(text);
await keyer.SendAsync(await File.ReadAllTextAsync(picked[0].Path.LocalPath));
}
catch (Exception failure) when (failure is IOException or InvalidOperationException)
{
@@ -129,27 +127,85 @@ public sealed partial class DigitalWindow
}
}
/// What is typed in the transmit pane goes out as it is typed, which is how
/// N1MM's window works: the engine transmits each character as it arrives.
/// 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.
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
{
if (engine is not { IsConnected: true } running)
{
return;
}
if (e.Key == Key.Enter)
{
e.Handled = true;
_ = running.TypeAsync('\r');
TransmitBox.Text += "\n";
TransmitBox.CaretIndex = TransmitBox.Text.Length;
int at = Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
TransmitBox.Text = (TransmitBox.Text ?? "").Insert(at, "\r");
TransmitBox.CaretIndex = at + 1;
return;
}
if (e.Key == Key.Escape)
{
e.Handled = true;
_ = running.AbortAsync();
TransmitBox.Text = "";
_ = session.DigitalKeyer?.AbortAsync();
_ = Running()?.AbortAsync();
}
}
/// 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.
private void OnTransmitTextChanged(object? sender, TextChangedEventArgs e)
{
if (showingBuffer || session.DigitalKeyer is not { } keyer)
{
return;
}
keyer.TypeAhead.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)
{
keyer.TypeAhead.Cursor = TransmitBox.IsFocused ? CursorInBox() : TypeAhead.NoCursor;
}
}
private int CursorInBox() =>
Math.Clamp(TransmitBox.CaretIndex, 0, TransmitBox.Text?.Length ?? 0);
/// 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.
private void ShowBuffer()
{
if (session.DigitalKeyer is not { } keyer)
{
return;
}
showingBuffer = true;
try
{
SentText.Text = keyer.TypeAhead.Sent;
string pending = keyer.TypeAhead.Pending;
string was = TransmitBox.Text ?? "";
if (was == pending)
{
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);
}
finally
{
showingBuffer = false;
}
}
@@ -195,6 +251,7 @@ public sealed partial class DigitalWindow
}
if (running.IsTransmitting)
{
_ = session.DigitalKeyer?.AbortAsync();
_ = running.ReturnToReceiveAsync();
return;
}
@@ -208,9 +265,19 @@ public sealed partial class DigitalWindow
TransmitBox.Focus();
}
private void OnReceive(object? sender, RoutedEventArgs e) => _ = Running()?.AbortAsync();
/// 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)
{
_ = session.DigitalKeyer?.AbortAsync();
_ = Running()?.AbortAsync();
}
private void OnClearTransmit(object? sender, RoutedEventArgs e) => TransmitBox.Text = "";
private void OnClearTransmit(object? sender, RoutedEventArgs e)
{
session.DigitalKeyer?.TypeAhead.Clear();
ShowBuffer();
}
private void OnClearReceive(object? sender, RoutedEventArgs e)
{

View File

@@ -166,9 +166,17 @@
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
<Border BorderThickness="1" BorderBrush="#40808080">
<TextBox Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
FontFamily="monospace" BorderThickness="0"
KeyDown="OnTransmitKeyDown" />
<!-- 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>
</Border>
<Grid Grid.Column="2" RowDefinitions="*,Auto">
<Border BorderThickness="1" BorderBrush="#40808080">

View File

@@ -36,6 +36,9 @@ public sealed partial class DigitalWindow : RefreshableWindow
/// What N1MM marks a call its resource files do not hold with.
private static readonly IBrush Unknown = Brushes.Gold;
/// What is drawn over the text that has already gone to the engine.
private static readonly IBrush Transmitted = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B));
private readonly AppSession session;
private readonly EntryWindow entry;
private readonly int radioNumber;
@@ -52,6 +55,13 @@ public sealed partial class DigitalWindow : RefreshableWindow
private bool paused;
private bool lastWasReturn;
/// True while the transmit box is being redrawn from the buffer, so the
/// redraw is not read back as an edit by the operator.
private bool showingBuffer;
/// The buffer this window is drawing, or null while no engine is running.
private TypeAhead? buffer;
/// Where the next line goes in a non-scrolling pane. It walks down the
/// window and starts again at the top; -1 is before the first line.
private int lineAt = -1;
@@ -72,6 +82,9 @@ 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 += (_, _) =>
@@ -91,6 +104,13 @@ 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.FontSize = Settings.DigitalFontSize;
if (buffer is not null)
{
buffer.Baud = Settings.DigitalBaud;
}
FollowRunAndFrequency();
ShowState();
ShowGrabList();
@@ -127,6 +147,12 @@ public sealed partial class DigitalWindow : RefreshableWindow
}
Detach();
engine = started;
buffer = session.DigitalKeyer?.TypeAhead;
if (buffer is not null)
{
buffer.Baud = Settings.DigitalBaud;
buffer.Changed += WhenBufferChanged;
}
started.Received += WhenReceived;
started.TransmitChanged += WhenTransmitChanged;
started.ConnectionChanged += WhenConnectionChanged;
@@ -139,6 +165,11 @@ public sealed partial class DigitalWindow : RefreshableWindow
{
return;
}
if (buffer is not null)
{
buffer.Changed -= WhenBufferChanged;
buffer = null;
}
engine.Received -= WhenReceived;
engine.TransmitChanged -= WhenTransmitChanged;
engine.ConnectionChanged -= WhenConnectionChanged;
@@ -154,12 +185,24 @@ public sealed partial class DigitalWindow : RefreshableWindow
Print(text);
});
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.
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();
}
});
private void WhenConnectionChanged(object? sender, bool connected) =>
@@ -525,3 +568,18 @@ 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();
}