diff --git a/CHANGELIST.md b/CHANGELIST.md index 4819a1c1d..af2c6906b 100644 --- a/CHANGELIST.md +++ b/CHANGELIST.md @@ -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 diff --git a/MPF.Avalonia/Helpers/OutputPump.cs b/MPF.Avalonia/Helpers/OutputPump.cs new file mode 100644 index 000000000..2f9c067d1 --- /dev/null +++ b/MPF.Avalonia/Helpers/OutputPump.cs @@ -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 +{ + /// + /// 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 () 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 (). + /// 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 + /// (), 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. + /// + public sealed class OutputPump + { + private readonly Channel _channel; + + public OutputPump(int capacity = 8192) + { + _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + } + + /// + /// Producer: enqueue a raw output chunk. Never blocks, never throws on full. + /// + 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); + } + + /// + /// Signal that no more output will arrive. + /// + public void Complete() => _channel.Writer.TryComplete(); + + /// + /// Drain every chunk currently queued into one list, without waiting. + /// Caller feeds the batch to a and pushes a + /// single UI update. caps a single drain so one + /// pathological burst cannot monopolize a flush tick. + /// + public List DrainAvailable(int max = 4096) + { + var batch = new List(); + while (batch.Count < max && _channel.Reader.TryRead(out string? chunk)) + { + batch.Add(chunk); + } + + return batch; + } + + /// + /// Await at least one chunk being available (or completion). For the flush loop. + /// + public ValueTask WaitToReadAsync(CancellationToken ct = default) + => _channel.Reader.WaitToReadAsync(ct); + + /// + /// 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. + /// + public async Task RunAsync(TerminalBuffer buffer, Action onBatch, int flushIntervalMs = 75, CancellationToken ct = default) + { + try + { + while (await WaitToReadAsync(ct).ConfigureAwait(false)) + { + List 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 tail = DrainAvailable(); + foreach (string chunk in tail) + { + buffer.Feed(chunk); + } + + buffer.Flush(); + onBatch(tail.Count); + } + } +} diff --git a/MPF.Avalonia/Helpers/TerminalBuffer.cs b/MPF.Avalonia/Helpers/TerminalBuffer.cs new file mode 100644 index 000000000..615b07d8c --- /dev/null +++ b/MPF.Avalonia/Helpers/TerminalBuffer.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace MPF.Avalonia.Helpers +{ + /// + /// Minimal terminal line-discipline for redirected child-process output. + /// + /// The point: read RAW characters from stdout (never ReadLine) and + /// interpret carriage returns the way a real terminal does. Dump tools + /// (redumper, DiscImageCreator, Aaru) update their progress with a bare + /// \r (no \n) so the same physical line is redrawn in place. + /// ReadLine/OutputDataReceived treat \r 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, \r moves the write cursor to column 0 of the live line + /// (overwrite, not new line); \n 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. + /// + public sealed class TerminalBuffer + { + private readonly StringBuilder _current = new(); + + /// + /// Write column within + /// + private int _cursor; + + /// + /// Mid ANSI escape sequence (survives chunk boundaries) + /// + private bool _inEscape; + + private readonly StringBuilder _escape = new(); + + /// + /// Lines committed so far (each was terminated by \n). + /// + public List CommittedLines { get; } = new(); + + /// + /// The live line being assembled after the last \n. + /// + public string CurrentLine => _current.ToString(); + + /// + /// Raised once per committed line. + /// + public event Action? LineCommitted; + + /// + /// Raised when the live line changes (printable char, \r overwrite, erase). + /// + public event Action? CurrentLineChanged; + + /// + /// Feed a raw chunk of output. Chunk boundaries are arbitrary: a \r\n + /// or an escape sequence may be split across two calls and is handled. + /// + 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; + + default: + if (!char.IsControl(c)) + { + WriteChar(c); + liveChanged = true; + } + // other control chars (bell, etc.) are dropped + break; + } + } + + if (liveChanged) + CurrentLineChanged?.Invoke(CurrentLine); + } + + /// + /// Force-commit whatever is in the live line (e.g. on process exit). + /// + 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++; + } + + private void CommitLine() + { + string line = _current.ToString(); + CommittedLines.Add(line); + LineCommitted?.Invoke(line); + _current.Clear(); + _cursor = 0; + } + + /// + /// 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. + /// + 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; + } + + /// + /// Apply a CSI sequence given its parameter string and final byte. + /// + 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; + } +} diff --git a/MPF.Avalonia/Resources/Strings.de.xaml b/MPF.Avalonia/Resources/Strings.de.xaml index 0aef46289..cf1537b3d 100644 --- a/MPF.Avalonia/Resources/Strings.de.xaml +++ b/MPF.Avalonia/Resources/Strings.de.xaml @@ -10,6 +10,8 @@ Abbrechen Minimieren Schließen + Kopieren + Alles auswählen Einstellungen Status WARNUNG: @@ -49,6 +51,10 @@ Löschen Speichern + + Programmausgabe + Dieses Fenster schließen, wenn das Programm beendet ist + Optionen Willkommen bei MPF, erkunden Sie die Optionen diff --git a/MPF.Avalonia/Resources/Strings.xaml b/MPF.Avalonia/Resources/Strings.xaml index 28ee3739f..d64273785 100644 --- a/MPF.Avalonia/Resources/Strings.xaml +++ b/MPF.Avalonia/Resources/Strings.xaml @@ -10,6 +10,8 @@ Cancel Minimize Close + Copy + Select All Settings Status WARNING: @@ -50,6 +52,10 @@ Clear Log Save Log + + Program Output + Close this window when the program exits + Options Welcome to MPF, Explore the Options diff --git a/MPF.Avalonia/Services/ToolOutputConsole.cs b/MPF.Avalonia/Services/ToolOutputConsole.cs new file mode 100644 index 000000000..5e1a7d790 --- /dev/null +++ b/MPF.Avalonia/Services/ToolOutputConsole.cs @@ -0,0 +1,128 @@ +using System; +using global::Avalonia; +using global::Avalonia.Controls; +using global::Avalonia.Platform; +using MPF.Avalonia.Windows; +using MPF.Frontend; +using MPF.Frontend.Tools; + +namespace MPF.Avalonia.Services +{ + /// + /// Bridges the frontend's seam to an Avalonia + /// . + /// + /// + /// Only created on platforms that need an in-app console for the dumping tool (Linux, + /// where the tool has no console window of its own). The window is shown modeless and + /// owned, so the MPF main window keeps working and its Stop button stays reachable. + /// + public sealed class ToolOutputConsole : IToolOutputConsole + { + /// + /// Gap between the main window and the tool-output window, in device-independent pixels + /// + private const double PlacementGap = 8; + + private readonly Window _owner; + private readonly Options _options; + private ToolOutputWindow? _window; + + public ToolOutputConsole(Window owner, Options options) + { + _owner = owner; + _options = options; + } + + /// + public void Open() + { + // Discard any leftover console from a previous run (kept open because the user + // turned off auto-close) so each dump starts with a clean console. + _window?.Close(); + _window = null; + + var window = new ToolOutputWindow(_options.GUI.ToolConsoleAutoClose); + window.AutoCloseChanged += OnAutoCloseChanged; + window.Closed += (_, _) => + { + if (ReferenceEquals(_window, window)) + _window = null; + }; + _window = window; + + TryPositionBesideOwner(window); + window.Show(_owner); + } + + /// + public void Append(string chunk) => _window?.Append(chunk); + + /// + public void NotifyToolExited() => _window?.NotifyToolExited(); + + /// + /// Persist the user's auto-close preference whenever the checkbox is toggled + /// + private void OnAutoCloseChanged(bool autoClose) + { + _options.GUI.ToolConsoleAutoClose = autoClose; + OptionsLoader.SaveToConfig(_options); + } + + /// + /// Place the console beside the main window on whichever side has room, falling back + /// to overlaying the main window; always clamped fully inside the monitor work area. + /// + private void TryPositionBesideOwner(ToolOutputWindow window) + { + try + { + Screen? screen = _owner.Screens.ScreenFromWindow(_owner) ?? _owner.Screens.Primary; + if (screen is null) + { + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + return; + } + + double scale = screen.Scaling; + PixelRect area = screen.WorkingArea; + + // Owner frame and the new window, converted to physical pixels. + Size ownerSize = _owner.FrameSize ?? _owner.Bounds.Size; + PixelPoint ownerPos = _owner.Position; + int ownerW = (int)Math.Round(ownerSize.Width * scale); + int winW = (int)Math.Round(window.Width * scale); + int winH = (int)Math.Round(window.Height * scale); + int gap = (int)Math.Round(PlacementGap * scale); + + int rightRoom = (area.X + area.Width) - (ownerPos.X + ownerW); + int leftRoom = ownerPos.X - area.X; + + int x; + if (rightRoom >= winW + gap) + x = ownerPos.X + ownerW + gap; // beside, to the right + else if (leftRoom >= winW + gap) + x = ownerPos.X - gap - winW; // beside, to the left + else + x = ownerPos.X + (ownerW - winW) / 2; // overlay, centered on the owner + + int y = ownerPos.Y; // align tops + + // Clamp the whole window inside the work area so it never lands off-screen. + int maxX = (area.X + area.Width) - winW; + int maxY = (area.Y + area.Height) - winH; + x = Math.Max(area.X, Math.Min(x, maxX)); + y = Math.Max(area.Y, Math.Min(y, maxY)); + + window.WindowStartupLocation = WindowStartupLocation.Manual; + window.Position = new PixelPoint(x, y); + } + catch + { + // Any platform quirk in the geometry math -> just center on the owner. + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + } + } + } +} diff --git a/MPF.Avalonia/Windows/MainWindow.axaml.cs b/MPF.Avalonia/Windows/MainWindow.axaml.cs index 687ea119e..677e45c53 100644 --- a/MPF.Avalonia/Windows/MainWindow.axaml.cs +++ b/MPF.Avalonia/Windows/MainWindow.axaml.cs @@ -116,10 +116,18 @@ private void OnOpened(object? sender, EventArgs e) if (MainViewModel.Options.GUI.ShowDebugViewMenuItem) DebugViewMenuItem!.IsVisible = true; + // On Linux the dumping tool has no console window of its own, so stream its + // live output into a separate MPF window. Windows/macOS keep the tool's own + // console (leave this null), preserving the original behavior. + IToolOutputConsole? toolConsole = OperatingSystem.IsLinux() + ? new ToolOutputConsole(this, MainViewModel.Options) + : null; + MainViewModel.Init( LogOutput!.EnqueueLog, DisplayUserMessage, - ShowMediaInformationWindow); + ShowMediaInformationWindow, + toolConsole); // Pass translation strings to MainViewModel var translationStrings = new Dictionary diff --git a/MPF.Avalonia/Windows/ToolOutputWindow.axaml b/MPF.Avalonia/Windows/ToolOutputWindow.axaml new file mode 100644 index 000000000..da7d87c86 --- /dev/null +++ b/MPF.Avalonia/Windows/ToolOutputWindow.axaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MPF.Avalonia/Windows/ToolOutputWindow.axaml.cs b/MPF.Avalonia/Windows/ToolOutputWindow.axaml.cs new file mode 100644 index 000000000..52b7c7122 --- /dev/null +++ b/MPF.Avalonia/Windows/ToolOutputWindow.axaml.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using global::Avalonia.Controls; +using global::Avalonia.Controls.Primitives; +using global::Avalonia.Input; +using global::Avalonia.Input.Platform; +using global::Avalonia.Interactivity; +using global::Avalonia.Layout; +using global::Avalonia.Threading; +using global::Avalonia.VisualTree; +using MPF.Avalonia.Helpers; + +namespace MPF.Avalonia.Windows +{ + /// + /// Separate window that streams a dumping tool's live standard output and error. + /// + /// + /// Shown modeless (via Show(owner)) so the MPF main window never freezes and + /// its Stop button stays reachable. Raw output chunks arrive on + /// from background reader threads; a bounded batches them and + /// a applies terminal line-discipline so a tool's bare + /// \r progress redraws collapse onto a single live line instead of flooding the + /// scrollback. Committed lines feed a virtualized list with a CMD-style fixed cap. + /// + public partial class ToolOutputWindow : Window + { + /// + /// CMD-style fixed scrollback: oldest lines are trimmed past this cap + /// + private const int MaxLines = 10_000; + + /// + /// One batched UI dispatch per ~60 fps frame + /// + private const int FlushIntervalMs = 16; + + private readonly ObservableCollection _lines = new(); + private readonly TerminalBuffer _buffer = new(); + private readonly OutputPump _pump = new(capacity: 8192); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _pumpTask; + + /// + /// Committed lines already pushed to the UI + /// + private int _committedIndex; + + /// + /// Follow the tail; toggled only by user input + /// + private bool _autoScroll = true; + + /// + /// Coalesces deferred ScrollToEnd posts + /// + private bool _scrollQueued; + + /// + /// Vertical scrollbar Scroll event subscribed + /// + private bool _scrollBarHooked; + + /// + /// ListBox's inner scroll viewer (found lazily) + /// + private ScrollViewer? _scroller; + + /// + /// Raised when the user toggles the auto-close checkbox, so the host can persist it + /// + public event Action? AutoCloseChanged; + + public ToolOutputWindow() : this(autoClose: true) { } + + public ToolOutputWindow(bool autoClose) + { + InitializeComponent(); + + LinesList.ItemsSource = _lines; + + CopyMenuItem.Click += (_, _) => CopySelectionToClipboard(); + SelectAllMenuItem.Click += (_, _) => LinesList.SelectAll(); + // Window-level so Ctrl+C / Ctrl+A work without giving the (deliberately + // non-focusable) log rows keyboard focus. + KeyDown += OnKeyDown; + + AutoCloseCheck.IsChecked = autoClose; + AutoCloseCheck.IsCheckedChanged += (_, _) => AutoCloseChanged?.Invoke(AutoCloseCheck.IsChecked == true); + + // The pump runs for the lifetime of the window, draining whatever Append posts. + _pumpTask = _pump.RunAsync(_buffer, OnBatch, FlushIntervalMs, _cts.Token); + } + + /// + /// Whether the window should close itself when the tool exits + /// + public bool AutoClose + { + get => AutoCloseCheck.IsChecked == true; + set => AutoCloseCheck.IsChecked = value; + } + + /// + /// Push a raw chunk of tool output. Thread-safe: called from reader threads. + /// + public void Append(string chunk) => _pump.Post(chunk); + + /// + /// The tool process exited: flush the remaining output, then close if the user + /// opted in. Safe to call on the UI thread. + /// + public async void NotifyToolExited() + { + // Complete the pump so its final drain flushes the tail to the UI, then wait + // for that drain to finish before deciding whether to close. + _pump.Complete(); + try + { + await _pumpTask; + } + catch + { + // shutdown races are fine + } + + if (AutoCloseCheck.IsChecked == true) + Close(); + } + + /// + /// Ctrl+C / context-menu Copy: copy the selected log lines as plain text. + /// Ctrl+A selects every committed line. + /// + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.C) + { + CopySelectionToClipboard(); + e.Handled = true; + } + else if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.A) + { + LinesList.SelectAll(); + e.Handled = true; + } + } + + /// + /// Copies the selected rows to the clipboard, newline-joined and in top-to-bottom + /// (source) order regardless of the order they were clicked. + /// + private async void CopySelectionToClipboard() + { + IReadOnlyList selected = LinesList.Selection?.SelectedIndexes ?? Array.Empty(); + if (selected.Count == 0) + return; + + var sb = new StringBuilder(selected.Count * 48); + foreach (int idx in selected.OrderBy(i => i)) + { + if (idx >= 0 && idx < _lines.Count) + sb.Append(_lines[idx]); + sb.Append('\n'); + } + if (sb.Length > 0 && sb[^1] == '\n') + sb.Length -= 1; // drop the trailing newline + + IClipboard? clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard is not null) + await clipboard.SetTextAsync(sb.ToString()); + } + + /// + /// Runs on the consumer thread after a batch was fed to the buffer. Snapshot the + /// new committed lines + the current live line, then do exactly ONE dispatch to + /// the UI thread for the whole batch. + /// + private void OnBatch(int consumed) + { + List? newLines = null; + List committed = _buffer.CommittedLines; + if (_committedIndex < committed.Count) + { + newLines = new List(committed.Count - _committedIndex); + for (; _committedIndex < committed.Count; _committedIndex++) + { + newLines.Add(committed[_committedIndex]); + } + } + + string live = _buffer.CurrentLine; + + Dispatcher.UIThread.Post(() => + { + EnsureScroller(); + + if (newLines is not null) + { + foreach (string line in newLines) + { + _lines.Add(line); + } + } + + // CMD-style fixed scrollback: always drop the oldest lines past the cap. + while (_lines.Count > MaxLines) + { + _lines.RemoveAt(0); + } + + LiveLine.Text = live; + + if (_autoScroll && _scroller is not null && !_scrollQueued) + { + _scrollQueued = true; + // Defer past the layout pass so the extent is fresh and we land on the bottom. + Dispatcher.UIThread.Post(() => + { + _scrollQueued = false; + if (_autoScroll) + _scroller!.ScrollToEnd(); + }, DispatcherPriority.Background); + } + }); + } + + /// + /// Lazily grab the ListBox's inner ScrollViewer (only exists after the control is + /// templated) and subscribe to its scroll changes for tail-follow tracking. + /// + private void EnsureScroller() + { + if (_scroller is null) + { + _scroller = LinesList.FindDescendantOfType(); + if (_scroller is not null) + LinesList.PointerWheelChanged += OnWheel; + } + + // The vertical ScrollBar realizes slightly later than the ScrollViewer. + if (_scroller is not null && !_scrollBarHooked) + { + ScrollBar? vbar = _scroller.GetVisualDescendants() + .OfType() + .FirstOrDefault(sb => sb.Orientation == Orientation.Vertical); + if (vbar is not null) + { + vbar.Scroll += OnUserScroll; + _scrollBarHooked = true; + } + } + } + + /// + /// Tail-follow is toggled ONLY by genuine user input: the scrollbar's Scroll event + /// (thumb drag / track / page) and the mouse wheel. Neither fires for the + /// programmatic ScrollToEnd or for the ring-buffer trim -- so following never drops + /// on its own under fast output. Position then decides direction: at/near the + /// bottom = follow, else not. + /// + private void OnUserScroll(object? sender, ScrollEventArgs e) + => SyncFollowToPosition(); + + private void OnWheel(object? sender, PointerWheelEventArgs e) + // The wheel scroll is applied after this event fires; check once it has. + => Dispatcher.UIThread.Post(SyncFollowToPosition, DispatcherPriority.Background); + + private void SyncFollowToPosition() + { + if (_scroller is null) + return; + + double distanceFromBottom = _scroller.Extent.Height - _scroller.Viewport.Height - _scroller.Offset.Y; + _autoScroll = distanceFromBottom <= 4; + } + + protected override void OnClosed(EventArgs e) + { + // Best-effort shutdown; if the tool is still running its output is simply + // dropped from here on (Post on a completed channel is a no-op). + _cts.Cancel(); + _pump.Complete(); + base.OnClosed(e); + } + } +} diff --git a/MPF.ExecutionContexts/BaseExecutionContext.cs b/MPF.ExecutionContexts/BaseExecutionContext.cs index 872a765d5..d3adaec99 100644 --- a/MPF.ExecutionContexts/BaseExecutionContext.cs +++ b/MPF.ExecutionContexts/BaseExecutionContext.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Text.RegularExpressions; +using System.Threading; using SabreTools.RedumpLib.Data; namespace MPF.ExecutionContexts @@ -45,6 +47,18 @@ public bool? this[string key] /// private Process? process; + /// + /// Optional sink for the tool's raw standard output and error + /// + /// + /// When set, the program is launched with its output redirected and streamed + /// here in raw chunks (carriage returns preserved), instead of letting the tool + /// draw its own console window. Used by a GUI frontend to display live tool + /// output on platforms without an external console. Leave null for the legacy + /// behavior, where the tool owns its console window (UseShellExecute). + /// + public Action? OutputReceived { get; set; } + #endregion #region Virtual Dumping Information @@ -195,15 +209,20 @@ protected abstract void SetDefaultParameters(string? drivePath, /// public void ExecuteInternalProgram() { + // When no output sink is attached, keep the original behavior exactly: the + // tool runs in its own console window (UseShellExecute) with no redirection. + // When a sink is attached, redirect output and stream it to the caller. + bool redirect = OutputReceived is not null; + // Create the start info var startInfo = new ProcessStartInfo() { FileName = ExecutablePath!, Arguments = GenerateParameters() ?? "", - CreateNoWindow = false, - UseShellExecute = true, - RedirectStandardOutput = false, - RedirectStandardError = false, + CreateNoWindow = redirect, + UseShellExecute = !redirect, + RedirectStandardOutput = redirect, + RedirectStandardError = redirect, }; // Create the new process @@ -211,10 +230,63 @@ public void ExecuteInternalProgram() // Start the process process.Start(); - process.WaitForExit(); + + if (redirect) + { + // Pump stdout and stderr on dedicated threads using RAW reads (never + // ReadLine): dump tools redraw progress with a bare '\r', and ReadLine + // would treat every '\r' as a new line -- the exact spam/lag that made + // redirected output unusable before. The consumer interprets '\r'. + Thread outThread = StartStreamPump(process.StandardOutput); + Thread errThread = StartStreamPump(process.StandardError); + + process.WaitForExit(); + outThread.Join(); + errThread.Join(); + } + else + { + process.WaitForExit(); + } + process.Close(); } + /// + /// Start a background thread that copies a redirected stream to + /// in raw chunks + /// + /// + /// Blocking reads on a dedicated thread keep this compatible across every target + /// framework (no async or channels needed here). Multiple pumps may run at once; + /// the sink is expected to be safe to call from several threads. + /// + private Thread StartStreamPump(StreamReader reader) + { + var thread = new Thread(() => + { + try + { + var buffer = new char[4096]; + int read; + while ((read = reader.Read(buffer, 0, buffer.Length)) > 0) + { + OutputReceived?.Invoke(new string(buffer, 0, read)); + } + } + catch + { + // The stream is closed when the process exits or is killed; nothing to do. + } + }) + { + IsBackground = true, + Name = "MPF tool output pump", + }; + thread.Start(); + return thread; + } + /// /// Cancel an in-progress dumping process /// diff --git a/MPF.Frontend/DumpEnvironment.cs b/MPF.Frontend/DumpEnvironment.cs index a6a1879a4..6dda32b91 100644 --- a/MPF.Frontend/DumpEnvironment.cs +++ b/MPF.Frontend/DumpEnvironment.cs @@ -97,6 +97,17 @@ public int? Speed /// public EventHandler? ReportStatus; + /// + /// Optional sink for the dumping tool's raw output + /// + /// + /// When set, the tool runs with its standard output and error redirected and + /// streamed here. Used by a GUI frontend to show live tool output in a separate + /// window on platforms without an external console. Leave null for the legacy + /// behavior, where the tool owns its own console window. + /// + public Action? ToolOutputReceived { get; set; } + #endregion /// @@ -489,6 +500,10 @@ public async Task Run(PhysicalMediaType? mediaType, IProgress { _executionContext.ExecuteInternalProgram(); return true; }); #else diff --git a/MPF.Frontend/IToolOutputConsole.cs b/MPF.Frontend/IToolOutputConsole.cs new file mode 100644 index 000000000..83523c875 --- /dev/null +++ b/MPF.Frontend/IToolOutputConsole.cs @@ -0,0 +1,39 @@ +namespace MPF.Frontend +{ + /// + /// Abstraction for a live tool-output console shown by a GUI frontend while a + /// dumping program runs. + /// + /// + /// Implemented by a frontend that can display the tool's redirected output in its + /// own surface (for example, a separate window on Linux, where the dumping tool has + /// no console window of its own). Frontends that rely on the tool drawing its own + /// console window (Windows) leave this null, preserving the original behavior. + /// + public interface IToolOutputConsole + { + /// + /// Show the console. Called on the UI thread just before the tool starts. + /// + void Open(); + + /// + /// Receive a raw chunk of the tool's standard output or error. + /// + /// Raw output chunk, with carriage returns preserved + /// + /// Thread-safe: called from background reader threads, so implementations must + /// not touch UI state directly here. + /// + void Append(string chunk); + + /// + /// Signal that the tool process has exited. Called on the UI thread. + /// + /// + /// The implementation closes the console or leaves it open per the user's + /// preference. + /// + void NotifyToolExited(); + } +} diff --git a/MPF.Frontend/Options.cs b/MPF.Frontend/Options.cs index f4c8ef5f3..f4aa34010 100644 --- a/MPF.Frontend/Options.cs +++ b/MPF.Frontend/Options.cs @@ -88,6 +88,7 @@ public Options(Options? source) GUI.CopyUpdateUrlToClipboard = source.GUI.CopyUpdateUrlToClipboard; GUI.OpenLogWindowAtStartup = source.GUI.OpenLogWindowAtStartup; + GUI.ToolConsoleAutoClose = source.GUI.ToolConsoleAutoClose; GUI.DefaultInterfaceLanguage = source.GUI.DefaultInterfaceLanguage; GUI.ShowDebugViewMenuItem = source.GUI.ShowDebugViewMenuItem; @@ -444,6 +445,12 @@ public class GuiSettings /// Version 1 and greater public bool OpenLogWindowAtStartup { get; set; } = true; + /// + /// Automatically close the separate tool-output window when the dumping program exits + /// + /// Version 1 and greater; currently used by the Linux GUI tool-output window + public bool ToolConsoleAutoClose { get; set; } = true; + #endregion #region Interface diff --git a/MPF.Frontend/Tools/OptionsLoader.cs b/MPF.Frontend/Tools/OptionsLoader.cs index 1918b9aff..82ffe72a9 100644 --- a/MPF.Frontend/Tools/OptionsLoader.cs +++ b/MPF.Frontend/Tools/OptionsLoader.cs @@ -367,6 +367,7 @@ private static Options ConvertFromDictionary(Dictionary? source options.GUI.CopyUpdateUrlToClipboard = GetBooleanSetting(source, "CopyUpdateUrlToClipboard", true); options.GUI.OpenLogWindowAtStartup = GetBooleanSetting(source, "OpenLogWindowAtStartup", true); + options.GUI.ToolConsoleAutoClose = GetBooleanSetting(source, "ToolConsoleAutoClose", true); valueString = GetStringSetting(source, "DefaultInterfaceLanguage", InterfaceLanguage.AutoDetect.ShortName()); options.GUI.DefaultInterfaceLanguage = valueString.ToInterfaceLanguage(); @@ -541,6 +542,7 @@ private static Options ConvertFromDictionary(Dictionary? source { "VerboseLogging", options.VerboseLogging.ToString() }, { "OpenLogWindowAtStartup", options.GUI.OpenLogWindowAtStartup.ToString() }, + { "ToolConsoleAutoClose", options.GUI.ToolConsoleAutoClose.ToString() }, { "RetrieveMatchInformation", options.Processing.Login.RetrieveMatchInformation.ToString() }, { "RedumpOrgUsername", options.Processing.Login.RedumpOrgUsername }, diff --git a/MPF.Frontend/ViewModels/MainViewModel.cs b/MPF.Frontend/ViewModels/MainViewModel.cs index 18d9e1a20..a93e79db2 100644 --- a/MPF.Frontend/ViewModels/MainViewModel.cs +++ b/MPF.Frontend/ViewModels/MainViewModel.cs @@ -71,6 +71,11 @@ public Options Options /// private ProcessUserInfoDelegate? _processUserInfo; + /// + /// Optional live tool-output console supplied by the frontend (null if unused) + /// + private IToolOutputConsole? _toolConsole; + #endregion #region Properties @@ -597,12 +602,14 @@ public MainViewModel() public void Init( Action loggerAction, Func displayUserMessage, - ProcessUserInfoDelegate processUserInfo) + ProcessUserInfoDelegate processUserInfo, + IToolOutputConsole? toolConsole = null) { // Set the callbacks _logger = loggerAction; _displayUserMessage = displayUserMessage; _processUserInfo = processUserInfo; + _toolConsole = toolConsole; // Finish initializing the rest of the values InitializeUIValues(removeEventHandlers: false, rebuildPrograms: true, rescanDrives: true); @@ -2259,9 +2266,20 @@ public async void StartDumping() protectionProgress.ProgressChanged += ProgressUpdated; _environment.ReportStatus += ProgressUpdated; + // Stream live tool output to the separate console window, if the frontend + // provides one (Linux GUI). Harmless no-op for frontends that do not. + if (_toolConsole is not null) + { + _toolConsole.Open(); + _environment.ToolOutputReceived = _toolConsole.Append; + } + // Run the program with the parameters ResultEventArgs result = await _environment.Run(CurrentPhysicalMediaType, resultProgress); + // The tool has exited; let the console close itself per the user's preference + _toolConsole?.NotifyToolExited(); + // If we didn't execute a dumping command we cannot get submission output if (!_environment.IsDumpingCommand()) {