diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 336e6f3f..f62dafb4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -121,13 +121,17 @@ jobs: id: ghostty-cache with: path: ghostty-src - key: ghostty-src-${{ steps.ghostty.outputs.full }} + key: ghostty-src-${{ steps.ghostty.outputs.full }}-${{ hashFiles('packages/libghostty/patches/*.patch') }} - name: download ghostty source if: steps.ghostty-cache.outputs.cache-hit != 'true' run: | curl -fsSL "https://github.com/ghostty-org/ghostty/archive/${{ steps.ghostty.outputs.full }}.tar.gz" -o ghostty.tar.gz mkdir -p ghostty-src tar xzf ghostty.tar.gz -C ghostty-src --strip-components=1 + git -C ghostty-src init --quiet + git -C ghostty-src apply --unidiff-zero "$GITHUB_WORKSPACE/packages/libghostty/patches/osc52-clipboard-write.patch" + rm -rf ghostty-src/.git + touch ghostty-src/.git rm ghostty.tar.gz - name: compile working-directory: ghostty-src @@ -164,13 +168,17 @@ jobs: id: ghostty-cache with: path: ghostty-src - key: ghostty-src-${{ steps.ghostty.outputs.full }} + key: ghostty-src-${{ steps.ghostty.outputs.full }}-${{ hashFiles('packages/libghostty/patches/*.patch') }} - name: download ghostty source if: steps.ghostty-cache.outputs.cache-hit != 'true' run: | curl -fsSL "https://github.com/ghostty-org/ghostty/archive/${{ steps.ghostty.outputs.full }}.tar.gz" -o ghostty.tar.gz mkdir -p ghostty-src tar xzf ghostty.tar.gz -C ghostty-src --strip-components=1 + git -C ghostty-src init --quiet + git -C ghostty-src apply --unidiff-zero "$GITHUB_WORKSPACE/packages/libghostty/patches/osc52-clipboard-write.patch" + rm -rf ghostty-src/.git + touch ghostty-src/.git rm ghostty.tar.gz - name: compile wasm working-directory: ghostty-src diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 56bfcf91..8977e324 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -183,7 +183,7 @@ jobs: id: ghostty-cache with: path: ghostty - key: ghostty-src-${{ steps.ghostty.outputs.commit }} + key: ghostty-src-${{ steps.ghostty.outputs.commit }}-${{ hashFiles('packages/libghostty/patches/*.patch') }} - name: download ghostty source if: steps.ghostty-cache.outputs.cache-hit != 'true' shell: bash @@ -191,6 +191,9 @@ jobs: curl -fsSL "https://github.com/ghostty-org/ghostty/archive/${{ steps.ghostty.outputs.commit }}.tar.gz" -o ghostty.tar.gz mkdir -p ghostty tar xzf ghostty.tar.gz -C ghostty --strip-components=1 + git -C ghostty init --quiet + git -C ghostty apply --unidiff-zero "$GITHUB_WORKSPACE/packages/libghostty/patches/osc52-clipboard-write.patch" + rm -rf ghostty/.git rm ghostty.tar.gz - run: flutter pub get - run: dart pub global activate very_good_cli @@ -229,13 +232,16 @@ jobs: id: ghostty-cache with: path: ghostty - key: ghostty-src-${{ steps.ghostty.outputs.commit }} + key: ghostty-src-${{ steps.ghostty.outputs.commit }}-${{ hashFiles('packages/libghostty/patches/*.patch') }} - name: download ghostty source if: steps.ghostty-cache.outputs.cache-hit != 'true' run: | curl -fsSL "https://github.com/ghostty-org/ghostty/archive/${{ steps.ghostty.outputs.commit }}.tar.gz" -o ghostty.tar.gz mkdir -p ghostty tar xzf ghostty.tar.gz -C ghostty --strip-components=1 + git -C ghostty init --quiet + git -C ghostty apply --unidiff-zero "$GITHUB_WORKSPACE/packages/libghostty/patches/osc52-clipboard-write.patch" + rm -rf ghostty/.git rm ghostty.tar.gz - name: isolate from repo git run: touch ghostty/.git @@ -282,7 +288,7 @@ jobs: id: ghostty-cache with: path: ghostty - key: ghostty-src-${{ steps.ghostty.outputs.commit }} + key: ghostty-src-${{ steps.ghostty.outputs.commit }}-${{ hashFiles('packages/libghostty/patches/*.patch') }} - name: download ghostty source if: steps.ghostty-cache.outputs.cache-hit != 'true' shell: bash @@ -290,6 +296,9 @@ jobs: curl -fsSL "https://github.com/ghostty-org/ghostty/archive/${{ steps.ghostty.outputs.commit }}.tar.gz" -o ghostty.tar.gz mkdir -p ghostty tar xzf ghostty.tar.gz -C ghostty --strip-components=1 + git -C ghostty init --quiet + git -C ghostty apply --unidiff-zero "$GITHUB_WORKSPACE/packages/libghostty/patches/osc52-clipboard-write.patch" + rm -rf ghostty/.git rm ghostty.tar.gz - run: flutter pub get - name: install very_good_cli diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index d07fffbb..fd6bd1a7 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Added + +- **OSC 52 clipboard writes**: `TerminalController.onClipboardWrite` forwards + write-only clipboard requests from libghostty to terminal embedders. + ## 0.0.4 ### Breaking @@ -13,6 +20,9 @@ - **Terminal links**: `TerminalView.linkSettings` detects OSC 8 links, text URLs, file paths, and custom regex links. +- **Accessibility semantics**: `TerminalView` exposes visible, non-concealed + terminal text and terminal-focus actions to assistive technologies, with + customizable labels and hints. - **Controller APIs**: `selectRange`, `hasSelection`, `pwd`, and `onPwdChanged` expose selection and working-directory state. - **Glyph Protocol**: `TerminalConfig.glyphProtocol` toggles Glyph Protocol @@ -23,6 +33,15 @@ - **Rendering pipeline**: selection, cursor viewport state, and cell metadata use refreshed libghostty render snapshots. +### Fixed + +- **Tracked pointer input**: mouse buttons, hover, wheel coordinates, and + touch/stylus scroll positions are preserved in terminal mouse reports. +- **Rendering invalidation**: size reports no longer consume renderer dirty + state, and Kitty image replacements stay drawable while uploads complete. +- **Accessibility rendering**: terminal semantics use the renderer snapshot, + coalesce repeated updates, and follow the visible scrollback viewport. + ## 0.0.3 ### Breaking diff --git a/packages/flterm/README.md b/packages/flterm/README.md index 7b1d1671..13dcb7e3 100644 --- a/packages/flterm/README.md +++ b/packages/flterm/README.md @@ -32,6 +32,9 @@ libghostty-vt engine. hyperlinks; fonts. Immutable and `lerp`-able. - Links for OSC 8 metadata, text URLs, file paths, and custom regex rules with activation callbacks. +- Screen-reader semantics expose the visible viewport as text and provide an + action for focusing terminal input without announcing every output update as + a live region. ## Getting started diff --git a/packages/flterm/lib/src/foundation.dart b/packages/flterm/lib/src/foundation.dart index 85ba6efa..28a580d3 100644 --- a/packages/flterm/lib/src/foundation.dart +++ b/packages/flterm/lib/src/foundation.dart @@ -7,5 +7,4 @@ export 'foundation/input_types.dart'; export 'foundation/platform_map.dart'; export 'foundation/terminal_config.dart'; export 'foundation/terminal_gesture_settings.dart'; -export 'foundation/terminal_render_observer.dart'; export 'foundation/terminal_theme.dart'; diff --git a/packages/flterm/lib/src/foundation/callbacks.dart b/packages/flterm/lib/src/foundation/callbacks.dart index af7bcd4d..87f92813 100644 --- a/packages/flterm/lib/src/foundation/callbacks.dart +++ b/packages/flterm/lib/src/foundation/callbacks.dart @@ -28,7 +28,7 @@ typedef OnResize = void Function(int cols, int rows); /// ``` typedef TerminalMouseEvent = ({ MouseAction action, - MouseButton button, + MouseButton? button, double pixelX, double pixelY, }); diff --git a/packages/flterm/lib/src/foundation/terminal_config.dart b/packages/flterm/lib/src/foundation/terminal_config.dart index 715356d6..5dbb5450 100644 --- a/packages/flterm/lib/src/foundation/terminal_config.dart +++ b/packages/flterm/lib/src/foundation/terminal_config.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart' show immutable; +import 'package:flutter/foundation.dart' show immutable, mapEquals; import 'package:libghostty/libghostty.dart'; /// When to auto-scroll the viewport to the bottom. @@ -43,11 +43,12 @@ enum ScrollToBottom { /// ``` @immutable class TerminalConfig { - /// Default terminal modes. + /// Overrides applied on top of libghostty's terminal defaults. /// - /// Includes grapheme cluster mode for proper multi-codepoint character - /// handling. Applied on terminal init and restored when the alternate - /// screen exits back to the primary screen. + /// Flterm only overrides behavior needed by its renderer: cursor blinking + /// and grapheme clustering. Other modes retain libghostty's defaults. + /// Applied on terminal init and restored when the alternate screen exits + /// back to the primary screen. /// /// Spread and override to change individual defaults: /// @@ -60,13 +61,7 @@ class TerminalConfig { /// ); /// ``` static const defaultModes = { - .srm(): true, - .autoWrap(): true, .cursorBlinking(): true, - .cursorVisible(): true, - .alternateScroll(): true, - .numlockKeypad(): true, - .altEscPrefix(): true, .graphemeCluster(): true, }; @@ -115,9 +110,10 @@ class TerminalConfig { /// - `false`: never blink, ignore DEC mode 12 (DECSCUSR still respected). final bool? cursorBlink; - /// Terminal modes applied on init and primary screen restore. + /// Terminal mode overrides applied on init and primary screen restore. /// - /// Programs can change modes at runtime via escape sequences. Use + /// Modes absent from this map retain libghostty's defaults. Programs can + /// change modes at runtime via escape sequences. Use /// [TerminalController.modeGet] and [TerminalController.modeSet] to /// query or override the live state. final Map modes; @@ -195,7 +191,7 @@ class TerminalConfig { glyphProtocol == other.glyphProtocol && cursorStyle == other.cursorStyle && cursorBlink == other.cursorBlink && - _modesEqual(modes, other.modes) && + mapEquals(modes, other.modes) && scrollToBottom == other.scrollToBottom && selectionClearOnTyping == other.selectionClearOnTyping && enquiryResponse == other.enquiryResponse && @@ -242,16 +238,4 @@ class TerminalConfig { 'cols: $cols, rows: $rows, ' 'scrollbackLimit: $scrollbackLimit, ' 'modes: ${modes.length} entries)'; - - static bool _modesEqual( - Map a, - Map b, - ) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (final entry in a.entries) { - if (b[entry.key] != entry.value) return false; - } - return true; - } } diff --git a/packages/flterm/lib/src/foundation/terminal_render_observer.dart b/packages/flterm/lib/src/foundation/terminal_render_observer.dart deleted file mode 100644 index 9c406836..00000000 --- a/packages/flterm/lib/src/foundation/terminal_render_observer.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/foundation.dart'; - -/// Observable focus state for the rendering layer. -/// -/// Implemented by [TerminalController] and consumed by painters that need -/// to react to focus changes or selection updates without depending on -/// the full controller API. -/// -/// Listeners are notified when [hasFocus] changes, triggering repaint of -/// cursor state. -abstract class TerminalRenderObserver implements Listenable { - /// Whether the terminal view has keyboard focus. - /// - /// Painters use this to adjust cursor rendering: a focused terminal - /// draws a filled cursor, while an unfocused terminal draws a hollow - /// block outline. - bool get hasFocus; -} diff --git a/packages/flterm/lib/src/rendering/atlas/atlas_config.dart b/packages/flterm/lib/src/rendering/atlas/atlas_config.dart index 8439de60..2d2c1513 100644 --- a/packages/flterm/lib/src/rendering/atlas/atlas_config.dart +++ b/packages/flterm/lib/src/rendering/atlas/atlas_config.dart @@ -1,5 +1,6 @@ import 'dart:ui' show FontWeight; +import 'package:flutter/foundation.dart' show listEquals; import 'package:meta/meta.dart'; import '../../foundation.dart'; @@ -53,7 +54,7 @@ class AtlasConfig { other.fontSize == fontSize && other.fontWeight == fontWeight && other.fontFamily == fontFamily && - _listEquals(other.fontFamilyFallback, fontFamilyFallback) && + listEquals(other.fontFamilyFallback, fontFamilyFallback) && other.metrics == metrics && other.devicePixelRatio == devicePixelRatio; @@ -74,13 +75,4 @@ class AtlasConfig { devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, ); } - - static bool _listEquals(List a, List b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } } diff --git a/packages/flterm/lib/src/rendering/font/font_data_resolver.dart b/packages/flterm/lib/src/rendering/font/font_data_resolver.dart index 338f46ff..4a52d44c 100644 --- a/packages/flterm/lib/src/rendering/font/font_data_resolver.dart +++ b/packages/flterm/lib/src/rendering/font/font_data_resolver.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'font_data_resolver_io.dart' @@ -44,10 +43,6 @@ class FontDataResolver { FontDataResolver._(); - /// Clears the resolution cache. - @visibleForTesting - static void clearCache() => _cache.clear(); - /// Resolves font file bytes for [fontFamily]. /// /// Returns cached results on subsequent calls. Returns `null` if the diff --git a/packages/flterm/lib/src/rendering/kitty_image_cache.dart b/packages/flterm/lib/src/rendering/kitty_image_cache.dart index a860cdbf..ad5e4e24 100644 --- a/packages/flterm/lib/src/rendering/kitty_image_cache.dart +++ b/packages/flterm/lib/src/rendering/kitty_image_cache.dart @@ -21,15 +21,15 @@ typedef KittyImageDecoder = /// RGBA formats reach this cache; anything else is stored as /// [KittyImageUnsupported] so subsequent paints do not retry. /// -/// Re-transmissions under the same id are detected by -/// [KittyImage.generation], so same-sized replacements cannot reuse stale -/// decoded images. +/// Re-transmissions under the same id are detected by libghostty's monotonic +/// image generation, including byte-level overwrites with unchanged dimensions. class KittyImageCache { final VoidCallback _onImageReady; final KittyImageDecoder _decodeImage; - final Map _entries = {}; - final Map _generations = {}; + final Map _fingerprints = {}; + final Map _activeDecodes = {}; + final Map _queuedDecodes = {}; /// [onImageReady] fires when a pending decode completes; typically /// wired to a render box's `markNeedsPaint`. @@ -44,7 +44,9 @@ class KittyImageCache { if (entry is KittyImageReady) entry.image.dispose(); } _entries.clear(); - _generations.clear(); + _fingerprints.clear(); + _activeDecodes.clear(); + _queuedDecodes.clear(); } /// Releases any cached entries whose id is not in [live]. @@ -52,24 +54,65 @@ class KittyImageCache { _entries.removeWhere((id, entry) { if (live.contains(id)) return false; if (entry is KittyImageReady) entry.image.dispose(); - _generations.remove(id); + _fingerprints.remove(id); + _activeDecodes.remove(id); + _queuedDecodes.remove(id); return true; }); } /// Returns the entry for [image], starting a decode on first lookup - /// or when the image's generation has changed. Never blocks. + /// or when its content generation has changed. Never blocks. KittyImageCacheEntry lookup(KittyImage image) { - final generation = image.generation; - final existing = _entries[image.id]; - if (existing != null && _generations[image.id] == generation) { + return _lookup( + imageId: image.id, + generation: image.generation, + width: image.width, + height: image.height, + rgba: () => _ensureRgba(image), + ); + } + + @visibleForTesting + KittyImageCacheEntry lookupRgba({ + required int imageId, + required int generation, + required int width, + required int height, + required Uint8List rgba, + }) => _lookup( + imageId: imageId, + generation: generation, + width: width, + height: height, + rgba: () => rgba, + ); + + KittyImageCacheEntry _lookup({ + required int imageId, + required int generation, + required int width, + required int height, + required Uint8List? Function() rgba, + }) { + final fingerprint = (generation: generation, width: width, height: height); + final existing = _entries[imageId]; + final previousFingerprint = _fingerprints[imageId]; + if (existing != null && previousFingerprint == fingerprint) { return existing; } - if (existing is KittyImageReady) existing.image.dispose(); - _entries[image.id] = KittyImagePending(); - _generations[image.id] = generation; - _beginDecode(image); - return _entries[image.id]!; + + final retainExisting = + existing is KittyImageReady && + previousFingerprint?.width == width && + previousFingerprint?.height == height; + if (!retainExisting) { + if (existing is KittyImageReady) existing.image.dispose(); + _entries[imageId] = KittyImagePending(); + } + _fingerprints[imageId] = fingerprint; + _beginDecode(imageId: imageId, fingerprint: fingerprint, rgba: rgba()); + return _entries[imageId]!; } /// Returns the cached entry for [imageId], or null if none. Unlike @@ -78,30 +121,88 @@ class KittyImageCache { /// Inserts a pre-decoded [image] under [imageId]. @visibleForTesting - void putReady(int imageId, Image image) { + void putReady(int imageId, Image image, {int generation = 0}) { final existing = _entries[imageId]; if (existing is KittyImageReady) existing.image.dispose(); _entries[imageId] = KittyImageReady(image); - _generations[imageId] = 0; + _fingerprints[imageId] = ( + generation: generation, + width: image.width, + height: image.height, + ); + _activeDecodes.remove(imageId); + _queuedDecodes.remove(imageId); } - void _beginDecode(KittyImage image) { - final imageId = image.id; - final generation = _generations[imageId]; - final rgba = _ensureRgba(image); + void _beginDecode({ + required int imageId, + required ({int generation, int width, int height}) fingerprint, + required Uint8List? rgba, + }) { if (rgba == null) { + final existing = _entries[imageId]; + if (existing is KittyImageReady) existing.image.dispose(); _entries[imageId] = KittyImageUnsupported(); + _activeDecodes.remove(imageId); + _queuedDecodes.remove(imageId); return; } - _decodeImage(rgba, image.width, image.height, .rgba8888, (decoded) { - if (_generations[imageId] == generation && - _entries[imageId] is KittyImagePending) { - _entries[imageId] = KittyImageReady(decoded); - _onImageReady(); - } else { - decoded.dispose(); - } - }); + final request = _KittyDecodeRequest( + imageId: imageId, + fingerprint: fingerprint, + rgba: rgba, + ); + if (_activeDecodes.containsKey(imageId)) { + _queuedDecodes[imageId] = request; + return; + } + _startDecode(request); + } + + void _startDecode(_KittyDecodeRequest request) { + _activeDecodes[request.imageId] = request; + _decodeImage( + request.rgba, + request.fingerprint.width, + request.fingerprint.height, + .rgba8888, + (decoded) => _finishDecode(request, decoded), + ); + } + + void _finishDecode(_KittyDecodeRequest request, Image decoded) { + final imageId = request.imageId; + if (!identical(_activeDecodes[imageId], request)) { + decoded.dispose(); + return; + } + _activeDecodes.remove(imageId); + + final queued = _queuedDecodes.remove(imageId); + final desired = _fingerprints[imageId]; + final isLatest = desired == request.fingerprint; + final isUsefulIntermediate = + queued != null && + desired == queued.fingerprint && + queued.fingerprint.width == request.fingerprint.width && + queued.fingerprint.height == request.fingerprint.height; + + var published = false; + if (isLatest || isUsefulIntermediate) { + final existing = _entries[imageId]; + _entries[imageId] = KittyImageReady(decoded); + if (existing is KittyImageReady) existing.image.dispose(); + published = true; + } else { + decoded.dispose(); + } + + if (queued != null && + _fingerprints[imageId] == queued.fingerprint && + _entries.containsKey(imageId)) { + _startDecode(queued); + } + if (published) _onImageReady(); } Uint8List? _ensureRgba(KittyImage image) { @@ -129,6 +230,18 @@ class KittyImageCache { } } +final class _KittyDecodeRequest { + final int imageId; + final ({int generation, int width, int height}) fingerprint; + final Uint8List rgba; + + const _KittyDecodeRequest({ + required this.imageId, + required this.fingerprint, + required this.rgba, + }); +} + /// Result of a cache lookup for a decoded image. sealed class KittyImageCacheEntry {} diff --git a/packages/flterm/lib/src/rendering/painters/background_painter.dart b/packages/flterm/lib/src/rendering/painters/background_painter.dart index fbb6c7f6..51f41b6f 100644 --- a/packages/flterm/lib/src/rendering/painters/background_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/background_painter.dart @@ -4,7 +4,6 @@ import 'package:flutter/painting.dart'; import '../atlas/sprite_buffer.dart'; import '../paint_state.dart'; -import 'terminal_painter.dart'; /// Paints the terminal background layer. /// @@ -18,7 +17,7 @@ import 'terminal_painter.dart'; /// composite twice against that backdrop. Per-cell explicit background /// rects still render on top, with alpha scaled by the frame builder when /// [TerminalPaintState.backgroundOpacityCells] is true. -class BackgroundPainter implements TerminalPainter { +class BackgroundPainter { final Paint _fillPaint; final Paint _vertexPaint; final SpriteBuffer _sprites; @@ -28,7 +27,6 @@ class BackgroundPainter implements TerminalPainter { : _fillPaint = Paint(), _vertexPaint = Paint(); - @override void paint(Canvas canvas) { if (_state.theme.backgroundOpacity >= 1.0) { _fillPaint.color = Color(_state.terminalBackgroundArgb); diff --git a/packages/flterm/lib/src/rendering/painters/cursor_painter.dart b/packages/flterm/lib/src/rendering/painters/cursor_painter.dart index bd88bcbb..95c2dbf0 100644 --- a/packages/flterm/lib/src/rendering/painters/cursor_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/cursor_painter.dart @@ -5,7 +5,6 @@ import 'package:libghostty/libghostty.dart'; import '../atlas/atlas.dart'; import '../paint_state.dart'; -import 'terminal_painter.dart'; /// Renders the terminal cursor in block, hollow, underline, and bar shapes. /// @@ -22,14 +21,13 @@ import 'terminal_painter.dart'; /// /// Cursor opacity from [CursorTheme.opacity] is applied when focused. /// Unfocused cursors draw at full opacity. -class CursorPainter implements TerminalPainter { +class CursorPainter { final Paint _paint; final Atlas _atlas; final TerminalPaintState _state; CursorPainter(this._state, this._atlas) : _paint = Paint(); - @override void paint(Canvas canvas) { final cursor = _state.cursor; if (_state.preeditActive) return; diff --git a/packages/flterm/lib/src/rendering/painters/decoration_painter.dart b/packages/flterm/lib/src/rendering/painters/decoration_painter.dart index a1b03dfe..b6bd4ecc 100644 --- a/packages/flterm/lib/src/rendering/painters/decoration_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/decoration_painter.dart @@ -3,19 +3,17 @@ import 'dart:ui'; import 'package:flutter/painting.dart'; import '../atlas/sprite_buffer.dart'; -import 'terminal_painter.dart'; /// Paints strikethrough and overline rects via batched [Canvas.drawVertices]. /// /// Drawn AFTER text so strikethrough is visibly crossing through glyphs. /// Underlines are handled separately by [UnderlinePainter]. -class DecorationPainter implements TerminalPainter { +class DecorationPainter { final Paint _paint; final SpriteBuffer _sprites; DecorationPainter(this._sprites) : _paint = Paint(); - @override void paint(Canvas canvas) { final vertices = _sprites.decorationVertices; if (vertices == null) return; diff --git a/packages/flterm/lib/src/rendering/painters/emoji_painter.dart b/packages/flterm/lib/src/rendering/painters/emoji_painter.dart index 5e14f527..e7f2edce 100644 --- a/packages/flterm/lib/src/rendering/painters/emoji_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/emoji_painter.dart @@ -4,21 +4,19 @@ import 'package:flutter/painting.dart'; import '../atlas/atlas.dart'; import '../atlas/sprite_buffer.dart'; -import 'terminal_painter.dart'; /// Paints emoji glyphs via a batched [Canvas.drawRawAtlas] call. /// /// Emoji use [BlendMode.src] instead of modulate because emoji glyphs are /// full-color bitmaps in the atlas that should render with their original /// colors, not tinted by a per-sprite color. -class EmojiPainter implements TerminalPainter { +class EmojiPainter { final Paint _paint; final Atlas _atlas; final SpriteBuffer _sprites; EmojiPainter(this._atlas, this._sprites) : _paint = Paint(); - @override void paint(Canvas canvas) { final emoji = _sprites.emoji; final image = _atlas.emojiImage; diff --git a/packages/flterm/lib/src/rendering/painters/kitty_graphics_painter.dart b/packages/flterm/lib/src/rendering/painters/kitty_graphics_painter.dart index 04b8d018..b35106c8 100644 --- a/packages/flterm/lib/src/rendering/painters/kitty_graphics_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/kitty_graphics_painter.dart @@ -5,13 +5,12 @@ import 'package:flutter/painting.dart'; import '../kitty_image_cache.dart'; import '../kitty_placement_cache.dart'; import '../paint_state.dart'; -import 'terminal_painter.dart'; /// Paints one ordered Kitty graphics placement list. /// /// The caller chooses where the list belongs in the surrounding paint order; /// this painter only clips and draws the snapshots it receives. -class KittyGraphicsPainter implements TerminalPainter { +class KittyGraphicsPainter { final Paint _paint; final KittyImageCache _cache; final TerminalPaintState _state; @@ -23,7 +22,6 @@ class KittyGraphicsPainter implements TerminalPainter { required this._snapshots, }) : _paint = Paint()..filterQuality = .low; - @override void paint(Canvas canvas) { if (_snapshots.isEmpty) return; final width = _state.cols * _state.metrics.cellWidth; diff --git a/packages/flterm/lib/src/rendering/painters/shaped_run_painter.dart b/packages/flterm/lib/src/rendering/painters/shaped_run_painter.dart index 8560de5b..6ea1e034 100644 --- a/packages/flterm/lib/src/rendering/painters/shaped_run_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/shaped_run_painter.dart @@ -1,15 +1,13 @@ import 'dart:ui'; import '../atlas/sprite_buffer.dart'; -import 'terminal_painter.dart'; /// Paints paragraph-shaped text runs that need ligature shaping. -final class ShapedRunPainter implements TerminalPainter { +final class ShapedRunPainter { final ShapedRunBuffer _runs; ShapedRunPainter(this._runs); - @override void paint(Canvas canvas) { if (_runs.count == 0) return; diff --git a/packages/flterm/lib/src/rendering/painters/sprite_painter.dart b/packages/flterm/lib/src/rendering/painters/sprite_painter.dart index 1cbe631f..5b58f53e 100644 --- a/packages/flterm/lib/src/rendering/painters/sprite_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/sprite_painter.dart @@ -4,20 +4,18 @@ import 'package:flutter/painting.dart'; import '../atlas/atlas.dart'; import '../atlas/sprite_buffer.dart'; -import 'terminal_painter.dart'; /// Paints built-in sprite glyphs via a batched [Canvas.drawRawAtlas] call. /// /// Sprite glyphs live in their own atlas texture and are tinted per-sprite /// with the resolved cell foreground. -class SpritePainter implements TerminalPainter { +class SpritePainter { final Paint _paint; final Atlas _atlas; final SpriteBuffer _sprites; SpritePainter(this._atlas, this._sprites) : _paint = Paint(); - @override void paint(Canvas canvas) { final sprites = _sprites.sprite; final image = _atlas.spriteImage; diff --git a/packages/flterm/lib/src/rendering/painters/terminal_painter.dart b/packages/flterm/lib/src/rendering/painters/terminal_painter.dart deleted file mode 100644 index 1405b1b2..00000000 --- a/packages/flterm/lib/src/rendering/painters/terminal_painter.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:ui'; - -/// Interface for terminal paint helpers. -/// -/// Each painter renders one visual layer (backgrounds, text, cursor, etc.) -/// during the paint phase. All painters draw in terminal-local coordinates -/// (the render box applies the canvas translate before calling [paint]). -/// -/// Painters are stateless beyond pre-allocated [Paint] objects. Paint data -/// comes from frame buffers such as [TerminalPaintState], [SpriteBuffer], and -/// paint-ready layers prepared before painting begins. -abstract interface class TerminalPainter { - void paint(Canvas canvas); -} diff --git a/packages/flterm/lib/src/rendering/painters/terminal_text_painter.dart b/packages/flterm/lib/src/rendering/painters/terminal_text_painter.dart index df6545b0..d419edbd 100644 --- a/packages/flterm/lib/src/rendering/painters/terminal_text_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/terminal_text_painter.dart @@ -4,7 +4,6 @@ import 'package:flutter/painting.dart'; import '../atlas/atlas.dart'; import '../atlas/sprite_buffer.dart'; -import 'terminal_painter.dart'; /// Paints regular-width and wide text glyphs via batched [Canvas.drawRawAtlas] /// calls. @@ -13,7 +12,7 @@ import 'terminal_painter.dart'; /// The atlas stores white glyph bitmaps tinted per-sprite via /// [BlendMode.modulate] to produce colored text with zero per-glyph draw /// calls. -class TerminalTextPainter implements TerminalPainter { +class TerminalTextPainter { final Paint _paint; final Atlas _atlas; final AtlasSprites _wide; @@ -22,7 +21,6 @@ class TerminalTextPainter implements TerminalPainter { TerminalTextPainter(this._atlas, this._wide, this._regular) : _paint = Paint(); - @override void paint(Canvas canvas) { final image = _atlas.textImage; if (image == null) return; diff --git a/packages/flterm/lib/src/rendering/painters/underline_painter.dart b/packages/flterm/lib/src/rendering/painters/underline_painter.dart index ac6bc97b..9c138782 100644 --- a/packages/flterm/lib/src/rendering/painters/underline_painter.dart +++ b/packages/flterm/lib/src/rendering/painters/underline_painter.dart @@ -4,7 +4,6 @@ import 'package:flutter/painting.dart'; import '../atlas/atlas.dart'; import '../atlas/sprite_buffer.dart'; -import 'terminal_painter.dart'; /// Paints underline decoration sprites via [Canvas.drawRawAtlas]. /// @@ -12,14 +11,13 @@ import 'terminal_painter.dart'; /// and tinted per-sprite with the underline color via [BlendMode.modulate]. /// Drawn BEFORE text so that descender glyphs cover the underline at /// intersections. -class UnderlinePainter implements TerminalPainter { +class UnderlinePainter { final Paint _paint; final Atlas _atlas; final SpriteBuffer _sprites; UnderlinePainter(this._atlas, this._sprites) : _paint = Paint(); - @override void paint(Canvas canvas) { final image = _atlas.decorationImage; final underline = _sprites.underline; diff --git a/packages/flterm/lib/src/rendering/terminal_frame_builder.dart b/packages/flterm/lib/src/rendering/terminal_frame_builder.dart index 3f38a75d..a1f2e516 100644 --- a/packages/flterm/lib/src/rendering/terminal_frame_builder.dart +++ b/packages/flterm/lib/src/rendering/terminal_frame_builder.dart @@ -200,6 +200,26 @@ class TerminalFrameBuilder { /// without a new terminal render state. void refreshCursorGlyph() => _cursorBuilder.refreshGlyph(); + String semanticsText() { + _rows.reset(_renderState); + final output = StringBuffer(); + + while (_rows.next()) { + _cells.reset(_rows); + final line = StringBuffer(); + while (_cells.next()) { + if (_cells.wide == CellWidth.spacerTail) continue; + final width = _cells.wide == CellWidth.wide ? 2 : 1; + final content = _cells.style.invisible ? '' : _cells.content; + line.write(content.isEmpty ? ' ' * width : content); + } + output.write(line.toString().trimRight()); + if (!_rows.wrap) output.writeln(); + } + + return output.toString().trimRight(); + } + /// Syncs terminal state into paint-ready buffers. void sync( Terminal terminal, { diff --git a/packages/flterm/lib/src/rendering/terminal_painter_stack.dart b/packages/flterm/lib/src/rendering/terminal_painter_stack.dart deleted file mode 100644 index c9fe02fc..00000000 --- a/packages/flterm/lib/src/rendering/terminal_painter_stack.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'dart:ui' show Canvas; - -import 'package:libghostty/libghostty.dart'; - -import 'atlas/atlas.dart'; -import 'atlas/sprite_buffer.dart'; -import 'kitty_image_cache.dart'; -import 'kitty_placement_cache.dart'; -import 'paint_state.dart'; -import 'painters/background_painter.dart'; -import 'painters/cursor_painter.dart'; -import 'painters/decoration_painter.dart'; -import 'painters/emoji_painter.dart'; -import 'painters/kitty_graphics_painter.dart'; -import 'painters/shaped_run_painter.dart'; -import 'painters/sprite_painter.dart'; -import 'painters/terminal_text_painter.dart'; -import 'painters/underline_painter.dart'; - -/// Owns paint helpers, paint order, and paint-only terminal resources. -final class TerminalPainterStack { - // The protocol splits negative z values in half at INT32_MIN / 2. - static const int _kittyBelowBackgroundThreshold = -1 << 30; - - final SpriteBuffer _sprites; - final TerminalPaintState _state; - final KittyImageCache _kittyImageCache; - final List _kittyBelowBackground = []; - final List _kittyBelowText = []; - final List _kittyAboveText = []; - final ShapedRunPainter _shapedRunPainter; - final BackgroundPainter _backgroundPainter; - final DecorationPainter _decorationPainter; - late final KittyGraphicsPainter _kittyBelowBackgroundPainter; - late final KittyGraphicsPainter _kittyBelowTextPainter; - late final KittyGraphicsPainter _kittyAboveTextPainter; - late final KittyPlacementCache _kittyPlacementCache; - - late EmojiPainter _emojiPainter; - late SpritePainter _spritePainter; - late CursorPainter _cursorPainter; - late TerminalTextPainter _textPainter; - late UnderlinePainter _underlinePainter; - - TerminalPainterStack({ - required Atlas atlas, - required this._sprites, - required this._state, - required void Function() onImageReady, - }) : _kittyImageCache = KittyImageCache(onImageReady: onImageReady), - _shapedRunPainter = ShapedRunPainter(_sprites.shaped), - _backgroundPainter = BackgroundPainter(_state, _sprites), - _decorationPainter = DecorationPainter(_sprites) { - _kittyPlacementCache = KittyPlacementCache( - state: _state, - images: _kittyImageCache, - ); - _kittyBelowBackgroundPainter = KittyGraphicsPainter( - state: _state, - cache: _kittyImageCache, - snapshots: _kittyBelowBackground, - ); - _kittyBelowTextPainter = KittyGraphicsPainter( - state: _state, - cache: _kittyImageCache, - snapshots: _kittyBelowText, - ); - _kittyAboveTextPainter = KittyGraphicsPainter( - state: _state, - cache: _kittyImageCache, - snapshots: _kittyAboveText, - ); - bindAtlas(atlas); - } - - void bindAtlas(Atlas atlas) { - _textPainter = TerminalTextPainter(atlas, _sprites.wide, _sprites.regular); - _spritePainter = SpritePainter(atlas, _sprites); - _cursorPainter = CursorPainter(_state, atlas); - _emojiPainter = EmojiPainter(atlas, _sprites); - _underlinePainter = UnderlinePainter(atlas, _sprites); - } - - void dispose() => _kittyImageCache.dispose(); - - void paint(Canvas canvas) { - _kittyBelowBackgroundPainter.paint(canvas); - _backgroundPainter.paint(canvas); - _kittyBelowTextPainter.paint(canvas); - _underlinePainter.paint(canvas); - _textPainter.paint(canvas); - _shapedRunPainter.paint(canvas); - _spritePainter.paint(canvas); - _cursorPainter.paint(canvas); - _emojiPainter.paint(canvas); - _decorationPainter.paint(canvas); - _kittyAboveTextPainter.paint(canvas); - } - - void sync(Terminal terminal, {required bool geometryDirty}) { - if (!_kittyPlacementCache.sync(terminal, geometryDirty: geometryDirty)) { - return; - } - _rebuildKittyLayers(); - } - - void _rebuildKittyLayers() { - _kittyBelowBackground.clear(); - _kittyBelowText.clear(); - _kittyAboveText.clear(); - for (final snapshot in _kittyPlacementCache.snapshots) { - if (snapshot.z >= 0) { - _kittyAboveText.add(snapshot); - } else if (snapshot.z < _kittyBelowBackgroundThreshold) { - _kittyBelowBackground.add(snapshot); - } else { - _kittyBelowText.add(snapshot); - } - } - } -} diff --git a/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart b/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart index 7c13a940..c4f3a1cb 100644 --- a/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart +++ b/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart @@ -5,55 +5,123 @@ import 'package:libghostty/libghostty.dart'; import '../links/link_snapshot.dart'; import 'atlas/atlas.dart'; import 'atlas/sprite_buffer.dart'; +import 'kitty_image_cache.dart'; +import 'kitty_placement_cache.dart'; import 'paint_state.dart'; +import 'painters/background_painter.dart'; +import 'painters/cursor_painter.dart'; +import 'painters/decoration_painter.dart'; +import 'painters/emoji_painter.dart'; +import 'painters/kitty_graphics_painter.dart'; +import 'painters/shaped_run_painter.dart'; +import 'painters/sprite_painter.dart'; +import 'painters/terminal_text_painter.dart'; +import 'painters/underline_painter.dart'; import 'terminal_frame_builder.dart'; -import 'terminal_painter_stack.dart'; +import 'terminal_render_cache.dart'; -/// Owns the frame buffers, frame builder, and paint stack for one render box. +/// Owns all paint-ready resources for one terminal render box. /// -/// [TerminalRenderBox] owns widget/render-object lifecycle. This class owns -/// the terminal frame pipeline that must be rebound together when the atlas or -/// grid changes. +/// The render box owns Flutter layout and lifecycle. This pipeline owns the +/// atlas lease, frame builder, retained row buffers, painters, Kitty image +/// state, paint order, and terminal synchronization state. final class TerminalRenderPipeline { + // The protocol splits negative z values in half at INT32_MIN / 2. + static const int _kittyBelowBackgroundThreshold = -1 << 30; + final TerminalPaintState _state; final SpriteBuffer _sprites; - late final TerminalPainterStack _painters; + final KittyImageCache _kittyImageCache; + final List _kittyBelowBackground = []; + final List _kittyBelowText = []; + final List _kittyAboveText = []; + + late TerminalAtlasHandle _atlasHandle; late TerminalFrameBuilder _frameBuilder; - var _needsTerminalSync = false; + late final KittyPlacementCache _kittyPlacementCache; + late final BackgroundPainter _backgroundPainter; + late final DecorationPainter _decorationPainter; + late final KittyGraphicsPainter _kittyBelowBackgroundPainter; + late final KittyGraphicsPainter _kittyBelowTextPainter; + late final KittyGraphicsPainter _kittyAboveTextPainter; + late final ShapedRunPainter _shapedRunPainter; + late EmojiPainter _emojiPainter; + late SpritePainter _spritePainter; + late CursorPainter _cursorPainter; + late TerminalTextPainter _textPainter; + late UnderlinePainter _underlinePainter; + var _terminalDirty = true; - TerminalRenderPipeline({ - required Atlas atlas, - required TerminalPaintState state, + TerminalRenderPipeline( + this._state, { + required TerminalRenderCache renderCache, + required AtlasConfig atlasConfig, required void Function() onImageReady, - }) : _state = state, - _sprites = SpriteBuffer() { + }) : _sprites = SpriteBuffer(), + _kittyImageCache = KittyImageCache(onImageReady: onImageReady) { + _atlasHandle = renderCache.acquireAtlas(atlasConfig); + final atlas = _atlasHandle.atlas; _frameBuilder = TerminalFrameBuilder(atlas, _sprites, _state); - _painters = TerminalPainterStack( - atlas: atlas, - state: state, - sprites: _sprites, - onImageReady: onImageReady, + _kittyPlacementCache = KittyPlacementCache( + state: _state, + images: _kittyImageCache, + ); + _backgroundPainter = BackgroundPainter(_state, _sprites); + _decorationPainter = DecorationPainter(_sprites); + _kittyBelowBackgroundPainter = KittyGraphicsPainter( + state: _state, + cache: _kittyImageCache, + snapshots: _kittyBelowBackground, + ); + _kittyBelowTextPainter = KittyGraphicsPainter( + state: _state, + cache: _kittyImageCache, + snapshots: _kittyBelowText, + ); + _kittyAboveTextPainter = KittyGraphicsPainter( + state: _state, + cache: _kittyImageCache, + snapshots: _kittyAboveText, ); + _shapedRunPainter = ShapedRunPainter(_sprites.shaped); + _bindAtlasPainters(atlas); } - void bindAtlas(Atlas atlas) { + bool bindAtlas( + TerminalRenderCache renderCache, + AtlasConfig config, { + bool force = false, + }) { + if (!force && config == _atlasHandle.config) return false; + + final previousHandle = _atlasHandle; final previousBuilder = _frameBuilder; + _atlasHandle = renderCache.acquireAtlas(config); + final atlas = _atlasHandle.atlas; _frameBuilder = TerminalFrameBuilder(atlas, _sprites, _state); if (_state.rows > 0 && _state.cols > 0) { _frameBuilder.configure(_state.rows, _state.cols); _frameBuilder.markAllRowsDirty(); } - _painters.bindAtlas(atlas); + _bindAtlasPainters(atlas); previousBuilder.dispose(); - _needsTerminalSync = true; + previousHandle.release(); + _terminalDirty = true; + return true; } - void configureGrid(int rows, int cols) => _frameBuilder.configure(rows, cols); + void configureGrid(int rows, int cols) { + _frameBuilder + ..configure(rows, cols) + ..markAllRowsDirty(); + _terminalDirty = true; + } void dispose() { - _painters.dispose(); + _kittyImageCache.dispose(); _frameBuilder.dispose(); _sprites.dispose(); + _atlasHandle.release(); _state.preeditActive = false; } @@ -63,30 +131,68 @@ final class TerminalRenderPipeline { _frameBuilder.markRowsDirty(from, toExclusive); } - void paint(Canvas canvas) => _painters.paint(canvas); + void markTerminalDirty() => _terminalDirty = true; + + void paint(Canvas canvas) { + _kittyBelowBackgroundPainter.paint(canvas); + _backgroundPainter.paint(canvas); + _kittyBelowTextPainter.paint(canvas); + _underlinePainter.paint(canvas); + _textPainter.paint(canvas); + _shapedRunPainter.paint(canvas); + _spritePainter.paint(canvas); + _cursorPainter.paint(canvas); + _emojiPainter.paint(canvas); + _decorationPainter.paint(canvas); + _kittyAboveTextPainter.paint(canvas); + } void refreshCursorGlyph() => _frameBuilder.refreshCursorGlyph(); - /// Syncs terminal cells and render-only preedit state into paint buffers. + String semanticsText() => _frameBuilder.semanticsText(); + + /// Syncs terminal cells and render-only state into paint-ready buffers. /// /// [preeditText] does not enter libghostty state. The frame builder overlays /// it on terminal-cell boundaries at the current cursor position. - /// Terminal-dirty frames also refresh Kitty placement geometry because screen - /// mutations can move placements without changing Kitty storage generation. void sync( Terminal terminal, { - required bool terminalDirty, String preeditText = '', LinkSnapshot linkSnapshot = .empty, }) { - final syncTerminal = terminalDirty || _needsTerminalSync; - _needsTerminalSync = false; + final terminalDirty = _terminalDirty; + _terminalDirty = false; _frameBuilder.sync( terminal, - terminalDirty: syncTerminal, + terminalDirty: terminalDirty, preeditText: preeditText, linkSnapshot: linkSnapshot, ); - _painters.sync(terminal, geometryDirty: syncTerminal); + if (_kittyPlacementCache.sync(terminal, geometryDirty: terminalDirty)) { + _rebuildKittyLayers(); + } + } + + void _bindAtlasPainters(Atlas atlas) { + _textPainter = TerminalTextPainter(atlas, _sprites.wide, _sprites.regular); + _spritePainter = SpritePainter(atlas, _sprites); + _cursorPainter = CursorPainter(_state, atlas); + _emojiPainter = EmojiPainter(atlas, _sprites); + _underlinePainter = UnderlinePainter(atlas, _sprites); + } + + void _rebuildKittyLayers() { + _kittyBelowBackground.clear(); + _kittyBelowText.clear(); + _kittyAboveText.clear(); + for (final snapshot in _kittyPlacementCache.snapshots) { + if (snapshot.z >= 0) { + _kittyAboveText.add(snapshot); + } else if (snapshot.z < _kittyBelowBackgroundThreshold) { + _kittyBelowBackground.add(snapshot); + } else { + _kittyBelowText.add(snapshot); + } + } } } diff --git a/packages/flterm/lib/src/rendering/terminal_renderer.dart b/packages/flterm/lib/src/rendering/terminal_renderer.dart index a51196a8..800b3ccb 100644 --- a/packages/flterm/lib/src/rendering/terminal_renderer.dart +++ b/packages/flterm/lib/src/rendering/terminal_renderer.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:libghostty/libghostty.dart'; @@ -28,7 +29,7 @@ import 'terminal_render_pipeline.dart'; /// theme: TerminalTheme.dark(), /// metrics: measureCellMetrics(fontFamily: 'monospace', fontSize: 14), /// offset: ViewportOffset.zero(), -/// renderObserver: controller, +/// focused: controller.hasFocus, /// ) /// ``` @internal @@ -55,11 +56,8 @@ class TerminalRenderer extends LeafRenderObjectWidget { /// At `pixels == maxScrollExtent`, the live screen is visible. final ViewportOffset offset; - /// Observable focus state. - /// - /// Listened to by the render box. Changes trigger a repaint to update - /// cursor appearance (filled vs hollow). - final TerminalRenderObserver renderObserver; + /// Whether the terminal currently has keyboard focus. + final bool focused; /// Whether the cursor blink is currently in the visible phase. /// @@ -82,18 +80,26 @@ class TerminalRenderer extends LeafRenderObjectWidget { /// Internal render cache used to share compatible atlas state. final TerminalRenderCache renderCache; + /// Monotonically increasing request for an accessible viewport snapshot. + final int semanticsGeneration; + + /// Receives accessible text after terminal state has synchronized for paint. + final ValueChanged? onSemanticsText; + const TerminalRenderer({ super.key, required this.terminal, required this.theme, required this.metrics, required this.offset, - required this.renderObserver, + required this.focused, required this.renderCache, this.blinkVisible = true, this.preeditText = '', this.linkSnapshot = .empty, this.onResize, + this.semanticsGeneration = 0, + this.onSemanticsText, }); @override @@ -108,7 +114,9 @@ class TerminalRenderer extends LeafRenderObjectWidget { blinkVisible: blinkVisible, preeditText: preeditText, linkSnapshot: linkSnapshot, - renderObserver: renderObserver, + focused: focused, + semanticsGeneration: semanticsGeneration, + onSemanticsText: onSemanticsText, ); } @@ -142,10 +150,12 @@ class TerminalRenderer extends LeafRenderObjectWidget { ..offset = offset ..metrics = metrics ..onResize = onResize - ..renderObserver = renderObserver + ..focused = focused ..blinkVisible = blinkVisible ..preeditText = preeditText - ..linkSnapshot = linkSnapshot; + ..linkSnapshot = linkSnapshot + ..semanticsGeneration = semanticsGeneration + ..onSemanticsText = onSemanticsText; } } @@ -170,16 +180,16 @@ class TerminalRenderer extends LeafRenderObjectWidget { class TerminalRenderBox extends RenderBox { Terminal _terminal; ViewportOffset _offset; - TerminalRenderObserver _renderObserver; OnResize? _onResize; TerminalRenderCache _renderCache; - late TerminalAtlasHandle _atlasHandle; var _performingLayout = false; - var _needsFrameSync = false; var _stickToBottom = true; var _lastScrollbackRows = 0; var _preeditText = ''; LinkSnapshot _linkSnapshot; + int _semanticsGeneration; + int _capturedSemanticsGeneration; + ValueChanged? _onSemanticsText; final TerminalPaintState _paintState; late final TerminalRenderPipeline _pipeline; @@ -189,26 +199,30 @@ class TerminalRenderBox extends RenderBox { required TerminalTheme theme, required CellMetrics metrics, required this._offset, - required this._renderObserver, + required bool focused, required this._renderCache, bool blinkVisible = true, this._linkSnapshot = .empty, this._preeditText = '', this._onResize, + int semanticsGeneration = 0, + ValueChanged? onSemanticsText, }) : _paintState = TerminalPaintState(theme, metrics) ..blinkVisible = blinkVisible - ..cursorFocused = _renderObserver.hasFocus { - _atlasHandle = _renderCache.acquireAtlas( - .fromTheme( + ..cursorFocused = focused, + _semanticsGeneration = semanticsGeneration, + _capturedSemanticsGeneration = onSemanticsText == null + ? semanticsGeneration + : semanticsGeneration - 1, + _onSemanticsText = onSemanticsText { + _pipeline = TerminalRenderPipeline( + _paintState, + renderCache: _renderCache, + atlasConfig: .fromTheme( theme: theme, metrics: metrics, devicePixelRatio: _currentDevicePixelRatio, ), - ); - final atlas = _atlasHandle.atlas; - _pipeline = TerminalRenderPipeline( - atlas: atlas, - state: _paintState, onImageReady: markNeedsPaint, ); @@ -306,12 +320,26 @@ class TerminalRenderBox extends RenderBox { set onResize(OnResize? value) => _onResize = value; - set renderObserver(TerminalRenderObserver value) { - if (_renderObserver == value) return; - if (attached) _renderObserver.removeListener(_onRenderObserverChanged); - _renderObserver = value; - if (attached) _renderObserver.addListener(_onRenderObserverChanged); - _onRenderObserverChanged(); + set onSemanticsText(ValueChanged? value) { + if (_onSemanticsText == value) return; + _onSemanticsText = value; + if (value != null) { + _capturedSemanticsGeneration = _semanticsGeneration - 1; + markNeedsPaint(); + } + } + + set semanticsGeneration(int value) { + if (_semanticsGeneration == value) return; + _semanticsGeneration = value; + if (_onSemanticsText != null) markNeedsPaint(); + } + + set focused(bool value) { + if (_paintState.cursorFocused == value) return; + _paintState.cursorFocused = value; + _pipeline.refreshCursorGlyph(); + markNeedsPaint(); } set renderCache(TerminalRenderCache value) { @@ -328,7 +356,7 @@ class TerminalRenderBox extends RenderBox { _terminal = value; if (attached) _terminal.addListener(_onTerminalChanged); _applyTerminalThemeColors(); - _needsFrameSync = true; + _pipeline.markTerminalDirty(); markNeedsLayout(); } @@ -347,11 +375,11 @@ class TerminalRenderBox extends RenderBox { oldTheme.fontSize != value.fontSize || oldTheme.fontWeight != value.fontWeight || oldTheme.fontFamily != value.fontFamily || - !_listEquals(oldTheme.fontFamilyFallback, value.fontFamilyFallback); + !listEquals(oldTheme.fontFamilyFallback, value.fontFamilyFallback); _paintState.updateTheme(value); _applyTerminalThemeColors(); _pipeline.markAllRowsDirty(); - _needsFrameSync = true; + _pipeline.markTerminalDirty(); if (fontChanged) { markNeedsLayout(); @@ -364,7 +392,6 @@ class TerminalRenderBox extends RenderBox { void attach(PipelineOwner owner) { super.attach(owner); _offset.addListener(_onScroll); - _renderObserver.addListener(_onRenderObserverChanged); _terminal.addListener(_onTerminalChanged); markNeedsLayout(); } @@ -384,18 +411,12 @@ class TerminalRenderBox extends RenderBox { ifTrue: 'cursor visible', ), ) - ..add( - DiagnosticsProperty( - 'renderObserver', - _renderObserver, - ), - ); + ..add(FlagProperty('focused', value: _paintState.cursorFocused)); } @override void detach() { _offset.removeListener(_onScroll); - _renderObserver.removeListener(_onRenderObserverChanged); _terminal.removeListener(_onTerminalChanged); super.detach(); } @@ -405,7 +426,6 @@ class TerminalRenderBox extends RenderBox { _paintState.rows = 0; _paintState.cols = 0; _pipeline.dispose(); - _atlasHandle.release(); super.dispose(); } @@ -415,6 +435,11 @@ class TerminalRenderBox extends RenderBox { @override void paint(PaintingContext context, Offset offset) { _syncFrameState(); + if (_onSemanticsText != null && + _capturedSemanticsGeneration != _semanticsGeneration) { + _capturedSemanticsGeneration = _semanticsGeneration; + _onSemanticsText!(_pipeline.semanticsText()); + } final canvas = context.canvas; @@ -467,10 +492,6 @@ class TerminalRenderBox extends RenderBox { _syncScrollLayout(); - // Grid changes invalidate every row's sprite slot layout. Atlas - // rebinding invalidates atlas references inside the pipeline. - if (gridChanged) _pipeline.markAllRowsDirty(); - if (gridChanged || atlasReconfigured) _markFrameDirty(); _performingLayout = false; @@ -495,13 +516,7 @@ class TerminalRenderBox extends RenderBox { metrics: _paintState.metrics, devicePixelRatio: dpr ?? _currentDevicePixelRatio, ); - if (!force && config == _atlasHandle.config) return false; - - final previousHandle = _atlasHandle; - _atlasHandle = _renderCache.acquireAtlas(config); - _pipeline.bindAtlas(_atlasHandle.atlas); - previousHandle.release(); - return true; + return _pipeline.bindAtlas(_renderCache, config, force: force); } double get _currentDevicePixelRatio { @@ -513,23 +528,8 @@ class TerminalRenderBox extends RenderBox { .devicePixelRatio; } - static bool _listEquals(List a, List b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } - void _markFrameDirty() { - _needsFrameSync = true; - markNeedsPaint(); - } - - void _onRenderObserverChanged() { - _paintState.cursorFocused = _renderObserver.hasFocus; - _pipeline.refreshCursorGlyph(); + _pipeline.markTerminalDirty(); markNeedsPaint(); } @@ -563,7 +563,7 @@ class TerminalRenderBox extends RenderBox { if (_paintState.rows == 0 || _performingLayout) return; if (_terminal.scrollbackRows != _lastScrollbackRows) { - _needsFrameSync = true; + _pipeline.markTerminalDirty(); markNeedsLayout(); return; } @@ -614,11 +614,8 @@ class TerminalRenderBox extends RenderBox { void _syncFrameState() { if (_paintState.rows == 0) return; - final terminalDirty = _needsFrameSync; - _needsFrameSync = false; _pipeline.sync( _terminal, - terminalDirty: terminalDirty, preeditText: _preeditText, linkSnapshot: _linkSnapshot, ); diff --git a/packages/flterm/lib/src/widgets.dart b/packages/flterm/lib/src/widgets.dart index fc39d3d1..e7f182bf 100644 --- a/packages/flterm/lib/src/widgets.dart +++ b/packages/flterm/lib/src/widgets.dart @@ -2,7 +2,6 @@ export 'widgets/terminal_controller.dart'; export 'widgets/terminal_controller_impl.dart'; export 'widgets/terminal_gesture_detector.dart'; export 'widgets/terminal_input_client.dart'; -export 'widgets/terminal_raw_gesture_detector.dart'; export 'widgets/terminal_scope.dart'; export 'widgets/terminal_scroll_controller.dart'; export 'widgets/terminal_shortcut_scope.dart'; diff --git a/packages/flterm/lib/src/widgets/terminal_controller.dart b/packages/flterm/lib/src/widgets/terminal_controller.dart index 8b3ac43d..9215f79c 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller.dart @@ -24,8 +24,7 @@ import 'terminal_controller_impl.dart'; /// pty.onData = (bytes) => controller.write(bytes); /// controller.sendText('ls -la\n'); /// ``` -abstract class TerminalController extends ChangeNotifier - implements TerminalRenderObserver { +abstract class TerminalController extends ChangeNotifier { /// Called with bytes to send to the backend (PTY, SSH, socket). /// /// Set this before calling [write]. Fires during [write], [sendKey], @@ -35,6 +34,9 @@ abstract class TerminalController extends ChangeNotifier /// Called when the terminal receives a BEL character (0x07). VoidCallback? onBell; + /// Called when the terminal receives an OSC 52 clipboard write request. + ValueChanged? onClipboardWrite; + /// Called when the terminal title changes. Read [title] for the value. VoidCallback? onTitleChanged; @@ -71,6 +73,9 @@ abstract class TerminalController extends ChangeNotifier /// Whether the terminal currently has an active text selection. bool get hasSelection; + /// Whether the attached terminal view has keyboard focus. + bool get hasFocus; + /// Current soft keyboard state. KeyboardState get keyboardState; diff --git a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart index f3b66f15..c1aacd09 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; @@ -28,14 +27,9 @@ class TerminalControllerImpl extends TerminalController static final _crBytes = Uint8List.fromList([_cr]); static final _formFeedBytes = Uint8List.fromList([_formFeed]); static final _clearScrollback = utf8.encode('\x1b[3J'); - static final _appCursorDown = Uint8List.fromList([0x1b, 0x4f, 0x42]); - static final _appCursorUp = Uint8List.fromList([0x1b, 0x4f, 0x41]); - static final _cursorDown = Uint8List.fromList([0x1b, 0x5b, 0x42]); - static final _cursorUp = Uint8List.fromList([0x1b, 0x5b, 0x41]); @override final Terminal terminal; - final _renderState = RenderState(); final _keyEncoder = KeyEncoder(); final _mouseEncoder = MouseEncoder(); late final SelectionGestureDriver _selectionGesture; @@ -49,9 +43,9 @@ class TerminalControllerImpl extends TerminalController KeyboardState _keyboardState = .hidden; Mods _virtualMods = const .none(); var _preeditText = ''; - var _cursorKeyApplication = false; Brightness _brightness = .dark; var _cursorBlinking = true; + var _mouseButtonPressed = false; var _wasFocused = false; var _selectionMutationDepth = 0; @@ -78,6 +72,8 @@ class TerminalControllerImpl extends TerminalController maxScrollback: config.scrollbackLimit, ), super.base() { + _lastCols = config.cols; + _lastRows = config.rows; _selectionGesture = SelectionGestureDriver(terminal); installDefaultKittyPngDecoder(); _textInput @@ -258,7 +254,6 @@ class TerminalControllerImpl extends TerminalController _selectionGesture.dispose(); _keyEncoder.dispose(); _mouseEncoder.dispose(); - _renderState.dispose(); terminal.dispose(); super.dispose(); } @@ -332,16 +327,32 @@ class TerminalControllerImpl extends TerminalController @override void handleMouseEvent(TerminalMouseEvent event) { + final button = event.button; _mouseEvent ..action = event.action - ..button = event.button ..mods = _currentMods() ..setPosition( x: event.pixelX * _lastDevicePixelRatio, y: event.pixelY * _lastDevicePixelRatio, ); + if (button == null) { + _mouseEvent.clearButton(); + } else { + _mouseEvent.button = button; + } + if (event.action == .press && + button != .four && + button != .five && + button != null) { + _mouseButtonPressed = true; + } _mouseEncoder.sync(terminal); + _mouseEncoder.setAnyButtonPressed(pressed: _mouseButtonPressed); final result = _mouseEncoder.encode(_mouseEvent); + if (event.action == .release) { + _mouseButtonPressed = false; + _mouseEncoder.setAnyButtonPressed(pressed: false); + } if (result.isEmpty) return; _emitOutput(utf8.encode(result)); } @@ -386,10 +397,11 @@ class TerminalControllerImpl extends TerminalController } @override - void handleScroll(int lines) { + void handleScroll(int lines, {Offset? localPosition}) { if (_activeScreen != .alternate || lines == 0) return; if (_mouseTracking != .none) { + if (localPosition == null) return; final button = lines < 0 ? MouseButton.four : MouseButton.five; final count = lines.abs(); @@ -400,22 +412,20 @@ class TerminalControllerImpl extends TerminalController ..action = .press ..button = button ..mods = _currentMods() - ..setPosition(x: 0, y: 0); + ..setPosition( + x: localPosition.dx * _lastDevicePixelRatio, + y: localPosition.dy * _lastDevicePixelRatio, + ); final result = _mouseEncoder.encode(_mouseEvent); if (result.isNotEmpty) _emitOutput(utf8.encode(result)); } return; } - final up = _cursorKeyApplication ? _appCursorUp : _cursorUp; - final down = _cursorKeyApplication ? _appCursorDown : _cursorDown; - final key = lines < 0 ? up : down; + final encoded = _encodeKeyPress(lines < 0 ? .arrowUp : .arrowDown); + if (encoded.isEmpty) return; final count = lines.abs(); - final bytes = Uint8List(key.length * count); - for (var i = 0; i < count; i++) { - bytes.setRange(i * key.length, (i + 1) * key.length, key); - } - _emitOutput(bytes); + _emitOutput(utf8.encode(List.filled(count, encoded).join())); } @override @@ -522,22 +532,7 @@ class TerminalControllerImpl extends TerminalController @override void sendKey(vt.Key key, {Mods mods = const .none()}) { - final effectiveMods = mods | _virtualMods; - final codepoint = unshiftedCodepointForKey(key); - _keyEvent - ..key = key - ..mods = effectiveMods - ..action = .press - ..consumedMods = const .none() - ..unshiftedCodepoint = codepoint - ..utf8 = codepoint > 0 ? String.fromCharCode(codepoint) : null - ..composing = false; - - _keyEncoder.sync(terminal); - final result = _keyEncoder.encode(_keyEvent); - if (result.isEmpty) return; - _emitOutput(utf8.encode(result)); - clearVirtualMods(); + _emitKeyPress(key, mods: mods | _virtualMods); } @override @@ -628,16 +623,10 @@ class TerminalControllerImpl extends TerminalController _cursorBlinking = _effectiveCursorBlinking(); } - int _clampInt(int value, int min, int max) { - if (value < min) return min; - if (value > max) return max; - return value; - } - Position _clampViewportPoint(Position position) { return Position( - row: _clampInt(position.row, 0, _lastRows - 1), - col: _clampInt(position.col, 0, _lastCols - 1), + row: position.row.clamp(0, _lastRows - 1), + col: position.col.clamp(0, _lastCols - 1), ); } @@ -694,6 +683,15 @@ class TerminalControllerImpl extends TerminalController Mods mods = const .none(), bool clearMods = true, }) { + final result = _encodeKeyPress(key, mods: mods); + if (result.isEmpty) return false; + + _emitOutput(utf8.encode(result)); + if (clearMods) clearVirtualMods(); + return true; + } + + String _encodeKeyPress(vt.Key key, {Mods mods = const .none()}) { final codepoint = unshiftedCodepointForKey(key); _keyEvent ..key = key @@ -705,21 +703,14 @@ class TerminalControllerImpl extends TerminalController ..composing = false; _keyEncoder.sync(terminal); - final result = _keyEncoder.encode(_keyEvent); - if (result.isEmpty) return false; - - _emitOutput(utf8.encode(result)); - if (clearMods) clearVirtualMods(); - return true; + return _keyEncoder.encode(_keyEvent); } void _emitOutput(Uint8List bytes) => onOutput?.call(bytes); void _ensureGridSize() { - if (_lastRows > 0 && _lastCols > 0) return; - _renderState.update(terminal); - _lastRows = _renderState.rows; - _lastCols = _renderState.cols; + if (_lastRows <= 0) _lastRows = _config.rows; + if (_lastCols <= 0) _lastCols = _config.cols; } bool _extendSelection(LogicalKeyboardKey arrowKey) { @@ -791,10 +782,10 @@ class TerminalControllerImpl extends TerminalController } TerminalSizeInfo _handleSizeQuery() { - _renderState.update(terminal); + _ensureGridSize(); return TerminalSizeInfo( - rows: _renderState.rows, - columns: _renderState.cols, + rows: _lastRows, + columns: _lastCols, cellWidth: (_lastMetrics.cellWidth * _lastDevicePixelRatio).round(), cellHeight: (_lastMetrics.cellHeight * _lastDevicePixelRatio).round(), ); @@ -869,12 +860,6 @@ class TerminalControllerImpl extends TerminalController changed = true; } - final newCursorKeyApp = terminal.modeGet(const .cursorKeys()); - if (newCursorKeyApp != _cursorKeyApplication) { - _cursorKeyApplication = newCursorKeyApp; - changed = true; - } - final newCursorBlinking = _effectiveCursorBlinking(); if (newCursorBlinking != _cursorBlinking) { _cursorBlinking = newCursorBlinking; @@ -963,7 +948,7 @@ class TerminalControllerImpl extends TerminalController scrollController.jumpTo(clamped); } - Future _updateKeyboardState(KeyboardState newState) async { + void _updateKeyboardState(KeyboardState newState) { if (newState == _keyboardState) return; _keyboardState = newState; @@ -994,6 +979,7 @@ class TerminalControllerImpl extends TerminalController void _wireTerminalCallbacks() { terminal.onWritePty = _emitOutput; terminal.onBell = () => onBell?.call(); + terminal.onClipboardWrite = (value) => onClipboardWrite?.call(value); terminal.onTitleChanged = () => onTitleChanged?.call(); terminal.onPwdChanged = _handlePwdChanged; terminal.onColorScheme = () => _brightness == .light ? .light : .dark; diff --git a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart index ffcdbc13..aadf7fff 100644 --- a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart +++ b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart @@ -4,13 +4,12 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:libghostty/libghostty.dart' - show MouseAction, MouseTracking, Position; + show MouseAction, MouseButton, MouseTracking, Position; import 'package:meta/meta.dart'; import '../foundation.dart'; import '../links/link_settings.dart'; import 'link_interaction.dart'; -import 'terminal_raw_gesture_detector.dart'; import 'terminal_view_binding.dart'; /// Interprets gestures as terminal actions: selection, mouse tracking @@ -51,6 +50,8 @@ class _TerminalGestureDetectorState extends State { Position? _pressCell; var _linkPressActive = false; Timer? _autoScrollTimer; + final Map _trackedButtons = {}; + double _wheelRemainder = 0; TerminalViewBinding get _binding => widget.binding; @@ -63,15 +64,48 @@ class _TerminalGestureDetectorState extends State { onPointerDown: tracked ? _handleTrackedDown : null, onPointerMove: tracked ? _handleTrackedMove : null, onPointerUp: tracked ? _handleTrackedUp : null, - child: TerminalRawGestureDetector( - onTapDown: _handleTapDown, - onTapUp: _handleTapUp, - onDragStart: _handleDragStart, - onDragUpdate: _handleDragUpdate, - onDragEnd: _handleDragEnd, - onLongPressStart: _handleLongPressStart, - onLongPressMoveUpdate: _handleLongPressMoveUpdate, - onLongPressUp: _handleLongPressUp, + onPointerCancel: tracked ? _handleTrackedCancel : null, + onPointerHover: tracked ? _handleTrackedHover : null, + onPointerSignal: tracked ? _handleTrackedSignal : null, + child: RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (recognizer) => recognizer + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp, + ), + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + debugOwner: this, + supportedDevices: const {PointerDeviceKind.touch}, + ), + (recognizer) => recognizer + ..onLongPressStart = _handleLongPressStart + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressUp = _handleLongPressUp, + ), + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer( + debugOwner: this, + supportedDevices: const {PointerDeviceKind.mouse}, + ), + (recognizer) { + recognizer + ..dragStartBehavior = .down + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = (_) { + _handleDragEnd(); + } + ..onCancel = _handleDragEnd; + }, + ), + }, child: widget.child, ), ); @@ -93,6 +127,7 @@ class _TerminalGestureDetectorState extends State { @override void dispose() { _autoScrollTimer?.cancel(); + _trackedButtons.clear(); super.dispose(); } @@ -125,12 +160,6 @@ class _TerminalGestureDetectorState extends State { _pressCell = null; } - int _clampInt(int value, int min, int max) { - if (value < min) return min; - if (value > max) return max; - return value; - } - void _endDrag() { final drag = _drag; if (drag != null) { @@ -158,7 +187,6 @@ class _TerminalGestureDetectorState extends State { } void _handleDragUpdate(DragUpdateDetails details) { - if (_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; if (_drag != null) _updateDrag(details.localPosition); } @@ -168,6 +196,10 @@ class _TerminalGestureDetectorState extends State { void _handleLongPressStart(LongPressStartDetails details) { _binding.requestFocus(); + if (_isMouseTracked(false)) { + _cancelSelectionPress(); + return; + } if (!widget.settings.longPressSelection) { _cancelSelectionPress(); return; @@ -177,6 +209,7 @@ class _TerminalGestureDetectorState extends State { rectangle: widget.settings.longPressSelectionShape == .rectangle, beginPress: _pressCell == null, ); + unawaited(Feedback.forLongPress(context)); } void _handleLongPressUp() => _endDrag(); @@ -225,21 +258,60 @@ class _TerminalGestureDetectorState extends State { } void _handleTrackedDown(PointerDownEvent event) { - final shift = - event.buttons & kSecondaryButton != 0 || - HardwareKeyboard.instance.isShiftPressed; - if (!_isMouseTracked(shift)) return; - _sendMouseEvent(.press, event.localPosition); + if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; + final button = _mouseButton(event.buttons); + _trackedButtons[event.pointer] = button; + _sendMouseEvent(.press, event.localPosition, button: button); } void _handleTrackedMove(PointerMoveEvent event) { if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - _sendMouseEvent(.motion, event.localPosition); + _sendMouseEvent( + .motion, + event.localPosition, + button: _trackedButtons[event.pointer] ?? _mouseButton(event.buttons), + ); } void _handleTrackedUp(PointerUpEvent event) { - if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; - _sendMouseEvent(.release, event.localPosition); + final button = _trackedButtons.remove(event.pointer); + if (button == null) return; + _sendMouseEvent(.release, event.localPosition, button: button); + } + + void _handleTrackedCancel(PointerCancelEvent event) { + final button = _trackedButtons.remove(event.pointer); + if (button == null) return; + _sendMouseEvent(.release, event.localPosition, button: button); + } + + void _handleTrackedHover(PointerHoverEvent event) { + if (event.kind == .touch || + !_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) { + return; + } + _sendMouseEvent(.motion, event.localPosition, button: null); + } + + void _handleTrackedSignal(PointerSignalEvent event) { + if (event is! PointerScrollEvent || + event.kind == .touch || + !_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) { + return; + } + GestureBinding.instance.pointerSignalResolver.register(event, (resolved) { + final scroll = resolved as PointerScrollEvent; + final cellHeight = widget.metrics.cellHeight; + if (cellHeight <= 0) return; + _wheelRemainder += scroll.scrollDelta.dy / cellHeight; + final lines = _wheelRemainder.truncate(); + _wheelRemainder -= lines; + if (lines == 0) return; + final button = lines < 0 ? MouseButton.four : MouseButton.five; + for (var i = 0; i < lines.abs(); i++) { + _sendMouseEvent(.press, scroll.localPosition, button: button); + } + }); } bool _isBlockModifierPressed() { @@ -268,10 +340,23 @@ class _TerminalGestureDetectorState extends State { _pressCell = null; } - void _sendMouseEvent(MouseAction action, Offset position) { + MouseButton _mouseButton(int buttons) { + if (buttons & kSecondaryButton != 0) return .right; + if (buttons & kMiddleMouseButton != 0) return .middle; + if (buttons & kBackMouseButton != 0) return .four; + if (buttons & kForwardMouseButton != 0) return .five; + if (buttons & kPrimaryButton != 0) return .left; + return .unknown; + } + + void _sendMouseEvent( + MouseAction action, + Offset position, { + required MouseButton? button, + }) { _binding.handleMouseEvent(( action: action, - button: .left, + button: button, pixelX: position.dx, pixelY: position.dy, )); @@ -320,7 +405,7 @@ class _TerminalGestureDetectorState extends State { } final clampedRow = visibleRows > 0 - ? _clampInt(cell.row, 0, visibleRows - 1) + ? cell.row.clamp(0, visibleRows - 1) : cell.row; final clampedCell = Position(row: clampedRow, col: cell.col); final rectangle = drag.baseRectangle || _isBlockModifierPressed(); diff --git a/packages/flterm/lib/src/widgets/terminal_input_client.dart b/packages/flterm/lib/src/widgets/terminal_input_client.dart index 2efa2192..8195ca5a 100644 --- a/packages/flterm/lib/src/widgets/terminal_input_client.dart +++ b/packages/flterm/lib/src/widgets/terminal_input_client.dart @@ -28,10 +28,10 @@ final class TerminalInputClient with DeltaTextInputClient { _CommittedCompositionEdit _committedCompositionEdit = .none; var _hadVisiblePreeditText = false; - VoidCallback? _onNewline; - ValueChanged? _onDelete; - ValueChanged? _onTextCommitted; - ValueChanged? _onPreeditChanged; + VoidCallback? onNewline; + ValueChanged? onDelete; + ValueChanged? onTextCommitted; + ValueChanged? onPreeditChanged; @override AutofillScope? get currentAutofillScope => null; @@ -51,18 +51,6 @@ final class TerminalInputClient with DeltaTextInputClient { _connection?.updateConfig(_configuration); } - set onDelete(ValueChanged? callback) => _onDelete = callback; - - set onNewline(VoidCallback? callback) => _onNewline = callback; - - set onPreeditChanged(ValueChanged? callback) { - _onPreeditChanged = callback; - } - - set onTextCommitted(ValueChanged? callback) { - _onTextCommitted = callback; - } - TextInputConfiguration get _configuration { return TextInputConfiguration( autocorrect: false, @@ -129,7 +117,7 @@ final class TerminalInputClient with DeltaTextInputClient { _clearNewlineActionSuppression(); return; } - _onNewline?.call(); + onNewline?.call(); _suppressNextNewlineDeltaSoon(); } @@ -252,13 +240,13 @@ final class TerminalInputClient with DeltaTextInputClient { var offset = 0; for (final match in _newlinePattern.allMatches(text)) { final chunk = text.substring(offset, match.start); - if (chunk.isNotEmpty) _onTextCommitted?.call(chunk); - _onNewline?.call(); + if (chunk.isNotEmpty) onTextCommitted?.call(chunk); + onNewline?.call(); offset = match.end; } final tail = text.substring(offset); - if (tail.isNotEmpty) _onTextCommitted?.call(tail); + if (tail.isNotEmpty) onTextCommitted?.call(tail); if (singleNewline) _suppressNextNewlineActionSoon(); } @@ -303,17 +291,17 @@ final class TerminalInputClient with DeltaTextInputClient { return; } final count = delta.deletedRange.end - delta.deletedRange.start; - _onDelete?.call(count); + onDelete?.call(count); _clearCommittedCompositionEdit(); _resetBuffer(); } if (hasVisiblePreeditText) { _clearCommittedCompositionEdit(); - _onPreeditChanged?.call(preeditText); + onPreeditChanged?.call(preeditText); } else if (_hadVisiblePreeditText) { if (!committedFromDelta) _commitEndedCompositionFromValue(); - _onPreeditChanged?.call(''); + onPreeditChanged?.call(''); } _hadVisiblePreeditText = hasVisiblePreeditText; @@ -323,14 +311,14 @@ final class TerminalInputClient with DeltaTextInputClient { final preeditText = value.terminalComposingText; if (preeditText.isNotEmpty) { _clearCommittedCompositionEdit(); - _onPreeditChanged?.call(preeditText); + onPreeditChanged?.call(preeditText); _hadVisiblePreeditText = true; return; } final hadVisiblePreeditText = _hadVisiblePreeditText; if (hadVisiblePreeditText) _commitEndedCompositionFromValue(); - if (hadVisiblePreeditText) _onPreeditChanged?.call(''); + if (hadVisiblePreeditText) onPreeditChanged?.call(''); _hadVisiblePreeditText = false; if (hadVisiblePreeditText) return; @@ -352,7 +340,7 @@ final class TerminalInputClient with DeltaTextInputClient { _clearNewlineActionSuppression(); _clearCommittedCompositionEdit(); _hadVisiblePreeditText = false; - if (hadVisiblePreeditText) _onPreeditChanged?.call(''); + if (hadVisiblePreeditText) onPreeditChanged?.call(''); } void _suppressNextNewlineActionSoon() { diff --git a/packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart b/packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart deleted file mode 100644 index 9123770c..00000000 --- a/packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; - -/// Gesture detector that recognizes taps, mouse drags, and touch long presses. -/// -/// Drag is restricted to mouse devices, long press to touch devices. -/// -/// ```dart -/// TerminalRawGestureDetector( -/// onTapDown: (details) => handleTapDown(details), -/// onTapUp: (details) => handleTapUp(details), -/// onDragStart: (details) => handleDragStart(details), -/// child: Container(), -/// ) -/// ``` -@internal -class TerminalRawGestureDetector extends StatelessWidget { - final Widget child; - - /// Fires when a tap begins. - final GestureTapDownCallback? onTapDown; - - /// Fires when a tap ends. - final GestureTapUpCallback? onTapUp; - - /// Fires when a mouse drag begins. - final GestureDragStartCallback? onDragStart; - - /// Fires as the mouse drag continues. - final GestureDragUpdateCallback? onDragUpdate; - - /// Fires when a mouse drag ends or is cancelled. - final VoidCallback? onDragEnd; - - /// Fires when a touch long press begins. - final GestureLongPressStartCallback? onLongPressStart; - - /// Fires as a touch long press moves. - final GestureLongPressMoveUpdateCallback? onLongPressMoveUpdate; - - /// Fires when a touch long press ends. - final VoidCallback? onLongPressUp; - - const TerminalRawGestureDetector({ - super.key, - required this.child, - this.onTapDown, - this.onTapUp, - this.onDragStart, - this.onDragUpdate, - this.onDragEnd, - this.onLongPressStart, - this.onLongPressMoveUpdate, - this.onLongPressUp, - }); - - @override - Widget build(BuildContext context) { - return RawGestureDetector( - behavior: HitTestBehavior.opaque, - gestures: { - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(debugOwner: this), - (instance) => instance - ..onTapDown = onTapDown - ..onTapUp = onTapUp, - ), - LongPressGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer( - debugOwner: this, - supportedDevices: const {PointerDeviceKind.touch}, - ), - (instance) => instance - ..onLongPressStart = onLongPressStart?.call - ..onLongPressMoveUpdate = onLongPressMoveUpdate - ..onLongPressUp = onLongPressUp, - ), - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer( - debugOwner: this, - supportedDevices: const {PointerDeviceKind.mouse}, - ), - (instance) { - instance - ..dragStartBehavior = .down - ..onStart = onDragStart - ..onUpdate = onDragUpdate - ..onEnd = (_) => onDragEnd?.call(); - instance.onCancel = () => onDragEnd?.call(); - }, - ), - }, - child: child, - ); - } -} diff --git a/packages/flterm/lib/src/widgets/terminal_view.dart b/packages/flterm/lib/src/widgets/terminal_view.dart index 3aa30ecf..e3581c57 100644 --- a/packages/flterm/lib/src/widgets/terminal_view.dart +++ b/packages/flterm/lib/src/widgets/terminal_view.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:libghostty/libghostty.dart' @@ -96,6 +98,15 @@ class TerminalView extends StatefulWidget { /// Ctrl+C/V/A/K on Windows. final Map? shortcuts; + /// Accessibility label for the terminal semantics node. + /// + /// Set to null to omit flterm's semantics node when an embedding application + /// provides an equivalent accessible terminal surface. + final String? semanticsLabel; + + /// Accessibility hint for focusing terminal input. + final String? semanticsHint; + /// Raw TTF/OTF font file bytes for exact metric extraction. /// /// When provided, takes priority over automatic font resolution. The @@ -117,6 +128,8 @@ class TerminalView extends StatefulWidget { this.shortcuts, this.scrollPhysics, this.scrollController, + this.semanticsLabel = 'Terminal', + this.semanticsHint = 'Activate to focus terminal input', this.autofocus = false, this.showKeyboard = true, this.padding = const .all(8), @@ -130,6 +143,8 @@ class TerminalView extends StatefulWidget { } class _TerminalViewState extends State { + static const _semanticsUpdateInterval = Duration(milliseconds: 100); + late FocusNode _focusNode; late TerminalTheme _theme; late CellMetrics _metrics; @@ -137,17 +152,23 @@ class _TerminalViewState extends State { late TerminalScrollController _scrollController; final _links = LinkInteraction(); final _rendererKey = GlobalKey(); + final _semanticsText = ValueNotifier(''); Uint8List? _resolvedFontData; var _ownsFocusNode = false; var _ownsScrollController = false; var _mouseCursorHidden = false; + Offset? _lastPointerPosition; var _lastAlternatePixels = 0.0; var _visibleCols = 0; var _visibleRows = 0; var _devicePixelRatio = 1.0; Timer? _blinkTimer; + Timer? _semanticsTimer; var _blinkVisible = true; + var _semanticsGeneration = 0; + String? _pendingSemanticsText; + var _semanticsNotificationScheduled = false; TerminalController get _controller => widget.controller; @@ -192,12 +213,30 @@ class _TerminalViewState extends State { void didUpdateWidget(TerminalView oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.semanticsLabel != oldWidget.semanticsLabel) { + if (widget.semanticsLabel == null) { + _semanticsTimer?.cancel(); + _semanticsTimer = null; + _pendingSemanticsText = null; + _semanticsText.value = ''; + } else { + _scheduleSemanticsUpdate(); + } + } + if (widget.controller != oldWidget.controller) { oldWidget.controller.removeListener(_onControllerChanged); + _binding.terminal.removeListener(_notifySemanticsChanged); _binding.detach(); + _semanticsTimer?.cancel(); + _semanticsTimer = null; + _pendingSemanticsText = null; + _semanticsText.value = ''; _binding = _asBinding(_controller); + _binding.terminal.addListener(_notifySemanticsChanged); _binding.brightness = _themeBrightness; _binding.attach(_focusNode, _scrollController); + _scheduleSemanticsUpdate(); _controller.addListener(_onControllerChanged); _links.invalidateContent(); } @@ -250,11 +289,17 @@ class _TerminalViewState extends State { @override void dispose() { _blinkTimer?.cancel(); + _semanticsTimer?.cancel(); + SemanticsBinding.instance.removeSemanticsEnabledListener( + _handleSemanticsEnabledChanged, + ); _controller.removeListener(_onControllerChanged); + _binding.terminal.removeListener(_notifySemanticsChanged); _binding.detach(); if (_ownsFocusNode) _focusNode.dispose(); _scrollController.removeListener(_onScrollChanged); if (_ownsScrollController) _scrollController.dispose(); + _semanticsText.dispose(); super.dispose(); } @@ -262,7 +307,12 @@ class _TerminalViewState extends State { void initState() { super.initState(); + SemanticsBinding.instance.addSemanticsEnabledListener( + _handleSemanticsEnabledChanged, + ); + _binding = _asBinding(_controller); + _binding.terminal.addListener(_notifySemanticsChanged); _focusNode = widget.focusNode ?? FocusNode(); _ownsFocusNode = widget.focusNode == null; @@ -282,34 +332,37 @@ class _TerminalViewState extends State { _binding.brightness = _themeBrightness; _binding.attach(_focusNode, _scrollController); + _scheduleSemanticsUpdate(); _controller.addListener(_onControllerChanged); _syncLinkInteraction(); } Widget _build(BuildContext context, TerminalRenderCache cache) { - return GestureDetector( - behavior: .translucent, - onTap: _controller.requestFocus, - child: ColoredBox( - // Backdrop tinted by backgroundOpacity. The repaint boundary - // TerminalRenderBox skips its own grid fill below 1.0 and - // relies on this as the sole tint source, so default background - // cells show through to whatever sits behind the widget without - // composing twice across the two layers. - color: _theme.background.withValues(alpha: _theme.backgroundOpacity), - child: Padding( - padding: widget.padding, - child: Focus( - onKeyEvent: _handleKeyEvent, - child: TerminalShortcutScope( - onPaste: _handlePaste, - controller: _controller, - shortcuts: widget.shortcuts, - enableSelectAll: widget.gestureSettings.selectAllShortcut, - child: MouseRegion( - onHover: _handleMouseHover, - onExit: _handleMouseExit, - cursor: _effectiveMouseCursor(), + final terminal = ColoredBox( + // Backdrop tinted by backgroundOpacity. The repaint boundary + // TerminalRenderBox skips its own grid fill below 1.0 and + // relies on this as the sole tint source, so default background + // cells show through to whatever sits behind the widget without + // composing twice across the two layers. + color: _theme.background.withValues(alpha: _theme.backgroundOpacity), + child: Padding( + padding: widget.padding, + child: Focus( + onKeyEvent: _handleKeyEvent, + child: TerminalShortcutScope( + onPaste: _handlePaste, + controller: _controller, + shortcuts: widget.shortcuts, + enableSelectAll: widget.gestureSettings.selectAllShortcut, + child: MouseRegion( + onHover: _handleMouseHover, + onExit: _handleMouseExit, + cursor: _effectiveMouseCursor(), + child: Listener( + onPointerDown: _recordPointerPosition, + onPointerMove: _recordPointerPosition, + onPointerUp: _recordPointerPosition, + onPointerSignal: _recordPointerPosition, child: Focus( focusNode: _focusNode, autofocus: widget.autofocus, @@ -330,12 +383,18 @@ class _TerminalViewState extends State { theme: _theme, offset: offset, metrics: _metrics, - renderObserver: _controller, + focused: _controller.hasFocus, terminal: _binding.terminal, renderCache: cache, preeditText: _binding.preeditText, blinkVisible: _blinkVisible, linkSnapshot: _links.snapshot(), + semanticsGeneration: _semanticsGeneration, + onSemanticsText: + SemanticsBinding.instance.semanticsEnabled && + widget.semanticsLabel != null + ? _handleSemanticsText + : null, onResize: _handleResize, ), ), @@ -347,6 +406,73 @@ class _TerminalViewState extends State { ), ), ); + final semanticsLabel = widget.semanticsLabel; + if (semanticsLabel == null || !SemanticsBinding.instance.semanticsEnabled) { + return terminal; + } + return ListenableBuilder( + listenable: _semanticsText, + child: terminal, + builder: (context, child) => Semantics( + container: true, + excludeSemantics: true, + label: semanticsLabel, + value: _semanticsText.value, + hint: widget.semanticsHint, + focusable: true, + focused: _focusNode.hasFocus, + onTap: _controller.requestFocus, + onFocus: _controller.requestFocus, + child: child, + ), + ); + } + + void _handleSemanticsEnabledChanged() { + if (!mounted) return; + if (SemanticsBinding.instance.semanticsEnabled) { + _scheduleSemanticsUpdate(); + } else { + _semanticsTimer?.cancel(); + _semanticsTimer = null; + _pendingSemanticsText = null; + _semanticsText.value = ''; + } + setState(() {}); + } + + void _notifySemanticsChanged() => _scheduleSemanticsUpdate(); + + void _scheduleSemanticsUpdate() { + if (_semanticsTimer != null || + widget.semanticsLabel == null || + !SemanticsBinding.instance.semanticsEnabled) { + return; + } + _semanticsTimer = Timer(_semanticsUpdateInterval, () { + _semanticsTimer = null; + if (!mounted || + widget.semanticsLabel == null || + !SemanticsBinding.instance.semanticsEnabled) { + return; + } + setState(() => _semanticsGeneration++); + }); + } + + void _handleSemanticsText(String text) { + _pendingSemanticsText = text; + if (_semanticsNotificationScheduled) return; + _semanticsNotificationScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _semanticsNotificationScheduled = false; + final pending = _pendingSemanticsText; + _pendingSemanticsText = null; + if (!mounted || pending == null || pending == _semanticsText.value) { + return; + } + _semanticsText.value = pending; + }); } MouseCursor _effectiveMouseCursor() { @@ -382,12 +508,14 @@ class _TerminalViewState extends State { } void _handleMouseExit(PointerExitEvent event) { + _lastPointerPosition = null; final previous = _links.highlighted; _links.cancelHover(); if (previous != null) setState(() {}); } void _handleMouseHover(PointerHoverEvent event) { + _lastPointerPosition = event.localPosition; final previous = _links.highlighted; _links.handleHover( localPosition: event.localPosition, @@ -399,6 +527,10 @@ class _TerminalViewState extends State { } } + void _recordPointerPosition(PointerEvent event) { + _lastPointerPosition = event.localPosition; + } + void _syncHoveredLink() { final previous = _links.highlighted; _links.refreshHover(metrics: _metrics, virtualMods: _binding.virtualMods); @@ -463,6 +595,7 @@ class _TerminalViewState extends State { void _onScrollChanged() { _syncBlink(); if (!_scrollController.hasClients) return; + if (_controller.activeScreen == .primary) _scheduleSemanticsUpdate(); final cellHeight = _metrics.cellHeight; if (cellHeight <= 0) return; final pixels = _scrollController.position.pixels; @@ -470,7 +603,7 @@ class _TerminalViewState extends State { final lines = (delta / cellHeight).truncate(); if (lines == 0) return; _lastAlternatePixels += lines * cellHeight; - _binding.handleScroll(lines); + _binding.handleScroll(lines, localPosition: _lastPointerPosition); _links.invalidateContent(); _syncLinkInteraction(); _updateTextInputGeometry(); diff --git a/packages/flterm/lib/src/widgets/terminal_view_binding.dart b/packages/flterm/lib/src/widgets/terminal_view_binding.dart index 78a3959d..cd0a4875 100644 --- a/packages/flterm/lib/src/widgets/terminal_view_binding.dart +++ b/packages/flterm/lib/src/widgets/terminal_view_binding.dart @@ -66,8 +66,9 @@ abstract interface class TerminalViewBinding { required double devicePixelRatio, }); - /// Reports scroll by line count. - void handleScroll(int lines); + /// Reports alternate-screen scrolling by line count. [localPosition] is + /// required when mouse tracking converts it into a terminal wheel report. + void handleScroll(int lines, {Offset? localPosition}); /// Applies a press selection gesture. void handleSelectionPress({ diff --git a/packages/flterm/test/foundation/terminal_config_test.dart b/packages/flterm/test/foundation/terminal_config_test.dart index 398caea6..e3754310 100644 --- a/packages/flterm/test/foundation/terminal_config_test.dart +++ b/packages/flterm/test/foundation/terminal_config_test.dart @@ -33,18 +33,12 @@ void main() { }); group('defaultModes', () { - test('contains terminal mode defaults', () { + test('only overrides renderer-required libghostty defaults', () { const modes = TerminalConfig.defaultModes; - expect(modes[const TerminalMode.srm()], isTrue); - expect(modes[const TerminalMode.autoWrap()], isTrue); expect(modes[const TerminalMode.cursorBlinking()], isTrue); - expect(modes[const TerminalMode.cursorVisible()], isTrue); - expect(modes[const TerminalMode.alternateScroll()], isTrue); - expect(modes[const TerminalMode.numlockKeypad()], isTrue); - expect(modes[const TerminalMode.altEscPrefix()], isTrue); expect(modes[const TerminalMode.graphemeCluster()], isTrue); - expect(modes.length, 8); + expect(modes.length, 2); }); }); diff --git a/packages/flterm/test/rendering/cursor_layer_test.dart b/packages/flterm/test/rendering/cursor_layer_test.dart index af2034e2..d8d31cd3 100644 --- a/packages/flterm/test/rendering/cursor_layer_test.dart +++ b/packages/flterm/test/rendering/cursor_layer_test.dart @@ -79,7 +79,7 @@ void main() { terminal: terminal, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: _TestRenderObserver(), + focused: true, ), ), ), @@ -342,14 +342,3 @@ void main() { }); }); } - -class _TestRenderObserver implements TerminalRenderObserver { - @override - bool get hasFocus => true; - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/emoji_golden_test.dart b/packages/flterm/test/rendering/emoji_golden_test.dart index 396b29cf..86a843f0 100644 --- a/packages/flterm/test/rendering/emoji_golden_test.dart +++ b/packages/flterm/test/rendering/emoji_golden_test.dart @@ -120,7 +120,7 @@ void main() { metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: _TestRenderObserver(hasFocus: focused), + focused: focused, ), ), ), @@ -449,16 +449,3 @@ void main() { }); }); } - -class _TestRenderObserver implements TerminalRenderObserver { - @override - final bool hasFocus; - - const _TestRenderObserver({this.hasFocus = true}); - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/kitty_image_cache_test.dart b/packages/flterm/test/rendering/kitty_image_cache_test.dart index 0b8c501d..b89a603d 100644 --- a/packages/flterm/test/rendering/kitty_image_cache_test.dart +++ b/packages/flterm/test/rendering/kitty_image_cache_test.dart @@ -12,10 +12,12 @@ import 'package:libghostty/libghostty.dart'; void main() { group('KittyImageCache', () { - Future testImage() { + Future testImage([ + List rgba = const [0xff, 0xff, 0xff, 0xff], + ]) { final completer = Completer(); ui.decodeImageFromPixels( - Uint8List.fromList([0xff, 0xff, 0xff, 0xff]), + Uint8List.fromList(rgba), 1, 1, ui.PixelFormat.rgba8888, @@ -45,6 +47,106 @@ void main() { }); }); + testWidgets('same-size retransmission keeps the previous image drawable', ( + tester, + ) async { + await tester.runAsync(() async { + var ready = Completer(); + final cache = KittyImageCache( + onImageReady: () { + if (!ready.isCompleted) ready.complete(); + }, + ); + addTearDown(cache.dispose); + + expect( + cache.lookupRgba( + imageId: 1, + generation: 10, + width: 1, + height: 1, + rgba: Uint8List.fromList([0xff, 0x00, 0x00, 0xff]), + ), + isA(), + ); + await ready.future; + + ready = Completer(); + final previous = cache.lookupById(1)! as KittyImageReady; + final replacing = cache.lookupRgba( + imageId: 1, + generation: 11, + width: 1, + height: 1, + rgba: Uint8List.fromList([0x00, 0xff, 0x00, 0xff]), + ); + expect(replacing, same(previous)); + + final previousBytes = await previous.image.toByteData(); + expect(previousBytes!.buffer.asUint8List(), [0xff, 0x00, 0x00, 0xff]); + + await ready.future; + final entry = cache.lookupById(1)! as KittyImageReady; + expect(entry, isNot(same(previous))); + final bytes = await entry.image.toByteData(); + expect(bytes!.buffer.asUint8List(), [0x00, 0xff, 0x00, 0xff]); + }); + }); + + testWidgets('coalesces rapid replacements to the newest queued frame', ( + tester, + ) async { + await tester.runAsync(() async { + final pending = + <({Uint8List rgba, ui.ImageDecoderCallback complete})>[]; + var readyCount = 0; + final cache = KittyImageCache( + onImageReady: () => readyCount++, + decodeImage: (rgba, width, height, format, complete) { + pending.add((rgba: rgba, complete: complete)); + }, + ); + addTearDown(cache.dispose); + + cache.lookupRgba( + imageId: 1, + generation: 10, + width: 1, + height: 1, + rgba: Uint8List.fromList([0xff, 0x00, 0x00, 0xff]), + ); + cache.lookupRgba( + imageId: 1, + generation: 11, + width: 1, + height: 1, + rgba: Uint8List.fromList([0x00, 0xff, 0x00, 0xff]), + ); + cache.lookupRgba( + imageId: 1, + generation: 12, + width: 1, + height: 1, + rgba: Uint8List.fromList([0x00, 0x00, 0xff, 0xff]), + ); + + expect(pending, hasLength(1)); + expect(pending.single.rgba, [0xff, 0x00, 0x00, 0xff]); + + pending.single.complete(await testImage([0xff, 0x00, 0x00, 0xff])); + expect(readyCount, 1); + expect(pending, hasLength(2)); + expect(pending.last.rgba, [0x00, 0x00, 0xff, 0xff]); + + pending.last.complete(await testImage([0x00, 0x00, 0xff, 0xff])); + expect(readyCount, 2); + + final entry = cache.lookupById(1)! as KittyImageReady; + final bytes = await entry.image.toByteData(); + expect(bytes!.buffer.asUint8List(), [0x00, 0x00, 0xff, 0xff]); + }); + }); + group('lookup', () { Uint8List transmitPixel({required int id, required List rgb}) { final payload = base64Encode(rgb); @@ -63,20 +165,21 @@ void main() { terminal.dispose(); }); - test('invalidates ready entry when image generation changes', () async { + test('retains ready entry while same-size generation decodes', () async { final cache = KittyImageCache(onImageReady: () {}); addTearDown(cache.dispose); final decoded = await testImage(); cache.putReady(7, decoded); + final previous = cache.lookupById(7); terminal.write(transmitPixel(id: 7, rgb: [0xff, 0x00, 0x00])); final image = KittyGraphics.of(terminal)!.image(7)!; final entry = cache.lookup(image); - expect(entry, isA()); + expect(entry, same(previous)); }); - test('discards stale pending decode after generation changes', () async { + test('queues the latest generation behind an active decode', () async { final callbacks = []; final cache = KittyImageCache( onImageReady: () {}, @@ -95,7 +198,10 @@ void main() { callbacks[0](stale); - expect(cache.lookupById(8), isA()); + expect(cache.lookupById(8), isA()); + expect(callbacks, hasLength(2)); + callbacks[1](await testImage()); + expect(cache.lookupById(8), isA()); }); }); }); diff --git a/packages/flterm/test/rendering/sprites_golden_test.dart b/packages/flterm/test/rendering/sprites_golden_test.dart index 5e295dce..4cc69906 100644 --- a/packages/flterm/test/rendering/sprites_golden_test.dart +++ b/packages/flterm/test/rendering/sprites_golden_test.dart @@ -98,7 +98,7 @@ void main() { metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: const _TestRenderObserver(), + focused: true, ), ), ), @@ -324,16 +324,3 @@ void main() { }); }); } - -class _TestRenderObserver implements TerminalRenderObserver { - const _TestRenderObserver(); - - @override - bool get hasFocus => true; - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/terminal_frame_builder_test.dart b/packages/flterm/test/rendering/terminal_frame_builder_test.dart index 30374264..69cebc4c 100644 --- a/packages/flterm/test/rendering/terminal_frame_builder_test.dart +++ b/packages/flterm/test/rendering/terminal_frame_builder_test.dart @@ -146,6 +146,18 @@ void main() { expect(atlas.emojiImage, isNotNull); }); + test('semantics iteration preserves subsequent terminal updates', () { + writeUtf8(terminal, 'visible'); + builder.sync(terminal, terminalDirty: true); + + expect(builder.semanticsText(), contains('visible')); + + writeUtf8(terminal, ' updated'); + builder.sync(terminal, terminalDirty: true); + + expect(builder.semanticsText(), contains('updated')); + }); + test('sync emits operator ligatures without adding text atlas entries', () { final initialCacheSize = atlas.cacheSize; writeUtf8(terminal, '=> !='); diff --git a/packages/flterm/test/rendering/terminal_render_pipeline_test.dart b/packages/flterm/test/rendering/terminal_render_pipeline_test.dart index ebb4ca64..4dcd0ea0 100644 --- a/packages/flterm/test/rendering/terminal_render_pipeline_test.dart +++ b/packages/flterm/test/rendering/terminal_render_pipeline_test.dart @@ -11,6 +11,7 @@ import 'package:flterm/src/foundation/terminal_theme.dart'; import 'package:flterm/src/links/link_snapshot.dart'; import 'package:flterm/src/rendering/atlas/atlas.dart'; import 'package:flterm/src/rendering/paint_state.dart'; +import 'package:flterm/src/rendering/terminal_render_cache.dart'; import 'package:flterm/src/rendering/terminal_render_pipeline.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart'; @@ -42,33 +43,34 @@ void main() { } late Terminal terminal; - late Atlas atlas; + late TerminalRenderCache renderCache; late TerminalPaintState state; late TerminalRenderPipeline pipeline; setUp(() { terminal = Terminal(cols: 8, rows: 2); - atlas = Atlas(config()); + renderCache = TerminalRenderCache(); state = TerminalPaintState(TerminalTheme.dark(), metrics) ..cols = 8 ..rows = 2; pipeline = TerminalRenderPipeline( - atlas: atlas, - state: state, + state, + renderCache: renderCache, + atlasConfig: config(), onImageReady: () {}, )..configureGrid(2, 8); }); tearDown(() { pipeline.dispose(); - atlas.dispose(); + renderCache.dispose(); terminal.dispose(); }); test('sync resolves cursor glyph and paints current frame', () { writeUtf8(terminal, 'A\x1b[1;1H'); - pipeline.sync(terminal, terminalDirty: true); + pipeline.sync(terminal); expect(state.cursor.visible, isTrue); expect(state.cursorAtlasEntry, isNotNull); @@ -77,13 +79,10 @@ void main() { test('bindAtlas keeps the frame pipeline configured', () { writeUtf8(terminal, 'A\x1b[1;1H'); - pipeline.sync(terminal, terminalDirty: true); + pipeline.sync(terminal); - final nextAtlas = Atlas(config(fontSize: 16)); - addTearDown(nextAtlas.dispose); - - pipeline.bindAtlas(nextAtlas); - pipeline.sync(terminal, terminalDirty: false); + pipeline.bindAtlas(renderCache, config(fontSize: 16)); + pipeline.sync(terminal); expect(state.cursorAtlasEntry, isNotNull); paint(pipeline); @@ -91,14 +90,15 @@ void main() { test('selection changes repaint through terminal dirty state', () { writeUtf8(terminal, 'hello'); - pipeline.sync(terminal, terminalDirty: true); + pipeline.sync(terminal); terminal.selection = Selection.fromRefs( start: GridRef.at(terminal, const Position(row: 0, col: 1)), end: GridRef.at(terminal, const Position(row: 0, col: 2)), ); - pipeline.sync(terminal, terminalDirty: true); + pipeline.markTerminalDirty(); + pipeline.sync(terminal); paint(pipeline); }); @@ -108,7 +108,6 @@ void main() { pipeline.sync( terminal, - terminalDirty: true, linkSnapshot: LinkSnapshot.highlighted( const CellRange( start: Position(row: 0, col: 0), diff --git a/packages/flterm/test/rendering/terminal_renderer_golden_test.dart b/packages/flterm/test/rendering/terminal_renderer_golden_test.dart index 66203eb1..23628a50 100644 --- a/packages/flterm/test/rendering/terminal_renderer_golden_test.dart +++ b/packages/flterm/test/rendering/terminal_renderer_golden_test.dart @@ -102,7 +102,7 @@ void main() { metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: _TestRenderObserver(hasFocus: focused), + focused: focused, blinkVisible: blinkVisible, preeditText: preeditText, linkSnapshot: linkSnapshot, @@ -978,16 +978,3 @@ void main() { }); }); } - -class _TestRenderObserver implements TerminalRenderObserver { - @override - final bool hasFocus; - - const _TestRenderObserver({this.hasFocus = true}); - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/terminal_renderer_test.dart b/packages/flterm/test/rendering/terminal_renderer_test.dart index 31e8e762..ab8f489e 100644 --- a/packages/flterm/test/rendering/terminal_renderer_test.dart +++ b/packages/flterm/test/rendering/terminal_renderer_test.dart @@ -62,7 +62,7 @@ void main() { metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache, - renderObserver: _TestRenderObserver(hasFocus: focused), + focused: focused, blinkVisible: blinkVisible, onResize: onResize, ), @@ -227,16 +227,3 @@ class _TrackingRenderCache extends TerminalRenderCache { return super.acquireAtlas(config); } } - -class _TestRenderObserver implements TerminalRenderObserver { - @override - final bool hasFocus; - - const _TestRenderObserver({this.hasFocus = true}); - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/rendering/transparent_background_golden_test.dart b/packages/flterm/test/rendering/transparent_background_golden_test.dart index 58176930..dcde9195 100644 --- a/packages/flterm/test/rendering/transparent_background_golden_test.dart +++ b/packages/flterm/test/rendering/transparent_background_golden_test.dart @@ -77,7 +77,7 @@ void main() { metrics: metrics, offset: ViewportOffset.zero(), renderCache: renderCache(), - renderObserver: const _Observer(), + focused: true, ), ), ), @@ -150,16 +150,3 @@ void main() { }); }); } - -class _Observer implements TerminalRenderObserver { - const _Observer(); - - @override - bool get hasFocus => true; - - @override - void addListener(VoidCallback listener) {} - - @override - void removeListener(VoidCallback listener) {} -} diff --git a/packages/flterm/test/widgets/terminal_controller_test.dart b/packages/flterm/test/widgets/terminal_controller_test.dart index 9138ea35..bd232c5f 100644 --- a/packages/flterm/test/widgets/terminal_controller_test.dart +++ b/packages/flterm/test/widgets/terminal_controller_test.dart @@ -7,6 +7,7 @@ import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/widgets/terminal_controller_impl.dart'; import 'package:flterm/src/widgets/terminal_view_binding.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show EdgeInsets; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' hide KeyEvent; @@ -47,6 +48,30 @@ void main() { }); }); + test('size query preserves renderer dirty state', () { + final renderState = RenderState(); + final output = []; + addTearDown(renderState.dispose); + controller.onOutput = output.add; + controller.handleResize( + cols: 91, + rows: 37, + metrics: const CellMetrics(cellWidth: 9, cellHeight: 18, baseline: 14), + padding: EdgeInsets.zero, + devicePixelRatio: 2, + ); + renderState.update(controller.terminal); + renderState.dirty = DirtyState.clean; + + writeControllerUtf8(controller, 'visible\x1b[18t\x1b[16t\x1b[14t'); + + expect( + utf8.decode(output.expand((chunk) => chunk).toList()), + '\x1b[8;37;91t\x1b[6;36;18t\x1b[4;1332;1638t', + ); + expect(renderState.update(controller.terminal), isNot(DirtyState.clean)); + }); + group('sendText', () { test('emits UTF-8 bytes via onOutput', () { final output = []; @@ -600,6 +625,18 @@ void main() { }); }); + group('clipboard writes', () { + test('forwards OSC 52 writes', () { + ClipboardWrite? received; + controller.onClipboardWrite = (value) => received = value; + + writeTerminalUtf8(controller.terminal, '\x1b]52;c;aGVsbG8=\x1b\\'); + + expect(received?.selector, 'c'.codeUnitAt(0)); + expect(String.fromCharCodes(received!.payload), 'aGVsbG8='); + }); + }); + group('pwd', () { test('updates via OSC 7 escape sequence', () { writeTerminalUtf8(controller.terminal, '\x1b]7;file:///tmp\x07'); diff --git a/packages/flterm/test/widgets/terminal_gesture_detector_test.dart b/packages/flterm/test/widgets/terminal_gesture_detector_test.dart index bf6b9248..496bb2c4 100644 --- a/packages/flterm/test/widgets/terminal_gesture_detector_test.dart +++ b/packages/flterm/test/widgets/terminal_gesture_detector_test.dart @@ -2,13 +2,15 @@ library; import 'dart:convert'; -import 'dart:typed_data'; import 'package:flterm/src/foundation.dart'; import 'package:flterm/src/links/link_settings.dart'; import 'package:flterm/src/widgets.dart'; import 'package:flterm/src/widgets/link_interaction.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' @@ -403,6 +405,18 @@ void main() { testWidgets('touch long press starts normal selection by default', ( tester, ) async { + final platformCalls = []; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + platformCalls.add(call); + return null; + }); + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); await tester.pumpWidget(buildHandler(controller: controller)); final gesture = await tester.startGesture(const Offset(40, 16)); @@ -410,12 +424,17 @@ void main() { await tester.pump(const Duration(milliseconds: 550)); expect(terminalFor(controller).selection, isNull); + expect( + platformCalls.where((call) => call.method == 'HapticFeedback.vibrate'), + hasLength(1), + ); await gesture.moveTo(const Offset(80, 32)); final sel = terminalFor(controller).selection!; expect(sel.mode, TerminalSelectionShape.normal); await gesture.up(); + debugDefaultTargetPlatformOverride = null; }); testWidgets('touch move cancels long press if distance exceeds threshold', ( @@ -839,6 +858,22 @@ void main() { }); group('mouse tracking', () { + void enableSgrMouse(String mode, {String format = '1006'}) { + writeToTerminal( + controller, + '\x1b[?$mode' + 'h\x1b[?$format' + 'h', + ); + bindingFor(controller).handleResize( + cols: 80, + rows: 24, + metrics: defaultMetrics, + padding: EdgeInsets.zero, + devicePixelRatio: 1, + ); + } + testWidgets('click fires press and release when mode is normal', ( tester, ) async { @@ -880,6 +915,98 @@ void main() { expect(events, isEmpty); }); + + testWidgets('preserves secondary and middle mouse buttons', ( + tester, + ) async { + enableSgrMouse('1000'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + final right = await mouseDown( + tester, + const Offset(24, 16), + buttons: kSecondaryButton, + ); + await right.up(); + final middle = await mouseDown( + tester, + const Offset(24, 16), + buttons: kMiddleMouseButton, + ); + await middle.up(); + + expect(utf8.decode(events[0]), '\x1b[<2;4;2M'); + expect(utf8.decode(events[1]), '\x1b[<2;4;2m'); + expect(utf8.decode(events[2]), '\x1b[<1;4;2M'); + expect(utf8.decode(events[3]), '\x1b[<1;4;2m'); + }); + + testWidgets('reports hover motion in any-event mode', (tester) async { + enableSgrMouse('1003'); + expect(bindingFor(controller).mouseTracking, MouseTracking.any); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + final mouse = await tester.createGesture(kind: .mouse); + await mouse.addPointer(location: const Offset(16, 16)); + await mouse.moveTo(const Offset(24, 16)); + + expect(events, hasLength(1)); + expect(utf8.decode(events.single), '\x1b[<35;4;2M'); + await mouse.removePointer(); + }); + + testWidgets('touch emits left-button SGR cell input when tracked', ( + tester, + ) async { + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + final touch = await tester.startGesture(const Offset(24, 16)); + await touch.up(); + + expect(events, hasLength(2)); + expect(utf8.decode(events[0]), '\x1b[<0;4;2M'); + expect(utf8.decode(events[1]), '\x1b[<0;4;2m'); + }); + + testWidgets('touch emits left-button SGR-pixel input in mode 1016', ( + tester, + ) async { + enableSgrMouse('1000', format: '1016'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + final touch = await tester.startGesture(const Offset(24, 16)); + await touch.up(); + + expect(events, hasLength(2)); + expect(utf8.decode(events[0]), '\x1b[<0;24;16M'); + expect(utf8.decode(events[1]), '\x1b[<0;24;16m'); + }); + + testWidgets('wheel reports its pointer position', (tester) async { + enableSgrMouse('1000'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget(buildHandler(controller: controller)); + + await tester.sendEventToBinding( + const PointerScrollEvent( + position: Offset(24, 16), + scrollDelta: Offset(0, 16), + ), + ); + + expect(events, hasLength(1)); + expect(utf8.decode(events.single), '\x1b[<65;4;2M'); + }); }); }); } diff --git a/packages/flterm/test/widgets/terminal_view_binding_test.dart b/packages/flterm/test/widgets/terminal_view_binding_test.dart index 65204c36..89deef97 100644 --- a/packages/flterm/test/widgets/terminal_view_binding_test.dart +++ b/packages/flterm/test/widgets/terminal_view_binding_test.dart @@ -98,7 +98,18 @@ void main() { binding.handleScroll(-3); expect(output, hasLength(1)); - expect(output.first.length, greaterThan(0)); + expect(utf8.decode(output.single), '\x1b[A\x1b[A\x1b[A'); + }); + + test('uses libghostty application cursor key state', () { + writeUtf8(controller.terminal, '\x1b[?1049h\x1b[?1h'); + final output = []; + controller.onOutput = output.add; + + binding.handleScroll(-2); + + expect(output, hasLength(1)); + expect(utf8.decode(output.single), '\x1bOA\x1bOA'); }); test('emits no output on primary screen', () { @@ -119,6 +130,37 @@ void main() { expect(output, isEmpty); }); + + test('tracked scroll preserves the supplied pointer position', () { + writeUtf8(controller.terminal, '\x1b[?1049h\x1b[?1000h\x1b[?1006h'); + binding.handleResize( + cols: 80, + rows: 24, + metrics: const CellMetrics( + cellWidth: 8, + cellHeight: 16, + baseline: 12, + ), + padding: EdgeInsets.zero, + devicePixelRatio: 1, + ); + final output = []; + controller.onOutput = output.add; + + binding.handleScroll(1, localPosition: const Offset(24, 16)); + + expect(utf8.decode(output.single), '\x1b[<65;4;2M'); + }); + + test('tracked scroll without a pointer position emits nothing', () { + writeUtf8(controller.terminal, '\x1b[?1049h\x1b[?1000h\x1b[?1006h'); + final output = []; + controller.onOutput = output.add; + + binding.handleScroll(1); + + expect(output, isEmpty); + }); }); group('selection drag', () { diff --git a/packages/flterm/test/widgets/terminal_view_test.dart b/packages/flterm/test/widgets/terminal_view_test.dart index 86a64c20..3b816619 100644 --- a/packages/flterm/test/widgets/terminal_view_test.dart +++ b/packages/flterm/test/widgets/terminal_view_test.dart @@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart' defaultTargetPlatform; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' hide ColorScheme, KeyEvent; @@ -141,6 +142,8 @@ void main() { MouseAutoHide mouseAutoHide = .onInput, TerminalGestureSettings gestureSettings = const TerminalGestureSettings(), LinkSettings linkSettings = const LinkSettings(), + String? semanticsLabel = 'Terminal', + String? semanticsHint = 'Activate to focus terminal input', EdgeInsets padding = EdgeInsets.zero, double width = 800, double height = 480, @@ -159,6 +162,8 @@ void main() { mouseAutoHide: mouseAutoHide, gestureSettings: gestureSettings, linkSettings: linkSettings, + semanticsLabel: semanticsLabel, + semanticsHint: semanticsHint, padding: padding, ), ), @@ -249,6 +254,53 @@ void main() { expect(find.byType(TerminalView), findsOneWidget); }); + testWidgets('semantics expose visible non-concealed terminal text', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + wrapInApp( + controller: controller, + semanticsLabel: 'Remote shell', + semanticsHint: 'Focus remote shell input', + ), + ); + await tester.pumpAndSettle(); + writeUtf8(controller, 'visible\r\nshow \x1b[8msecret\x1b[0m text'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + final node = tester.getSemantics(find.bySemanticsLabel('Remote shell')); + final data = node.getSemanticsData(); + expect(data.label, 'Remote shell'); + expect(data.value, contains('visible')); + expect(data.value, contains('show')); + expect(data.value, contains('text')); + expect(data.value, isNot(contains('secret'))); + expect(data.hint, 'Focus remote shell input'); + expect(data.hasAction(SemanticsAction.tap), isTrue); + expect(data.hasAction(SemanticsAction.focus), isTrue); + expect(data.flagsCollection.isLiveRegion, isFalse); + } finally { + semantics.dispose(); + } + }); + + testWidgets('semantics can be delegated to an embedding application', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + wrapInApp(controller: controller, semanticsLabel: null), + ); + expect(find.bySemanticsLabel('Terminal'), findsNothing); + } finally { + semantics.dispose(); + } + }); + testWidgets('creates an isolated render cache without explicit scope', ( tester, ) async { @@ -374,6 +426,37 @@ void main() { expect(renderer(tester).blinkVisible, isFalse); }); + testWidgets('semantics follow the visible scrollback viewport', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + final scrollController = await pumpBlinkingScrollableTerminal(tester); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + String semanticsValue() => tester + .getSemantics(find.bySemanticsLabel('Terminal')) + .getSemanticsData() + .value; + + expect(semanticsValue(), contains('line 39')); + + scrollController.jumpTo(0); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(semanticsValue(), contains('line 0')); + expect(semanticsValue(), isNot(contains('line 39'))); + + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect(semanticsValue(), contains('line 39')); + } finally { + semantics.dispose(); + } + }); + testWidgets('text input produces output via onOutput', (tester) async { final output = []; controller.onOutput = output.add; @@ -988,6 +1071,38 @@ void main() { expect(find.byType(TerminalView), findsOneWidget); }); + testWidgets('changing controller clears cached terminal semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final controller2 = TerminalController(); + addTearDown(controller2.dispose); + try { + writeUtf8(controller, 'old terminal'); + await tester.pumpWidget(wrapInApp(controller: controller)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + expect( + tester + .getSemantics(find.bySemanticsLabel('Terminal')) + .getSemanticsData() + .value, + contains('old terminal'), + ); + + await tester.pumpWidget(wrapInApp(controller: controller2)); + expect( + tester + .getSemantics(find.bySemanticsLabel('Terminal')) + .getSemanticsData() + .value, + isEmpty, + ); + } finally { + semantics.dispose(); + } + }); + testWidgets('changing scrollController keeps the view mounted', ( tester, ) async { @@ -1327,6 +1442,59 @@ void main() { fixture.terminal.scrollbackRows, ); }); + + for (final kind in [PointerDeviceKind.touch, PointerDeviceKind.stylus]) { + testWidgets('$kind tracked scroll uses its latest pointer position', ( + tester, + ) async { + final output = []; + final scrollController = TerminalScrollController(); + addTearDown(scrollController.dispose); + controller.onOutput = output.add; + await tester.pumpWidget( + wrapInApp( + controller: controller, + scrollController: scrollController, + autofocus: true, + showKeyboard: false, + width: 400, + height: 320, + ), + ); + await tester.pumpAndSettle(); + writeUtf8(controller, '\x1b[?1049h\x1b[?1000h\x1b[?1016h'); + await tester.pump(); + output.clear(); + + final topLeft = tester.getTopLeft(find.byType(TerminalView)); + final gesture = await tester.startGesture( + topLeft + const Offset(120, 240), + kind: kind, + ); + await gesture.moveTo(topLeft + const Offset(120, 120)); + await tester.pump(); + output.clear(); + + scrollController.jumpTo(scrollController.offset + 160); + await tester.pump(); + await gesture.up(); + + final reports = utf8 + .decode( + Uint8List.fromList(output.expand((bytes) => bytes).toList()), + ) + .split('\x1b') + .where((report) => report.startsWith('[<65;')) + .toList(); + expect(reports, isNotEmpty); + final expectedPixel = (120 * tester.view.devicePixelRatio).round(); + expect( + reports, + contains(startsWith('[<65;$expectedPixel;${expectedPixel}M')), + ); + expect(reports, everyElement(isNot(contains(';0;0M')))); + }); + } }); testWidgets('selectAll via controller updates view', (tester) async { diff --git a/packages/libghostty/CHANGELOG.md b/packages/libghostty/CHANGELOG.md index 465f6f78..5c268445 100644 --- a/packages/libghostty/CHANGELOG.md +++ b/packages/libghostty/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +### Added + +- **OSC 52 clipboard writes**: `Terminal.onClipboardWrite` exposes write-only + clipboard requests with their raw selector and base64 payload. Clipboard read + queries remain disabled. + +### Fixed + +- **Native asset refreshes**: rerunning the build hook replaces an existing + output library so source or ABI changes cannot reuse a stale binary. +- **Source patch isolation**: downloaded Ghostty sources are patched in their + own Git boundary and marked before cache reuse. +- **Embedded tagged builds**: source compilation passes Ghostty's own version + explicitly instead of inheriting Git tags from an embedding repository. + ## 0.0.11 ### Added diff --git a/packages/libghostty/hook/build.dart b/packages/libghostty/hook/build.dart index fba1f32e..585bfa2c 100644 --- a/packages/libghostty/hook/build.dart +++ b/packages/libghostty/hook/build.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:code_assets/code_assets.dart'; import 'package:hooks/hooks.dart'; import 'package:libghostty/src/hook/fix_ios_page_alignment.dart'; +import 'package:libghostty/src/hook/ghostty_source.dart'; import 'package:libghostty/src/hook/library_provider.dart'; void main(List args) async { @@ -13,16 +14,18 @@ Future _build(BuildInput input, BuildOutputBuilder output) async { if (!input.config.buildCodeAssets) return; output.dependencies.add(input.packageRoot.resolve('ghostty.version')); + for (final patch in ghosttyPatchFiles(input.packageRoot)) { + output.dependencies.add(patch.uri); + } final targetOS = input.config.code.targetOS; final libFileName = targetOS.dylibFileName('ghostty'); final installDir = input.outputDirectory; final libFile = File.fromUri(installDir.resolve('lib/$libFileName')); - if (!libFile.existsSync()) { - final provider = LibraryProvider.resolve(input); - await provider.provide(libFile); - } + if (libFile.existsSync()) libFile.deleteSync(); + final provider = LibraryProvider.resolve(input); + await provider.provide(libFile); if (!libFile.existsSync()) { throw Exception( diff --git a/packages/libghostty/lib/libghostty.dart b/packages/libghostty/lib/libghostty.dart index 4832680b..4f3aedd7 100644 --- a/packages/libghostty/lib/libghostty.dart +++ b/packages/libghostty/lib/libghostty.dart @@ -7,7 +7,12 @@ library; export 'src/bindings/bindings.dart' show initializeForWeb; export 'src/bindings/types/aliases.dart' - show DecodedImage, PngDecoder, TerminalGeometry, X11ColorName; + show + ClipboardWrite, + DecodedImage, + PngDecoder, + TerminalGeometry, + X11ColorName; export 'src/bindings/types/types.dart' show CellColor, diff --git a/packages/libghostty/lib/src/bindings/interface.dart b/packages/libghostty/lib/src/bindings/interface.dart index aac8487a..e59f27fa 100644 --- a/packages/libghostty/lib/src/bindings/interface.dart +++ b/packages/libghostty/lib/src/bindings/interface.dart @@ -180,6 +180,10 @@ abstract interface class GhosttyBindings { void terminalSetOnWritePty(int handle, ValueSetter? callback); void terminalSetOnBell(int handle, VoidCallback? callback); + void terminalSetOnClipboardWrite( + int handle, + ValueSetter? callback, + ); void terminalSetOnTitleChanged(int handle, VoidCallback? callback); void terminalSetOnPwdChanged(int handle, VoidCallback? callback); void terminalSetOnEnquiry(int handle, ValueGetter? callback); diff --git a/packages/libghostty/lib/src/bindings/native/native.dart b/packages/libghostty/lib/src/bindings/native/native.dart index 12687f01..e52c90bf 100644 --- a/packages/libghostty/lib/src/bindings/native/native.dart +++ b/packages/libghostty/lib/src/bindings/native/native.dart @@ -3480,6 +3480,46 @@ class NativeBindings implements GhosttyBindings { ); } + @override + void terminalSetOnClipboardWrite( + int handle, + ValueSetter? callback, + ) { + final map = _callables.putIfAbsent(handle, () => {}); + const option = TerminalOption.clipboardWrite; + map[option]?.close(); + + if (callback == null) { + map.remove(option); + ghostty_terminal_set(Pointer.fromAddress(handle), option, nullptr); + return; + } + + final callable = + NativeCallable< + Void Function(Terminal, Pointer, Uint8, Pointer, Size) + >.isolateLocal(( + Terminal terminal, + Pointer userdata, + int selector, + Pointer data, + int len, + ) { + try { + callback(( + selector: selector, + payload: Uint8List.fromList(data.asTypedList(len)), + )); + } on Object catch (_) {} + }); + map[option] = callable; + ghostty_terminal_set( + Pointer.fromAddress(handle), + option, + callable.nativeFunction.cast(), + ); + } + @override void terminalSetOnTitleChanged(int handle, VoidCallback? callback) { final map = _callables.putIfAbsent(handle, () => {}); diff --git a/packages/libghostty/lib/src/bindings/types/aliases.dart b/packages/libghostty/lib/src/bindings/types/aliases.dart index 305d5d89..c32f8e38 100644 --- a/packages/libghostty/lib/src/bindings/types/aliases.dart +++ b/packages/libghostty/lib/src/bindings/types/aliases.dart @@ -33,6 +33,12 @@ typedef ValueGetter = T Function(); typedef ValueSetter = void Function(T value); typedef VoidCallback = void Function(); +/// An OSC 52 clipboard write request. +/// +/// [selector] is the raw one-byte clipboard selector and [payload] is the raw +/// base64 data. Clipboard read queries are never emitted. +typedef ClipboardWrite = ({int selector, Uint8List payload}); + /// An untracked grid reference value. /// /// The value follows libghostty's untracked grid-reference lifetime rules and diff --git a/packages/libghostty/lib/src/bindings/wasm/wasm.dart b/packages/libghostty/lib/src/bindings/wasm/wasm.dart index 98cf97fa..ebf91b45 100644 --- a/packages/libghostty/lib/src/bindings/wasm/wasm.dart +++ b/packages/libghostty/lib/src/bindings/wasm/wasm.dart @@ -1539,6 +1539,38 @@ class WasmBindings implements GhosttyBindings { _exports.ghostty_terminal_set(handle, option.value, index); } + @override + void terminalSetOnClipboardWrite( + int handle, + ValueSetter? callback, + ) { + final map = _callbacks.putIfAbsent(handle, () => {}); + const option = TerminalOption.clipboardWrite; + + if (callback == null) { + final existing = map.remove(option); + if (existing != null) _table.set(existing.$1); + _exports.ghostty_terminal_set(handle, option.value, 0); + return; + } + + final reuseIndex = map[option]?.$1; + final index = _registerCallback( + ((int terminal, int userdata, int selector, int dataPtr, int len) { + try { + callback(( + selector: selector, + payload: Uint8List.fromList(_mem.readBytes(dataPtr, len)), + )); + } on Object catch (_) {} + }).toJS, + ['i32', 'i32', 'i32', 'i32', 'i32'], + reuseIndex: reuseIndex, + ); + map[option] = (index, callback); + _exports.ghostty_terminal_set(handle, option.value, index); + } + @override void terminalSetOnTitleChanged(int handle, VoidCallback? callback) { final map = _callbacks.putIfAbsent(handle, () => {}); diff --git a/packages/libghostty/lib/src/ffi/libghostty.g.dart b/packages/libghostty/lib/src/ffi/libghostty.g.dart index 9f2f8ea5..3e0326c0 100644 --- a/packages/libghostty/lib/src/ffi/libghostty.g.dart +++ b/packages/libghostty/lib/src/ffi/libghostty.g.dart @@ -6102,6 +6102,33 @@ typedef TerminalBellFn = > >; +/// Callback function type for OSC 52 clipboard writes. +/// +/// The payload is the raw base64 data from the OSC sequence. An empty payload +/// requests clearing the selected clipboard. Clipboard read queries are +/// ignored and do not invoke this callback. The payload pointer is valid only +/// for the duration of the callback. +/// +/// @param terminal The terminal handle +/// @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA +/// @param kind OSC 52 clipboard selector (for example 'c' or 'p') +/// @param data Pointer to the raw base64 payload +/// @param len Length of the payload in bytes +/// +/// @ingroup terminal +typedef TerminalClipboardWriteFn = + ffi.Pointer< + ffi.NativeFunction< + ffi.Void Function( + Terminal terminal, + ffi.Pointer userdata, + ffi.Uint8 kind, + ffi.Pointer data, + ffi.Size len, + ) + > + >; + /// Callback function type for color scheme queries (CSI ? 996 n). /// /// Called when the terminal receives a color scheme device status report diff --git a/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart b/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart index 258bd0de..71463afc 100644 --- a/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart +++ b/packages/libghostty/lib/src/ffi/libghostty_enums.g.dart @@ -2891,7 +2891,13 @@ enum TerminalOption { /// to ignore pwd change events. /// /// Input type: TerminalPwdChangedFn - pwdChanged(25); + pwdChanged(25), + + /// Callback invoked for OSC 52 clipboard writes. Clipboard read queries are + /// ignored. Set to NULL to ignore clipboard writes. + /// + /// Input type: TerminalClipboardWriteFn + clipboardWrite(26); final int value; const TerminalOption(this.value); @@ -2923,6 +2929,7 @@ enum TerminalOption { 23 => defaultCursorBlink, 24 => glyphProtocol, 25 => pwdChanged, + 26 => clipboardWrite, _ => throw ArgumentError('Unknown value for TerminalOption: $value'), }; } diff --git a/packages/libghostty/lib/src/hook/ghostty_source.dart b/packages/libghostty/lib/src/hook/ghostty_source.dart index befff016..b126b0c6 100644 --- a/packages/libghostty/lib/src/hook/ghostty_source.dart +++ b/packages/libghostty/lib/src/hook/ghostty_source.dart @@ -1,9 +1,94 @@ import 'dart:io'; +import 'package:crypto/crypto.dart'; + /// Environment variable that overrides source resolution with a local checkout. const ghosttySrcEnvKey = 'GHOSTTY_SRC'; const _defaultTarballBase = 'https://github.com/ghostty-org/ghostty/archive'; +const _patchMarkerName = '.libghostty-patch-key'; +final _semanticVersion = RegExp( + r'^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$', +); + +/// Reads the Ghostty application version without consulting Git metadata. +String ghosttySourceVersion(Directory source) { + final versionFile = File.fromUri(source.uri.resolve('VERSION')); + final String? version; + if (versionFile.existsSync()) { + version = versionFile.readAsStringSync().trim(); + } else { + final zonFile = File.fromUri(source.uri.resolve('build.zig.zon')); + final zon = zonFile.existsSync() ? zonFile.readAsStringSync() : ''; + version = RegExp( + r'^\s*\.version\s*=\s*"([^"]+)"\s*,', + multiLine: true, + ).firstMatch(zon)?.group(1); + } + if (version == null || !_semanticVersion.hasMatch(version)) { + throw StateError('Cannot determine Ghostty version from ${source.path}'); + } + return version; +} + +/// Source patches applied to downloaded and cloned Ghostty checkouts. +List ghosttyPatchFiles(Uri packageRoot) { + final directory = Directory.fromUri(packageRoot.resolve('patches/')); + if (!directory.existsSync()) return const []; + return directory + .listSync() + .whereType() + .where((file) => file.path.endsWith('.patch')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); +} + +/// Returns a cache key that changes when the Ghostty pin or patches change. +String ghosttySourceCacheKey(Uri packageRoot) { + final commit = pinnedCommit(packageRoot); + final patches = ghosttyPatchFiles(packageRoot); + if (patches.isEmpty) return '${commit.substring(0, 12)}-none'; + final bytes = []; + for (final patch in patches) { + bytes.addAll(patch.readAsBytesSync()); + } + final patchHash = sha256.convert(bytes).toString().substring(0, 12); + return '${commit.substring(0, 12)}-$patchHash'; +} + +/// Applies all packaged patches to a freshly acquired Ghostty checkout. +void applyGhosttyPatches(Directory source, Uri packageRoot) { + final gitDirectory = Directory.fromUri(source.uri.resolve('.git/')); + final isolated = !gitDirectory.existsSync(); + if (isolated) { + final result = Process.runSync('git', [ + 'init', + '--quiet', + ], workingDirectory: source.path); + if (result.exitCode != 0) { + throw Exception('Failed to isolate Ghostty source: ${result.stderr}'); + } + } + try { + for (final patch in ghosttyPatchFiles(packageRoot)) { + final result = Process.runSync('git', [ + 'apply', + '--unidiff-zero', + patch.path, + ], workingDirectory: source.path); + if (result.exitCode != 0) { + throw Exception( + 'Failed to apply Ghostty patch ${patch.path}: ${result.stderr}', + ); + } + } + } finally { + if (isolated) { + gitDirectory.deleteSync(recursive: true); + gitDirectory.createSync(); + } + } +} /// Downloads a source tarball, extracts it, and caches the result. /// @@ -15,11 +100,18 @@ Future downloadSource( String? tarballUrl, }) async { final commit = pinnedCommit(packageRoot); - final cacheKey = commit.substring(0, 12); + final cacheKey = ghosttySourceCacheKey(packageRoot); final cacheDir = Directory.fromUri( cacheBase.resolve('ghostty-source-$cacheKey/'), ); - if (cacheDir.existsSync()) return cacheDir; + final patchMarker = File.fromUri(cacheDir.uri.resolve(_patchMarkerName)); + if (cacheDir.existsSync()) { + if (patchMarker.existsSync() && + patchMarker.readAsStringSync() == cacheKey) { + return cacheDir; + } + cacheDir.deleteSync(recursive: true); + } tarballUrl ??= '$_defaultTarballBase/$commit.tar.gz'; @@ -53,11 +145,21 @@ Future downloadSource( ]); if (extractResult.exitCode != 0) { cacheDir.deleteSync(recursive: true); + tarball.deleteSync(); throw Exception( 'Failed to extract Ghostty source: ${extractResult.stderr}', ); } + try { + applyGhosttyPatches(cacheDir, packageRoot); + patchMarker.writeAsStringSync(cacheKey); + } on Object { + cacheDir.deleteSync(recursive: true); + tarball.deleteSync(); + rethrow; + } + tarball.deleteSync(); return cacheDir; diff --git a/packages/libghostty/lib/src/hook/library_provider.dart b/packages/libghostty/lib/src/hook/library_provider.dart index efc1bbe5..1a357123 100644 --- a/packages/libghostty/lib/src/hook/library_provider.dart +++ b/packages/libghostty/lib/src/hook/library_provider.dart @@ -106,7 +106,9 @@ final class CompileFromSource extends LibraryProvider { '-p', Directory.fromUri(installDir).path, '--release=fast', - if (os == .windows) ...['--global-cache-dir', _zigCacheDir(sourceDir)], + '-Dversion-string=${ghosttySourceVersion(sourceDir)}', + '--global-cache-dir', + _zigCacheDir(sourceDir), if (os != .current || arch != .current) '-Dtarget=$zig', if (ios == .iPhoneSimulator && arch == .arm64) '-Dcpu=apple_a17', ]; @@ -134,7 +136,7 @@ final class CompileFromSource extends LibraryProvider { String _zigCacheDir(Directory sourceDir) { final envDir = Platform.environment['ZIG_GLOBAL_CACHE_DIR']; if (envDir != null && envDir.isNotEmpty) return envDir; - return '${sourceDir.path}${Platform.pathSeparator}.zig-cache'; + return '${sourceDir.path}${Platform.pathSeparator}.zig-global-cache'; } Future _downloadTarball() async { @@ -151,11 +153,18 @@ final class CompileFromSource extends LibraryProvider { Future _gitClone() async { final commit = pinnedCommit(input.packageRoot); + final cacheKey = ghosttySourceCacheKey(input.packageRoot); final cacheDir = Directory.fromUri( - input.outputDirectoryShared.resolve('ghostty-git-$commit/'), + input.outputDirectoryShared.resolve('ghostty-git-$cacheKey/'), + ); + final patchMarker = File.fromUri( + cacheDir.uri.resolve('.libghostty-patch-key'), ); - if (!cacheDir.existsSync()) { + if (!cacheDir.existsSync() || + !patchMarker.existsSync() || + patchMarker.readAsStringSync() != cacheKey) { + if (cacheDir.existsSync()) cacheDir.deleteSync(recursive: true); cacheDir.createSync(recursive: true); final result = Process.runSync('git', [ @@ -172,6 +181,13 @@ final class CompileFromSource extends LibraryProvider { cacheDir.deleteSync(recursive: true); throw Exception('Git clone failed: ${result.stderr}'); } + try { + applyGhosttyPatches(cacheDir, input.packageRoot); + patchMarker.writeAsStringSync(cacheKey); + } on Object { + cacheDir.deleteSync(recursive: true); + rethrow; + } } return cacheDir; diff --git a/packages/libghostty/lib/src/impl/terminal/terminal.dart b/packages/libghostty/lib/src/impl/terminal/terminal.dart index 198da112..2a00dc71 100644 --- a/packages/libghostty/lib/src/impl/terminal/terminal.dart +++ b/packages/libghostty/lib/src/impl/terminal/terminal.dart @@ -276,6 +276,14 @@ final class Terminal with Listenable { /// Fires synchronously during [write]. Set to null to ignore bell events. set onBell(VoidCallback? value) => bindings.terminalSetOnBell(_handle, value); + /// Registers a callback for OSC 52 clipboard writes. + /// + /// The callback receives the raw clipboard selector and base64 payload. + /// Clipboard read queries are ignored. Fires synchronously during [write]. + set onClipboardWrite(ValueSetter? value) { + bindings.terminalSetOnClipboardWrite(_handle, value); + } + /// Registers a callback for color scheme queries (CSI ? 996 n). /// /// Return the current [ColorScheme], or null to silently ignore the query. diff --git a/packages/libghostty/patches/osc52-clipboard-write.patch b/packages/libghostty/patches/osc52-clipboard-write.patch new file mode 100644 index 00000000..0ff8bd99 --- /dev/null +++ b/packages/libghostty/patches/osc52-clipboard-write.patch @@ -0,0 +1,192 @@ +diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h +index b03653129..6a0f0f704 100644 +--- a/include/ghostty/vt/terminal.h ++++ b/include/ghostty/vt/terminal.h +@@ -77,0 +78 @@ extern "C" { ++ * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 | +@@ -304,0 +306,22 @@ typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, ++/** ++ * Callback function type for OSC 52 clipboard writes. ++ * ++ * The payload is the raw base64 data from the OSC sequence. An empty payload ++ * requests clearing the selected clipboard. Clipboard read queries are ++ * ignored and do not invoke this callback. The payload pointer is valid only ++ * for the duration of the callback. ++ * ++ * @param terminal The terminal handle ++ * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA ++ * @param kind OSC 52 clipboard selector (for example 'c' or 'p') ++ * @param data Pointer to the raw base64 payload ++ * @param len Length of the payload in bytes ++ * ++ * @ingroup terminal ++ */ ++typedef void (*GhosttyTerminalClipboardWriteFn)(GhosttyTerminal terminal, ++ void* userdata, ++ uint8_t kind, ++ const uint8_t* data, ++ size_t len); ++ +@@ -713,0 +737,8 @@ typedef enum GHOSTTY_ENUM_TYPED { ++ ++ /** ++ * Callback invoked for OSC 52 clipboard writes. Clipboard read queries are ++ * ignored. Set to NULL to ignore clipboard writes. ++ * ++ * Input type: GhosttyTerminalClipboardWriteFn ++ */ ++ GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE = 26, +diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig +index a2a75147f..d5124f2a4 100644 +--- a/src/terminal/c/terminal.zig ++++ b/src/terminal/c/terminal.zig +@@ -49,0 +50 @@ const Effects = struct { ++ clipboard_write: ?ClipboardWriteFn = null, +@@ -70,0 +72,4 @@ const Effects = struct { ++ /// C function pointer type for OSC 52 clipboard writes. The data is the ++ /// raw base64 payload and is only valid for the duration of the callback. ++ pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, u8, [*]const u8, usize) callconv(lib.calling_conv) void; ++ +@@ -140,0 +146,7 @@ const Effects = struct { ++ fn clipboardWriteTrampoline(handler: *Handler, kind: u8, data: []const u8) void { ++ const stream_ptr: *Stream = @fieldParentPtr("handler", handler); ++ const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); ++ const func = wrapper.effects.clipboard_write orelse return; ++ func(@ptrCast(wrapper), wrapper.effects.userdata, kind, data.ptr, data.len); ++ } ++ +@@ -291,0 +304 @@ fn new_( ++ .clipboard_write = &Effects.clipboardWriteTrampoline, +@@ -345,0 +359 @@ pub const Option = enum(c_int) { ++ clipboard_write = 26, +@@ -352,0 +367 @@ pub const Option = enum(c_int) { ++ .clipboard_write => ?Effects.ClipboardWriteFn, +@@ -408,0 +424 @@ fn setTyped( ++ .clipboard_write => wrapper.effects.clipboard_write = value, +@@ -2324,0 +2341,60 @@ test "bell without callback is silent" { ++test "set clipboard_write callback" { ++ var t: Terminal = null; ++ try testing.expectEqual(Result.success, new( ++ &lib.alloc.test_allocator, ++ &t, ++ .{ ++ .cols = 80, ++ .rows = 24, ++ .max_scrollback = 0, ++ }, ++ )); ++ defer free(t); ++ ++ const S = struct { ++ var count: usize = 0; ++ var kind: u8 = 0; ++ var data: []u8 = &.{}; ++ var last_userdata: ?*anyopaque = null; ++ ++ fn deinit() void { ++ if (data.len > 0) testing.allocator.free(data); ++ data = &.{}; ++ } ++ ++ fn clipboardWrite( ++ _: Terminal, ++ userdata: ?*anyopaque, ++ value_kind: u8, ++ ptr: [*]const u8, ++ len: usize, ++ ) callconv(lib.calling_conv) void { ++ if (data.len > 0) testing.allocator.free(data); ++ data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); ++ count += 1; ++ kind = value_kind; ++ last_userdata = userdata; ++ } ++ }; ++ S.count = 0; ++ S.kind = 0; ++ S.data = &.{}; ++ S.last_userdata = null; ++ defer S.deinit(); ++ ++ var sentinel: u8 = 42; ++ try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); ++ try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite))); ++ ++ const write = "\x1b]52;c;aGVsbG8=\x1b\\"; ++ vt_write(t, write, write.len); ++ try testing.expectEqual(@as(usize, 1), S.count); ++ try testing.expectEqual(@as(u8, 'c'), S.kind); ++ try testing.expectEqualStrings("aGVsbG8=", S.data); ++ try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); ++ ++ const query = "\x1b]52;c;?\x1b\\"; ++ vt_write(t, query, query.len); ++ try testing.expectEqual(@as(usize, 1), S.count); ++} ++ +diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig +index 38d59bcd2..66d94daa9 100644 +--- a/src/terminal/stream_terminal.zig ++++ b/src/terminal/stream_terminal.zig +@@ -62,0 +63,5 @@ pub const Handler = struct { ++ /// Called when OSC 52 requests a clipboard write. The data is the raw ++ /// base64 payload and is only valid for the duration of the callback. ++ /// Clipboard read queries are ignored and never reach this callback. ++ clipboard_write: ?*const fn (*Handler, u8, []const u8) void, ++ +@@ -103,0 +109 @@ pub const Handler = struct { ++ .clipboard_write = null, +@@ -280,0 +287 @@ pub const Handler = struct { ++ .clipboard_contents => self.clipboardWrite(value), +@@ -293 +299,0 @@ pub const Handler = struct { +- .clipboard_contents, +@@ -309,0 +316,9 @@ pub const Handler = struct { ++ fn clipboardWrite( ++ self: *Handler, ++ value: Action.ClipboardContents, ++ ) void { ++ if (std.mem.eql(u8, value.data, "?")) return; ++ const func = self.effects.clipboard_write orelse return; ++ func(self, value.kind, value.data); ++ } ++ +@@ -1596,0 +1612,39 @@ test "bell effect callback" { ++test "OSC 52 clipboard write effect" { ++ var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); ++ defer t.deinit(testing.allocator); ++ ++ const S = struct { ++ var count: usize = 0; ++ var kind: u8 = 0; ++ var data: []const u8 = ""; ++ ++ fn clipboardWrite(_: *Handler, value_kind: u8, value_data: []const u8) void { ++ count += 1; ++ kind = value_kind; ++ data = value_data; ++ } ++ }; ++ S.count = 0; ++ S.kind = 0; ++ S.data = ""; ++ ++ var handler: Handler = .init(&t); ++ handler.effects.clipboard_write = &S.clipboardWrite; ++ ++ var s: Stream = .initAlloc(testing.allocator, handler); ++ defer s.deinit(); ++ ++ s.nextSlice("\x1b]52;c;aGVsbG8=\x1b\\"); ++ try testing.expectEqual(@as(usize, 1), S.count); ++ try testing.expectEqual(@as(u8, 'c'), S.kind); ++ try testing.expectEqualStrings("aGVsbG8=", S.data); ++ ++ s.nextSlice("\x1b]52;c;?\x1b\\"); ++ try testing.expectEqual(@as(usize, 1), S.count); ++ ++ s.nextSlice("\x1b]52;p;\x07"); ++ try testing.expectEqual(@as(usize, 2), S.count); ++ try testing.expectEqual(@as(u8, 'p'), S.kind); ++ try testing.expectEqualStrings("", S.data); ++} ++ diff --git a/packages/libghostty/test/hook/ghostty_source_test.dart b/packages/libghostty/test/hook/ghostty_source_test.dart index f850612e..eb7de107 100644 --- a/packages/libghostty/test/hook/ghostty_source_test.dart +++ b/packages/libghostty/test/hook/ghostty_source_test.dart @@ -9,6 +9,39 @@ import 'package:test/test.dart'; import 'helpers/test_server.dart'; void main() { + group('ghosttySourceVersion', () { + late Directory tmpDir; + + setUp(() { + tmpDir = Directory.systemTemp.createTempSync('ghostty_version_test_'); + }); + + tearDown(() => tmpDir.deleteSync(recursive: true)); + + test('reads the source archive VERSION file', () { + File('${tmpDir.path}/VERSION').writeAsStringSync('1.2.3-dev\n'); + + expect(ghosttySourceVersion(tmpDir), '1.2.3-dev'); + }); + + test('falls back to build.zig.zon', () { + File('${tmpDir.path}/build.zig.zon').writeAsStringSync(''' +.{ + .name = .ghostty, + .version = "1.3.2-dev", +} +'''); + + expect(ghosttySourceVersion(tmpDir), '1.3.2-dev'); + }); + + test('rejects missing or invalid versions', () { + expect(() => ghosttySourceVersion(tmpDir), throwsStateError); + File('${tmpDir.path}/VERSION').writeAsStringSync('not-semver'); + expect(() => ghosttySourceVersion(tmpDir), throwsStateError); + }); + }); + group('pinnedCommit', () { test('is a 40-character hex string', () { final tmpDir = Directory.systemTemp.createTempSync('pinnedCommit_test_'); @@ -30,6 +63,55 @@ void main() { }); }); + group('Ghostty patches', () { + late Directory tmpDir; + late Uri packageRoot; + + setUp(() { + tmpDir = Directory.systemTemp.createTempSync('ghostty_patch_test_'); + packageRoot = Uri.directory('${tmpDir.path}/pkg/'); + Directory.fromUri( + packageRoot.resolve('patches/'), + ).createSync(recursive: true); + File.fromUri( + packageRoot.resolve('ghostty.version'), + ).writeAsStringSync('861a9cf537a58a380bc6a0784573b3de3a70415e\n'); + }); + + tearDown(() => tmpDir.deleteSync(recursive: true)); + + test('cache key changes with patch content', () { + final patch = File.fromUri(packageRoot.resolve('patches/test.patch')); + patch.writeAsStringSync('first'); + final first = ghosttySourceCacheKey(packageRoot); + + patch.writeAsStringSync('second'); + + expect(ghosttySourceCacheKey(packageRoot), isNot(first)); + }); + + test('applies packaged patches outside a Git checkout', () { + Process.runSync('git', [ + 'init', + '--quiet', + ], workingDirectory: tmpDir.path); + final source = Directory('${tmpDir.path}/source')..createSync(); + File('${source.path}/value.txt').writeAsStringSync('before\n'); + File.fromUri(packageRoot.resolve('patches/test.patch')).writeAsStringSync( + 'diff --git a/value.txt b/value.txt\n' + '--- a/value.txt\n' + '+++ b/value.txt\n' + '@@ -1 +1 @@\n' + '-before\n' + '+after\n', + ); + + applyGhosttyPatches(source, packageRoot); + + expect(File('${source.path}/value.txt').readAsStringSync(), 'after\n'); + }); + }); + group('resolveSource', () { late Directory tmpDir; diff --git a/packages/libghostty/test/impl/terminal/terminal_test.dart b/packages/libghostty/test/impl/terminal/terminal_test.dart index a7434571..e18e6e4c 100644 --- a/packages/libghostty/test/impl/terminal/terminal_test.dart +++ b/packages/libghostty/test/impl/terminal/terminal_test.dart @@ -294,6 +294,29 @@ void main() { }); }); + group('onClipboardWrite', () { + test('fires for OSC 52 writes with the raw selector and payload', () { + ClipboardWrite? received; + terminal.onClipboardWrite = (value) => received = value; + + terminal.write( + Uint8List.fromList('\x1b]52;c;aGVsbG8=\x1b\\'.codeUnits), + ); + + expect(received?.selector, 'c'.codeUnitAt(0)); + expect(String.fromCharCodes(received!.payload), 'aGVsbG8='); + }); + + test('ignores OSC 52 clipboard read queries', () { + var count = 0; + terminal.onClipboardWrite = (_) => count++; + + terminal.write(Uint8List.fromList('\x1b]52;c;?\x07'.codeUnits)); + + expect(count, 0); + }); + }); + group('renderState dirty', () { test('writing content makes renderState dirty', () { renderState.update(terminal); diff --git a/packages/libghostty/test/wasm/terminal/terminal_test.dart b/packages/libghostty/test/wasm/terminal/terminal_test.dart index 9a75fb3e..c359ddba 100644 --- a/packages/libghostty/test/wasm/terminal/terminal_test.dart +++ b/packages/libghostty/test/wasm/terminal/terminal_test.dart @@ -276,6 +276,29 @@ void main() { }); }); + group('onClipboardWrite', () { + test('fires for OSC 52 writes with the raw selector and payload', () { + ClipboardWrite? received; + terminal.onClipboardWrite = (value) => received = value; + + terminal.write( + Uint8List.fromList('\x1b]52;c;aGVsbG8=\x1b\\'.codeUnits), + ); + + expect(received?.selector, 'c'.codeUnitAt(0)); + expect(String.fromCharCodes(received!.payload), 'aGVsbG8='); + }); + + test('ignores OSC 52 clipboard read queries', () { + var count = 0; + terminal.onClipboardWrite = (_) => count++; + + terminal.write(Uint8List.fromList('\x1b]52;c;?\x07'.codeUnits)); + + expect(count, 0); + }); + }); + group('renderState dirty', () { test('writing content makes renderState dirty', () { renderState.update(terminal); diff --git a/packages/libghostty/tool/build_wasm.dart b/packages/libghostty/tool/build_wasm.dart index 1cd8a525..9056c4cc 100644 --- a/packages/libghostty/tool/build_wasm.dart +++ b/packages/libghostty/tool/build_wasm.dart @@ -31,7 +31,10 @@ void main() async { void _compileWithZig(Directory sourceDir) { final result = Process.runSync('zig', [ 'build', + '--global-cache-dir', + '${sourceDir.path}/.zig-global-cache', '-Demit-lib-vt=true', + '-Dversion-string=${ghosttySourceVersion(sourceDir)}', '-Dtarget=wasm32-freestanding', '-Doptimize=ReleaseSmall', ], workingDirectory: sourceDir.path);