Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELIST.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### WIP (xxxx-xx-xx)

- Add live tool output console for the Linux GUI (gmipf)

### 3.8.2 (2026-07-01)

- Output used configuration path for commandline programs
Expand Down
121 changes: 121 additions & 0 deletions MPF.Avalonia/Helpers/OutputPump.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

namespace MPF.Avalonia.Helpers
{
/// <summary>
/// Decouples the process-output producer from the (slow) UI consumer with a
/// bounded channel, so a firehose of output can never block the reader thread
/// or grow memory without bound -- the two failure modes behind the old lag.
///
/// - Producer side (<see cref="Post"/>) is called from the stdout/stderr read
/// loops. It never blocks: when the consumer falls behind and the buffer is
/// full, the OLDEST queued chunk is dropped (<see cref="BoundedChannelFullMode.DropOldest"/>).
/// The raw stream is still written to the log file in full elsewhere, so
/// dropping only affects the live on-screen tail, never the saved log.
/// - Consumer side drains everything currently available in ONE batch
/// (<see cref="DrainAvailable"/>), so the UI does a single dispatch per
/// flush tick instead of one per line. That is what keeps a high output
/// rate from saturating the UI thread.
/// </summary>
public sealed class OutputPump
{
private readonly Channel<string> _channel;

public OutputPump(int capacity = 8192)
{
_channel = Channel.CreateBounded<string>(new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
}

/// <summary>
/// Producer: enqueue a raw output chunk. Never blocks, never throws on full.
/// </summary>
public void Post(string chunk)
{
// With DropOldest, TryWrite always succeeds for a bounded channel and
// silently evicts the oldest item when full -- exactly the backpressure
// we want for a live tail. After completion TryWrite returns false, which
// we ignore (output arriving after the window closed is simply dropped).
_channel.Writer.TryWrite(chunk);
}

/// <summary>
/// Signal that no more output will arrive.
/// </summary>
public void Complete() => _channel.Writer.TryComplete();

/// <summary>
/// Drain every chunk currently queued into one list, without waiting.
/// Caller feeds the batch to a <see cref="TerminalBuffer"/> and pushes a
/// single UI update. <paramref name="max"/> caps a single drain so one
/// pathological burst cannot monopolize a flush tick.
/// </summary>
public List<string> DrainAvailable(int max = 4096)
{
var batch = new List<string>();
while (batch.Count < max && _channel.Reader.TryRead(out string? chunk))
Comment thread
mnadareski marked this conversation as resolved.
{
batch.Add(chunk);
}

return batch;
}

/// <summary>
/// Await at least one chunk being available (or completion). For the flush loop.
/// </summary>
public ValueTask<bool> WaitToReadAsync(CancellationToken ct = default)
=> _channel.Reader.WaitToReadAsync(ct);

/// <summary>
/// Reference flush loop: wait, drain a batch, feed the buffer, raise one
/// onBatch callback (with the number of chunks consumed in the batch), then
/// throttle. In the GUI this callback marshals to the UI thread once per
/// batch. Throttling is what bounds the dispatch rate.
/// </summary>
public async Task RunAsync(TerminalBuffer buffer, Action<int> onBatch, int flushIntervalMs = 75, CancellationToken ct = default)
{
try
{
while (await WaitToReadAsync(ct).ConfigureAwait(false))
{
List<string> batch = DrainAvailable();
if (batch.Count > 0)
{
foreach (string chunk in batch)
{
buffer.Feed(chunk);
}

onBatch(batch.Count);
}

if (flushIntervalMs > 0)
await Task.Delay(flushIntervalMs, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// expected on shutdown
}

// Final drain after completion so the tail is not lost.
List<string> tail = DrainAvailable();
foreach (string chunk in tail)
{
buffer.Feed(chunk);
}

buffer.Flush();
onBatch(tail.Count);
}
}
}
237 changes: 237 additions & 0 deletions MPF.Avalonia/Helpers/TerminalBuffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace MPF.Avalonia.Helpers
{
/// <summary>
/// Minimal terminal line-discipline for redirected child-process output.
///
/// The point: read RAW characters from stdout (never <c>ReadLine</c>) and
/// interpret carriage returns the way a real terminal does. Dump tools
/// (redumper, DiscImageCreator, Aaru) update their progress with a bare
/// <c>\r</c> (no <c>\n</c>) so the same physical line is redrawn in place.
/// <c>ReadLine</c>/<c>OutputDataReceived</c> treat <c>\r</c> as a line break,
/// which explodes one updating line into thousands of separate lines -- the
/// spam + lag the maintainer saw when output was redirected years ago.
///
/// Here, <c>\r</c> moves the write cursor to column 0 of the live line
/// (overwrite, not new line); <c>\n</c> commits the live line. Progress
/// updates therefore mutate a single live line and never grow the log.
///
/// No UI dependency: pure and unit-testable. The UI binds the committed lines
/// to a virtualized list and the live line to one trailing row.
/// </summary>
public sealed class TerminalBuffer
{
private readonly StringBuilder _current = new();

/// <summary>
/// Write column within <see cref="_current"/>
/// </summary>
private int _cursor;

/// <summary>
/// Mid ANSI escape sequence (survives chunk boundaries)
/// </summary>
private bool _inEscape;

private readonly StringBuilder _escape = new();

/// <summary>
/// Lines committed so far (each was terminated by <c>\n</c>).
/// </summary>
public List<string> CommittedLines { get; } = new();

/// <summary>
/// The live line being assembled after the last <c>\n</c>.
/// </summary>
public string CurrentLine => _current.ToString();

/// <summary>
/// Raised once per committed line.
/// </summary>
public event Action<string>? LineCommitted;

/// <summary>
/// Raised when the live line changes (printable char, <c>\r</c> overwrite, erase).
/// </summary>
public event Action<string>? CurrentLineChanged;

/// <summary>
/// Feed a raw chunk of output. Chunk boundaries are arbitrary: a <c>\r\n</c>
/// or an escape sequence may be split across two calls and is handled.
/// </summary>
public void Feed(string? chunk)
{
if (string.IsNullOrEmpty(chunk))
return;

bool liveChanged = false;
for (int i = 0; i < chunk.Length; i++)
{
char c = chunk[i];

if (_inEscape)
{
liveChanged |= FeedEscape(c);
continue;
}

switch (c)
{
case '\x1b': // ESC -- start of an ANSI sequence
_inEscape = true;
_escape.Clear();
break;

case '\n':
CommitLine();
break;

case '\r':
_cursor = 0; // carriage return: back to column 0, keep the chars
break;

case '\t':
do { WriteChar(' '); } while (_cursor % 8 != 0);
liveChanged = true;
break;

case '\b':
if (_cursor > 0)
_cursor--;

break;
Comment thread
mnadareski marked this conversation as resolved.

default:
if (!char.IsControl(c))
{
WriteChar(c);
liveChanged = true;
}
// other control chars (bell, etc.) are dropped
break;
}
}

if (liveChanged)
CurrentLineChanged?.Invoke(CurrentLine);
}

/// <summary>
/// Force-commit whatever is in the live line (e.g. on process exit).
/// </summary>
public void Flush()
{
if (_current.Length > 0)
CommitLine();
}

private void WriteChar(char c)
{
if (_cursor < _current.Length)
_current[_cursor] = c;
else
_current.Append(c);

_cursor++;
Comment thread
mnadareski marked this conversation as resolved.
}

private void CommitLine()
{
string line = _current.ToString();
CommittedLines.Add(line);
LineCommitted?.Invoke(line);
_current.Clear();
_cursor = 0;
}

/// <summary>
/// Consume one character of an in-progress escape sequence. We only model
/// the few CSI sequences progress writers actually use; everything else
/// (colors, cursor moves) is recognized and stripped so it never shows as
/// literal garbage. Returns whether the live line changed.
/// </summary>
private bool FeedEscape(char c)
{
// First char after ESC decides the family.
if (_escape.Length == 0)
{
if (c == '[')
{
_escape.Append(c); // CSI -- keep collecting until a final byte
return false;
}

// Not a CSI (e.g. ESC( charset, ESC] OSC). We don't model these;
// drop ESC + this byte and resume. Good enough for tool output.
_inEscape = false;
return false;
}

// Inside CSI: parameters/intermediates until a final byte 0x40-0x7E.
if (c >= '@' && c <= '~')
{
bool changed = ApplyCsi(_escape.ToString(1, _escape.Length - 1), c);
_inEscape = false;
_escape.Clear();
return changed;
}

_escape.Append(c);
return false;
}

/// <summary>
/// Apply a CSI sequence given its parameter string and final byte.
/// </summary>
private bool ApplyCsi(string parameters, char final)
{
switch (final)
{
case 'K': // EL -- erase in line
EraseInLine(ParseInt(parameters, 0));
return true;

case 'G': // CHA -- cursor to absolute column (1-based)
_cursor = Math.Max(0, ParseInt(parameters, 1) - 1);
return false;

case 'D': // CUB -- cursor back N
_cursor = Math.Max(0, _cursor - Math.Max(1, ParseInt(parameters, 1)));
return false;

default:
// SGR colors ('m'), cursor up/down, etc. -- recognized and ignored.
return false;
}
}

private void EraseInLine(int mode)
{
switch (mode)
{
case 0: // cursor to end of line
if (_cursor < _current.Length)
_current.Length = _cursor;

break;
case 1: // start of line to cursor (blank them, keep length)
for (int i = 0; i < _cursor && i < _current.Length; i++)
{
_current[i] = ' ';
}

break;
case 2: // entire line
_current.Clear();
_cursor = 0;
break;
}
}

private static int ParseInt(string s, int fallback)
=> int.TryParse(s, out int v) ? v : fallback;
}
}
6 changes: 6 additions & 0 deletions MPF.Avalonia/Resources/Strings.de.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<x:String x:Key="CancelButtonString">Abbrechen</x:String>
<x:String x:Key="MinimizeMenuItemString">Minimieren</x:String>
<x:String x:Key="CloseMenuItemString">Schließen</x:String>
<x:String x:Key="CopyMenuItemString">Kopieren</x:String>
<x:String x:Key="SelectAllMenuItemString">Alles auswählen</x:String>
<x:String x:Key="SettingsGroupBoxString">Einstellungen</x:String>
<x:String x:Key="StatusGroupBoxString">Status</x:String>
<x:String x:Key="WarningLabelString">WARNUNG:</x:String>
Expand Down Expand Up @@ -49,6 +51,10 @@
<x:String x:Key="ClearButtonString">Löschen</x:String>
<x:String x:Key="SaveButtonString">Speichern</x:String>

<!-- Tool Output Window -->
<x:String x:Key="ToolOutputTitleString">Programmausgabe</x:String>
<x:String x:Key="ToolOutputCloseOnExitCheckBoxString">Dieses Fenster schließen, wenn das Programm beendet ist</x:String>

<!-- Options Window -->
<x:String x:Key="OptionsTitleString">Optionen</x:String>
<x:String x:Key="OptionsFirstRunTitleString">Willkommen bei MPF, erkunden Sie die Optionen</x:String>
Expand Down
Loading