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
co-authored by Claude Opus 5
parent 7ae60be4a0
commit f49a8c10fd
20 changed files with 2604 additions and 390 deletions
@@ -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>
+37
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);
}
}
@@ -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)));
}
}