diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 8b14508..40dcb81 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -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; } diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index ebf6b96..060eae0 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -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; diff --git a/src/Nonemm.App/Dialogs/DigitalDialog.axaml b/src/Nonemm.App/Dialogs/DigitalDialog.axaml index 63032a7..0d4cc05 100644 --- a/src/Nonemm.App/Dialogs/DigitalDialog.axaml +++ b/src/Nonemm.App/Dialogs/DigitalDialog.axaml @@ -103,13 +103,17 @@ - + + + + diff --git a/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs b/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs index f747613..ab29993 100644 --- a/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs +++ b/src/Nonemm.App/Dialogs/DigitalDialog.axaml.cs @@ -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, diff --git a/src/Nonemm.App/Windows/DigitalWindow.Macros.cs b/src/Nonemm.App/Windows/DigitalWindow.Macros.cs index 306b926..f41fff5 100644 --- a/src/Nonemm.App/Windows/DigitalWindow.Macros.cs +++ b/src/Nonemm.App/Windows/DigitalWindow.Macros.cs @@ -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) { diff --git a/src/Nonemm.App/Windows/DigitalWindow.axaml b/src/Nonemm.App/Windows/DigitalWindow.axaml index 2fb8de5..8056a66 100644 --- a/src/Nonemm.App/Windows/DigitalWindow.axaml +++ b/src/Nonemm.App/Windows/DigitalWindow.axaml @@ -166,9 +166,17 @@ - + + + + + diff --git a/src/Nonemm.App/Windows/DigitalWindow.axaml.cs b/src/Nonemm.App/Windows/DigitalWindow.axaml.cs index 3a331f7..a44f9a5 100644 --- a/src/Nonemm.App/Windows/DigitalWindow.axaml.cs +++ b/src/Nonemm.App/Windows/DigitalWindow.axaml.cs @@ -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 +{ + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(int value) => changed(); +}