Pace the pump on what MMTTY says it has left
The engine probe answered two questions on the air, and they point opposite
ways.
TxBufLen is the number of characters left to transmit. Seventeen characters
pushed, five decoded back, and it read 12, counting down to 0 as the message
went out. So the pump no longer counts character times off the clock: it asks
the engine, feeds while the answer is under Lead, and asks again. The baud rate
is now only the fallback for an engine that will not answer.
A backspace is not an edit. Pushed in as a character it made the count go up by
four and the text went out unchanged, so text the engine has been given cannot
be taken back, only aborted. EngineTypeAhead was built on the opposite belief
and is gone, along with the setting that chose it and the interface that existed
to switch between the two.
A count that stops going down means the engine is holding what it has: MMTTY set
to Word out keeps a word until the space after it. The pump feeds one character
per look while that lasts, so the space arrives and the word goes out rather
than the message sitting there.
The probe kept two bugs of its own that this run showed: it read the count a
millisecond after pushing, saw 0 and called the message finished, and it started
a step while the previous unkey was still in flight, so that step ran with the
engine down. It now needs two empty answers in a row and waits for the engine to
say it has stopped. It also asks what {RX} does to a word the engine is holding,
which decides whether a macro without a trailing space loses its last word.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtspmWmS7f8kUvcyaHpRWZ
This commit is contained in:
@@ -6,27 +6,22 @@ namespace Nonemm.Digital;
|
||||
/// through the same expander, the same `{END}` handling and the same ESM as a
|
||||
/// CW message does.
|
||||
///
|
||||
/// A message goes into the transmit buffer rather than to the engine whole, so
|
||||
/// the part that has not been transmitted can still be rewritten. `Finished` is
|
||||
/// A message goes into the type-ahead buffer rather than to the engine whole,
|
||||
/// so the part that has not gone out can still be rewritten. `Finished` is
|
||||
/// raised when the buffer has run dry and the engine has transmitted what it
|
||||
/// was given.
|
||||
///
|
||||
/// Which buffer depends on `engineHoldsBuffer`: `EngineTypeAhead` pushes
|
||||
/// everything to the engine and takes it back with backspaces, `TypeAhead`
|
||||
/// holds it here and feeds the engine a couple of characters at a time. Only an
|
||||
/// engine that has a buffer of its own can hold one.
|
||||
/// An engine that can say how much it still has to transmit paces the buffer;
|
||||
/// the rest are paced by the clock at `baud`.
|
||||
public sealed class DigitalEngineSender : MessageSender
|
||||
{
|
||||
private readonly DigitalEngine engine;
|
||||
|
||||
public DigitalEngineSender(
|
||||
DigitalEngine engine,
|
||||
double baud = TypeAhead.DefaultBaud,
|
||||
bool engineHoldsBuffer = false)
|
||||
public DigitalEngineSender(DigitalEngine engine, double baud = TypeAhead.DefaultBaud)
|
||||
{
|
||||
this.engine = engine;
|
||||
Buffer = engineHoldsBuffer && engine is EngineBuffer holder
|
||||
? new EngineTypeAhead(holder)
|
||||
Buffer = engine is EngineBuffer counter
|
||||
? new TypeAhead(counter, baud)
|
||||
: new TypeAhead(
|
||||
(character, cancellation) => engine.SendAsync(character.ToString(), cancellation),
|
||||
baud);
|
||||
@@ -35,7 +30,7 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
}
|
||||
|
||||
/// What is waiting to go out, which the digital window shows and edits.
|
||||
public TransmitBuffer Buffer { get; }
|
||||
public TypeAhead Buffer { get; }
|
||||
|
||||
public bool IsReady => engine.IsConnected;
|
||||
|
||||
@@ -73,19 +68,16 @@ public sealed class DigitalEngineSender : MessageSender
|
||||
|
||||
private void WhenDrained(object? sender, EventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
/// The engine dropping the transmitter empties its buffer, which the
|
||||
/// type-ahead buffer is told so it stops waiting for characters that have
|
||||
/// already gone. It ends the message only when there is nothing left to
|
||||
/// send: an engine that keys itself off what it is given drops between two
|
||||
/// characters of a message the pump is still feeding, and that is not the
|
||||
/// end of anything.
|
||||
/// The engine dropping the transmitter ends the message, but only when
|
||||
/// there is nothing left to send: an engine that keys itself off what it is
|
||||
/// given drops between two characters of a message the pump is still
|
||||
/// feeding, and that is not the end of anything.
|
||||
private void WhenTransmitChanged(object? sender, bool transmitting)
|
||||
{
|
||||
if (transmitting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Buffer.EngineIdle();
|
||||
if (!Buffer.IsSending)
|
||||
{
|
||||
Finished?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The transmit buffer with the engine holding the text rather than this.
|
||||
///
|
||||
/// MMTTY has a type-ahead buffer of its own, which is what its keyboard drives:
|
||||
/// characters go into it, a backspace takes back one that has not been
|
||||
/// transmitted, and `TxBufLen` says how many are left. Everything is pushed to
|
||||
/// it as soon as it is typed, so the engine never runs out of characters
|
||||
/// partway through a message and never transmits idle in the middle of one.
|
||||
///
|
||||
/// An edit is expressed as backspaces followed by the new text. How much can be
|
||||
/// taken back is what `TxBufLen` last said, less `Guard`: the count is a poll
|
||||
/// old, and a backspace over a character that has already been transmitted is
|
||||
/// refused by the engine, which would put the text after it on the air twice.
|
||||
/// Holding one character back stops that, at the cost of one character that
|
||||
/// could have been retracted.
|
||||
///
|
||||
/// Characters, backspaces and the questions all go to the engine through one
|
||||
/// queue, in the order they were made, so an answer is about the text the
|
||||
/// engine had when it was asked. An answer that arrives after something else
|
||||
/// has been written is thrown away rather than guessed at.
|
||||
///
|
||||
/// `docs/unfinished.md` lists what has been checked against a real engine and
|
||||
/// what has not.
|
||||
public sealed class EngineTypeAhead : TransmitBuffer
|
||||
{
|
||||
/// How often the engine is asked how much it has left. Under a third of a
|
||||
/// character at 45.45 baud, which is what keeps `Guard` at one character.
|
||||
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// How many characters at the front of the engine's buffer are treated as
|
||||
/// gone even though the last count said they were still there.
|
||||
public const int Guard = 1;
|
||||
|
||||
private readonly EngineBuffer engine;
|
||||
private readonly TimeSpan pollInterval;
|
||||
private readonly Lock gate = new();
|
||||
private readonly StringBuilder sent = new();
|
||||
private readonly StringBuilder pending = new();
|
||||
private readonly Channel<Instruction> outgoing = Channel.CreateUnbounded<Instruction>();
|
||||
|
||||
private CancellationTokenSource? stopping;
|
||||
private Task pump = Task.CompletedTask;
|
||||
private Task writer = Task.CompletedTask;
|
||||
|
||||
/// Characters of `pending` that have been queued for the engine.
|
||||
private int held;
|
||||
|
||||
/// Characters of `pending` the engine has actually been given. It trails
|
||||
/// `held` by whatever is still in the queue.
|
||||
private int given;
|
||||
|
||||
/// Counts everything written to the engine, so an answer can be checked
|
||||
/// against the state it was asked in.
|
||||
private long written;
|
||||
private long writtenWhenAsked = -1;
|
||||
private int givenWhenAsked;
|
||||
private int cursor = TransmitBuffer.NoCursor;
|
||||
|
||||
public EngineTypeAhead(EngineBuffer engine, TimeSpan? pollInterval = null)
|
||||
{
|
||||
this.engine = engine;
|
||||
this.pollInterval = pollInterval ?? PollInterval;
|
||||
engine.Buffered += WhenBuffered;
|
||||
}
|
||||
|
||||
/// False once the engine has refused to say how much it holds. It still
|
||||
/// takes text; nothing can be taken back and the pane shows what has been
|
||||
/// handed over rather than what has gone out.
|
||||
public bool Counts { get; private set; } = true;
|
||||
|
||||
public string Sent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return sent.ToString() + pending.ToString(0, Frozen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Pending
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return pending.ToString(Frozen, pending.Length - Frozen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int Cursor
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
cursor = Math.Max(0, value);
|
||||
Push();
|
||||
}
|
||||
Start();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSending
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return pending.Length > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public event EventHandler? Drained;
|
||||
|
||||
public void Append(string text)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock (gate)
|
||||
{
|
||||
pending.Append(text);
|
||||
if (cursor != TransmitBuffer.NoCursor)
|
||||
{
|
||||
cursor += text.Length;
|
||||
}
|
||||
Push();
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Start();
|
||||
}
|
||||
|
||||
/// The text the operator left in the box, which is everything except what
|
||||
/// this has already given up on taking back.
|
||||
public void Rewrite(string text, int wanted)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
string whole = pending.ToString(0, Frozen) + text;
|
||||
int shared = Shared(pending, whole);
|
||||
for (int over = held - shared; over > 0; over--)
|
||||
{
|
||||
Write(new Instruction('\b'));
|
||||
}
|
||||
held = Math.Min(held, shared);
|
||||
pending.Clear();
|
||||
pending.Append(whole);
|
||||
cursor = Math.Clamp(wanted, 0, text.Length);
|
||||
Push();
|
||||
}
|
||||
Start();
|
||||
}
|
||||
|
||||
/// The engine's own abort empties its buffer, so nothing has to be taken
|
||||
/// back a character at a time.
|
||||
public void Drop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Empty();
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Empty();
|
||||
sent.Clear();
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// The engine stopped transmitting, so everything it had been given has
|
||||
/// gone out. What is still in the queue has not.
|
||||
public void EngineIdle()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Transmitted(given);
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
engine.Buffered -= WhenBuffered;
|
||||
outgoing.Writer.TryComplete();
|
||||
stopping?.Cancel();
|
||||
stopping?.Dispose();
|
||||
stopping = null;
|
||||
}
|
||||
|
||||
/// How many characters two strings start the same.
|
||||
private static int Shared(StringBuilder was, string now)
|
||||
{
|
||||
int same = 0;
|
||||
while (same < was.Length && same < now.Length && was[same] == now[same])
|
||||
{
|
||||
same++;
|
||||
}
|
||||
return same;
|
||||
}
|
||||
|
||||
private int Frozen => Math.Min(held, Guard);
|
||||
|
||||
/// Queues everything the operator has released. Called with the lock held.
|
||||
private void Push()
|
||||
{
|
||||
int limit = cursor == TransmitBuffer.NoCursor
|
||||
? pending.Length
|
||||
: Math.Min(pending.Length, Frozen + cursor);
|
||||
while (held < limit)
|
||||
{
|
||||
Write(new Instruction(pending[held]));
|
||||
held++;
|
||||
}
|
||||
}
|
||||
|
||||
private void Write(Instruction instruction)
|
||||
{
|
||||
written++;
|
||||
outgoing.Writer.TryWrite(instruction);
|
||||
}
|
||||
|
||||
private void Empty()
|
||||
{
|
||||
pending.Clear();
|
||||
held = 0;
|
||||
given = 0;
|
||||
cursor = TransmitBuffer.NoCursor;
|
||||
}
|
||||
|
||||
/// An answer to a question asked when the engine held `givenWhenAsked`
|
||||
/// characters. Anything written since then makes it useless: the engine had
|
||||
/// already moved on when it answered.
|
||||
private void WhenBuffered(object? sender, int left)
|
||||
{
|
||||
if (left < 0)
|
||||
{
|
||||
Counts = false;
|
||||
return;
|
||||
}
|
||||
Counts = true;
|
||||
lock (gate)
|
||||
{
|
||||
if (written != writtenWhenAsked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Transmitted(givenWhenAsked - left);
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// The first `gone` characters have been transmitted and cannot be taken
|
||||
/// back. Called with the lock held.
|
||||
private void Transmitted(int gone)
|
||||
{
|
||||
gone = Math.Min(gone, pending.Length);
|
||||
if (gone <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int frozenBefore = Frozen;
|
||||
sent.Append(pending.ToString(0, gone));
|
||||
if (sent.Length > TransmitBuffer.KeptSent)
|
||||
{
|
||||
sent.Remove(0, sent.Length - TransmitBuffer.KeptSent);
|
||||
}
|
||||
pending.Remove(0, gone);
|
||||
held -= gone;
|
||||
given -= gone;
|
||||
if (cursor != TransmitBuffer.NoCursor)
|
||||
{
|
||||
// the visible text starts at `Frozen`, which moves as well
|
||||
cursor = Math.Max(0, cursor - gone - Frozen + frozenBefore);
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (writer.IsCompleted)
|
||||
{
|
||||
writer = Task.Run(WriteAsync);
|
||||
}
|
||||
if (!pump.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
stopping?.Dispose();
|
||||
stopping = new CancellationTokenSource();
|
||||
pump = Task.Run(() => PollAsync(stopping.Token));
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts a question in the queue behind whatever is waiting, until there is
|
||||
/// nothing left to send.
|
||||
private async Task PollAsync(CancellationToken cancellation)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Write(new Instruction(Ask: true));
|
||||
}
|
||||
await Task.Delay(pollInterval, cancellation).ConfigureAwait(false);
|
||||
if (!IsSending)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// The one place the engine is written to, so characters, backspaces and
|
||||
/// questions reach it in the order they were made.
|
||||
private async Task WriteAsync()
|
||||
{
|
||||
await foreach (Instruction instruction in outgoing.Reader.ReadAllAsync().ConfigureAwait(false))
|
||||
{
|
||||
if (instruction.Ask)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
writtenWhenAsked = written;
|
||||
givenWhenAsked = given;
|
||||
}
|
||||
await engine.AskBufferedAsync("").ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
await engine.TypeAsync(instruction.Character).ConfigureAwait(false);
|
||||
lock (gate)
|
||||
{
|
||||
given += instruction.Character == '\b' ? -1 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One thing to do to the engine: a character to type, a backspace, or the
|
||||
/// question about how much is left.
|
||||
private readonly record struct Instruction(char Character = '\0', bool Ask = false);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
namespace Nonemm.Digital;
|
||||
|
||||
/// The text waiting to go out. Two of these exist: `TypeAhead` holds the text
|
||||
/// here and feeds the engine, `EngineTypeAhead` hands it to the engine and
|
||||
/// takes it back with backspaces. The digital window works through this and
|
||||
/// does not know which one it has.
|
||||
public interface TransmitBuffer : IDisposable
|
||||
{
|
||||
/// `Cursor` set to this lets everything pending go out, which is where it
|
||||
/// stands while the operator is not typing into the pane.
|
||||
public const int NoCursor = int.MaxValue;
|
||||
|
||||
/// How much of the text that has gone out is kept. It is there to be read
|
||||
/// back, not to be a log.
|
||||
public const int KeptSent = 2000;
|
||||
|
||||
/// What has gone out and can no longer be changed.
|
||||
string Sent { get; }
|
||||
|
||||
/// What is still to go, which is what the operator edits.
|
||||
string Pending { get; }
|
||||
|
||||
/// How many of the pending characters may go out. The window sets it to
|
||||
/// where the operator's cursor is; `NoCursor` while nobody is typing.
|
||||
int Cursor { get; set; }
|
||||
|
||||
/// There is text to send, or the engine has not finished what it was given.
|
||||
bool IsSending { get; }
|
||||
|
||||
/// The text moved: a character went out, or a message was added. Raised off
|
||||
/// the screen thread, so a handler that draws has to post.
|
||||
event EventHandler? Changed;
|
||||
|
||||
/// Everything that was waiting has gone out and the engine has transmitted
|
||||
/// it. This is what tells the entry window that a message is finished, so
|
||||
/// what stands after `{END}` runs and `{RX}` drops the transmitter at the
|
||||
/// right moment.
|
||||
event EventHandler? Drained;
|
||||
|
||||
/// A message to send. It goes on the end of what is already waiting, so two
|
||||
/// function keys pressed together send one after the other rather than one
|
||||
/// over the other.
|
||||
void Append(string text);
|
||||
|
||||
/// The operator rewrote what has not gone out yet.
|
||||
void Rewrite(string text, int cursor);
|
||||
|
||||
/// Drops what has not gone out. Escape and the RX button do this.
|
||||
void Drop();
|
||||
|
||||
/// Empties the pane, which is what the CLR button does between
|
||||
/// transmissions.
|
||||
void Clear();
|
||||
|
||||
/// The engine has stopped transmitting, so whatever it held has gone out.
|
||||
void EngineIdle();
|
||||
}
|
||||
@@ -7,30 +7,29 @@ namespace Nonemm.Digital;
|
||||
///
|
||||
/// A digital engine takes a whole message and transmits it at the baud rate,
|
||||
/// which is slow: a callsign and a report take several seconds. Once the engine
|
||||
/// has the message nothing can be changed, so the operator who sees a wrong
|
||||
/// call go out has to stop the transmission and start again. This keeps the
|
||||
/// message here instead and feeds it to the engine a few characters at a time,
|
||||
/// so everything that has not gone out yet can still be rewritten, added to or
|
||||
/// deleted.
|
||||
/// has a character nothing can take it back — MMTTY treats a backspace as
|
||||
/// another character to transmit rather than as an edit, which the engine probe
|
||||
/// showed on the air. So the message is held here and the engine is given
|
||||
/// `Lead` characters at a time: one being transmitted and one behind it, so it
|
||||
/// never runs dry and never transmits idle in the middle of a message, while
|
||||
/// everything further back can still be rewritten, added to or deleted.
|
||||
///
|
||||
/// `Sent` is what has gone to the engine and cannot be taken back. `Pending` is
|
||||
/// what is still to go. `Cursor` is how much of the pending text may go out,
|
||||
/// which is where the operator is typing: the pump stops when it reaches the
|
||||
/// cursor, because the operator has not finished the word yet.
|
||||
///
|
||||
/// The pump keeps `Lead` characters in the engine rather than one. An engine
|
||||
/// that runs out of characters partway through a message does not wait: it
|
||||
/// transmits idle until the next one arrives, so every gap the pump leaves is
|
||||
/// added to the time the message takes. Keeping a second character queued
|
||||
/// behind the one being transmitted means the engine never runs dry, and the
|
||||
/// cost is that the last `Lead` characters cannot be taken back rather than the
|
||||
/// last one.
|
||||
/// How far the engine has got comes from the engine when it can say. MMTTY
|
||||
/// answers `TxBufLen` with the number of characters it still has to transmit,
|
||||
/// so the pump asks, feeds while the answer is under `Lead`, and asks again. An
|
||||
/// engine that will not answer is paced by the clock at the baud rate instead,
|
||||
/// which drifts and is what the baud setting is for.
|
||||
///
|
||||
/// `Lead` characters at the front are timed off the clock at the baud rate,
|
||||
/// which is an estimate of how far the engine has got. It is corrected by
|
||||
/// `EngineIdle`: an engine that has stopped transmitting has an empty buffer,
|
||||
/// whatever the estimate says.
|
||||
public sealed class TypeAhead : TransmitBuffer
|
||||
/// A count that stops going down means the engine is holding what it has:
|
||||
/// MMTTY set to Word out keeps a word until the space after it arrives. The
|
||||
/// pump feeds one character per look while that lasts, so the space gets there
|
||||
/// and the word goes out.
|
||||
public sealed class TypeAhead : IDisposable
|
||||
{
|
||||
/// A RTTY character is a start bit, five data bits and a stop bit and a
|
||||
/// half.
|
||||
@@ -45,21 +44,35 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
/// message beyond reach.
|
||||
public const int DefaultLead = 2;
|
||||
|
||||
/// The longest the pump sleeps between looks at the buffer. It is what
|
||||
/// stands between the operator moving the cursor on and the next character
|
||||
/// going out, so it is short against a character time.
|
||||
/// How much of the text that has gone out is kept. It is there to be read
|
||||
/// back, not to be a log.
|
||||
public const int KeptSent = 2000;
|
||||
|
||||
/// `Cursor` set to this lets everything pending go out, which is where it
|
||||
/// stands while the operator is not typing into the pane.
|
||||
public const int NoCursor = int.MaxValue;
|
||||
|
||||
/// How often the engine is asked how much it has left.
|
||||
public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
/// The longest the clock-paced pump sleeps between looks at the buffer. It
|
||||
/// is what stands between the operator moving the cursor on and the next
|
||||
/// character going out, so it is short against a character time.
|
||||
private static readonly TimeSpan LongestTick = TimeSpan.FromMilliseconds(10);
|
||||
|
||||
/// An engine that has not answered by now is not going to.
|
||||
private static readonly TimeSpan AnswerPatience = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly Func<char, CancellationToken, Task> send;
|
||||
private readonly EngineBuffer? counter;
|
||||
private readonly Lock gate = new();
|
||||
private readonly StringBuilder pending = new();
|
||||
private readonly StringBuilder sent = new();
|
||||
|
||||
private CancellationTokenSource? stopping;
|
||||
private Task pump = Task.CompletedTask;
|
||||
private int cursor = TransmitBuffer.NoCursor;
|
||||
private int inEngine;
|
||||
private DateTime nextOut = DateTime.MinValue;
|
||||
private TaskCompletionSource<int>? asking;
|
||||
private int cursor = NoCursor;
|
||||
|
||||
public TypeAhead(Func<char, CancellationToken, Task> send, double baud = DefaultBaud)
|
||||
{
|
||||
@@ -67,16 +80,34 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
Baud = baud;
|
||||
}
|
||||
|
||||
/// The speed the engine transmits at, which is what the estimate of its
|
||||
/// buffer is paced by.
|
||||
/// An engine that holds a buffer of its own and can say how much of it is
|
||||
/// left, which is what paces the pump instead of the clock.
|
||||
public TypeAhead(EngineBuffer engine, double baud = DefaultBaud)
|
||||
: this((character, cancellation) => engine.TypeAsync(character, cancellation), baud)
|
||||
{
|
||||
counter = engine;
|
||||
engine.Buffered += WhenBuffered;
|
||||
}
|
||||
|
||||
/// The speed the engine transmits at, which paces the pump when the engine
|
||||
/// will not say how much it holds.
|
||||
public double Baud { get; set; }
|
||||
|
||||
/// How many characters may sit in the engine at once.
|
||||
public int Lead { get; set; } = DefaultLead;
|
||||
|
||||
/// False once the engine has refused to say how much it holds, which puts
|
||||
/// the pump back on the clock.
|
||||
public bool Counts { get; private set; } = true;
|
||||
|
||||
public TimeSpan CharacterTime =>
|
||||
TimeSpan.FromSeconds(BitsPerCharacter / (Baud > 0 ? Baud : DefaultBaud));
|
||||
|
||||
/// How long the engine may hold what it has before the pump feeds it
|
||||
/// anyway. Three characters is longer than any gap between transmitted
|
||||
/// characters and short enough that a held word goes out at once.
|
||||
public TimeSpan HoldingPatience => CharacterTime * 3;
|
||||
|
||||
/// What has gone to the engine.
|
||||
public string Sent
|
||||
{
|
||||
@@ -102,7 +133,7 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
}
|
||||
|
||||
/// How many of the pending characters may go out. The window sets it to
|
||||
/// where the operator's cursor is; `TransmitBuffer.NoCursor` while nobody is typing.
|
||||
/// where the operator's cursor is; `NoCursor` while nobody is typing.
|
||||
public int Cursor
|
||||
{
|
||||
get
|
||||
@@ -121,16 +152,13 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
}
|
||||
}
|
||||
|
||||
/// True while there is text to send or the engine is estimated to be still
|
||||
/// transmitting what it was given.
|
||||
public bool IsSending
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Advance(DateTime.UtcNow);
|
||||
return pending.Length > 0 || inEngine > 0;
|
||||
return pending.Length > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,10 +167,10 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
/// the pump's thread, so a handler that touches the screen has to post.
|
||||
public event EventHandler? Changed;
|
||||
|
||||
/// Everything that was waiting has gone out, and the engine is estimated to
|
||||
/// have transmitted it. This is what tells the entry window that a message
|
||||
/// is finished, so what stands after `{END}` runs and `{RX}` drops the
|
||||
/// transmitter at the right moment.
|
||||
/// Everything that was waiting has gone out and the engine has transmitted
|
||||
/// it. This is what tells the entry window that a message is finished, so
|
||||
/// what stands after `{END}` runs and `{RX}` drops the transmitter at the
|
||||
/// right moment.
|
||||
public event EventHandler? Drained;
|
||||
|
||||
/// A message to send. It goes on the end of what is already waiting, so two
|
||||
@@ -157,7 +185,7 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
lock (gate)
|
||||
{
|
||||
pending.Append(text);
|
||||
if (cursor != TransmitBuffer.NoCursor)
|
||||
if (cursor != NoCursor)
|
||||
{
|
||||
// text added behind the operator's cursor is still text to
|
||||
// send, so the cursor moves out with it
|
||||
@@ -180,18 +208,6 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
Start();
|
||||
}
|
||||
|
||||
/// The engine has stopped transmitting, so whatever it was given has gone
|
||||
/// out. This corrects the estimate: an engine that is faster than the
|
||||
/// estimate would otherwise be left waiting for a character it could have
|
||||
/// had.
|
||||
public void EngineIdle()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
inEngine = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops what has not gone out. Escape and the RX button do this: what is
|
||||
/// already in the engine cannot be stopped from here, and the engine's own
|
||||
/// abort takes care of that.
|
||||
@@ -200,8 +216,7 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
lock (gate)
|
||||
{
|
||||
pending.Clear();
|
||||
inEngine = 0;
|
||||
cursor = TransmitBuffer.NoCursor;
|
||||
cursor = NoCursor;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -214,14 +229,17 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
{
|
||||
pending.Clear();
|
||||
sent.Clear();
|
||||
inEngine = 0;
|
||||
cursor = TransmitBuffer.NoCursor;
|
||||
cursor = NoCursor;
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (counter is not null)
|
||||
{
|
||||
counter.Buffered -= WhenBuffered;
|
||||
}
|
||||
stopping?.Cancel();
|
||||
stopping?.Dispose();
|
||||
stopping = null;
|
||||
@@ -241,34 +259,109 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the engine a character whenever it has room for one, until there
|
||||
/// is nothing left to send. A pump held at the cursor keeps running: the
|
||||
/// operator is typing, and the next character is theirs to release.
|
||||
private async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
if (counter is not null && Counts && await CountedAsync(cancellation).ConfigureAwait(false))
|
||||
{
|
||||
if (Take() is { } next)
|
||||
{
|
||||
await send(next, cancellation).ConfigureAwait(false);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
continue;
|
||||
}
|
||||
if (!IsSending)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
await PacedAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the engine how much it has left, feeds it up to `Lead`, and asks
|
||||
/// again. The pump is the only thing that writes to the engine, so an
|
||||
/// answer is about characters it has already been given.
|
||||
///
|
||||
/// Returns false if the engine will not say, which puts the pump on the
|
||||
/// clock instead.
|
||||
private async Task<bool> CountedAsync(CancellationToken cancellation)
|
||||
{
|
||||
DateTime moved = DateTime.UtcNow;
|
||||
int last = int.MaxValue;
|
||||
bool wasEmpty = false;
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
int left = await AskAsync(cancellation).ConfigureAwait(false);
|
||||
if (left < 0)
|
||||
{
|
||||
Counts = false;
|
||||
return false;
|
||||
}
|
||||
if (left < last)
|
||||
{
|
||||
moved = DateTime.UtcNow;
|
||||
}
|
||||
bool holding = DateTime.UtcNow - moved > HoldingPatience;
|
||||
while ((left < Lead || holding) && Take() is { } next)
|
||||
{
|
||||
await send(next, cancellation).ConfigureAwait(false);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
left++;
|
||||
holding = false;
|
||||
moved = DateTime.UtcNow;
|
||||
}
|
||||
last = left;
|
||||
// the engine takes a moment to count what it has just been given,
|
||||
// so one empty answer is not the end of the message
|
||||
if (left == 0 && !IsSending)
|
||||
{
|
||||
if (wasEmpty)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
wasEmpty = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
wasEmpty = false;
|
||||
}
|
||||
await Task.Delay(PollInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The fallback: count character times off the clock at the baud rate, so
|
||||
/// the engine is fed at the speed it transmits. It drifts, which shows up
|
||||
/// as a gap between characters near the end of a long message.
|
||||
private async Task PacedAsync(CancellationToken cancellation)
|
||||
{
|
||||
int inEngine = 0;
|
||||
DateTime nextOut = DateTime.MinValue;
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
while (inEngine > 0 && now >= nextOut)
|
||||
{
|
||||
inEngine--;
|
||||
nextOut += CharacterTime;
|
||||
}
|
||||
if (inEngine < Lead && Take() is { } next)
|
||||
{
|
||||
if (inEngine == 0)
|
||||
{
|
||||
nextOut = now + CharacterTime;
|
||||
}
|
||||
inEngine++;
|
||||
await send(next, cancellation).ConfigureAwait(false);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
continue;
|
||||
}
|
||||
if (inEngine == 0 && !IsSending)
|
||||
{
|
||||
Drained?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
await Task.Delay(Tick, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan Tick
|
||||
{
|
||||
get
|
||||
@@ -278,47 +371,59 @@ public sealed class TypeAhead : TransmitBuffer
|
||||
}
|
||||
}
|
||||
|
||||
/// The next character to send, or null when there is none to send now: the
|
||||
/// engine is full, nothing is waiting, or what is waiting is behind the
|
||||
/// cursor.
|
||||
/// One question and its answer, or -1 when the engine would not say.
|
||||
private async Task<int> AskAsync(CancellationToken cancellation)
|
||||
{
|
||||
if (counter is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
TaskCompletionSource<int> answer = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
lock (gate)
|
||||
{
|
||||
asking = answer;
|
||||
}
|
||||
await counter.AskBufferedAsync("", cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await answer.Task.WaitAsync(AnswerPatience, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private void WhenBuffered(object? sender, int left)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
asking?.TrySetResult(left);
|
||||
}
|
||||
}
|
||||
|
||||
/// The next character to send, or null when there is none to send now:
|
||||
/// nothing is waiting, or what is waiting is behind the cursor.
|
||||
private char? Take()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
Advance(now);
|
||||
if (pending.Length == 0 || cursor == 0 || inEngine >= Lead)
|
||||
if (pending.Length == 0 || cursor == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
char next = pending[0];
|
||||
pending.Remove(0, 1);
|
||||
if (cursor != TransmitBuffer.NoCursor)
|
||||
if (cursor != NoCursor)
|
||||
{
|
||||
cursor--;
|
||||
}
|
||||
if (inEngine == 0)
|
||||
{
|
||||
nextOut = now + CharacterTime;
|
||||
}
|
||||
inEngine++;
|
||||
sent.Append(next);
|
||||
if (sent.Length > TransmitBuffer.KeptSent)
|
||||
if (sent.Length > KeptSent)
|
||||
{
|
||||
sent.Remove(0, sent.Length - TransmitBuffer.KeptSent);
|
||||
sent.Remove(0, sent.Length - KeptSent);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes off the estimate the characters the engine has had time to
|
||||
/// transmit since the last look. Called with the lock held.
|
||||
private void Advance(DateTime now)
|
||||
{
|
||||
while (inEngine > 0 && now >= nextOut)
|
||||
{
|
||||
inEngine--;
|
||||
nextOut += CharacterTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user