From 738bda206f6af84975bf3680369cb73bd17f6505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 4 Sep 2026 20:09:59 -0400 Subject: [PATCH 1/3] perf: coalesce output-pump invalidations and gate idle blink repaints Applies the winterm-ghostty throughput lessons to the Devolutions Terminal output path: - TermControl collapses queued engine invalidations into a single trailing UI-thread drain (interlocked pending flag) instead of one dispatcher post per 16 KiB ConPTY read; accessibility/scroll-mark/viewport/IME listener fan-out now runs once per burst. Engine.Feed stays on the PTY thread for CSI 6n response timing. - Cursor blink timer is damage-gated: ticks only invalidate when pixels can change (focused pane, blinking + visible cursor per DECSET 12/25, effectively visible control); focus loss repaints once out of a mid-blink dark phase. - New tools/Devolutions.Terminal.Bench harness (engine/control modes, 16 KiB chunks, frame-paced dispatcher drains, medians): an 8 MiB burst drops from 514 UI drains to ~1 per frame. - Correctness pins from the winterm-ghostty audit: paste vs WriteInput separation, ConPTY per-sequence write serialization under a two-writer race, event-resolved key symbols for shifted Ctrl keys (modifyOtherKeys and kitty associated text), and libghostty-vt stack headroom (survives an adversarial corpus on a 256 KB thread, 4x under the host default). - Docs: renderer.md coalescing/blink contract, ghostty-engine.md stack note, parity-status.md measurement entry. --- Devolutions.Terminal.slnx | 1 + Directory.Packages.props | 1 + docs/ghostty-engine.md | 11 + docs/parity-status.md | 9 + docs/renderer.md | 23 ++ .../AssemblyInfo.cs | 1 + .../TermControl.cs | 71 ++++- .../ConnectionContractTests.cs | 72 ++++++ .../KeyMapperTests.cs | 27 ++ .../TermControlBlinkTests.cs | 185 +++++++++++++ .../TermControlOutputPumpTests.cs | 43 +++ .../TermControlPasteTests.cs | 181 +++++++++++++ .../GhosttyTerminalEngineTests.cs | 54 ++++ .../Devolutions.Terminal.Bench.csproj | 16 ++ tools/Devolutions.Terminal.Bench/Program.cs | 244 ++++++++++++++++++ 15 files changed, 936 insertions(+), 3 deletions(-) create mode 100644 tests/Devolutions.Terminal.Control.Tests/TermControlBlinkTests.cs create mode 100644 tests/Devolutions.Terminal.Control.Tests/TermControlPasteTests.cs create mode 100644 tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj create mode 100644 tools/Devolutions.Terminal.Bench/Program.cs diff --git a/Devolutions.Terminal.slnx b/Devolutions.Terminal.slnx index c479bd2..539133c 100644 --- a/Devolutions.Terminal.slnx +++ b/Devolutions.Terminal.slnx @@ -31,5 +31,6 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 16cda3a..69fc3f0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/docs/ghostty-engine.md b/docs/ghostty-engine.md index aa12748..587d463 100644 --- a/docs/ghostty-engine.md +++ b/docs/ghostty-engine.md @@ -38,6 +38,17 @@ copied into JIT, NativeAOT, and MSIX outputs. The ABI type manifest is validated at startup. See `native/ghostty/ghostty-upstream.json` and `native/Restore-NativeLibraries.ps1`. +## Stack headroom + +The engine's P/Invoke calls execute on whatever thread the host is using — +including the ConPTY read loop's thread-pool thread with the default ~1 MB stack +reserve. Full libghostty (renderer included) has overflowed a 1 MB stack in +other hosts; the VT-only `libghostty-vt` has a much smaller surface. A test +(`EngineSurvivesAdversarialCorpusOnSmallStackThread`) feeds an adversarial +corpus on a 256 KB-stack thread — 4x below the host default — to pin that +headroom. If the pinned upstream changes and that test fails, route engine calls +onto a dedicated thread with an explicit `maxStackSize`. + ## Current rendering boundary Ghostty owns VT parsing, modes, viewport state, resize/reflow, scrollback, diff --git a/docs/parity-status.md b/docs/parity-status.md index c1fdc27..0b23e82 100644 --- a/docs/parity-status.md +++ b/docs/parity-status.md @@ -86,6 +86,15 @@ registrations, and notices for both x64 and ARM64. | Extended keyboard | Built-in Kitty set/query/push/pop flags, CSI-u event bytes, `modifyOtherKeys`, Win32-input mode, and press/repeat/release encoding implemented | Kitty alternate-key reporting and associated-text reporting are not advertised; the pinned Ghostty C ABI exposes no keyboard protocol state and reports these capabilities unavailable | | Shader effects | Optional deterministic, bounded Skia retro/scanline pass, toggleable per active terminal | Custom arbitrary HLSL/pixel-shader files are not loaded or advertised | +Output bursts from the PTY read loop coalesce into one UI-thread invalidation +drain per frame instead of one per 16 KiB chunk, and the cursor blink timer is +damage-gated (no repaints for unfocused, steady-cursor, or hidden panes). The +`tools/Devolutions.Terminal.Bench` harness measures the path (`engine` and +`control` modes, 16 KiB chunks, medians over runs); an 8 MiB burst drains once +per frame rather than 514 times. Concurrent host writes to ConPTY are serialized +per sequence so query responses and key input cannot interleave mid-sequence, +pinned by a live two-writer contract test. + ## Distribution and validation The `linux-arm64-hardware` CI job runs on GitHub's native diff --git a/docs/renderer.md b/docs/renderer.md index 7ec89e6..dfbf54b 100644 --- a/docs/renderer.md +++ b/docs/renderer.md @@ -44,6 +44,29 @@ contract. The control retains the previous frame and computes changed text and cursor rows for compositor integration. Dynamic selection, search, and hovered hyperlink ranges are separate overlays so they do not invalidate glyph entries. +## Output-pump coalescing + +The ConPTY read loop raises one engine invalidation per 16 KiB read. `TermControl` +collapses queued invalidations into a single trailing UI-thread drain +(`DrainEngineInvalidation`): a producer that finds the pending flag already set +adds no new dispatcher post, and a drain that observes fresh output re-queues +itself. The engine feed itself stays on the PTY thread so cursor-position report +replies (CSI 6n) return before the application prints more. The drain is what +fires the accessibility, scroll-mark, viewport, and IME-cursor notifications, so +listener fan-out runs once per burst instead of once per chunk. + +The cursor blink timer is damage-gated: a tick only invalidates when it can +change pixels — the pane must be focused (unfocused panes draw a static cursor), +the engine must report a blinking, visible cursor (DECSET 12/25), and the +control must be effectively visible. Losing focus out of a mid-blink dark phase +repaints once so the static cursor is solid. + +`tools/Devolutions.Terminal.Bench` measures this path end to end +(`bench control --mb 8`): a deterministic 8 MiB corpus is fed in 16 KiB chunks +through a fake connection with dispatcher drains paced at 60 Hz, reporting +engine invalidations vs posts vs drains alongside MB/s (medians over runs). +Coalescing takes an 8 MiB burst from 514 drains to roughly one per frame. + ## Performance contract Steady-state paint reuses Skia paints, cached text blobs, Powerline paths, and a diff --git a/src/Devolutions.Terminal.Control/AssemblyInfo.cs b/src/Devolutions.Terminal.Control/AssemblyInfo.cs index dbb9ef8..a77771b 100644 --- a/src/Devolutions.Terminal.Control/AssemblyInfo.cs +++ b/src/Devolutions.Terminal.Control/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Devolutions.Terminal.Control.Tests")] +[assembly: InternalsVisibleTo("Devolutions.Terminal.Bench")] diff --git a/src/Devolutions.Terminal.Control/TermControl.cs b/src/Devolutions.Terminal.Control/TermControl.cs index 2059fce..dfee7bd 100644 --- a/src/Devolutions.Terminal.Control/TermControl.cs +++ b/src/Devolutions.Terminal.Control/TermControl.cs @@ -69,6 +69,23 @@ public sealed class TermControl : Avalonia.Controls.Control private bool _selectionAlternateBuffer; private bool _rendererDisposed; private bool _shaderEffectsEnabled = true; + private long _invalidationPosts; + private long _invalidationDrains; + private int _invalidationPending; + + // Throughput-harness diagnostics (Devolutions.Terminal.Bench): posts requested by + // the engine-invalidated handler vs UI drains actually executed. + internal long InvalidationPosts => Interlocked.Read(ref _invalidationPosts); + internal long InvalidationDrains => Interlocked.Read(ref _invalidationDrains); + + // True when a blink-timer tick can change pixels. Unfocused panes draw a static + // cursor, steady/hidden cursor modes (DECRST 12/25) never blink, and hidden + // controls do not present. + internal bool ShouldAnimateCursor => + IsFocused && + Engine.CursorBlinking && + Engine.CursorVisible && + IsEffectivelyVisible; public TermControl(ITerminalEngine? engine = null) { @@ -86,11 +103,37 @@ public TermControl(ITerminalEngine? engine = null) ClipToBounds = true; TextInputMethodClientRequested += OnTextInputMethodClientRequested; GotFocus += (_, _) => SendFocusChanged(focused: true); - LostFocus += (_, _) => SendFocusChanged(focused: false); + LostFocus += (_, _) => + { + SendFocusChanged(focused: false); + if (!_cursorOn) + { + // The unfocused pane draws a static cursor and the blink timer no + // longer animates it — repaint out of a mid-blink dark phase. + _cursorOn = true; + InvalidateVisual(); + } + }; _blinkTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) }; _blinkTimer.Tick += (_, _) => { + // Damage-gated idle rendering: skip the tick when blinking cannot change + // any pixel — unfocused panes draw a static cursor (see the drawCursor + // expression in Render), steady/hidden cursor modes never blink, and + // hidden controls do not present. When the animation is off, pin + // _cursorOn so the cursor is solid, repainting once if it was mid-blink. + if (!ShouldAnimateCursor) + { + if (!_cursorOn) + { + _cursorOn = true; + InvalidateVisual(); + } + + return; + } + _cursorOn = !_cursorOn; InvalidateVisual(); }; @@ -101,13 +144,20 @@ public TermControl(ITerminalEngine? engine = null) // Avalonia controls and may snapshot history. That work must not // run on the PTY thread or throw back into Engine.Feed — either // kills ConPTY ReadLoop and leaves the constructor-sized blank grid. + // + // Coalesce bursts: the PTY ReadLoop raises one invalidation per 16 KiB + // chunk, and each drain fires listener fan-out + InvalidateVisual. + // Collapse queued invalidations into a single trailing drain (the + // winterm-ghostty lesson: per-chunk UI work was the whole throughput + // gap). A drain re-queues itself if more output arrived mid-drain. + Interlocked.Increment(ref _invalidationPosts); if (Dispatcher.UIThread.CheckAccess()) { HandleEngineInvalidated(); } - else + else if (Interlocked.Exchange(ref _invalidationPending, 1) == 0) { - Dispatcher.UIThread.Post(HandleEngineInvalidated, DispatcherPriority.Render); + Dispatcher.UIThread.Post(DrainEngineInvalidation, DispatcherPriority.Render); } }; Engine.TitleChanged += (_, title) => @@ -1258,8 +1308,23 @@ private void OnOutput(object? sender, ReadOnlyMemory data) } } + private void DrainEngineInvalidation() + { + // Trailing edge: clear the flag before handling so an invalidation that + // arrives during handling is observed by the loop check below. A producer + // that exchanges the flag from 0 to 1 posts its own drain; one that finds + // it already 1 relies on this loop's recheck. + do + { + Interlocked.Exchange(ref _invalidationPending, 0); + HandleEngineInvalidated(); + } + while (Volatile.Read(ref _invalidationPending) != 0); + } + private void HandleEngineInvalidated() { + Interlocked.Increment(ref _invalidationDrains); try { if (_selection is not null && diff --git a/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs b/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs index 4d8ecb9..a43e733 100644 --- a/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs +++ b/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Runtime.Versioning; using System.Text; +using System.Text.RegularExpressions; using Devolutions.Terminal.Connection; using Xunit; @@ -561,6 +562,77 @@ private static TaskCompletionSource EnqueueExit( return completion; } + [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))] + public async Task ConcurrentWritesNeverSplitSequence() + { + // Pins the ConPTY injection lesson: query responses (engine, PTY thread) and + // key input (UI thread) race on the input pipe — every write must reach the + // child as one indivisible sequence. The child answers 'ok' for a uniform + // payload line and 'BAD' for a byte-interleaved one. + await using var connection = new ConPtyConnection(); + var output = new List(); + connection.OutputReceived += (_, bytes) => + { + lock (output) + { + output.AddRange(bytes.ToArray()); + } + }; + + await connection.StartAsync( + "powershell.exe -NoProfile -NonInteractive -Command \"" + + "while (($line = [Console]::In.ReadLine()) -ne $null) { " + + "if ($line -match '^(A+|B+)$') { [Console]::Out.Write('ok`n') } " + + "else { [Console]::Out.Write('BAD:' + $line + '`n') } " + + "[Console]::Out.Flush() }\"", + null, + 200, + 50); + + var payloadA = new string('A', 100); + var payloadB = new string('B', 100); + const int writesPerThread = 100; + await Task.WhenAll( + Task.Run(() => + { + for (var i = 0; i < writesPerThread; i++) + { + connection.Write(payloadA + "\r"); + } + }), + Task.Run(() => + { + for (var i = 0; i < writesPerThread; i++) + { + connection.Write(payloadB + "\r"); + } + })); + + var deadline = DateTime.UtcNow.AddSeconds(30); + var okCount = 0; + var sawBad = false; + while (DateTime.UtcNow < deadline) + { + string text; + lock (output) + { + text = Encoding.UTF8.GetString([.. output]); + } + + okCount = Regex.Matches(text, "ok", RegexOptions.None, TimeSpan.FromSeconds(1)).Count; + sawBad = text.Contains("BAD:", StringComparison.Ordinal); + if (sawBad || okCount >= writesPerThread * 2) + { + break; + } + + await Task.Delay(50); + } + + Assert.False(sawBad, "child received a byte-interleaved line"); + Assert.Equal(writesPerThread * 2, okCount); + } + private static async Task RunShortSessionAsync() { var connection = new ConPtyConnection(); diff --git a/tests/Devolutions.Terminal.Control.Tests/KeyMapperTests.cs b/tests/Devolutions.Terminal.Control.Tests/KeyMapperTests.cs index fc8e5c6..94394bf 100644 --- a/tests/Devolutions.Terminal.Control.Tests/KeyMapperTests.cs +++ b/tests/Devolutions.Terminal.Control.Tests/KeyMapperTests.cs @@ -244,6 +244,33 @@ public void KittyTextOnlyInputUsesAssociatedTextCodepoints() KittyKeyboardFlags.ReportAssociatedText)); } + [Fact] + public void ModifyOtherKeysUsesEventResolvedSymbolForShiftedKeys() + { + // US layout: Ctrl+Shift+/ produces '?'. The encoded rune must come from the + // routed event's own symbol (63 = '?'), never from the unshifted key + // (47 = '/') — translating against a keyboard-state snapshot instead of the + // event is exactly the mix-up winterm-ghostty hit with their translator. + var question = KeyMapper.ToVt( + Key.OemQuestion, + KeyModifiers.Control | KeyModifiers.Shift, + PhysicalKey.Slash, + "?", + new TerminalInputMode(true, false, false, KittyKeyboardFlags.None, 2, false)); + + Assert.Equal("\u001b[27;6;63~", question); + } + + [Fact] + public void KittyAssociatedTextUsesEventResolvedShiftedSymbol() + { + var encoded = KeyMapper.EncodeKittyTextInput( + "?", + KittyKeyboardFlags.ReportAssociatedText); + + Assert.Equal("\u001b[0;;63u", encoded); + } + [Fact] public void ModifyOtherKeysAndWin32InputHaveDistinctEncodings() { diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlBlinkTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlBlinkTests.cs new file mode 100644 index 0000000..4adb9d8 --- /dev/null +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlBlinkTests.cs @@ -0,0 +1,185 @@ +using System.Text; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Devolutions.Terminal.Connection; +using Devolutions.Terminal.Settings; +using Xunit; + +namespace Devolutions.Terminal.Control.Tests; + +public sealed class TermControlBlinkTests +{ + private const string Esc = "\u001b"; + + [AvaloniaFact] + public async Task UnfocusedPaneDoesNotAnimateCursor() + { + var connection = new FakePtyConnection(); + var control = new TermControl { ConnectionFactory = _ => connection }; + try + { + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + + // Never focused: the pane draws a static cursor, so the blink timer must + // not invalidate (damage-gated idle rendering). + Assert.False(control.ShouldAnimateCursor); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task FocusedPaneAnimatesCursorWhenEngineBlinks() + { + var connection = new FakePtyConnection(); + var window = new Window + { + Width = 800, + Height = 600, + Content = new TermControl { ConnectionFactory = _ => connection }, + }; + var control = (TermControl)window.Content!; + try + { + window.Show(); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + control.Focus(); + + Assert.True(control.IsFocused); + Assert.True(control.ShouldAnimateCursor); + } + finally + { + await control.CloseAsync(); + window.Close(); + } + } + + [AvaloniaFact] + public async Task SteadyCursorModeDoesNotAnimate() + { + var connection = new FakePtyConnection(); + var window = new Window + { + Width = 800, + Height = 600, + Content = new TermControl { ConnectionFactory = _ => connection }, + }; + var control = (TermControl)window.Content!; + try + { + window.Show(); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + control.Focus(); + Assert.True(control.ShouldAnimateCursor); + + // DECRST 12: steady cursor — the engine reports blinking off. + control.Engine.Feed($"{Esc}[?12l"); + + Assert.False(control.Engine.CursorBlinking); + Assert.False(control.ShouldAnimateCursor); + } + finally + { + await control.CloseAsync(); + window.Close(); + } + } + + [AvaloniaFact] + public async Task HiddenCursorDoesNotAnimate() + { + var connection = new FakePtyConnection(); + var window = new Window + { + Width = 800, + Height = 600, + Content = new TermControl { ConnectionFactory = _ => connection }, + }; + var control = (TermControl)window.Content!; + try + { + window.Show(); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + control.Focus(); + + // DECRST 25: cursor hidden. + control.Engine.Feed($"{Esc}[?25l"); + + Assert.False(control.Engine.CursorVisible); + Assert.False(control.ShouldAnimateCursor); + } + finally + { + await control.CloseAsync(); + window.Close(); + } + } + + private sealed class FakePtyConnection : IRestartableTerminalConnection + { +#pragma warning disable CS0067 + public event EventHandler>? OutputReceived; + public event EventHandler? Exited; + public event EventHandler? Faulted; + public event EventHandler? SessionExited; +#pragma warning restore CS0067 + + public bool IsRunning => true; + public int Columns { get; private set; } + public int Rows { get; private set; } + public TerminalConnectionCapabilities Capabilities => TerminalConnectionCapabilities.Resize; + public TerminalConnectionState State => TerminalConnectionState.Connected; + public TerminalProcessMetadata? ProcessMetadata => null; + public TerminalExitInfo? LastExitInfo => null; + + public Task StartAsync(TerminalLaunchOptions options, CancellationToken cancellationToken = default) + { + Columns = options.Columns; + Rows = options.Rows; + return Task.CompletedTask; + } + + public Task StartAsync( + string commandLine, + string? workingDirectory, + int columns, + int rows, + CancellationToken cancellationToken = default) => + StartAsync( + new TerminalLaunchOptions + { + CommandLine = commandLine, + WorkingDirectory = workingDirectory, + Columns = columns, + Rows = rows, + }, + cancellationToken); + + public void Write(ReadOnlySpan data) + { + } + + public void Write(string text) + { + } + + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => + ValueTask.CompletedTask; + + public void Resize(int columns, int rows) + { + Columns = columns; + Rows = rows; + } + + public Task RestartAsync(TerminalLaunchOptions? options = null, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task CloseAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlOutputPumpTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlOutputPumpTests.cs index 2fc8936..226025d 100644 --- a/tests/Devolutions.Terminal.Control.Tests/TermControlOutputPumpTests.cs +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlOutputPumpTests.cs @@ -1,5 +1,6 @@ using System.Text; using Avalonia.Headless.XUnit; +using Avalonia.Threading; using Devolutions.Terminal.Connection; using Devolutions.Terminal.Settings; using Xunit; @@ -35,6 +36,48 @@ await Task.Run(() => } } + [AvaloniaFact] + public async Task OutputBurstCoalescesIntoFewUiDrains() + { + var connection = new FakePtyConnection(); + var control = new TermControl(); + control.ConnectionFactory = _ => connection; + + try + { + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + Dispatcher.UIThread.RunJobs(); + var postsBefore = control.InvalidationPosts; + var drainsBefore = control.InvalidationDrains; + + // The UI thread is busy in this test method, so every chunk's + // invalidation queues before any drain can run — the production burst + // shape (one frame, many 16 KiB ConPTY reads). + await Task.Run(() => + { + for (var index = 0; index < 64; index++) + { + connection.Emit($"line {index:D4} filler filler filler filler\r\n"); + } + }); + + Dispatcher.UIThread.RunJobs(); + + var posts = control.InvalidationPosts - postsBefore; + var drains = control.InvalidationDrains - drainsBefore; + Assert.True(posts >= 64, $"expected at least one post per chunk, got {posts}"); + Assert.True(drains <= 2, $"expected the burst to coalesce into <= 2 drains, got {drains}"); + + var viewport = string.Concat(control.Engine.CreateSnapshot().Buffer.Lines + .SelectMany(static line => line.Cells.Select(static cell => cell.Text))); + Assert.Contains("line 0063", viewport, StringComparison.Ordinal); + } + finally + { + await control.CloseAsync(); + } + } + private sealed class FakePtyConnection : IRestartableTerminalConnection { public event EventHandler>? OutputReceived; diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlPasteTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlPasteTests.cs new file mode 100644 index 0000000..6bb9b78 --- /dev/null +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlPasteTests.cs @@ -0,0 +1,181 @@ +using System.Text; +using Avalonia.Headless.XUnit; +using Devolutions.Terminal.Connection; +using Devolutions.Terminal.Settings; +using Xunit; + +namespace Devolutions.Terminal.Control.Tests; + +/// +/// Pins the paste vs send-input separation (winterm-ghostty GD-08/GD-15): paste is +/// sanitized and bracketed by the engine; SendInput/WriteInput writes literal bytes. +/// +public sealed class TermControlPasteTests +{ + private const string Esc = "\u001b"; + + [AvaloniaFact] + public async Task WriteInputSendsLiteralBytesEvenInBracketedPasteMode() + { + var connection = new RecordingConnection(); + var control = new TermControl { ConnectionFactory = _ => connection }; + try + { + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + control.Engine.Feed($"{Esc}[?2004h"); + Assert.True(control.Engine.BracketedPaste); + + control.WriteInput($"a{Esc}[Xb"); + + Assert.Equal($"a{Esc}[Xb", connection.WrittenText); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task PasteTextWrapsAndStripsWhenBracketedPasteEnabled() + { + var connection = new RecordingConnection(); + var control = new TermControl { ConnectionFactory = _ => connection }; + try + { + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + control.Engine.Feed($"{Esc}[?2004h"); + Assert.True(control.Engine.BracketedPaste); + + var result = control.PasteText($"echo hi{Esc}[31m"); + + Assert.Equal(TerminalPasteResult.Written, result); + Assert.Equal($"{Esc}[200~echo hi[31m{Esc}[201~", connection.WrittenText); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task PasteTextSendsRawTextWhenBracketedPasteDisabled() + { + var connection = new RecordingConnection(); + var control = new TermControl { ConnectionFactory = _ => connection }; + try + { + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + Assert.False(control.Engine.BracketedPaste); + + var result = control.PasteText("echo hi"); + + Assert.Equal(TerminalPasteResult.Written, result); + Assert.Equal("echo hi", connection.WrittenText); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task PasteTextTrimsTrailingWhitespaceWhenNotBracketed() + { + var connection = new RecordingConnection(); + var control = new TermControl { ConnectionFactory = _ => connection }; + try + { + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 80, 24); + + var result = control.PasteText( + "echo hi \r\n", + new TerminalPasteOptions + { + TrimWhitespace = true, + WarnAboutLargePaste = false, + WarnAboutMultiLinePaste = "never", + }); + + Assert.Equal(TerminalPasteResult.Written, result); + Assert.Equal("echo hi", connection.WrittenText); + } + finally + { + await control.CloseAsync(); + } + } + + private sealed class RecordingConnection : IRestartableTerminalConnection + { + private readonly MemoryStream _written = new(); + +#pragma warning disable CS0067 + public event EventHandler>? OutputReceived; + public event EventHandler? Exited; + public event EventHandler? Faulted; + public event EventHandler? SessionExited; +#pragma warning restore CS0067 + + public string WrittenText => Encoding.UTF8.GetString(_written.ToArray()); + + public bool IsRunning => true; + public int Columns { get; private set; } + public int Rows { get; private set; } + public TerminalConnectionCapabilities Capabilities => TerminalConnectionCapabilities.Resize; + public TerminalConnectionState State => TerminalConnectionState.Connected; + public TerminalProcessMetadata? ProcessMetadata => null; + public TerminalExitInfo? LastExitInfo => null; + + public Task StartAsync(TerminalLaunchOptions options, CancellationToken cancellationToken = default) + { + Columns = options.Columns; + Rows = options.Rows; + return Task.CompletedTask; + } + + public Task StartAsync( + string commandLine, + string? workingDirectory, + int columns, + int rows, + CancellationToken cancellationToken = default) => + StartAsync( + new TerminalLaunchOptions + { + CommandLine = commandLine, + WorkingDirectory = workingDirectory, + Columns = columns, + Rows = rows, + }, + cancellationToken); + + public void Write(ReadOnlySpan data) + { + lock (_written) + { + _written.Write(data); + } + } + + public void Write(string text) => Write(Encoding.UTF8.GetBytes(text)); + + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + Write(data.Span); + return ValueTask.CompletedTask; + } + + public void Resize(int columns, int rows) + { + Columns = columns; + Rows = rows; + } + + public Task RestartAsync(TerminalLaunchOptions? options = null, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task CloseAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs b/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs index 5877b1d..d60d3fc 100644 --- a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs +++ b/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs @@ -1,3 +1,4 @@ +using System.Text; using Devolutions.Terminal.Core; using Devolutions.Terminal; using Devolutions.Terminal.Ghostty; @@ -80,6 +81,59 @@ public void NonImageDcsQueriesDoNotProduceImageDiagnostics() Assert.Empty(diagnostics); } + [Fact] + public void EngineSurvivesAdversarialCorpusOnSmallStackThread() + { + // winterm-ghostty lesson: full libghostty assumed a 16 MB stack and + // overflowed the host default in production. libghostty-vt is VT-only, but + // pin the headroom: the engine must survive an adversarial corpus on a + // 256 KB-stack thread — 4x smaller than the ~1 MB default of the .NET + // thread-pool threads the ConPTY ReadLoop feeds it from. + var corpus = BuildAdversarialCorpus(); + Exception? failure = null; + var thread = new Thread(() => + { + try + { + using var engine = new GhosttyTerminalEngine(); + engine.Feed(corpus); + _ = engine.CreateSnapshot(includeHistory: true); + _ = engine.Title; + } + catch (Exception ex) + { + failure = ex; + } + }, maxStackSize: 256 * 1024); + thread.Start(); + thread.Join(); + + Assert.Null(failure); + } + + private static string BuildAdversarialCorpus() + { + var builder = new StringBuilder(capacity: 512 * 1024); + builder.Append(new string('x', 100 * 1024)); // single over-long line: wrap/reflow + builder.Append("\r\n"); + builder.Append("\u001b]8;;").Append(new string('a', 64 * 1024)).Append("link\u001b]8;;\u0007\r\n"); // long OSC 8 + for (var i = 0; i < 200; i++) + { + builder.Append("\u001b[38;5;").Append(i % 256).Append('m'); // SGR churn + } + + builder.Append("e").Append(new string('̃', 4096)).Append("\r\n"); // deep grapheme + builder.Append("\u001bP7q").Append(new string('~', 4096)).Append("\u001b\\\r\n"); // sixel-ish DCS + builder.Append(new string('進', 16 * 1024)).Append("\r\n"); // wide chars + for (var i = 0; i < 500; i++) + { + builder.Append("\u001b[").Append(i % 40 + 1).Append(';').Append(i % 100 + 1).Append('H'); + builder.Append("cell").Append(i).Append("\r\n"); + } + + return builder.ToString(); + } + [Theory] [InlineData("\u001b")] [InlineData("\u001bP")] diff --git a/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj b/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj new file mode 100644 index 0000000..232b266 --- /dev/null +++ b/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj @@ -0,0 +1,16 @@ + + + Exe + Devolutions.Terminal.Bench + false + false + false + enable + + + + + + + + diff --git a/tools/Devolutions.Terminal.Bench/Program.cs b/tools/Devolutions.Terminal.Bench/Program.cs new file mode 100644 index 0000000..f67ea16 --- /dev/null +++ b/tools/Devolutions.Terminal.Bench/Program.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Headless; +using Avalonia.Threading; +using Devolutions.Terminal.Connection; +using Devolutions.Terminal.Core; +using Devolutions.Terminal.Settings; + +namespace Devolutions.Terminal.Bench; + +/// +/// Throughput harness for the PTY -> engine -> invalidation path, modeled on the +/// winterm-ghostty methodology (fixed corpus, 16 KiB chunks like the ConPTY read loop, +/// medians over runs). +/// +/// Modes: +/// engine — pure TerminalEngine.Feed throughput, no UI. +/// control — full TermControl path: a producer thread raises OutputReceived like the +/// ConPTY ReadLoop; the UI dispatcher drains invalidations via RunJobs. +/// Reports engine invalidations vs posts vs actual UI drains (the +/// coalescing ratio). +/// +/// Caveat: headless mode does not paint, so drain cost covers dispatch + listener +/// fan-out, not Skia rendering. +/// +internal static class Program +{ + private const string Esc = "\u001b"; + + private static int Main(string[] args) + { + var mode = args.Length > 0 ? args[0] : "control"; + var megabytes = GetOption(args, "--mb", 8); + var runs = GetOption(args, "--runs", 5); + var chunkKb = GetOption(args, "--chunk-kb", 16); + + var corpus = Corpus.Build(megabytes * 1024 * 1024); + Console.WriteLine($"mode={mode} corpus={corpus.Length / (1024.0 * 1024):F1} MiB chunk={chunkKb} KiB runs={runs}"); + + if (mode == "control") + { + // Avalonia setup is process-global; do it once before any run. + AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions()) + .SetupWithoutStarting(); + if (!Dispatcher.UIThread.CheckAccess()) + { + throw new InvalidOperationException("headless setup did not bind the UI dispatcher to this thread"); + } + } + + var samples = new List(); + for (var run = 0; run < runs; run++) + { + var mbPerSec = mode switch + { + "engine" => RunEngine(corpus, chunkKb * 1024), + "control" => RunControl(corpus, chunkKb * 1024), + _ => throw new ArgumentException($"unknown mode '{mode}' (expected engine|control)"), + }; + samples.Add(mbPerSec); + Console.WriteLine($" run {run + 1}: {mbPerSec:F1} MB/s"); + } + + samples.Sort(); + Console.WriteLine($"median: {samples[samples.Count / 2]:F1} MB/s min: {samples[0]:F1} max: {samples[^1]:F1}"); + return 0; + } + + private static int GetOption(string[] args, string name, int fallback) + { + var index = Array.IndexOf(args, name); + return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value) + ? value + : fallback; + } + + private static double RunEngine(byte[] corpus, int chunkSize) + { + using var engine = new TerminalEngine(); + var watch = Stopwatch.StartNew(); + for (var offset = 0; offset < corpus.Length; offset += chunkSize) + { + engine.Feed(corpus.AsSpan(offset, Math.Min(chunkSize, corpus.Length - offset))); + } + + watch.Stop(); + return corpus.Length / (1024.0 * 1024.0) / watch.Elapsed.TotalSeconds; + } + + private static double RunControl(byte[] corpus, int chunkSize) + { + var connection = new FakeConnection(); + var control = new TermControl + { + ConnectionFactory = _ => connection, + }; + long engineInvalidations = 0; + control.Engine.Invalidated += (_, _) => Interlocked.Increment(ref engineInvalidations); + // Emulate the App shell's scrollbar/notification listeners. + control.ScrollMarksChanged += (_, _) => _ = control.Engine.HistoryCount; + control.ViewportChanged += (_, _) => _ = control.Engine.ScrollOffset; + control.AccessibilityTextChanged += (_, _) => _ = control.Engine.CursorY; + + control.StartAsync(new ProfileSettings { Name = "bench" }, columns: 120, rows: 30) + .GetAwaiter().GetResult(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.SystemIdle); + + var watch = Stopwatch.StartNew(); + var producer = new Thread(() => + { + for (var offset = 0; offset < corpus.Length; offset += chunkSize) + { + connection.Emit(corpus.AsMemory(offset, Math.Min(chunkSize, corpus.Length - offset))); + } + }); + producer.Start(); + + // Drain posted invalidations while the producer is feeding, paced at one + // display frame (60 Hz): in the real app the UI thread is busy rendering + // between vsyncs, so per-chunk invalidations queue up within a frame. + // Draining eagerly here would hide exactly the batching this measures. + var markerRan = false; + while (producer.IsAlive || !markerRan) + { + if (!producer.IsAlive && !markerRan) + { + // Marker at Send priority executes after every queued Render-priority drain. + Dispatcher.UIThread.Post(() => markerRan = true, DispatcherPriority.Send); + } + + Dispatcher.UIThread.RunJobs(DispatcherPriority.SystemIdle); + Thread.Sleep(16); + } + + watch.Stop(); + var mbPerSec = corpus.Length / (1024.0 * 1024.0) / watch.Elapsed.TotalSeconds; + Console.WriteLine( + $" engine invalidations: {Interlocked.Read(ref engineInvalidations)}, " + + $"posts: {control.InvalidationPosts}, drains: {control.InvalidationDrains}"); + return mbPerSec; + } + + private sealed class FakeConnection : IRestartableTerminalConnection + { +#pragma warning disable CS0067 // events required by the interface, unused by the bench + public event EventHandler>? OutputReceived; + public event EventHandler? Exited; + public event EventHandler? Faulted; + public event EventHandler? SessionExited; +#pragma warning restore CS0067 + + public bool IsRunning => true; + public int Columns => 120; + public int Rows => 30; + public TerminalConnectionCapabilities Capabilities => TerminalConnectionCapabilities.None; + public TerminalConnectionState State => TerminalConnectionState.Connected; + public TerminalProcessMetadata? ProcessMetadata => null; + public TerminalExitInfo? LastExitInfo => null; + + public void Emit(ReadOnlyMemory data) => OutputReceived?.Invoke(this, data); + + public Task StartAsync(TerminalLaunchOptions options, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task StartAsync(string commandLine, string? workingDirectory, int columns, int rows, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public void Write(ReadOnlySpan data) + { + } + + public void Write(string text) + { + } + + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => + ValueTask.CompletedTask; + + public void Resize(int columns, int rows) + { + } + + public Task RestartAsync(TerminalLaunchOptions? options = null, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task CloseAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private static class Corpus + { + private static readonly string[] Sgr = + [ + $"{Esc}[31m", $"{Esc}[32m", $"{Esc}[33m", $"{Esc}[34m", + $"{Esc}[1m", $"{Esc}[0m", $"{Esc}[38;5;123m", $"{Esc}[48;5;240m", + ]; + + public static byte[] Build(int targetBytes) + { + var random = new Random(1234); + using var stream = new MemoryStream(targetBytes + 4096); + using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true); + var line = 0; + while (stream.Length < targetBytes) + { + var roll = random.Next(100); + if (roll < 70) + { + writer.Write($"{line:D8} The quick brown fox jumps over the lazy dog {random.Next(1_000_000):D6}"); + if (roll % 3 == 0) + { + writer.Write(Sgr[random.Next(Sgr.Length)]); + } + + writer.Write(" pack my box with five dozen liquor jugs\r\n"); + } + else if (roll < 85) + { + writer.Write(Sgr[random.Next(Sgr.Length)]); + writer.Write($"[INFO] worker-{random.Next(64)} processed batch {line} in {random.Next(900)}ms"); + writer.Write($"{Esc}[0m\r\n"); + } + else if (roll < 95) + { + writer.Write($"進捗 {line}: 完了 ✅ テスト用文字列 🚀\r\n"); + } + else + { + writer.Write($"{Esc}[{random.Next(1, 30)};{random.Next(1, 110)}H"); + writer.Write(Sgr[random.Next(Sgr.Length)]); + writer.Write($"status:{random.Next(100)}%"); + writer.Write($"{Esc}[K"); + } + + line++; + } + + writer.Flush(); + return stream.ToArray(); + } + } +} From 575f3c7f73a89665201d0dcd588d5c16af61f394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 4 Sep 2026 21:25:00 -0400 Subject: [PATCH 2/3] feat: implement the kitty graphics protocol Adds APC G support end to end: - VtParser: APC string states (ESC _ / C1 0x9F entry, ST-only termination, CAN/SUB abort, 1 MiB bound) with a default IVtDispatch.ApcDispatch member. - Core: KittyGraphicsDecoder parses control keys (a/t/f/o/s/v/i/I/p/c/r/x/y/ X/Y/w/h/z/q/C/m/d), validates, decodes base64 + zlib, and converts raw RGB/RGBA to RGBA32; format 100 retains codec bytes for the renderer (the OSC 1337 contract). - TerminalEngine: image store keyed by image id with independent budget and oldest-first eviction, chunked transmission assembly, actions t/T/p/d/q, OK/error responses honoring quiet flags, cursor advance below the image for a=T (C=1 opts out; a=p never moves), deletes by all/id/placement with uppercase forms freeing data, and logical-line anchored overlays that survive scrollback/reflow. New KittyImages capability flag. - Renderer: kitty placements decode via the existing image cache, size to c/r cells with aspect preservation, honor in-cell pixel offsets and source crops, and composite below text for z<0 or above text for z>=0. - Ghostty engine: deterministic image.kitty.unsupported diagnostic, deduped per engine lifetime for chunked transmissions. - Rejected without I/O per repository policy: file/temp-file/shared-memory media; animation frames and Unicode placeholders are ENOTSUP; cell/z/number deletes are silent no-ops. Tests: 19 Core tests (decoder, engine, parser boundaries incl. split APC, C1 entry, CAN cancel, BEL non-termination), 6 renderer pixel tests (raw, PNG, cell sizing, z-order both directions, crop), Ghostty diagnostic test. Docs: advanced-vt-protocols.md, parity-status.md. --- docs/advanced-vt-protocols.md | 21 +- docs/parity-status.md | 1 + .../ITerminalEngine.cs | 1 + src/Devolutions.Terminal.Core/IVtDispatch.cs | 3 + .../KittyGraphicsDecoder.cs | 414 +++++++++++++++++ .../TerminalEngine.cs | 338 +++++++++++++- .../TerminalImages.cs | 57 +++ src/Devolutions.Terminal.Core/VtParser.cs | 94 +++- .../GhosttyTerminalEngine.cs | 43 ++ .../SkiaTerminalRenderer.cs | 144 +++++- .../KittyGraphicsTests.cs | 437 ++++++++++++++++++ .../UnicodeLegacyParityTests.cs | 3 +- .../GhosttyTerminalEngineTests.cs | 16 + .../SkiaTerminalRendererTests.cs | 110 +++++ 14 files changed, 1653 insertions(+), 29 deletions(-) create mode 100644 src/Devolutions.Terminal.Core/KittyGraphicsDecoder.cs create mode 100644 tests/Devolutions.Terminal.Core.Tests/KittyGraphicsTests.cs diff --git a/docs/advanced-vt-protocols.md b/docs/advanced-vt-protocols.md index 6ed6e29..ffa2a65 100644 --- a/docs/advanced-vt-protocols.md +++ b/docs/advanced-vt-protocols.md @@ -2,10 +2,11 @@ > [!NOTE] > The public out-of-process Windows ConPTY may filter DCS payloads before they -> reach a terminal client on some Windows builds. The Core parser and renderer -> support Sixel when a connection transports DCS bytes unchanged (for example, -> remote/Azure transports); local ConPTY support is limited by the installed -> Windows pseudoconsole implementation. +> reach a terminal client on some Windows builds, and APC sequences (kitty +> graphics) can be affected the same way. The Core parser and renderer +> support Sixel and kitty graphics when a connection transports those bytes +> unchanged (for example, remote/Azure transports); local ConPTY support is +> limited by the installed Windows pseudoconsole implementation. `Devolutions.Terminal.Core` parses advanced string protocols without depending on Avalonia, Skia, Win32, or an image codec. It exposes decoded Sixel pixels and bounded @@ -27,6 +28,7 @@ encoded OSC 1337/ConEmu images as renderer-neutral overlay metadata. | `CSI Ps $ w` / `DCS Ps $ t ... ST` | Reports and restores cursor presentation state (`Ps=1`) and tab stops (`Ps=2`). | | `OSC 1337 ; File=... : base64 ST` | Parses inline iTerm2 image name, declared size, width, height, aspect-ratio preference, and bounded encoded bytes. Non-inline file transfers are explicitly rejected without I/O. | | `OSC 9 ; 4 ; st=0 ; sz=N ; base64 ST` | Parses bounded, single-part ConEmu encoded images. Multipart, malformed, size-mismatched, and oversized transfers are rejected. | +| `APC G control ; base64 ST` | Implements the kitty graphics protocol: direct transmission (`t=d`) of raw RGBA/RGB (`f=32`/`f=24`) and PNG-encoded (`f=100`) images, optional zlib compression (`o=z`), multi-chunk assembly (`m=`), transmit/transmit-and-display/put (`a=t/T/p`), query probe (`a=q`), and deletes by all/id/placement (`a=d` with `d=a/A/i/I/p`). Placements honor cell sizing (`c`/`r`, aspect-preserving when one axis is given), in-cell pixel offsets (`x`/`y`), source crop (`X`/`Y`/`w`/`h`), and z-index (`z<0` below text, `z>=0` above). `a=T` moves the cursor below the image unless `C=1`; `a=p` never moves it. Responds `APC G i=id ; OK/error ST` honoring the `q` quiet flags. | The DCS state machine handles 7-bit and C1 entry/termination, parameter and intermediate collection, passthrough, CAN/SUB cancellation, and an `ESC` @@ -42,6 +44,8 @@ Limits are public constants on `TerminalImageLimits` and `VtResourceLimits`. | --- | ---: | | Collected DCS payload | 4 MiB | | Decoded OSC 1337 image | 768 KiB | +| Assembled kitty image (decompressed) | 32 MiB | +| Kitty pixel dimension / count | shared pixel limits below | | Sixel width or height | 4096 pixels | | Sixel pixel count | 16,777,216 | | Sixel pixel writes per sequence | 67,108,864 | @@ -71,7 +75,7 @@ Renderers can consume `TerminalEngine.Images`, the detached - a monotonic ID and protocol; - the primary/alternate buffer identity; - the cursor cell where the image was received; -- either a `SixelImage` or an `InlineImage`. +- a `SixelImage`, an `InlineImage`, or a `KittyImage` placement. `SixelImage.PixelIndices` contains 16-bit palette indexes. Index 256 is transparent; indexes 0 through 255 address `SixelImage.Palette`, whose entries @@ -100,6 +104,13 @@ evicted. Main and alternate buffers retain independent identities. Core tracks cursor-key and keypad modes for input layers to consume. - non-inline OSC 1337 file transfer and remote file access (explicitly rejected) - multipart ConEmu image payloads (explicitly rejected) +- kitty graphics file/temp-file/shared-memory media (`t=f/t/s`) — rejected without + I/O, matching the non-inline OSC 1337 policy +- kitty animation frame control (`a=f`) and Unicode placeholder placements + (answered `ENOTSUP`) +- kitty cell/z-index/number-targeted deletes (`d=c/x/y/z/n`) — silent no-op; + the cursor for `f=100` images without `r` stays put because Core retains codec + bytes without decoding dimensions - Ghostty image projection (the pinned C ABI exposes no image resources) These gaps avoid remote I/O and renderer/input dependencies while keeping every diff --git a/docs/parity-status.md b/docs/parity-status.md index 0b23e82..fe99043 100644 --- a/docs/parity-status.md +++ b/docs/parity-status.md @@ -80,6 +80,7 @@ registrations, and notices for both x64 and ARM64. | Sixel | Built-in decode/render, DECSDM scrolling/display behavior, retained cell geometry, and stable ownership implemented | The pinned Ghostty C ABI exposes no image resources and reports the capability unavailable | | OSC 1337 | Built-in bounded inline decode/render and stable ownership implemented; non-inline transfer is explicitly rejected without I/O | The pinned Ghostty C ABI exposes no image resources and reports the capability unavailable | | ConEmu images | Bounded single-part `st=0;sz=` payloads decode into shared overlay metadata and render safely | Multipart payloads are explicitly rejected; the pinned Ghostty C ABI exposes no image resources | +| Kitty graphics | Direct-medium RGBA/RGB/PNG transmissions with chunking, zlib, placements, crop, z-order, deletes, and query responses implemented; unsupported media and animation are rejected deterministically | File/shared-memory media and animation frames are intentional gaps; the pinned Ghostty C ABI exposes no image resources and reports the capability unavailable | | Image ownership | Stable logical-line anchors survive scrollback and reflow in main and alternate buffers and are removed on owning-line eviction | Ghostty image projection is unavailable in the pinned C ABI and produces deterministic unsupported diagnostics | | VT52 | Output plus host cursor/PF/application-keypad encoding implemented, with built-in/Ghostty differential mode coverage | No remaining shared subset work | | DRCS | Built-in parse/resource mapping, bounded snapshot masks, render planning, and downloaded-pixel rendering implemented | The pinned Ghostty C ABI does not expose DRCS resources; capability is explicitly unavailable there | diff --git a/src/Devolutions.Terminal.Core/ITerminalEngine.cs b/src/Devolutions.Terminal.Core/ITerminalEngine.cs index 967b13c..c962ae2 100644 --- a/src/Devolutions.Terminal.Core/ITerminalEngine.cs +++ b/src/Devolutions.Terminal.Core/ITerminalEngine.cs @@ -14,6 +14,7 @@ public enum TerminalEngineCapabilities SixelImages = 1 << 7, Iterm2Images = 1 << 8, ConEmuImages = 1 << 9, + KittyImages = 1 << 10, } public sealed record TerminalEngineDiagnostic(string Code, string Message); diff --git a/src/Devolutions.Terminal.Core/IVtDispatch.cs b/src/Devolutions.Terminal.Core/IVtDispatch.cs index 2278e55..dac4344 100644 --- a/src/Devolutions.Terminal.Core/IVtDispatch.cs +++ b/src/Devolutions.Terminal.Core/IVtDispatch.cs @@ -24,4 +24,7 @@ void DcsDispatch( { } void OscDispatch(int command, ReadOnlySpan data); + void ApcDispatch(ReadOnlySpan data) + { + } } diff --git a/src/Devolutions.Terminal.Core/KittyGraphicsDecoder.cs b/src/Devolutions.Terminal.Core/KittyGraphicsDecoder.cs new file mode 100644 index 0000000..2f2a084 --- /dev/null +++ b/src/Devolutions.Terminal.Core/KittyGraphicsDecoder.cs @@ -0,0 +1,414 @@ +using System.Globalization; +using System.IO.Compression; + +namespace Devolutions.Terminal.Core; + +public enum KittyGraphicsAction : byte +{ + Transmit, + TransmitAndDisplay, + Put, + Delete, + Query, + Unsupported, +} + +/// +/// One parsed APC G command. Pixel data is still base64-decoded but otherwise raw +/// (possibly zlib-compressed); decoding to happens in +/// . +/// +public sealed class KittyGraphicsCommand +{ + public KittyGraphicsAction Action { get; set; } = KittyGraphicsAction.TransmitAndDisplay; + public char Medium { get; set; } = 'd'; + public int Format { get; set; } = 32; + public bool Compressed { get; set; } + public int SourceWidth { get; set; } + public int SourceHeight { get; set; } + public uint ImageId { get; set; } + public uint PlacementId { get; set; } + public int Columns { get; set; } + public int Rows { get; set; } + public int PixelOffsetX { get; set; } + public int PixelOffsetY { get; set; } + public int CropX { get; set; } + public int CropY { get; set; } + public int CropWidth { get; set; } + public int CropHeight { get; set; } + public int ZIndex { get; set; } + public int Quiet { get; set; } + public bool NoCursorMove { get; set; } + public char DeleteWhat { get; set; } = 'a'; + public bool MoreChunks { get; set; } + public byte[] Payload { get; set; } = []; +} + +/// +/// Parser for the kitty graphics protocol (APC G). Supports the direct transmission +/// medium only; file/shared-memory media are rejected without I/O, matching the +/// repository's policy for non-inline OSC 1337 transfers. Animation frame control +/// (a=f) and Unicode placeholders are intentionally unsupported. +/// +public static class KittyGraphicsDecoder +{ + /// + /// Splits an APC G body (everything after 'G') at the payload separator and parses + /// the comma-separated control keys. The payload is base64-decoded per chunk. + /// + public static bool TryParse( + ReadOnlySpan body, + out KittyGraphicsCommand? command, + out string? error) + { + command = null; + error = null; + + var separator = body.IndexOf(';'); + var control = separator >= 0 ? body[..separator] : body; + var payload = separator >= 0 ? body[(separator + 1)..] : ReadOnlySpan.Empty; + + var parsed = new KittyGraphicsCommand(); + foreach (var pair in control.Split(',')) + { + var part = control[pair]; + if (part.IsEmpty) + { + continue; + } + + var equals = part.IndexOf('='); + if (equals != 1) + { + error = "EINVAL: malformed control key"; + return false; + } + + var key = part[0]; + var value = part[2..]; + switch (key) + { + case 'a': + parsed.Action = value.Length == 1 ? value[0] switch + { + 't' => KittyGraphicsAction.Transmit, + 'T' => KittyGraphicsAction.TransmitAndDisplay, + 'p' => KittyGraphicsAction.Put, + 'd' => KittyGraphicsAction.Delete, + 'q' => KittyGraphicsAction.Query, + _ => KittyGraphicsAction.Unsupported, + } : KittyGraphicsAction.Unsupported; + break; + case 't': + parsed.Medium = value.Length == 1 ? value[0] : '\0'; + break; + case 'f': + if (!TryInt(value, out var parsedFormat)) + { + error = "EINVAL: bad format"; + return false; + } + + parsed.Format = parsedFormat; + break; + case 'o': + parsed.Compressed = value.SequenceEqual("z"); + break; + case 's': + if (!TryInt(value, out var parsedSourceWidth)) + { + error = "EINVAL: bad width"; + return false; + } + + parsed.SourceWidth = parsedSourceWidth; + break; + case 'v': + if (!TryInt(value, out var parsedSourceHeight)) + { + error = "EINVAL: bad height"; + return false; + } + + parsed.SourceHeight = parsedSourceHeight; + break; + case 'i': + if (!TryUInt(value, out var parsedImageId)) + { + error = "EINVAL: bad image id"; + return false; + } + + parsed.ImageId = parsedImageId; + break; + case 'I': + // Terminal-assigned image number; accepted and ignored. + break; + case 'p': + if (!TryUInt(value, out var parsedPlacementId)) + { + error = "EINVAL: bad placement id"; + return false; + } + + parsed.PlacementId = parsedPlacementId; + break; + case 'c': + if (!TryInt(value, out var parsedColumns)) + { + error = "EINVAL: bad columns"; + return false; + } + + parsed.Columns = parsedColumns; + break; + case 'r': + if (!TryInt(value, out var parsedRows)) + { + error = "EINVAL: bad rows"; + return false; + } + + parsed.Rows = parsedRows; + break; + case 'x': + if (!TryInt(value, out var parsedPixelOffsetX)) + { + error = "EINVAL: bad x offset"; + return false; + } + + parsed.PixelOffsetX = parsedPixelOffsetX; + break; + case 'y': + if (!TryInt(value, out var parsedPixelOffsetY)) + { + error = "EINVAL: bad y offset"; + return false; + } + + parsed.PixelOffsetY = parsedPixelOffsetY; + break; + case 'X': + if (!TryInt(value, out var parsedCropX)) + { + error = "EINVAL: bad crop x"; + return false; + } + + parsed.CropX = parsedCropX; + break; + case 'Y': + if (!TryInt(value, out var parsedCropY)) + { + error = "EINVAL: bad crop y"; + return false; + } + + parsed.CropY = parsedCropY; + break; + case 'w': + if (!TryInt(value, out var parsedCropWidth)) + { + error = "EINVAL: bad crop width"; + return false; + } + + parsed.CropWidth = parsedCropWidth; + break; + case 'h': + if (!TryInt(value, out var parsedCropHeight)) + { + error = "EINVAL: bad crop height"; + return false; + } + + parsed.CropHeight = parsedCropHeight; + break; + case 'z': + if (!TryInt(value, out var parsedZIndex)) + { + error = "EINVAL: bad z-index"; + return false; + } + + parsed.ZIndex = parsedZIndex; + break; + case 'q': + if (!TryInt(value, out var parsedQuiet)) + { + error = "EINVAL: bad quiet flag"; + return false; + } + + parsed.Quiet = parsedQuiet; + break; + case 'C': + parsed.NoCursorMove = value.SequenceEqual("1"); + break; + case 'm': + parsed.MoreChunks = value.SequenceEqual("1"); + break; + case 'd': + parsed.DeleteWhat = value.Length == 1 ? value[0] : '\0'; + break; + case 'S': + case 'O': + case 'u': + case 'U': + // Animation frame size/offset and Unicode placeholders: parsed + // and ignored; animation actions themselves are unsupported. + break; + default: + // Unknown keys are ignored for forward compatibility. + break; + } + } + + if (!payload.IsEmpty) + { + try + { + parsed.Payload = Convert.FromBase64String(payload.ToString()); + } + catch (FormatException) + { + error = "EINVAL: payload is not valid base64"; + return false; + } + } + + command = parsed; + return true; + } + + /// + /// Validates a transmission and decodes its assembled payload into + /// . Raw formats (24/32) become 4-byte RGBA; + /// format 100 retains codec bytes for the renderer (the OSC 1337 contract). + /// + public static bool TryDecodeImageData( + KittyGraphicsCommand command, + byte[] payload, + out KittyImageData? data, + out string? error) + { + data = null; + error = null; + + if (command.Medium != 'd') + { + error = "ENOTSUP: only the direct transmission medium is supported"; + return false; + } + + if (command.Format is not (24 or 32 or 100)) + { + error = "EINVAL: unsupported pixel format"; + return false; + } + + var bytes = payload; + if (command.Compressed) + { + if (!TryInflate(payload, out bytes)) + { + error = "EINVAL: zlib payload did not decompress"; + return false; + } + } + + if (bytes.Length > TerminalImageLimits.MaximumKittyImageBytes) + { + error = "ETOOMANY: image data exceeds the size limit"; + return false; + } + + if (command.Format == 100) + { + if (bytes.Length == 0) + { + error = "ENODATA: empty image payload"; + return false; + } + + data = new KittyImageData(bytes); + return true; + } + + var width = command.SourceWidth; + var height = command.SourceHeight; + if (width <= 0 || height <= 0) + { + error = "EINVAL: raw formats require s and v pixel dimensions"; + return false; + } + + if (width > TerminalImageLimits.MaximumPixelDimension || + height > TerminalImageLimits.MaximumPixelDimension || + (long)width * height > TerminalImageLimits.MaximumPixelCount) + { + error = "ETOOMANY: image dimensions exceed the pixel limits"; + return false; + } + + var bytesPerPixel = command.Format == 32 ? 4 : 3; + var expected = (long)width * height * bytesPerPixel; + if (bytes.LongLength != expected) + { + error = "EINVAL: payload length does not match s*v*format"; + return false; + } + + var rgba = new byte[width * height * 4]; + if (bytesPerPixel == 4) + { + Array.Copy(bytes, rgba, rgba.Length); + } + else + { + for (int source = 0, target = 0; target < rgba.Length; source += 3, target += 4) + { + rgba[target] = bytes[source]; + rgba[target + 1] = bytes[source + 1]; + rgba[target + 2] = bytes[source + 2]; + rgba[target + 3] = 0xFF; + } + } + + data = new KittyImageData(width, height, rgba); + return true; + } + + private static bool TryInflate(byte[] payload, out byte[] bytes) + { + try + { + using var input = new MemoryStream(payload); + using var zlib = new ZLibStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + var remaining = (long)TerminalImageLimits.MaximumKittyImageBytes + 1; + var buffer = new byte[64 * 1024]; + int read; + while (remaining > 0 && (read = zlib.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining))) > 0) + { + output.Write(buffer, 0, read); + remaining -= read; + } + + bytes = output.ToArray(); + return true; + } + catch (InvalidDataException) + { + bytes = []; + return false; + } + } + + private static bool TryInt(ReadOnlySpan value, out int result) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out result); + + private static bool TryUInt(ReadOnlySpan value, out uint result) => + uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out result); +} diff --git a/src/Devolutions.Terminal.Core/TerminalEngine.cs b/src/Devolutions.Terminal.Core/TerminalEngine.cs index a963a08..29092b6 100644 --- a/src/Devolutions.Terminal.Core/TerminalEngine.cs +++ b/src/Devolutions.Terminal.Core/TerminalEngine.cs @@ -40,6 +40,11 @@ public sealed class TerminalEngine : ITerminalEngine, IVtDispatch private readonly TextBuffer _primary; private readonly TextBuffer _alternate; private readonly List _images = []; + private readonly Dictionary _kittyImages = []; + private readonly List _kittyImageOrder = []; + private readonly List _kittyChunkPayload = []; + private KittyGraphicsCommand? _kittyChunkControl; + private long _kittyImageStoreBytes; private readonly Dictionary _drcsGlyphs = []; private readonly ReadOnlyDictionary _readOnlyDrcsGlyphs; private readonly byte[]?[] _macros = new byte[VtResourceLimits.MaximumMacros][]; @@ -96,7 +101,8 @@ public TerminalEngine(int columns = 120, int rows = 30, int historySize = 9001) TerminalEngineCapabilities.Win32Input | TerminalEngineCapabilities.SixelImages | TerminalEngineCapabilities.Iterm2Images | - TerminalEngineCapabilities.ConEmuImages; + TerminalEngineCapabilities.ConEmuImages | + TerminalEngineCapabilities.KittyImages; public ColorScheme Scheme { get => _scheme; @@ -243,6 +249,11 @@ public void Reset() _sixelDecoder.Reset(); _images.Clear(); _retainedImageBytes = 0; + _kittyImages.Clear(); + _kittyImageOrder.Clear(); + _kittyImageStoreBytes = 0; + _kittyChunkControl = null; + _kittyChunkPayload.Clear(); _primary.Reset(keepHistory: false); _alternate.Reset(keepHistory: false); Invalidated?.Invoke(this, EventArgs.Empty); @@ -1519,6 +1530,324 @@ private void DispatchConEmuImage(ReadOnlySpan data) } } + void IVtDispatch.ApcDispatch(ReadOnlySpan data) + { + if (data.StartsWith("G", StringComparison.Ordinal)) + { + DispatchKittyImage(data[1..]); + } + } + + private void DispatchKittyImage(ReadOnlySpan body) + { + if (!KittyGraphicsDecoder.TryParse(body, out var parsed, out var parseError) || parsed is null) + { + ReportDiagnostic("image.kitty.rejected", parseError ?? "The kitty graphics control data was malformed."); + RespondKitty(0, parseError ?? "EINVAL: malformed control data", quiet: 0, isError: true); + return; + } + + var command = parsed; + var payload = command.Payload; + if (command.MoreChunks || _kittyChunkControl is not null) + { + // Chunked transmission: the first chunk carries the metadata, later + // chunks append payload and may only re-assert i and m. + if (_kittyChunkControl is null) + { + if (!command.MoreChunks) + { + // Unreachable in this branch, kept as a guard. + _kittyChunkPayload.Clear(); + return; + } + + _kittyChunkControl = command; + _kittyChunkPayload.Clear(); + _kittyChunkPayload.AddRange(command.Payload); + return; + } + + _kittyChunkPayload.AddRange(command.Payload); + if (_kittyChunkPayload.Count > TerminalImageLimits.MaximumKittyImageBytes * 2) + { + var pendingId = _kittyChunkControl.ImageId; + _kittyChunkControl = null; + _kittyChunkPayload.Clear(); + ReportDiagnostic("image.kitty.rejected", "The chunked kitty image exceeded the size limit."); + RespondKitty(pendingId, "ETOOMANY: chunked image exceeds the size limit", command.Quiet, isError: true); + return; + } + + if (command.MoreChunks) + { + return; + } + + command = _kittyChunkControl; + _kittyChunkControl = null; + if (parsed.ImageId != 0) + { + command.ImageId = parsed.ImageId; + } + + payload = [.. _kittyChunkPayload]; + _kittyChunkPayload.Clear(); + } + + switch (command.Action) + { + case KittyGraphicsAction.Delete: + ExecuteKittyDelete(command); + return; + case KittyGraphicsAction.Unsupported: + ReportDiagnostic("image.kitty.rejected", "The kitty graphics action is not supported."); + RespondKitty(command.ImageId, "ENOTSUP: unsupported action", command.Quiet, isError: true); + return; + case KittyGraphicsAction.Put: + ExecuteKittyPut(command); + return; + default: + ExecuteKittyTransmission(command, payload); + return; + } + } + + private void ExecuteKittyTransmission(KittyGraphicsCommand command, byte[] payload) + { + var imageId = command.ImageId; + if (imageId == 0) + { + imageId = NextKittyImageId(); + } + + if (!KittyGraphicsDecoder.TryDecodeImageData(command, payload, out var data, out var error) || data is null) + { + ReportDiagnostic("image.kitty.rejected", error ?? "The kitty image payload was rejected."); + RespondKitty(imageId, error ?? "EINVAL: rejected payload", command.Quiet, isError: true); + return; + } + + if (command.Action == KittyGraphicsAction.Query) + { + // Support probe: validate and acknowledge without storing. + RespondKitty(imageId, "OK", command.Quiet, isError: false); + return; + } + + StoreKittyImage(imageId, data); + if (command.Action == KittyGraphicsAction.TransmitAndDisplay) + { + CreateKittyPlacement(command, imageId, data, moveCursor: !command.NoCursorMove); + } + + RespondKitty(imageId, "OK", command.Quiet, isError: false); + } + + // Transmit-only images must survive until an explicit delete: clients transmit + // with a=t and place later with a=p. The store is budgeted independently and + // evicts oldest-insertion under pressure (placements keep their pixel data + // alive through the overlay reference). + private void StoreKittyImage(uint imageId, KittyImageData data) + { + if (_kittyImages.TryGetValue(imageId, out var existing)) + { + _kittyImageStoreBytes -= existing.EstimatedByteSize; + } + else + { + _kittyImageOrder.Add(imageId); + } + + _kittyImages[imageId] = data; + _kittyImageStoreBytes += data.EstimatedByteSize; + while (_kittyImageOrder.Count > 0 && _kittyImageStoreBytes > TerminalImageLimits.MaximumRetainedImageBytes) + { + var oldest = _kittyImageOrder[0]; + _kittyImageOrder.RemoveAt(0); + if (_kittyImages.Remove(oldest, out var removed)) + { + _kittyImageStoreBytes -= removed.EstimatedByteSize; + } + } + } + + private void RemoveKittyImage(uint imageId) + { + if (_kittyImages.Remove(imageId, out var removed)) + { + _kittyImageStoreBytes -= removed.EstimatedByteSize; + _kittyImageOrder.Remove(imageId); + } + } + + private void ExecuteKittyPut(KittyGraphicsCommand command) + { + if (command.ImageId == 0 || !_kittyImages.TryGetValue(command.ImageId, out var data)) + { + ReportDiagnostic("image.kitty.rejected", "The kitty put referenced an unknown image id."); + RespondKitty(command.ImageId, "ENOENT: no image with that id", command.Quiet, isError: true); + return; + } + + // Put leaves the cursor in place (kitty semantics). + CreateKittyPlacement(command, command.ImageId, data, moveCursor: false); + RespondKitty(command.ImageId, "OK", command.Quiet, isError: false); + } + + private void ExecuteKittyDelete(KittyGraphicsCommand command) + { + switch (command.DeleteWhat) + { + case 'a': + RemoveKittyOverlays(static _ => true); + break; + case 'A': + RemoveKittyOverlays(static _ => true); + _kittyImages.Clear(); + _kittyImageOrder.Clear(); + _kittyImageStoreBytes = 0; + break; + case 'i': + RemoveKittyOverlays(kitty => kitty.ImageId == command.ImageId); + break; + case 'I': + RemoveKittyOverlays(kitty => kitty.ImageId == command.ImageId); + RemoveKittyImage(command.ImageId); + break; + case 'p': + case 'P': + RemoveKittyOverlays( + kitty => kitty.ImageId == command.ImageId && kitty.PlacementId == command.PlacementId); + break; + default: + // Cell/z-index/number-targeted deletes are not supported; deletes + // have no response, so this is a silent no-op. + break; + } + + ReapKittyImageStore(); + } + + private void CreateKittyPlacement( + KittyGraphicsCommand command, + uint imageId, + KittyImageData data, + bool moveCursor) + { + var kitty = new KittyImage(imageId, command.PlacementId, data) + { + Columns = Math.Clamp(command.Columns, 0, Buffer.Columns), + Rows = Math.Clamp(command.Rows, 0, Buffer.Rows), + PixelOffsetX = Math.Max(0, command.PixelOffsetX), + PixelOffsetY = Math.Max(0, command.PixelOffsetY), + CropX = Math.Max(0, command.CropX), + CropY = Math.Max(0, command.CropY), + CropWidth = Math.Max(0, command.CropWidth), + CropHeight = Math.Max(0, command.CropHeight), + ZIndex = command.ZIndex, + }; + AddImage(new TerminalImageOverlay( + ++_nextImageId, + TerminalImageProtocol.KittyGraphics, + AlternateBufferActive, + Buffer.CursorX, + Buffer.ViewportStart + Buffer.CursorY, + null, + null) + { + Kitty = kitty, + LogicalAnchor = Buffer.CreateImageAnchor(Buffer.CursorX, Buffer.CursorY), + CellGeometry = new TerminalImageCellGeometry(_cellWidth, _cellHeight), + }); + + if (!moveCursor) + { + return; + } + + // The cursor moves below the image (kitty semantics). Encoded payloads + // (f=100) have no Core-known pixel height; without an explicit r the + // cursor stays put — image clients always send c/r for those. + var rows = kitty.Rows > 0 + ? kitty.Rows + : data.Height > 0 + ? (int)Math.Ceiling((kitty.CropHeight > 0 ? kitty.CropHeight : data.Height) / _cellHeight) + : 0; + for (var row = 0; row < rows; row++) + { + Buffer.LineFeed(); + } + } + + private void RemoveKittyOverlays(Func predicate) + { + for (var index = _images.Count - 1; index >= 0; index--) + { + var image = _images[index]; + if (image.Kitty is null || !predicate(image.Kitty)) + { + continue; + } + + _retainedImageBytes -= ImageByteSize(image); + _images.RemoveAt(index); + } + } + + private void ReapKittyImageStore() + { + if (_kittyImages.Count == 0) + { + return; + } + + var live = new HashSet(); + foreach (var image in _images) + { + if (image.Kitty is { } kitty) + { + live.Add(kitty.ImageId); + } + } + + var stale = new List(); + foreach (var id in _kittyImages.Keys) + { + if (!live.Contains(id)) + { + stale.Add(id); + } + } + + foreach (var id in stale) + { + RemoveKittyImage(id); + } + } + + private uint _nextKittyImageIdValue; + private uint NextKittyImageId() + { + do + { + _nextKittyImageIdValue = _nextKittyImageIdValue == uint.MaxValue ? 1 : _nextKittyImageIdValue + 1; + } + while (_kittyImages.ContainsKey(_nextKittyImageIdValue)); + + return _nextKittyImageIdValue; + } + + private void RespondKitty(uint imageId, string message, int quiet, bool isError) + { + if (isError ? quiet >= 2 : quiet >= 1) + { + return; + } + + Respond($"\u001b_Gi={imageId};{message}\u001b\\"); + } + private static TerminalImageDimension ParseInlineDimension(ReadOnlySpan value) { if (value.SequenceEqual("auto")) @@ -1621,7 +1950,10 @@ private void PruneEvictedImages() } private static long ImageByteSize(TerminalImageOverlay image) => - image.Sixel?.EstimatedByteSize ?? image.InlineImage?.EstimatedByteSize ?? 0; + image.Sixel?.EstimatedByteSize ?? + image.InlineImage?.EstimatedByteSize ?? + image.Kitty?.Data.EstimatedByteSize ?? + 0; private void ReportDiagnostic(string code, string message) => Diagnostic?.Invoke(this, new TerminalEngineDiagnostic(code, message)); @@ -2310,6 +2642,8 @@ private void RemoveImages(bool alternateBuffer) _retainedImageBytes -= ImageByteSize(image); _images.RemoveAt(index); } + + ReapKittyImageStore(); } private void RepeatLastCharacter(int count) diff --git a/src/Devolutions.Terminal.Core/TerminalImages.cs b/src/Devolutions.Terminal.Core/TerminalImages.cs index 51b8e60..06129b7 100644 --- a/src/Devolutions.Terminal.Core/TerminalImages.cs +++ b/src/Devolutions.Terminal.Core/TerminalImages.cs @@ -4,6 +4,7 @@ public static class TerminalImageLimits { public const int MaximumDcsPayloadBytes = 4 * 1024 * 1024; public const int MaximumInlineImageBytes = 768 * 1024; + public const int MaximumKittyImageBytes = 32 * 1024 * 1024; public const int MaximumPixelDimension = 4096; public const int MaximumPixelCount = 16 * 1024 * 1024; public const int MaximumSixelPixelWrites = 64 * 1024 * 1024; @@ -16,6 +17,7 @@ public enum TerminalImageProtocol : byte Sixel, Iterm2Inline, ConEmuInline, + KittyGraphics, } public enum TerminalImageDimensionKind : byte @@ -100,6 +102,60 @@ public uint[] ToRgba32() } } +/// +/// Immutable kitty-graphics pixel storage, shared by every placement of the same +/// image id. Either (formats 24/32, converted to +/// 4-bytes-per-pixel RGBA) or (format 100, codec bytes +/// retained for the renderer, mirroring the OSC 1337 contract) is set. +/// +public sealed class KittyImageData +{ + public KittyImageData(int width, int height, byte[] rgba32Pixels) + { + Width = width; + Height = height; + Rgba32Pixels = rgba32Pixels; + } + + public KittyImageData(byte[] encodedData) + { + EncodedData = encodedData; + } + + public int Width { get; } + public int Height { get; } + public byte[]? Rgba32Pixels { get; } + public byte[]? EncodedData { get; } + public long EstimatedByteSize => Rgba32Pixels?.Length ?? EncodedData?.Length ?? 0; +} + +/// +/// One kitty-graphics placement of a . Cell geometry of +/// zero means "natural pixel size"; the renderer applies crop and cell offset. +/// +public sealed class KittyImage +{ + public KittyImage(uint imageId, uint placementId, KittyImageData data) + { + ImageId = imageId; + PlacementId = placementId; + Data = data; + } + + public uint ImageId { get; } + public uint PlacementId { get; } + public KittyImageData Data { get; } + public int Columns { get; init; } + public int Rows { get; init; } + public int PixelOffsetX { get; init; } + public int PixelOffsetY { get; init; } + public int CropX { get; init; } + public int CropY { get; init; } + public int CropWidth { get; init; } + public int CropHeight { get; init; } + public int ZIndex { get; init; } +} + public readonly record struct TerminalImageAnchor(long LogicalLineId, int LogicalOffset); public readonly record struct TerminalImageCellGeometry( @@ -115,6 +171,7 @@ public sealed record TerminalImageOverlay( SixelImage? Sixel, InlineImage? InlineImage) { + public KittyImage? Kitty { get; init; } public TerminalImageAnchor LogicalAnchor { get; init; } public TerminalImageCellGeometry CellGeometry { get; init; } = new(10, 20); } diff --git a/src/Devolutions.Terminal.Core/VtParser.cs b/src/Devolutions.Terminal.Core/VtParser.cs index 7591c41..4af011c 100644 --- a/src/Devolutions.Terminal.Core/VtParser.cs +++ b/src/Devolutions.Terminal.Core/VtParser.cs @@ -29,6 +29,8 @@ private enum State Vt52CursorColumn, OscString, OscEscape, + ApcString, + ApcEscape, StringIgnore, StringEscape, } @@ -36,6 +38,7 @@ private enum State private readonly IVtDispatch _dispatch; private readonly int[] _parameters = new int[MaxParameters]; private readonly List _osc = []; + private readonly List _apc = []; private readonly List _dcs = []; private readonly byte[] _dcsIntermediates = new byte[MaxDcsIntermediates]; private readonly byte[] _escIntermediates = new byte[MaxEscIntermediates]; @@ -72,6 +75,7 @@ public void Reset() _state = State.Ground; ClearSequence(); _osc.Clear(); + _apc.Clear(); ClearDcs(); ResetUtf8(); _ansiMode = true; @@ -114,6 +118,34 @@ private void ProcessByte(byte value) return; } + if (_state == State.ApcString) + { + ProcessApc(value); + return; + } + + if (_state == State.ApcEscape) + { + if (value is 0x18 or 0x1A) + { + _apc.Clear(); + _state = State.Ground; + _dispatch.ExecuteC0(value); + } + else if (value == (byte)'\\') + { + FinishApc(); + } + else + { + AppendApc(0x1B); + _state = State.ApcString; + ProcessApc(value); + } + + return; + } + if (_state == State.StringIgnore) { if (value is 0x18 or 0x1A) @@ -276,7 +308,13 @@ private void ProcessGround(byte value) return; } - if (value is 0x98 or 0x9E or 0x9F) + if (value is 0x9F) + { + EnterApc(); + return; + } + + if (value is 0x98 or 0x9E) { _state = State.StringIgnore; return; @@ -344,9 +382,11 @@ private void ProcessEscape(byte value) break; case (byte)'X': case (byte)'^': - case (byte)'_': _state = State.StringIgnore; break; + case (byte)'_': + EnterApc(); + break; case >= 0x20 and <= 0x2F: AppendEscIntermediate(value); _state = State.EscapeIntermediate; @@ -535,6 +575,56 @@ private void EnterOsc() _state = State.OscString; } + private void EnterApc() + { + _apc.Clear(); + _state = State.ApcString; + } + + private void ProcessApc(byte value) + { + // ECMA-48: APC is terminated by ST only — BEL does not end it. + if (value is 0x18 or 0x1A) + { + _apc.Clear(); + _state = State.Ground; + _dispatch.ExecuteC0(value); + } + else if (value is 0x9C) + { + FinishApc(); + } + else if (value == 0x1B) + { + _state = State.ApcEscape; + } + else if (value >= 0x20 || value == 0x09) + { + AppendApc(value); + } + } + + private void AppendApc(byte value) + { + if (_apc.Count < MaxStringBytes) + { + _apc.Add(value); + } + else + { + _apc.Clear(); + _state = State.StringIgnore; + } + } + + private void FinishApc() + { + var text = Encoding.UTF8.GetString(_apc.ToArray()); + _apc.Clear(); + _state = State.Ground; + _dispatch.ApcDispatch(text); + } + private void EnterDcs() { ClearSequence(); diff --git a/src/Devolutions.Terminal.Ghostty/GhosttyTerminalEngine.cs b/src/Devolutions.Terminal.Ghostty/GhosttyTerminalEngine.cs index 60ff01b..52b918a 100644 --- a/src/Devolutions.Terminal.Ghostty/GhosttyTerminalEngine.cs +++ b/src/Devolutions.Terminal.Ghostty/GhosttyTerminalEngine.cs @@ -58,6 +58,7 @@ public sealed unsafe class GhosttyTerminalEngine : ITerminalEngine private bool _ansiMode = true; private int _modifyOtherKeys; private KeyboardModeScanState _keyboardModeScan; + private bool _kittyImageReported; private byte _keyboardCsiPrivate; private int _keyboardCsiValue; private bool _keyboardCsiHasValue; @@ -75,6 +76,9 @@ private enum ImageProbeState : byte OscEscape, OscIgnored, OscIgnoredEscape, + Apc, + ApcIgnored, + ApcIgnoredEscape, } private enum KeyboardModeScanState : byte @@ -514,6 +518,10 @@ private void ProbeUnsupportedImages(ReadOnlySpan data) { StartOscProbe(); } + else if (value == 0x9F) + { + _imageProbeState = ImageProbeState.Apc; + } break; case ImageProbeState.Escape: if (value == (byte)'P') @@ -525,11 +533,46 @@ private void ProbeUnsupportedImages(ReadOnlySpan data) { StartOscProbe(); } + else if (value == (byte)'_') + { + _imageProbeState = ImageProbeState.Apc; + } else { _imageProbeState = ImageProbeState.Ground; } break; + case ImageProbeState.Apc: + if (value == (byte)'G') + { + // Chunked kitty transmissions repeat APC G per chunk; + // report once per engine lifetime instead of per chunk. + if (!_kittyImageReported) + { + _kittyImageReported = true; + ReportUnsupportedImage( + "image.kitty.unsupported", + "The pinned libghostty-vt C ABI does not expose kitty graphics resources."); + } + } + + _imageProbeState = ImageProbeState.ApcIgnored; + break; + case ImageProbeState.ApcIgnored: + if (value == 0x9C) + { + _imageProbeState = ImageProbeState.Ground; + } + else if (value == 0x1B) + { + _imageProbeState = ImageProbeState.ApcIgnoredEscape; + } + break; + case ImageProbeState.ApcIgnoredEscape: + _imageProbeState = value == (byte)'\\' + ? ImageProbeState.Ground + : ImageProbeState.ApcIgnored; + break; case ImageProbeState.DcsHeader: if (value is >= 0x40 and <= 0x7E) { diff --git a/src/Devolutions.Terminal.Render/SkiaTerminalRenderer.cs b/src/Devolutions.Terminal.Render/SkiaTerminalRenderer.cs index 341b0b9..95b9c1e 100644 --- a/src/Devolutions.Terminal.Render/SkiaTerminalRenderer.cs +++ b/src/Devolutions.Terminal.Render/SkiaTerminalRenderer.cs @@ -127,13 +127,16 @@ public void Render( _paint.Style = SKPaintStyle.Fill; _paint.Color = ToColor(frame.Background); canvas.DrawRect(bounds, _paint); - DrawImages(canvas, frame, bounds, padding); + DrawImages(canvas, frame, bounds, padding, overText: false); for (var rowIndex = 0; rowIndex < frame.RowsData.Count; rowIndex++) { DrawRow(canvas, frame, frame.RowsData[rowIndex], padding); } + // Kitty placements with a non-negative z-index composite over text. + DrawImages(canvas, frame, bounds, padding, overText: true); + DrawRanges(canvas, frame, overlays.Selection, padding); DrawRanges(canvas, frame, overlays.Search, padding); DrawRanges(canvas, frame, overlays.Hyperlink, padding); @@ -301,33 +304,48 @@ private void DrawImages( SKCanvas canvas, TerminalRenderFrame frame, SKRect bounds, - float padding) + float padding, + bool overText) { - if (frame.Images.Count == 0) + if (!overText) { - if (_images.Count > 0) + if (frame.Images.Count == 0) { - foreach (var image in _images.Values) + if (_images.Count > 0) { - image.Dispose(); + foreach (var image in _images.Values) + { + image.Dispose(); + } + + _images.Clear(); + _imageBytes = 0; } - _images.Clear(); - _imageBytes = 0; + return; } - return; - } + var activeIds = frame.Images.Select(static image => image.Id).ToHashSet(); + foreach (var staleId in _images.Keys.Where(id => !activeIds.Contains(id)).ToArray()) + { + _imageBytes -= _images[staleId].ByteSize; + _images[staleId].Dispose(); + _images.Remove(staleId); + } - var activeIds = frame.Images.Select(static image => image.Id).ToHashSet(); - foreach (var staleId in _images.Keys.Where(id => !activeIds.Contains(id)).ToArray()) + _invalidImages.RemoveWhere(id => !activeIds.Contains(id)); + } + else if (frame.Images.Count == 0 || frame.Images.All(static image => image.Kitty is null)) { - _imageBytes -= _images[staleId].ByteSize; - _images[staleId].Dispose(); - _images.Remove(staleId); + return; } - _invalidImages.RemoveWhere(id => !activeIds.Contains(id)); + // Under-text pass: everything except non-negative-z kitty placements. + // Over-text pass: kitty placements with z >= 0, in z order. + var ordered = overText + ? frame.Images.Where(static i => i.Kitty is { ZIndex: >= 0 }).OrderBy(static i => i.Kitty!.ZIndex) + : frame.Images.Where(static i => i.Kitty is null || i.Kitty.ZIndex < 0); + var viewport = new SKRect( bounds.Left + padding, bounds.Top + padding, @@ -336,7 +354,7 @@ private void DrawImages( canvas.Save(); canvas.ClipRect(viewport); _paint.Color = SKColors.White; - foreach (var image in frame.Images) + foreach (var image in ordered) { if (_invalidImages.Contains(image.Id)) { @@ -349,6 +367,12 @@ private void DrawImages( : 1; var left = padding + (image.AnchorColumn * columnScale * (float)CellSize.Width); var top = padding + (image.AnchorRow * (float)CellSize.Height); + if (image.Kitty is { } kittyPlacement) + { + left += kittyPlacement.PixelOffsetX; + top += kittyPlacement.PixelOffsetY; + } + if (left >= viewport.Right || top >= viewport.Bottom) { continue; @@ -386,7 +410,19 @@ private void DrawImages( } var destination = ImageDestination(image, cached.Bitmap, left, top, viewport); - canvas.DrawBitmap(cached.Bitmap, destination, _paint); + if (image.Kitty is { CropWidth: > 0, CropHeight: > 0 } cropped) + { + var source = SKRect.Create( + Math.Min(cropped.CropX, cached.Bitmap.Width - 1), + Math.Min(cropped.CropY, cached.Bitmap.Height - 1), + Math.Min(cropped.CropWidth, cached.Bitmap.Width), + Math.Min(cropped.CropHeight, cached.Bitmap.Height)); + canvas.DrawBitmap(cached.Bitmap, source, destination, _paint); + } + else + { + canvas.DrawBitmap(cached.Bitmap, destination, _paint); + } } canvas.Restore(); @@ -416,12 +452,45 @@ private void DrawImages( return bitmap; } + if (image.Kitty is { Data.Rgba32Pixels: { } rgbaPixels } kittyRaw) + { + var raw = kittyRaw.Data; + var bitmap = new SKBitmap( + raw.Width, + raw.Height, + SKColorType.Rgba8888, + SKAlphaType.Unpremul); + var pixels = new SKColor[raw.Width * raw.Height]; + for (var index = 0; index < pixels.Length; index++) + { + var offset = index * 4; + pixels[index] = new SKColor( + rgbaPixels[offset], + rgbaPixels[offset + 1], + rgbaPixels[offset + 2], + rgbaPixels[offset + 3]); + } + + bitmap.Pixels = pixels; + return bitmap; + } + + if (image.Kitty is { Data.EncodedData: { } kittyEncoded }) + { + return DecodeCodecImage(kittyEncoded); + } + if (image.InlineImage is not { } inline) { return null; } - using var data = SKData.CreateCopy(inline.Data.ToArray()); + return DecodeCodecImage(inline.Data.ToArray()); + } + + private static SKBitmap? DecodeCodecImage(byte[] encoded) + { + using var data = SKData.CreateCopy(encoded); using var codec = SKCodec.Create(data); if (codec is null || codec.Info.Width is <= 0 or > TerminalImageLimits.MaximumPixelDimension || @@ -462,11 +531,48 @@ private SKRect ImageDestination( Math.Min(Math.Max(0.1f, sixelHeight), bounds.Bottom - top)); } + if (image.Kitty is { } kitty) + { + // Source extent after crop; display size from c/r cells when given, + // preserving aspect when only one axis is specified (kitty semantics). + var sourceWidth = kitty.CropWidth > 0 ? (float)kitty.CropWidth : naturalWidth; + var sourceHeight = kitty.CropHeight > 0 ? (float)kitty.CropHeight : naturalHeight; + float kittyWidth; + float kittyHeight; + if (kitty.Columns > 0 && kitty.Rows > 0) + { + kittyWidth = kitty.Columns * (float)CellSize.Width; + kittyHeight = kitty.Rows * (float)CellSize.Height; + } + else if (kitty.Columns > 0) + { + kittyWidth = kitty.Columns * (float)CellSize.Width; + kittyHeight = sourceHeight * (kittyWidth / Math.Max(0.1f, sourceWidth)); + } + else if (kitty.Rows > 0) + { + kittyHeight = kitty.Rows * (float)CellSize.Height; + kittyWidth = sourceWidth * (kittyHeight / Math.Max(0.1f, sourceHeight)); + } + else + { + kittyWidth = sourceWidth; + kittyHeight = sourceHeight; + } + + return SKRect.Create( + left, + top, + Math.Min(Math.Max(0.1f, kittyWidth), bounds.Right - left), + Math.Min(Math.Max(0.1f, kittyHeight), bounds.Bottom - top)); + } + if (image.InlineImage is not { } inline) { return SKRect.Create(left, top, naturalWidth, naturalHeight); } + var width = ResolveDimension( inline.Metadata.Width, naturalWidth, diff --git a/tests/Devolutions.Terminal.Core.Tests/KittyGraphicsTests.cs b/tests/Devolutions.Terminal.Core.Tests/KittyGraphicsTests.cs new file mode 100644 index 0000000..4b62d46 --- /dev/null +++ b/tests/Devolutions.Terminal.Core.Tests/KittyGraphicsTests.cs @@ -0,0 +1,437 @@ +using System.IO.Compression; +using System.Text; +using Devolutions.Terminal.Core; +using Xunit; + +namespace Devolutions.Terminal.Core.Tests; + +public sealed class KittyGraphicsTests +{ + private const string Esc = "\u001b"; + + private static string Apc(string control, string? base64Payload = null) => + base64Payload is null + ? $"{Esc}_G{control}{Esc}\\" + : $"{Esc}_G{control};{base64Payload}{Esc}\\"; + + private static string B64(byte[] data) => Convert.ToBase64String(data); + + private static (TerminalEngine Engine, List Responses) CreateEngine(int columns = 80, int rows = 24) + { + var engine = new TerminalEngine(columns, rows); + var responses = new List(); + engine.ResponseReady += (_, bytes) => responses.Add(Encoding.UTF8.GetString(bytes)); + return (engine, responses); + } + + // --- decoder --- + + [Fact] + public void ParsesAllControlKeys() + { + var ok = KittyGraphicsDecoder.TryParse( + "a=t,t=d,f=32,o=z,s=4,v=2,i=7,I=9,p=3,c=10,r=5,x=1,y=2,X=1,Y=1,w=2,h=2,z=-3,q=1,C=1,m=1,d=a", + out var command, + out var error); + + Assert.True(ok, error); + Assert.NotNull(command); + Assert.Equal(KittyGraphicsAction.Transmit, command.Action); + Assert.Equal('d', command.Medium); + Assert.Equal(32, command.Format); + Assert.True(command.Compressed); + Assert.Equal(4, command.SourceWidth); + Assert.Equal(2, command.SourceHeight); + Assert.Equal(7u, command.ImageId); + Assert.Equal(3u, command.PlacementId); + Assert.Equal(10, command.Columns); + Assert.Equal(5, command.Rows); + Assert.Equal(1, command.PixelOffsetX); + Assert.Equal(2, command.PixelOffsetY); + Assert.Equal(1, command.CropX); + Assert.Equal(1, command.CropY); + Assert.Equal(2, command.CropWidth); + Assert.Equal(2, command.CropHeight); + Assert.Equal(-3, command.ZIndex); + Assert.Equal(1, command.Quiet); + Assert.True(command.NoCursorMove); + Assert.True(command.MoreChunks); + } + + [Fact] + public void DefaultsMatchKittySpec() + { + Assert.True(KittyGraphicsDecoder.TryParse("", out var command, out _)); + Assert.Equal(KittyGraphicsAction.TransmitAndDisplay, command!.Action); + Assert.Equal('d', command.Medium); + Assert.Equal(32, command.Format); + Assert.Equal(0u, command.ImageId); + Assert.False(command.MoreChunks); + } + + [Fact] + public void UnknownKeysAreIgnored() + { + Assert.True(KittyGraphicsDecoder.TryParse("n=42,a=q", out var command, out _)); + Assert.Equal(KittyGraphicsAction.Query, command!.Action); + } + + [Theory] + [InlineData("aa=1")] + [InlineData("=1")] + [InlineData("i=abc")] + [InlineData("s=1.5")] + public void MalformedControlKeysAreRejected(string control) + { + Assert.False(KittyGraphicsDecoder.TryParse(control, out _, out var error)); + Assert.StartsWith("EINVAL", error, StringComparison.Ordinal); + } + + [Fact] + public void InvalidBase64PayloadIsRejected() + { + Assert.False(KittyGraphicsDecoder.TryParse("a=t;!!!not-base64!!!", out _, out var error)); + Assert.Contains("base64", error, StringComparison.Ordinal); + } + + [Fact] + public void RgbFormatExpandsToRgba() + { + Assert.True(KittyGraphicsDecoder.TryParse("a=t,f=24,s=2,v=1", out var command, out _)); + var rgb = new byte[] { 255, 0, 0, 0, 255, 0 }; + Assert.True(KittyGraphicsDecoder.TryDecodeImageData(command!, rgb, out var data, out var error)); + + Assert.Equal(2, data!.Width); + Assert.Equal(1, data.Height); + Assert.Equal( + new byte[] { 255, 0, 0, 255, 0, 255, 0, 255 }, + data.Rgba32Pixels); + } + + [Fact] + public void RgbaFormatPassesThrough() + { + Assert.True(KittyGraphicsDecoder.TryParse("a=t,f=32,s=1,v=1", out var command, out _)); + var rgba = new byte[] { 1, 2, 3, 4 }; + Assert.True(KittyGraphicsDecoder.TryDecodeImageData(command!, rgba, out var data, out _)); + Assert.Equal(rgba, data!.Rgba32Pixels); + } + + [Fact] + public void ZlibPayloadDecompresses() + { + var rgba = new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 }; + byte[] compressed; + using (var output = new MemoryStream()) + { + using (var zlib = new ZLibStream(output, CompressionMode.Compress, leaveOpen: true)) + { + zlib.Write(rgba); + } + + compressed = output.ToArray(); + } + + Assert.True(KittyGraphicsDecoder.TryParse("a=t,f=32,o=z,s=2,v=1", out var command, out _)); + Assert.True(command!.Compressed); + Assert.True(KittyGraphicsDecoder.TryDecodeImageData(command, compressed, out var data, out var error)); + Assert.Equal(rgba, data!.Rgba32Pixels); + Assert.Null(error); + } + + [Fact] + public void EncodedFormatRetainsCodecBytes() + { + Assert.True(KittyGraphicsDecoder.TryParse("a=t,f=100", out var command, out _)); + var pngMagic = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + Assert.True(KittyGraphicsDecoder.TryDecodeImageData(command!, pngMagic, out var data, out _)); + Assert.Null(data!.Rgba32Pixels); + Assert.Equal(pngMagic, data.EncodedData); + } + + [Theory] + // raw formats require dimensions + [InlineData("a=t,f=32", "EINVAL")] + // payload length must match s*v*format + [InlineData("a=t,f=32,s=2,v=2", "EINVAL")] + // dimensions beyond pixel limits + [InlineData("a=t,f=32,s=99999,v=99999", "ETOOMANY")] + // only direct transmission + [InlineData("a=t,t=f,f=100", "ENOTSUP")] + [InlineData("a=t,t=s,f=100", "ENOTSUP")] + // unsupported pixel formats + [InlineData("a=t,f=1,s=1,v=1", "EINVAL")] + public void InvalidTransmissionsAreRejected(string control, string expectedCode) + { + Assert.True(KittyGraphicsDecoder.TryParse(control, out var command, out _)); + // 15 bytes: wrong for any 2x2 RGBA payload, irrelevant for earlier failures. + var payload = control.Contains("f=100", StringComparison.Ordinal) ? new byte[] { 1, 2, 3 } : new byte[15]; + Assert.False(KittyGraphicsDecoder.TryDecodeImageData(command!, payload, out _, out var error)); + Assert.StartsWith(expectedCode, error, StringComparison.Ordinal); + } + + // --- engine --- + + [Fact] + public void TransmitAndDisplayCreatesOverlayAtCursorAndResponds() + { + var (engine, responses) = CreateEngine(); + var payload = B64(new byte[] { 255, 0, 0, 255 }); + + engine.Feed($"{Apc($"a=T,f=32,s=1,v=1,i=7,r=1", payload)}"); + + var overlay = Assert.Single(engine.Images); + Assert.Equal(TerminalImageProtocol.KittyGraphics, overlay.Protocol); + Assert.NotNull(overlay.Kitty); + Assert.Equal(7u, overlay.Kitty.ImageId); + Assert.Equal(1, overlay.Kitty.Rows); + Assert.Equal(0, overlay.AnchorColumn); + Assert.Equal(0, overlay.AnchorRow); + Assert.Equal([$"{Esc}_Gi=7;OK{Esc}\\"], responses); + // Cursor moved below the image (r=1, no C=1). + Assert.Equal(1, engine.CursorY); + } + + [Fact] + public void NoCursorMoveKeepsCursorInPlace() + { + var (engine, _) = CreateEngine(); + engine.Feed(Apc("a=T,f=32,s=1,v=1,i=7,r=3,C=1", B64(new byte[] { 1, 2, 3, 4 }))); + Assert.Equal(0, engine.CursorY); + } + + [Fact] + public void NaturalHeightMovesCursorByPixelRows() + { + var (engine, _) = CreateEngine(); + engine.Resize(80, 24, 10, 20); + // 60px tall / 20px cell = 3 rows, no explicit r. + var pixels = new byte[10 * 60 * 4]; + engine.Feed(Apc("a=T,f=32,s=10,v=60,i=1", B64(pixels))); + Assert.Equal(3, engine.CursorY); + } + + [Fact] + public void AutoAssignedImageIdIsReported() + { + var (engine, responses) = CreateEngine(); + engine.Feed(Apc("a=t,f=32,s=1,v=1", B64(new byte[] { 1, 2, 3, 4 }))); + Assert.Equal([$"{Esc}_Gi=1;OK{Esc}\\"], responses); + Assert.Empty(engine.Images); // transmit-only: nothing displayed + } + + [Fact] + public void ChunkedTransmissionAssemblesOneImage() + { + var (engine, responses) = CreateEngine(); + var pixels = new byte[2 * 2 * 4]; + for (var i = 0; i < pixels.Length; i++) + { + pixels[i] = (byte)i; + } + + var base64 = B64(pixels); + var half = base64.Length / 2; + half -= half % 4; // chunks split on a base64 quantum + + engine.Feed(Apc("a=T,f=32,s=2,v=2,i=9,m=1", base64[..half])); + Assert.Empty(engine.Images); + Assert.Empty(responses); + + engine.Feed(Apc("i=9,m=0", base64[half..])); + + var overlay = Assert.Single(engine.Images); + Assert.Equal(pixels, overlay.Kitty!.Data.Rgba32Pixels); + Assert.Equal([$"{Esc}_Gi=9;OK{Esc}\\"], responses); + } + + [Fact] + public void PutDisplaysTransmittedImageWithoutMovingCursor() + { + var (engine, responses) = CreateEngine(); + engine.Feed(Apc("a=t,f=32,s=1,v=1,i=4", B64(new byte[] { 1, 2, 3, 4 }))); + engine.Feed("AB"); // move cursor off the origin + engine.Feed(Apc("a=p,i=4,p=2,c=4,r=2")); + + var overlay = Assert.Single(engine.Images); + Assert.Equal(4u, overlay.Kitty!.ImageId); + Assert.Equal(2u, overlay.Kitty.PlacementId); + Assert.Equal(4, overlay.Kitty.Columns); + Assert.Equal(2, overlay.Kitty.Rows); + Assert.Equal(2, overlay.AnchorColumn); + Assert.Equal(0, engine.CursorY); // put does not move the cursor + Assert.Equal(2, engine.CursorX); + Assert.Equal( + [$"{Esc}_Gi=4;OK{Esc}\\", $"{Esc}_Gi=4;OK{Esc}\\"], + responses); + } + + [Fact] + public void PutWithUnknownIdRespondsEnoent() + { + var (engine, responses) = CreateEngine(); + engine.Feed(Apc("a=p,i=42")); + Assert.Equal([$"{Esc}_Gi=42;ENOENT: no image with that id{Esc}\\"], responses); + Assert.Empty(engine.Images); + } + + [Fact] + public void QueryRespondsOkWithoutStoring() + { + var (engine, responses) = CreateEngine(); + engine.Feed(Apc("a=q,f=32,s=1,v=1,i=1", B64(new byte[] { 1, 2, 3, 4 }))); + Assert.Equal([$"{Esc}_Gi=1;OK{Esc}\\"], responses); + Assert.Empty(engine.Images); + + // A put for the queried id fails: query does not store. + engine.Feed(Apc("a=p,i=1")); + Assert.EndsWith("ENOENT: no image with that id" + Esc + "\\", responses[^1], StringComparison.Ordinal); + } + + [Fact] + public void DeleteByIdRemovesPlacements() + { + var (engine, _) = CreateEngine(); + var payload = B64(new byte[] { 1, 2, 3, 4 }); + engine.Feed(Apc("a=T,f=32,s=1,v=1,i=1,C=1", payload)); + engine.Feed(Apc("a=T,f=32,s=1,v=1,i=2,C=1", payload)); + Assert.Equal(2, engine.Images.Count); + + engine.Feed(Apc("a=d,d=i,i=1")); + var remaining = Assert.Single(engine.Images); + Assert.Equal(2u, remaining.Kitty!.ImageId); + } + + [Fact] + public void DeleteUppercaseFreesImageData() + { + var (engine, responses) = CreateEngine(); + engine.Feed(Apc("a=T,f=32,s=1,v=1,i=3,C=1", B64(new byte[] { 1, 2, 3, 4 }))); + engine.Feed(Apc("a=d,d=I,i=3")); + Assert.Empty(engine.Images); + + // Data freed: a later put for the id fails. + engine.Feed(Apc("a=p,i=3")); + Assert.EndsWith("ENOENT: no image with that id" + Esc + "\\", responses[^1], StringComparison.Ordinal); + } + + [Fact] + public void DeleteAllClearsEverything() + { + var (engine, _) = CreateEngine(); + var payload = B64(new byte[] { 1, 2, 3, 4 }); + engine.Feed(Apc("a=T,f=32,s=1,v=1,i=1,C=1", payload)); + engine.Feed(Apc("a=T,f=32,s=1,v=1,i=2,C=1", payload)); + + engine.Feed(Apc("a=d,d=A")); + Assert.Empty(engine.Images); + } + + [Fact] + public void QuietFlagsSuppressResponses() + { + var (engine, responses) = CreateEngine(); + engine.Feed(Apc("a=q,f=32,s=1,v=1,i=1,q=1", B64(new byte[] { 1, 2, 3, 4 }))); + Assert.Empty(responses); // q=1 suppresses OK + + engine.Feed(Apc("a=p,i=99,q=1")); + Assert.Single(responses); // q=1 still reports errors + Assert.Contains("ENOENT", responses[0], StringComparison.Ordinal); + + engine.Feed(Apc("a=p,i=99,q=2")); + Assert.Single(responses); // q=2 suppresses errors too + } + + [Fact] + public void UnsupportedActionRespondsNotsup() + { + var (engine, responses) = CreateEngine(); + engine.Feed(Apc("a=f,i=1")); + Assert.Contains("ENOTSUP", responses[0], StringComparison.Ordinal); + } + + [Fact] + public void ResetClearsKittyState() + { + var (engine, _) = CreateEngine(); + engine.Feed(Apc("a=T,f=32,s=1,v=1,i=1,C=1", B64(new byte[] { 1, 2, 3, 4 }))); + Assert.Single(engine.Images); + + engine.Reset(); + Assert.Empty(engine.Images); + } + + [Fact] + public void KittyOverlaySurvivesScrollbackAnchoring() + { + var (engine, _) = CreateEngine(rows: 5); + engine.Feed(Apc("a=T,f=32,s=1,v=1,i=1,C=1", B64(new byte[] { 1, 2, 3, 4 }))); + // Scroll past the viewport: the anchor must keep the image attached. + engine.Feed(string.Concat(Enumerable.Repeat("line\r\n", 20))); + + var snapshot = engine.CreateSnapshot(includeHistory: true); + Assert.Contains(snapshot.Images, image => image.Kitty is { ImageId: 1 }); + } + + [Fact] + public void CapabilityAdvertised() + { + var (engine, _) = CreateEngine(); + Assert.True(engine.Capabilities.HasFlag(TerminalEngineCapabilities.KittyImages)); + } + + [Fact] + public void ApcWithoutGIsIgnored() + { + var (engine, responses) = CreateEngine(); + engine.Feed($"{Esc}_Xsome-other-apc{Esc}\\"); + Assert.Empty(engine.Images); + Assert.Empty(responses); + } + + [Fact] + public void ApcSplitAcrossFeedsAssembles() + { + var (engine, responses) = CreateEngine(); + var sequence = Apc("a=T,f=32,s=1,v=1,i=1,C=1", B64(new byte[] { 1, 2, 3, 4 })); + var midpoint = sequence.Length / 2; + engine.Feed(sequence[..midpoint]); + Assert.Empty(engine.Images); + engine.Feed(sequence[midpoint..]); + + Assert.Single(engine.Images); + Assert.Single(responses); + } + + [Fact] + public void ApcEntryViaC1Works() + { + var (engine, responses) = CreateEngine(); + var bytes = Encoding.ASCII.GetBytes("Ga=q,f=32,s=1,v=1,i=1;" + B64(new byte[] { 1, 2, 3, 4 })); + engine.Feed(new byte[] { 0x9F }.Concat(bytes).Concat(new byte[] { 0x9C }).ToArray()); + Assert.Equal([$"{Esc}_Gi=1;OK{Esc}\\"], responses); + } + + [Fact] + public void CanCancelsApc() + { + var (engine, responses) = CreateEngine(); + engine.Feed($"{Esc}_Ga=T,i=1\u0018text after cancel"); + Assert.Empty(engine.Images); + Assert.Empty(responses); + // The printable tail was still processed as text. + Assert.Equal('t', engine.CreateSnapshot().Buffer.Lines[0].Cells[0].Text.First()); + } + + [Fact] + public void BelDoesNotTerminateApc() + { + var (engine, responses) = CreateEngine(); + // BEL inside the payload is not a terminator: it corrupts the base64, + // which proves the APC ran to its ST. + engine.Feed($"{Esc}_Ga=T,i=1;\a{Esc}\\"); + var response = Assert.Single(responses); + Assert.Contains("EINVAL", response, StringComparison.Ordinal); + Assert.Empty(engine.Images); + } +} diff --git a/tests/Devolutions.Terminal.Core.Tests/UnicodeLegacyParityTests.cs b/tests/Devolutions.Terminal.Core.Tests/UnicodeLegacyParityTests.cs index edd4a5a..4f2eff1 100644 --- a/tests/Devolutions.Terminal.Core.Tests/UnicodeLegacyParityTests.cs +++ b/tests/Devolutions.Terminal.Core.Tests/UnicodeLegacyParityTests.cs @@ -137,7 +137,8 @@ public void KittyModeHonorsProfileCapabilityGate() TerminalEngineCapabilities.Win32Input | TerminalEngineCapabilities.SixelImages | TerminalEngineCapabilities.Iterm2Images | - TerminalEngineCapabilities.ConEmuImages, + TerminalEngineCapabilities.ConEmuImages | + TerminalEngineCapabilities.KittyImages, engine.Capabilities); } diff --git a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs b/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs index d60d3fc..d3e35e0 100644 --- a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs +++ b/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs @@ -81,6 +81,22 @@ public void NonImageDcsQueriesDoNotProduceImageDiagnostics() Assert.Empty(diagnostics); } + [Fact] + public void KittyGraphicsProducesSingleUnsupportedDiagnostic() + { + using var engine = new GhosttyTerminalEngine(); + var diagnostics = new List(); + engine.Diagnostic += (_, value) => diagnostics.Add(value); + + // Chunked kitty transmissions repeat APC G per chunk; only one diagnostic. + engine.Feed("\u001b_Ga=T,f=32,s=1,v=1,i=1,m=1;AQID\u001b\\"); + engine.Feed("\u001b_Gi=1,m=0;BA==\u001b\\"); + + Assert.Equal( + ["image.kitty.unsupported"], + diagnostics.Select(static value => value.Code)); + } + [Fact] public void EngineSurvivesAdversarialCorpusOnSmallStackThread() { diff --git a/tests/Devolutions.Terminal.Render.Tests/SkiaTerminalRendererTests.cs b/tests/Devolutions.Terminal.Render.Tests/SkiaTerminalRendererTests.cs index 6472f46..d01523a 100644 --- a/tests/Devolutions.Terminal.Render.Tests/SkiaTerminalRendererTests.cs +++ b/tests/Devolutions.Terminal.Render.Tests/SkiaTerminalRendererTests.cs @@ -375,6 +375,116 @@ public void RendersBoundedConEmuEncodedImage() Assert.True(bitmap.GetPixel(8, 8).Red > 200); } + [Fact] + public void RendersKittyRawRgbaAtAnchor() + { + var red = Enumerable.Repeat(new byte[] { 255, 0, 0, 255 }, 16).SelectMany(static b => b).ToArray(); + var engine = new TerminalEngine(16, 2); + engine.Feed($"\u001b_Ga=T,f=32,s=4,v=4,i=1,C=1;{Convert.ToBase64String(red)}\u001b\\"); + var frame = TerminalRenderPlanner.Create(engine.CreateSnapshot(), engine.Scheme); + using var renderer = new SkiaTerminalRenderer(); + using var bitmap = NewBitmap(renderer, frame); + using var canvas = new SKCanvas(bitmap); + + Draw(renderer, canvas, frame); + + Assert.Equal(TerminalImageProtocol.KittyGraphics, Assert.Single(frame.Images).Protocol); + var pixel = bitmap.GetPixel(10, 10); + Assert.True(pixel.Red > 200 && pixel.Green < 30 && pixel.Blue < 30); + } + + [Fact] + public void RendersKittyEncodedPng() + { + using var source = new SKBitmap(4, 4); + source.Erase(SKColors.Blue); + using var encoded = source.Encode(SKEncodedImageFormat.Png, 100); + var engine = new TerminalEngine(16, 2); + engine.Feed($"\u001b_Ga=T,f=100,i=2,C=1;{Convert.ToBase64String(encoded.ToArray())}\u001b\\"); + var frame = TerminalRenderPlanner.Create(engine.CreateSnapshot(), engine.Scheme); + using var renderer = new SkiaTerminalRenderer(); + using var bitmap = NewBitmap(renderer, frame); + using var canvas = new SKCanvas(bitmap); + + Draw(renderer, canvas, frame); + + Assert.True(bitmap.GetPixel(10, 10).Blue > 200); + } + + [Fact] + public void KittyCellSizingScalesToColumnsAndRows() + { + var green = Enumerable.Repeat(new byte[] { 0, 255, 0, 255 }, 16).SelectMany(static b => b).ToArray(); + var engine = new TerminalEngine(16, 4); + engine.Feed($"\u001b_Ga=T,f=32,s=4,v=4,i=3,c=2,r=1,C=1;{Convert.ToBase64String(green)}\u001b\\"); + var frame = TerminalRenderPlanner.Create(engine.CreateSnapshot(), engine.Scheme); + using var renderer = new SkiaTerminalRenderer(); + using var bitmap = NewBitmap(renderer, frame); + using var canvas = new SKCanvas(bitmap); + + Draw(renderer, canvas, frame); + + var cellWidth = (int)renderer.CellSize.Width; + var cellHeight = (int)renderer.CellSize.Height; + // Inside the 2x1 cell rectangle: green. + Assert.True(bitmap.GetPixel(8 + cellWidth + (cellWidth / 2), 8 + (cellHeight / 2)).Green > 200); + // Past the second column: not green. + Assert.True(bitmap.GetPixel(8 + (2 * cellWidth) + 1, 8 + (cellHeight / 2)).Green < 30); + // Below the first row: not green. + Assert.True(bitmap.GetPixel(8 + (cellWidth / 2), 8 + cellHeight + 1).Green < 30); + } + + [Fact] + public void KittyNegativeZIndexDrawsUnderTextBackground() + { + var pixel = RenderKittyBehindOrOverText(zIndex: -1); + Assert.True(pixel.Red > 200 && pixel.Green > 200 && pixel.Blue > 200, "white run background must cover a z<0 image"); + } + + [Fact] + public void KittyNonNegativeZIndexDrawsOverText() + { + var pixel = RenderKittyBehindOrOverText(zIndex: 1); + Assert.True(pixel.Red > 200 && pixel.Green < 30, "z>=0 image must composite over text"); + } + + [Fact] + public void KittyCropSelectsSourceRegion() + { + // 4x1 pixels: red, green, blue, white. Crop to the green pixel only. + var pixels = new byte[] + { + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, + }; + var engine = new TerminalEngine(16, 2); + engine.Feed($"\u001b_Ga=T,f=32,s=4,v=1,i=6,X=1,Y=0,w=1,h=1,c=1,r=1,C=1;{Convert.ToBase64String(pixels)}\u001b\\"); + var frame = TerminalRenderPlanner.Create(engine.CreateSnapshot(), engine.Scheme); + using var renderer = new SkiaTerminalRenderer(); + using var bitmap = NewBitmap(renderer, frame); + using var canvas = new SKCanvas(bitmap); + + Draw(renderer, canvas, frame); + + var center = bitmap.GetPixel(8 + ((int)renderer.CellSize.Width / 2), 8 + ((int)renderer.CellSize.Height / 2)); + Assert.True(center.Green > 200 && center.Red < 30 && center.Blue < 30, $"expected green, got {center}"); + } + + private static SKColor RenderKittyBehindOrOverText(int zIndex) + { + var red = Enumerable.Repeat(new byte[] { 255, 0, 0, 255 }, 64).SelectMany(static b => b).ToArray(); + var engine = new TerminalEngine(16, 2); + // White-background space in the anchor cell, then back to origin. + engine.Feed("\u001b[47m \u001b[0m\u001b[H"); + engine.Feed($"\u001b_Ga=T,f=32,s=8,v=8,i=5,z={zIndex},c=1,r=1,C=1;{Convert.ToBase64String(red)}\u001b\\"); + var frame = TerminalRenderPlanner.Create(engine.CreateSnapshot(), engine.Scheme); + using var renderer = new SkiaTerminalRenderer(); + using var bitmap = NewBitmap(renderer, frame); + using var canvas = new SKCanvas(bitmap); + + Draw(renderer, canvas, frame); + return bitmap.GetPixel(8 + ((int)renderer.CellSize.Width / 2), 8 + ((int)renderer.CellSize.Height / 2)); + } + [Fact] public void WarmRenderDoesNotAllocatePerCell() { From 8a2f0cf5d968a51628172eee1394a3acfab85043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 4 Sep 2026 21:46:43 -0400 Subject: [PATCH 3/3] test: keep the UI thread from pumping during the coalescing burst Awaiting Task.Run let the headless dispatcher interleave drains mid-burst on the macOS and Linux CI runners (55 drains instead of <= 2). Drive the producer on a raw Thread and block the UI thread on Join so every chunk queues before any drain runs, on every platform. --- .../TermControlOutputPumpTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlOutputPumpTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlOutputPumpTests.cs index 226025d..f694336 100644 --- a/tests/Devolutions.Terminal.Control.Tests/TermControlOutputPumpTests.cs +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlOutputPumpTests.cs @@ -50,16 +50,20 @@ public async Task OutputBurstCoalescesIntoFewUiDrains() var postsBefore = control.InvalidationPosts; var drainsBefore = control.InvalidationDrains; - // The UI thread is busy in this test method, so every chunk's + // The UI thread must not pump the dispatcher while the burst arrives: + // awaiting here would let the headless dispatcher interleave drains + // (observed on the macOS CI runner). Spin on Join so every chunk's // invalidation queues before any drain can run — the production burst // shape (one frame, many 16 KiB ConPTY reads). - await Task.Run(() => + var producer = new Thread(() => { for (var index = 0; index < 64; index++) { connection.Emit($"line {index:D4} filler filler filler filler\r\n"); } }); + producer.Start(); + producer.Join(); Dispatcher.UIThread.RunJobs();