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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ linux/*.sh text eol=lf
linux/*.env text eol=lf
linux/*.desktop text eol=lf
linux/*.xml text eol=lf
tests/Devolutions.Terminal.Core.Tests/UnicodeData/16.0.0/*.txt text eol=lf
29 changes: 28 additions & 1 deletion docs/advanced-vt-protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Limits are public constants on `TerminalImageLimits` and `VtResourceLimits`.
| --- | ---: |
| Collected DCS payload | 4 MiB |
| Decoded OSC 1337 image | 768 KiB |
| Collected OSC payload | 1 MiB + 16 KiB (base64 of a 768-KiB image plus bounded metadata) |
| Collected APC payload per chunk | 1 MiB |
| Assembled kitty image (decompressed) | 32 MiB |
| Kitty pixel dimension / count | shared pixel limits below |
| Sixel width or height | 4096 pixels |
Expand All @@ -66,7 +68,22 @@ dimensions and malformed macro hex/repeat payloads do not publish partial state.
Macro definitions are rejected during macro invocation, and recursion plus total
expansion are independently bounded. A failed Sixel decode does not modify
persistent color registers. When the retained overlay budget is reached, Core
evicts the oldest overlay before publishing the new one.
evicts the oldest overlay before publishing the new one, including when
different image protocols share that capacity.

Kitty direct-media regressions pin the exact pixel-count boundary separately
from the first value over it, and rejected file/shared-memory media report
`ENOTSUP` without publishing an overlay or performing I/O.
An inline OSC 1337 or single-part ConEmu transfer of exactly 768 KiB decoded
bytes is accepted; one byte beyond that limit is rejected. OSC collection
reserves enough room for the base64 expansion **and** its header, so an
otherwise valid maximum-size image is not silently discarded before decoding.
Split OSC/DCS/APC terminators publish no partial image, and malformed
continuations of kitty chunks discard pending data (a mismatched nonzero
image ID is rejected). Oversized individual APC fragments are discarded;
subsequent valid transmissions still work. Sixel raster/repeat dimensions
accept the 4096-pixel edge, reject the next pixel, and do not commit palette
changes from a rejected image.

## Renderer contract

Expand All @@ -93,6 +110,16 @@ Overlay anchors retain a logical-line identity and logical cell offset.
Snapshots resolve that anchor against the current scrollback/reflow layout, and
the overlay is removed deterministically when its owning line segment is
evicted. Main and alternate buffers retain independent identities.
Regression tests exercise reflow, owning-line eviction, and reset independently
for all four image protocols; reset clears both buffers' images, while kitty
lowercase placement/all/id deletes remove overlays without freeing transmitted
pixels (a later put can place the image again). Uppercase deletes free the
corresponding image data. Kitty deletes leave unrelated Sixel overlays untouched.
The Skia renderer
uses only the available source pixels when a kitty crop extends beyond the
right or bottom edge; the cropped region fills its requested cell rectangle.
Invalid encoded inline data cannot paint an image, and a renderer reused after
delete or reset does not paint stale cached pixels.

## Intentional gaps

Expand Down
20 changes: 18 additions & 2 deletions docs/parity-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ process environment block.

| Area | Status | Remaining acceptance |
| --- | --- | --- |
| Search | Implemented across both engines | Add canonical-equivalence/ZWJ cases and preserve selected matches across reflow/eviction |
| Grapheme/emoji | Built-in supports Hangul, Indic conjuncts, prepend, spacing marks, RI pairs, emoji modifiers/selectors, and emoji ZWJ sequences at arbitrary UTF-8 feed boundaries; bundled Noto Color Emoji provides deterministic Linux fallback | Full Unicode GraphemeBreakTest conformance remains; the pinned Ghostty render ABI does not expose equivalent cluster geometry |
| Search | Implemented across both engines; Core canonicalizes equivalent Unicode spellings and maps ZWJ/wide grapheme matches to cell ranges; built-in search anchors the selected match by logical-line ID and cell offset across reflow, duplicate lines, viewport scrolling, and bounded scrollback eviction, selecting the next surviving match if the selected line is lost | Ghostty projection regenerates line IDs, so selection uses a text/neighbor heuristic; its pinned render ABI does not expose equivalent cluster geometry |
| Grapheme/emoji | Built-in supports Hangul, Indic conjuncts, prepend, spacing marks, RI pairs, emoji modifiers/selectors, and emoji ZWJ sequences at arbitrary UTF-8 feed boundaries; bundled Noto Color Emoji provides deterministic Linux fallback | Unicode 16.0.0 `GraphemeBreakTest.txt` is pinned: all 369 terminal-representable rows pass ordered-cell and chunk-invariance checks. The other 724 rows are classified as unsuitable for terminal-cell assertions, not claimed as pure-segmenter passes; full UAX #29 conformance outside the tested subset and equivalent Ghostty cluster geometry remain unavailable |
| Row rendition | Built-in DECDWL/DECDHL parser, snapshots, logical cursor clipping, reflow preservation, and render transforms implemented | The pinned Ghostty C ABI does not expose row rendition; capability is explicitly unavailable there |
| 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 |
Expand All @@ -114,6 +114,22 @@ process environment block.
| 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 |

The image regression matrix covers split protocol feeds, exact/over-limit
decoded payloads and Sixel dimensions, Kitty continuation recovery and
placement/crop/delete behavior, as well as mixed-protocol reflow, scrollback,
eviction, reset, and renderer-cache isolation. It does not allocate a full
64-MiB assembled Kitty image in the test suite; decompression limits and
per-sequence rejection are exercised directly.

The Unicode fixture contains 1,093 cases. Its 724 terminal-cell exclusions
comprise 259 control-code, 78 unassigned-scalar, 238 orphan zero-width,
124 dangling-Prepend, and 25 blank-cell extender cases. CR/LF and C1 controls
are tested as VT actions separately from Unicode segmentation. Core has no
standalone pure grapheme segmenter; this result does not establish conformance
for excluded cases or every possible printable string. Outside the fixture,
Indic `Extend` still uses a category fallback rather than a complete Unicode
16 InCB property table.

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
Expand Down
58 changes: 52 additions & 6 deletions src/Devolutions.Terminal.Control/TerminalSearchSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public sealed class TerminalSearchSession : IDisposable
private IReadOnlyList<BufferRange> _matches = [];
private int _currentIndex = -1;
private TextBufferSnapshot? _snapshot;
private bool _snapshotAlternateBuffer;
private bool _stale;

public TerminalSearchSession(ITerminalEngine engine)
Expand Down Expand Up @@ -114,6 +115,7 @@ public void Clear()
_matches = [];
_currentIndex = -1;
_snapshot = null;
_snapshotAlternateBuffer = false;
_stale = false;
Changed?.Invoke(this, EventArgs.Empty);
}
Expand All @@ -133,10 +135,11 @@ private void Recompute(TextBufferSnapshot snapshot, bool preserveSelection)
Query,
new TextSearchOptions(CaseSensitive, WholeWord));
_snapshot = snapshot;
_snapshotAlternateBuffer = _engine.AlternateBufferActive;
_stale = false;
_currentIndex = anchor is null
? (_matches.Count > 0 ? 0 : -1)
: FindAnchor(anchor, previousIndex);
: FindAnchor(anchor.Value, previousIndex);
}

private SearchAnchor? CaptureAnchor()
Expand All @@ -147,6 +150,9 @@ private void Recompute(TextBufferSnapshot snapshot, bool preserveSelection)
}

return new SearchAnchor(
_snapshot.Lines[current.Start.Line].LogicalLineId,
_snapshot.Lines[current.Start.Line].LogicalOffset + current.Start.Column,
_snapshotAlternateBuffer,
LineText(_snapshot, current.Start.Line),
current.Start.Column,
current.End.Column,
Expand All @@ -163,13 +169,50 @@ private int FindAnchor(SearchAnchor anchor, int fallbackIndex)
return -1;
}

if (_engine.AlternateBufferActive != anchor.AlternateBuffer)
{
return 0;
}

// Ghostty rebuilds projected rows (and their IDs) for each snapshot.
if (_engine is not TerminalEngine || anchor.LogicalLineId == 0)
{
return FindTextAnchor(anchor, fallbackIndex);
}

var next = -1;
for (var index = 0; index < _matches.Count; index++)
{
var match = _matches[index];
var line = _snapshot.Lines[match.Start.Line];
if (line.LogicalLineId == anchor.LogicalLineId &&
line.LogicalOffset + match.Start.Column == anchor.LogicalOffset)
{
return index;
}

if (next < 0 &&
(line.LogicalLineId > anchor.LogicalLineId ||
line.LogicalLineId == anchor.LogicalLineId &&
line.LogicalOffset + match.Start.Column > anchor.LogicalOffset))
{
next = index;
}
}

return next >= 0 ? next : _matches.Count - 1;
}

private int FindTextAnchor(SearchAnchor anchor, int fallbackIndex)
{
var snapshot = _snapshot!;
var bestIndex = -1;
var bestScore = int.MinValue;
for (var index = 0; index < _matches.Count; index++)
{
var match = _matches[index];
var score = 0;
if (LineText(_snapshot, match.Start.Line) == anchor.LineText)
if (LineText(snapshot, match.Start.Line) == anchor.LineText)
{
score += 8;
}
Expand All @@ -181,14 +224,14 @@ private int FindAnchor(SearchAnchor anchor, int fallbackIndex)

if (anchor.PreviousLine is not null &&
match.Start.Line > 0 &&
LineText(_snapshot, match.Start.Line - 1) == anchor.PreviousLine)
LineText(snapshot, match.Start.Line - 1) == anchor.PreviousLine)
{
score += 2;
}

if (anchor.NextLine is not null &&
match.Start.Line + 1 < _snapshot.Lines.Count &&
LineText(_snapshot, match.Start.Line + 1) == anchor.NextLine)
match.Start.Line + 1 < snapshot.Lines.Count &&
LineText(snapshot, match.Start.Line + 1) == anchor.NextLine)
{
score += 2;
}
Expand Down Expand Up @@ -234,7 +277,10 @@ private void RevealCurrent(TextBufferSnapshot snapshot)
_engine.SetScrollOffset(liveViewportStart - desiredTop);
}

private sealed record SearchAnchor(
private readonly record struct SearchAnchor(
long LogicalLineId,
int LogicalOffset,
bool AlternateBuffer,
string LineText,
int StartColumn,
int EndColumn,
Expand Down
21 changes: 19 additions & 2 deletions src/Devolutions.Terminal.Core/TerminalEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1612,8 +1612,11 @@ private void DispatchKittyImage(ReadOnlySpan<char> body)
{
if (!KittyGraphicsDecoder.TryParse(body, out var parsed, out var parseError) || parsed is null)
{
var rejectedId = _kittyChunkControl?.ImageId ?? 0;
_kittyChunkControl = null;
_kittyChunkPayload.Clear();
ReportDiagnostic("image.kitty.rejected", parseError ?? "The kitty graphics control data was malformed.");
RespondKitty(0, parseError ?? "EINVAL: malformed control data", quiet: 0, isError: true);
RespondKitty(rejectedId, parseError ?? "EINVAL: malformed control data", quiet: 0, isError: true);
return;
}

Expand All @@ -1638,6 +1641,18 @@ private void DispatchKittyImage(ReadOnlySpan<char> body)
return;
}

if (command.ImageId != 0 &&
_kittyChunkControl.ImageId != 0 &&
command.ImageId != _kittyChunkControl.ImageId)
{
var pendingId = _kittyChunkControl.ImageId;
_kittyChunkControl = null;
_kittyChunkPayload.Clear();
ReportDiagnostic("image.kitty.rejected", "The kitty continuation image id did not match the first chunk.");
RespondKitty(pendingId, "EINVAL: mismatched chunk image id", command.Quiet, isError: true);
return;
}

_kittyChunkPayload.AddRange(command.Payload);
if (_kittyChunkPayload.Count > TerminalImageLimits.MaximumKittyImageBytes * 2)
{
Expand Down Expand Up @@ -1796,7 +1811,9 @@ private void ExecuteKittyDelete(KittyGraphicsCommand command)
break;
}

ReapKittyImageStore();
// Lowercase deletes remove placements, not transmitted image data.
// Explicit uppercase deletes free data; retained unplaced images remain
// subject to the independent kitty store byte budget.
}

private void CreateKittyPlacement(
Expand Down
Loading
Loading