From 694e75ff7b72edf44ce12ce2a47075b2ddb9ce07 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Tue, 14 Jul 2026 23:04:44 +0800 Subject: [PATCH 01/15] refactor(flterm): rely on libghostty terminal state --- .../lib/src/foundation/terminal_config.dart | 36 +++------- .../src/widgets/terminal_controller_impl.dart | 68 +++++-------------- .../test/foundation/terminal_config_test.dart | 10 +-- .../widgets/terminal_view_binding_test.dart | 13 +++- 4 files changed, 41 insertions(+), 86 deletions(-) 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/widgets/terminal_controller_impl.dart b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart index f3b66f15..0311de31 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,10 +27,6 @@ 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; @@ -49,7 +44,6 @@ class TerminalControllerImpl extends TerminalController KeyboardState _keyboardState = .hidden; Mods _virtualMods = const .none(); var _preeditText = ''; - var _cursorKeyApplication = false; Brightness _brightness = .dark; var _cursorBlinking = true; var _wasFocused = false; @@ -407,15 +401,10 @@ class TerminalControllerImpl extends TerminalController 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 +511,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 +602,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 +662,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,12 +682,7 @@ 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); @@ -869,12 +841,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 +929,7 @@ class TerminalControllerImpl extends TerminalController scrollController.jumpTo(clamped); } - Future _updateKeyboardState(KeyboardState newState) async { + void _updateKeyboardState(KeyboardState newState) { if (newState == _keyboardState) return; _keyboardState = newState; 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/widgets/terminal_view_binding_test.dart b/packages/flterm/test/widgets/terminal_view_binding_test.dart index 65204c36..d8fc2e20 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', () { From 9cf53c0e339fee42d71b3108f7c489a280cbadc3 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Tue, 14 Jul 2026 23:09:39 +0800 Subject: [PATCH 02/15] refactor(flterm): consolidate render ownership --- packages/flterm/lib/src/foundation.dart | 1 - .../foundation/terminal_render_observer.dart | 18 -- .../lib/src/rendering/atlas/atlas_config.dart | 12 +- .../rendering/font/font_data_resolver.dart | 5 - .../painters/background_painter.dart | 4 +- .../rendering/painters/cursor_painter.dart | 4 +- .../painters/decoration_painter.dart | 4 +- .../src/rendering/painters/emoji_painter.dart | 4 +- .../painters/kitty_graphics_painter.dart | 4 +- .../painters/shaped_run_painter.dart | 4 +- .../rendering/painters/sprite_painter.dart | 4 +- .../rendering/painters/terminal_painter.dart | 14 -- .../painters/terminal_text_painter.dart | 4 +- .../rendering/painters/underline_painter.dart | 4 +- .../src/rendering/terminal_painter_stack.dart | 121 ------------- .../rendering/terminal_render_pipeline.dart | 166 ++++++++++++++---- .../lib/src/rendering/terminal_renderer.dart | 95 +++------- .../lib/src/widgets/terminal_controller.dart | 6 +- .../flterm/lib/src/widgets/terminal_view.dart | 98 +++++------ .../test/rendering/cursor_layer_test.dart | 13 +- .../test/rendering/emoji_golden_test.dart | 15 +- .../test/rendering/sprites_golden_test.dart | 15 +- .../terminal_render_pipeline_test.dart | 29 ++- .../terminal_renderer_golden_test.dart | 15 +- .../rendering/terminal_renderer_test.dart | 15 +- .../transparent_background_golden_test.dart | 15 +- 26 files changed, 242 insertions(+), 447 deletions(-) delete mode 100644 packages/flterm/lib/src/foundation/terminal_render_observer.dart delete mode 100644 packages/flterm/lib/src/rendering/painters/terminal_painter.dart delete mode 100644 packages/flterm/lib/src/rendering/terminal_painter_stack.dart 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/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/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_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..bf083412 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,66 @@ 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. + /// 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..fe8ff400 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. /// @@ -88,7 +86,7 @@ class TerminalRenderer extends LeafRenderObjectWidget { required this.theme, required this.metrics, required this.offset, - required this.renderObserver, + required this.focused, required this.renderCache, this.blinkVisible = true, this.preeditText = '', @@ -108,7 +106,7 @@ class TerminalRenderer extends LeafRenderObjectWidget { blinkVisible: blinkVisible, preeditText: preeditText, linkSnapshot: linkSnapshot, - renderObserver: renderObserver, + focused: focused, ); } @@ -142,7 +140,7 @@ class TerminalRenderer extends LeafRenderObjectWidget { ..offset = offset ..metrics = metrics ..onResize = onResize - ..renderObserver = renderObserver + ..focused = focused ..blinkVisible = blinkVisible ..preeditText = preeditText ..linkSnapshot = linkSnapshot; @@ -170,12 +168,9 @@ 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 = ''; @@ -189,7 +184,7 @@ 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, @@ -197,18 +192,15 @@ class TerminalRenderBox extends RenderBox { this._onResize, }) : _paintState = TerminalPaintState(theme, metrics) ..blinkVisible = blinkVisible - ..cursorFocused = _renderObserver.hasFocus { - _atlasHandle = _renderCache.acquireAtlas( - .fromTheme( + ..cursorFocused = focused { + _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 +298,11 @@ 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 focused(bool value) { + if (_paintState.cursorFocused == value) return; + _paintState.cursorFocused = value; + _pipeline.refreshCursorGlyph(); + markNeedsPaint(); } set renderCache(TerminalRenderCache value) { @@ -328,7 +319,7 @@ class TerminalRenderBox extends RenderBox { _terminal = value; if (attached) _terminal.addListener(_onTerminalChanged); _applyTerminalThemeColors(); - _needsFrameSync = true; + _pipeline.markTerminalDirty(); markNeedsLayout(); } @@ -347,11 +338,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 +355,6 @@ class TerminalRenderBox extends RenderBox { void attach(PipelineOwner owner) { super.attach(owner); _offset.addListener(_onScroll); - _renderObserver.addListener(_onRenderObserverChanged); _terminal.addListener(_onTerminalChanged); markNeedsLayout(); } @@ -384,18 +374,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 +389,6 @@ class TerminalRenderBox extends RenderBox { _paintState.rows = 0; _paintState.cols = 0; _pipeline.dispose(); - _atlasHandle.release(); super.dispose(); } @@ -467,10 +450,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 +474,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 +486,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 +521,7 @@ class TerminalRenderBox extends RenderBox { if (_paintState.rows == 0 || _performingLayout) return; if (_terminal.scrollbackRows != _lastScrollbackRows) { - _needsFrameSync = true; + _pipeline.markTerminalDirty(); markNeedsLayout(); return; } @@ -614,11 +572,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/terminal_controller.dart b/packages/flterm/lib/src/widgets/terminal_controller.dart index 8b3ac43d..08a57284 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], @@ -71,6 +70,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_view.dart b/packages/flterm/lib/src/widgets/terminal_view.dart index 3aa30ecf..491216a1 100644 --- a/packages/flterm/lib/src/widgets/terminal_view.dart +++ b/packages/flterm/lib/src/widgets/terminal_view.dart @@ -287,57 +287,53 @@ class _TerminalViewState extends State { } 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(), - child: Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - onFocusChange: _handleFocusChange, - child: TerminalGestureDetector( - links: _links, - metrics: _metrics, - binding: _binding, - visibleRows: _visibleRows, - settings: widget.gestureSettings, - scrollController: _scrollController, - onLinkActivate: widget.linkSettings.onActivate, - child: Scrollable( - controller: _scrollController, - physics: widget.scrollPhysics, - viewportBuilder: (_, offset) => TerminalRenderer( - key: _rendererKey, - theme: _theme, - offset: offset, - metrics: _metrics, - renderObserver: _controller, - terminal: _binding.terminal, - renderCache: cache, - preeditText: _binding.preeditText, - blinkVisible: _blinkVisible, - linkSnapshot: _links.snapshot(), - onResize: _handleResize, - ), + return 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: Focus( + focusNode: _focusNode, + autofocus: widget.autofocus, + onFocusChange: _handleFocusChange, + child: TerminalGestureDetector( + links: _links, + metrics: _metrics, + binding: _binding, + visibleRows: _visibleRows, + settings: widget.gestureSettings, + scrollController: _scrollController, + onLinkActivate: widget.linkSettings.onActivate, + child: Scrollable( + controller: _scrollController, + physics: widget.scrollPhysics, + viewportBuilder: (_, offset) => TerminalRenderer( + key: _rendererKey, + theme: _theme, + offset: offset, + metrics: _metrics, + focused: _controller.hasFocus, + terminal: _binding.terminal, + renderCache: cache, + preeditText: _binding.preeditText, + blinkVisible: _blinkVisible, + linkSnapshot: _links.snapshot(), + onResize: _handleResize, ), ), ), 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/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_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) {} -} From e2cf4705c462697b9012fe4e3722eaa5ee95d7a4 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Tue, 14 Jul 2026 23:11:12 +0800 Subject: [PATCH 03/15] refactor(flterm): inline widget adapters --- packages/flterm/lib/src/widgets.dart | 1 - .../widgets/terminal_gesture_detector.dart | 57 +++++++--- .../src/widgets/terminal_input_client.dart | 40 +++---- .../terminal_raw_gesture_detector.dart | 100 ------------------ 4 files changed, 54 insertions(+), 144 deletions(-) delete mode 100644 packages/flterm/lib/src/widgets/terminal_raw_gesture_detector.dart 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_gesture_detector.dart b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart index ffcdbc13..1cf6b5c3 100644 --- a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart +++ b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart @@ -10,7 +10,6 @@ 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 @@ -63,15 +62,45 @@ 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, + 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, ), ); @@ -125,12 +154,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) { @@ -320,7 +343,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, - ); - } -} From 170bcec69b78e7abcda981d3f000fa93c7ea1264 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Tue, 14 Jul 2026 23:47:28 +0800 Subject: [PATCH 04/15] fix(flterm): preserve tracked pointer state --- .../flterm/lib/src/foundation/callbacks.dart | 2 +- .../src/widgets/terminal_controller_impl.dart | 43 ++++-- .../widgets/terminal_gesture_detector.dart | 86 ++++++++++-- .../flterm/lib/src/widgets/terminal_view.dart | 69 ++++++---- .../src/widgets/terminal_view_binding.dart | 5 +- .../widgets/terminal_controller_test.dart | 25 ++++ .../terminal_gesture_detector_test.dart | 129 +++++++++++++++++- .../widgets/terminal_view_binding_test.dart | 31 +++++ .../test/widgets/terminal_view_test.dart | 53 +++++++ 9 files changed, 387 insertions(+), 56 deletions(-) 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/widgets/terminal_controller_impl.dart b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart index 0311de31..4a191ace 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart @@ -30,7 +30,6 @@ class TerminalControllerImpl extends TerminalController @override final Terminal terminal; - final _renderState = RenderState(); final _keyEncoder = KeyEncoder(); final _mouseEncoder = MouseEncoder(); late final SelectionGestureDriver _selectionGesture; @@ -46,6 +45,7 @@ class TerminalControllerImpl extends TerminalController var _preeditText = ''; Brightness _brightness = .dark; var _cursorBlinking = true; + var _mouseButtonPressed = false; var _wasFocused = false; var _selectionMutationDepth = 0; @@ -72,6 +72,8 @@ class TerminalControllerImpl extends TerminalController maxScrollback: config.scrollbackLimit, ), super.base() { + _lastCols = config.cols; + _lastRows = config.rows; _selectionGesture = SelectionGestureDriver(terminal); installDefaultKittyPngDecoder(); _textInput @@ -252,7 +254,6 @@ class TerminalControllerImpl extends TerminalController _selectionGesture.dispose(); _keyEncoder.dispose(); _mouseEncoder.dispose(); - _renderState.dispose(); terminal.dispose(); super.dispose(); } @@ -326,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)); } @@ -380,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(); @@ -394,7 +412,10 @@ 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)); } @@ -688,10 +709,8 @@ class TerminalControllerImpl extends TerminalController 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) { @@ -763,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(), ); diff --git a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart index 1cf6b5c3..aadf7fff 100644 --- a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart +++ b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart @@ -4,7 +4,7 @@ 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'; @@ -50,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; @@ -62,6 +64,9 @@ class _TerminalGestureDetectorState extends State { onPointerDown: tracked ? _handleTrackedDown : null, onPointerMove: tracked ? _handleTrackedMove : null, onPointerUp: tracked ? _handleTrackedUp : null, + onPointerCancel: tracked ? _handleTrackedCancel : null, + onPointerHover: tracked ? _handleTrackedHover : null, + onPointerSignal: tracked ? _handleTrackedSignal : null, child: RawGestureDetector( behavior: HitTestBehavior.opaque, gestures: { @@ -122,6 +127,7 @@ class _TerminalGestureDetectorState extends State { @override void dispose() { _autoScrollTimer?.cancel(); + _trackedButtons.clear(); super.dispose(); } @@ -181,7 +187,6 @@ class _TerminalGestureDetectorState extends State { } void _handleDragUpdate(DragUpdateDetails details) { - if (_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; if (_drag != null) _updateDrag(details.localPosition); } @@ -191,6 +196,10 @@ class _TerminalGestureDetectorState extends State { void _handleLongPressStart(LongPressStartDetails details) { _binding.requestFocus(); + if (_isMouseTracked(false)) { + _cancelSelectionPress(); + return; + } if (!widget.settings.longPressSelection) { _cancelSelectionPress(); return; @@ -200,6 +209,7 @@ class _TerminalGestureDetectorState extends State { rectangle: widget.settings.longPressSelectionShape == .rectangle, beginPress: _pressCell == null, ); + unawaited(Feedback.forLongPress(context)); } void _handleLongPressUp() => _endDrag(); @@ -248,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() { @@ -291,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, )); diff --git a/packages/flterm/lib/src/widgets/terminal_view.dart b/packages/flterm/lib/src/widgets/terminal_view.dart index 491216a1..ef401f74 100644 --- a/packages/flterm/lib/src/widgets/terminal_view.dart +++ b/packages/flterm/lib/src/widgets/terminal_view.dart @@ -142,6 +142,7 @@ class _TerminalViewState extends State { var _ownsFocusNode = false; var _ownsScrollController = false; var _mouseCursorHidden = false; + Offset? _lastPointerPosition; var _lastAlternatePixels = 0.0; var _visibleCols = 0; var _visibleRows = 0; @@ -307,33 +308,39 @@ class _TerminalViewState extends State { onHover: _handleMouseHover, onExit: _handleMouseExit, cursor: _effectiveMouseCursor(), - child: Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - onFocusChange: _handleFocusChange, - child: TerminalGestureDetector( - links: _links, - metrics: _metrics, - binding: _binding, - visibleRows: _visibleRows, - settings: widget.gestureSettings, - scrollController: _scrollController, - onLinkActivate: widget.linkSettings.onActivate, - child: Scrollable( - controller: _scrollController, - physics: widget.scrollPhysics, - viewportBuilder: (_, offset) => TerminalRenderer( - key: _rendererKey, - theme: _theme, - offset: offset, - metrics: _metrics, - focused: _controller.hasFocus, - terminal: _binding.terminal, - renderCache: cache, - preeditText: _binding.preeditText, - blinkVisible: _blinkVisible, - linkSnapshot: _links.snapshot(), - onResize: _handleResize, + child: Listener( + onPointerDown: _recordPointerPosition, + onPointerMove: _recordPointerPosition, + onPointerUp: _recordPointerPosition, + onPointerSignal: _recordPointerPosition, + child: Focus( + focusNode: _focusNode, + autofocus: widget.autofocus, + onFocusChange: _handleFocusChange, + child: TerminalGestureDetector( + links: _links, + metrics: _metrics, + binding: _binding, + visibleRows: _visibleRows, + settings: widget.gestureSettings, + scrollController: _scrollController, + onLinkActivate: widget.linkSettings.onActivate, + child: Scrollable( + controller: _scrollController, + physics: widget.scrollPhysics, + viewportBuilder: (_, offset) => TerminalRenderer( + key: _rendererKey, + theme: _theme, + offset: offset, + metrics: _metrics, + focused: _controller.hasFocus, + terminal: _binding.terminal, + renderCache: cache, + preeditText: _binding.preeditText, + blinkVisible: _blinkVisible, + linkSnapshot: _links.snapshot(), + onResize: _handleResize, + ), ), ), ), @@ -378,12 +385,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, @@ -395,6 +404,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); @@ -466,7 +479,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/widgets/terminal_controller_test.dart b/packages/flterm/test/widgets/terminal_controller_test.dart index 9138ea35..3824e1bf 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 = []; 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 d8fc2e20..89deef97 100644 --- a/packages/flterm/test/widgets/terminal_view_binding_test.dart +++ b/packages/flterm/test/widgets/terminal_view_binding_test.dart @@ -130,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..42341e2a 100644 --- a/packages/flterm/test/widgets/terminal_view_test.dart +++ b/packages/flterm/test/widgets/terminal_view_test.dart @@ -1327,6 +1327,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 { From c6c670750b30473180357438b87ca4a8a93bff3a Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Tue, 14 Jul 2026 23:50:13 +0800 Subject: [PATCH 05/15] fix(flterm): avoid Kitty replacement flicker --- .../lib/src/rendering/kitty_image_cache.dart | 175 ++++++++++++++---- .../rendering/kitty_image_cache_test.dart | 118 +++++++++++- 2 files changed, 256 insertions(+), 37 deletions(-) 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/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()); }); }); }); From 243d84171566af7796914473e2d6251494608860 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Tue, 14 Jul 2026 23:53:49 +0800 Subject: [PATCH 06/15] feat(flterm): expose accessible terminal semantics --- packages/flterm/CHANGELOG.md | 12 ++ packages/flterm/README.md | 3 + .../src/rendering/terminal_frame_builder.dart | 20 +++ .../rendering/terminal_render_pipeline.dart | 2 + .../lib/src/rendering/terminal_renderer.dart | 46 ++++++- .../flterm/lib/src/widgets/terminal_view.dart | 126 +++++++++++++++++- .../terminal_frame_builder_test.dart | 12 ++ .../test/widgets/terminal_view_test.dart | 115 ++++++++++++++++ 8 files changed, 333 insertions(+), 3 deletions(-) diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index d07fffbb..fd74649b 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -13,6 +13,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 +26,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/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_render_pipeline.dart b/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart index bf083412..c4f3a1cb 100644 --- a/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart +++ b/packages/flterm/lib/src/rendering/terminal_render_pipeline.dart @@ -149,6 +149,8 @@ final class TerminalRenderPipeline { void refreshCursorGlyph() => _frameBuilder.refreshCursorGlyph(); + 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 diff --git a/packages/flterm/lib/src/rendering/terminal_renderer.dart b/packages/flterm/lib/src/rendering/terminal_renderer.dart index fe8ff400..800b3ccb 100644 --- a/packages/flterm/lib/src/rendering/terminal_renderer.dart +++ b/packages/flterm/lib/src/rendering/terminal_renderer.dart @@ -80,6 +80,12 @@ 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, @@ -92,6 +98,8 @@ class TerminalRenderer extends LeafRenderObjectWidget { this.preeditText = '', this.linkSnapshot = .empty, this.onResize, + this.semanticsGeneration = 0, + this.onSemanticsText, }); @override @@ -107,6 +115,8 @@ class TerminalRenderer extends LeafRenderObjectWidget { preeditText: preeditText, linkSnapshot: linkSnapshot, focused: focused, + semanticsGeneration: semanticsGeneration, + onSemanticsText: onSemanticsText, ); } @@ -143,7 +153,9 @@ class TerminalRenderer extends LeafRenderObjectWidget { ..focused = focused ..blinkVisible = blinkVisible ..preeditText = preeditText - ..linkSnapshot = linkSnapshot; + ..linkSnapshot = linkSnapshot + ..semanticsGeneration = semanticsGeneration + ..onSemanticsText = onSemanticsText; } } @@ -175,6 +187,9 @@ class TerminalRenderBox extends RenderBox { var _lastScrollbackRows = 0; var _preeditText = ''; LinkSnapshot _linkSnapshot; + int _semanticsGeneration; + int _capturedSemanticsGeneration; + ValueChanged? _onSemanticsText; final TerminalPaintState _paintState; late final TerminalRenderPipeline _pipeline; @@ -190,9 +205,16 @@ class TerminalRenderBox extends RenderBox { this._linkSnapshot = .empty, this._preeditText = '', this._onResize, + int semanticsGeneration = 0, + ValueChanged? onSemanticsText, }) : _paintState = TerminalPaintState(theme, metrics) ..blinkVisible = blinkVisible - ..cursorFocused = focused { + ..cursorFocused = focused, + _semanticsGeneration = semanticsGeneration, + _capturedSemanticsGeneration = onSemanticsText == null + ? semanticsGeneration + : semanticsGeneration - 1, + _onSemanticsText = onSemanticsText { _pipeline = TerminalRenderPipeline( _paintState, renderCache: _renderCache, @@ -298,6 +320,21 @@ class TerminalRenderBox extends RenderBox { set onResize(OnResize? value) => _onResize = value; + 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; @@ -398,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; diff --git a/packages/flterm/lib/src/widgets/terminal_view.dart b/packages/flterm/lib/src/widgets/terminal_view.dart index ef401f74..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,6 +152,7 @@ class _TerminalViewState extends State { late TerminalScrollController _scrollController; final _links = LinkInteraction(); final _rendererKey = GlobalKey(); + final _semanticsText = ValueNotifier(''); Uint8List? _resolvedFontData; var _ownsFocusNode = false; @@ -148,7 +164,11 @@ class _TerminalViewState extends State { 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; @@ -193,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(); } @@ -251,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(); } @@ -263,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; @@ -283,12 +332,13 @@ class _TerminalViewState extends State { _binding.brightness = _themeBrightness; _binding.attach(_focusNode, _scrollController); + _scheduleSemanticsUpdate(); _controller.addListener(_onControllerChanged); _syncLinkInteraction(); } Widget _build(BuildContext context, TerminalRenderCache cache) { - return ColoredBox( + 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 @@ -339,6 +389,12 @@ class _TerminalViewState extends State { preeditText: _binding.preeditText, blinkVisible: _blinkVisible, linkSnapshot: _links.snapshot(), + semanticsGeneration: _semanticsGeneration, + onSemanticsText: + SemanticsBinding.instance.semanticsEnabled && + widget.semanticsLabel != null + ? _handleSemanticsText + : null, onResize: _handleResize, ), ), @@ -350,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() { @@ -472,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; 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/widgets/terminal_view_test.dart b/packages/flterm/test/widgets/terminal_view_test.dart index 42341e2a..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 { From 9b3a12cb74e1b93a89ac6650923d48da87453d49 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 09:38:02 +0800 Subject: [PATCH 07/15] fix(libghostty): make source builds deterministic --- packages/libghostty/CHANGELOG.md | 11 ++ packages/libghostty/hook/build.dart | 11 +- .../lib/src/hook/ghostty_source.dart | 106 +++++++++++++++++- .../lib/src/hook/library_provider.dart | 24 +++- .../test/hook/ghostty_source_test.dart | 82 ++++++++++++++ packages/libghostty/tool/build_wasm.dart | 3 + 6 files changed, 227 insertions(+), 10 deletions(-) diff --git a/packages/libghostty/CHANGELOG.md b/packages/libghostty/CHANGELOG.md index 465f6f78..506845b6 100644 --- a/packages/libghostty/CHANGELOG.md +++ b/packages/libghostty/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### 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/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/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/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); From 7bb1817d94fc4710c50ad1f160fcad60366bc6a3 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 09:38:02 +0800 Subject: [PATCH 08/15] feat(libghostty): expose OSC 52 clipboard writes --- .github/workflows/build.yml | 12 +- .github/workflows/checks.yml | 15 +- packages/flterm/CHANGELOG.md | 7 + .../lib/src/widgets/terminal_controller.dart | 3 + .../src/widgets/terminal_controller_impl.dart | 1 + .../widgets/terminal_controller_test.dart | 12 ++ packages/libghostty/CHANGELOG.md | 6 + packages/libghostty/lib/libghostty.dart | 7 +- .../lib/src/bindings/interface.dart | 4 + .../lib/src/bindings/native/native.dart | 40 ++++ .../lib/src/bindings/types/aliases.dart | 6 + .../lib/src/bindings/wasm/wasm.dart | 32 +++ .../libghostty/lib/src/ffi/libghostty.g.dart | 27 +++ .../lib/src/ffi/libghostty_enums.g.dart | 9 +- .../lib/src/impl/terminal/terminal.dart | 8 + .../patches/osc52-clipboard-write.patch | 192 ++++++++++++++++++ .../test/impl/terminal/terminal_test.dart | 23 +++ .../test/wasm/terminal/terminal_test.dart | 23 +++ 18 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 packages/libghostty/patches/osc52-clipboard-write.patch 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 fd74649b..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 diff --git a/packages/flterm/lib/src/widgets/terminal_controller.dart b/packages/flterm/lib/src/widgets/terminal_controller.dart index 08a57284..9215f79c 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller.dart @@ -34,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; diff --git a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart index 4a191ace..c1aacd09 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart @@ -979,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/test/widgets/terminal_controller_test.dart b/packages/flterm/test/widgets/terminal_controller_test.dart index 3824e1bf..bd232c5f 100644 --- a/packages/flterm/test/widgets/terminal_controller_test.dart +++ b/packages/flterm/test/widgets/terminal_controller_test.dart @@ -625,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/libghostty/CHANGELOG.md b/packages/libghostty/CHANGELOG.md index 506845b6..5c268445 100644 --- a/packages/libghostty/CHANGELOG.md +++ b/packages/libghostty/CHANGELOG.md @@ -2,6 +2,12 @@ ## 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 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/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/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); From c684c8ec86ccfb7d2351ffa6f2992ad04659adf4 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 12:28:59 +0800 Subject: [PATCH 09/15] fix(flterm): make tracked touch scroll-first --- packages/flterm/CHANGELOG.md | 2 + packages/flterm/lib/flterm.dart | 3 +- .../foundation/terminal_gesture_settings.dart | 27 ++++- .../widgets/terminal_gesture_detector.dart | 21 +++- .../flterm/lib/src/widgets/terminal_view.dart | 4 +- .../terminal_gesture_settings_test.dart | 7 ++ .../terminal_gesture_detector_test.dart | 109 ++++++++++++++++++ .../test/widgets/terminal_view_test.dart | 16 ++- 8 files changed, 177 insertions(+), 12 deletions(-) diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index fd6bd1a7..17736b73 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -6,6 +6,8 @@ - **OSC 52 clipboard writes**: `TerminalController.onClipboardWrite` forwards write-only clipboard requests from libghostty to terminal embedders. +- **Tracked touch policy**: `TerminalGestureSettings.touchMouseTracking` can + preserve terminal taps while routing touch and stylus drags to scrolling. ## 0.0.4 diff --git a/packages/flterm/lib/flterm.dart b/packages/flterm/lib/flterm.dart index 12421baf..2f3e0a67 100644 --- a/packages/flterm/lib/flterm.dart +++ b/packages/flterm/lib/flterm.dart @@ -37,7 +37,8 @@ export 'src/foundation/terminal_gesture_settings.dart' GestureModifier, LineSelectMode, TerminalGestureSettings, - TerminalSelectionShape; + TerminalSelectionShape, + TouchMouseTracking; export 'src/foundation/terminal_theme.dart' show CursorTheme, diff --git a/packages/flterm/lib/src/foundation/terminal_gesture_settings.dart b/packages/flterm/lib/src/foundation/terminal_gesture_settings.dart index fddfbabe..c5105db9 100644 --- a/packages/flterm/lib/src/foundation/terminal_gesture_settings.dart +++ b/packages/flterm/lib/src/foundation/terminal_gesture_settings.dart @@ -19,12 +19,9 @@ enum LineSelectMode { full, } -/// Controls which terminal selection affordances are enabled and how press -/// gestures behave. +/// Controls terminal selection and tracked-pointer gesture behavior. /// -/// Passed to [TerminalView.gestureSettings]. Only affects selection -/// behavior: mouse tracking (for terminal programs) and focus gestures -/// work regardless of these settings. +/// Passed to [TerminalView.gestureSettings]. /// /// ```dart /// TerminalView( @@ -61,6 +58,14 @@ final class TerminalGestureSettings { /// [TerminalController.selectAll] still selects programmatically. final bool selectAllShortcut; + /// How touch-like pointers interact with terminal mouse tracking. + /// + /// [TouchMouseTracking.direct] (default) forwards touch down, motion, and up + /// events directly to the terminal program. [TouchMouseTracking.tapAndScroll] + /// forwards recognized taps as clicks while leaving drags to terminal + /// scrolling. + final TouchMouseTracking touchMouseTracking; + /// How triple-click line selection determines the end column. /// /// [LineSelectMode.content] (default) trims trailing empty cells. @@ -103,6 +108,7 @@ final class TerminalGestureSettings { this.dragSelection = true, this.selectAllShortcut = true, this.longPressSelection = true, + this.touchMouseTracking = .direct, this.lineSelectMode = .content, this.blockSelectionModifier = .alt, this.selectionBehaviors = .standard, @@ -121,6 +127,7 @@ final class TerminalGestureSettings { dragSelection, longPressSelection, selectAllShortcut, + touchMouseTracking, wordBoundaries, ); @@ -140,9 +147,19 @@ final class TerminalGestureSettings { dragSelection == other.dragSelection && longPressSelection == other.longPressSelection && selectAllShortcut == other.selectAllShortcut && + touchMouseTracking == other.touchMouseTracking && wordBoundaries == other.wordBoundaries; } +/// How touch-like pointers interact with terminal mouse tracking. +enum TouchMouseTracking { + /// Forwards touch down, motion, and up as terminal mouse events. + direct, + + /// Forwards recognized taps as clicks and leaves drags to scrolling. + tapAndScroll, +} + /// Selection shape used for gestures that start without a keyboard modifier. enum TerminalSelectionShape { /// Selects contiguous terminal text. diff --git a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart index aadf7fff..9bcb374a 100644 --- a/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart +++ b/packages/flterm/lib/src/widgets/terminal_gesture_detector.dart @@ -196,7 +196,8 @@ class _TerminalGestureDetectorState extends State { void _handleLongPressStart(LongPressStartDetails details) { _binding.requestFocus(); - if (_isMouseTracked(false)) { + if (_isMouseTracked(false) && + widget.settings.touchMouseTracking == TouchMouseTracking.direct) { _cancelSelectionPress(); return; } @@ -241,6 +242,12 @@ class _TerminalGestureDetectorState extends State { } void _handleTapUp(TapUpDetails details) { + if (_defersTrackedPointer(details.kind) && + _isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) { + _sendMouseEvent(.press, details.localPosition, button: .left); + _sendMouseEvent(.release, details.localPosition, button: .left); + return; + } if (_linkPressActive) { _linkPressActive = false; final link = widget.links.handleRelease( @@ -258,6 +265,7 @@ class _TerminalGestureDetectorState extends State { } void _handleTrackedDown(PointerDownEvent event) { + if (_defersTrackedPointer(event.kind)) return; if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; final button = _mouseButton(event.buttons); _trackedButtons[event.pointer] = button; @@ -265,6 +273,7 @@ class _TerminalGestureDetectorState extends State { } void _handleTrackedMove(PointerMoveEvent event) { + if (_defersTrackedPointer(event.kind)) return; if (!_isMouseTracked(HardwareKeyboard.instance.isShiftPressed)) return; _sendMouseEvent( .motion, @@ -274,12 +283,14 @@ class _TerminalGestureDetectorState extends State { } void _handleTrackedUp(PointerUpEvent event) { + if (_defersTrackedPointer(event.kind)) return; final button = _trackedButtons.remove(event.pointer); if (button == null) return; _sendMouseEvent(.release, event.localPosition, button: button); } void _handleTrackedCancel(PointerCancelEvent event) { + if (_defersTrackedPointer(event.kind)) return; final button = _trackedButtons.remove(event.pointer); if (button == null) return; _sendMouseEvent(.release, event.localPosition, button: button); @@ -327,6 +338,14 @@ class _TerminalGestureDetectorState extends State { }; } + bool _defersTrackedPointer(PointerDeviceKind kind) { + if (widget.settings.touchMouseTracking != .tapAndScroll) return false; + return switch (kind) { + .touch || .stylus || .invertedStylus => true, + _ => false, + }; + } + bool _isMouseTracked(bool shift) { return _binding.mouseTracking != .none && !shift && diff --git a/packages/flterm/lib/src/widgets/terminal_view.dart b/packages/flterm/lib/src/widgets/terminal_view.dart index e3581c57..85746768 100644 --- a/packages/flterm/lib/src/widgets/terminal_view.dart +++ b/packages/flterm/lib/src/widgets/terminal_view.dart @@ -83,8 +83,8 @@ class TerminalView extends StatefulWidget { /// Scroll physics for scrollback navigation. /// - /// Disabled automatically when the terminal program requests mouse - /// tracking, so gestures are forwarded as mouse events instead. + /// With [TouchMouseTracking.tapAndScroll], touch-like drags continue to use + /// these physics while a terminal program requests mouse tracking. final ScrollPhysics? scrollPhysics; /// Scroll controller for programmatic scrollback access. diff --git a/packages/flterm/test/foundation/terminal_gesture_settings_test.dart b/packages/flterm/test/foundation/terminal_gesture_settings_test.dart index 99bb1638..e41b59cd 100644 --- a/packages/flterm/test/foundation/terminal_gesture_settings_test.dart +++ b/packages/flterm/test/foundation/terminal_gesture_settings_test.dart @@ -10,6 +10,7 @@ void main() { expect(settings.dragSelection, isTrue); expect(settings.longPressSelection, isTrue); expect(settings.selectAllShortcut, isTrue); + expect(settings.touchMouseTracking, TouchMouseTracking.direct); expect(settings.blockSelectionModifier, GestureModifier.alt); expect(settings.longPressSelectionShape, TerminalSelectionShape.normal); expect(settings.lineSelectMode, LineSelectMode.content); @@ -22,12 +23,14 @@ void main() { dragSelection: false, longPressSelection: false, selectAllShortcut: false, + touchMouseTracking: TouchMouseTracking.tapAndScroll, blockSelectionModifier: null, ); expect(settings.dragSelection, isFalse); expect(settings.longPressSelection, isFalse); expect(settings.selectAllShortcut, isFalse); + expect(settings.touchMouseTracking, TouchMouseTracking.tapAndScroll); expect(settings.blockSelectionModifier, isNull); }); @@ -49,6 +52,9 @@ void main() { const differentSelectAll = TerminalGestureSettings( selectAllShortcut: false, ); + const differentTouchTracking = TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ); const differentModifier = TerminalGestureSettings( blockSelectionModifier: GestureModifier.meta, ); @@ -74,6 +80,7 @@ void main() { expect(a, isNot(equals(differentDrag))); expect(a, isNot(equals(differentLongPressSelection))); expect(a, isNot(equals(differentSelectAll))); + expect(a, isNot(equals(differentTouchTracking))); expect(a, isNot(equals(differentModifier))); expect(a, isNot(equals(differentLongPress))); expect(a, isNot(equals(differentLineSelect))); diff --git a/packages/flterm/test/widgets/terminal_gesture_detector_test.dart b/packages/flterm/test/widgets/terminal_gesture_detector_test.dart index 496bb2c4..8dcafcf8 100644 --- a/packages/flterm/test/widgets/terminal_gesture_detector_test.dart +++ b/packages/flterm/test/widgets/terminal_gesture_detector_test.dart @@ -991,6 +991,115 @@ void main() { expect(utf8.decode(events[1]), '\x1b[<0;24;16m'); }); + for (final kind in [ + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + ]) { + testWidgets('$kind tap-and-scroll forwards recognized taps only', ( + tester, + ) async { + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + ), + ); + + final pointer = await tester.startGesture( + const Offset(24, 16), + kind: kind, + ); + expect(events, isEmpty); + + await pointer.up(); + + expect(events, hasLength(2)); + expect(utf8.decode(events[0]), '\x1b[<0;4;2M'); + expect(utf8.decode(events[1]), '\x1b[<0;4;2m'); + }); + + testWidgets('$kind tap-and-scroll does not forward drags', ( + tester, + ) async { + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + ), + ); + + final pointer = await tester.startGesture( + const Offset(24, 16), + kind: kind, + ); + await pointer.moveTo(const Offset(24, 80)); + await pointer.up(); + + expect(events, isEmpty); + }); + } + + testWidgets('tap-and-scroll permits tracked touch long-press selection', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + ), + ); + + final touch = await tester.startGesture(const Offset(8, 0)); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + await touch.moveTo(const Offset(40, 0)); + await touch.up(); + + expect(controller.hasSelection, isTrue); + expect(events, isEmpty); + }); + + testWidgets('disabled tracked touch long press remains inert', ( + tester, + ) async { + writeToTerminal(controller, 'hello world'); + enableSgrMouse('1002'); + final events = []; + controller.onOutput = events.add; + await tester.pumpWidget( + buildHandler( + controller: controller, + gestureSettings: const TerminalGestureSettings( + longPressSelection: false, + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), + ), + ); + + final touch = await tester.startGesture(const Offset(8, 0)); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + await touch.up(); + + expect(controller.hasSelection, isFalse); + expect(events, isEmpty); + }); + testWidgets('wheel reports its pointer position', (tester) async { enableSgrMouse('1000'); final events = []; diff --git a/packages/flterm/test/widgets/terminal_view_test.dart b/packages/flterm/test/widgets/terminal_view_test.dart index 3b816619..d5621da5 100644 --- a/packages/flterm/test/widgets/terminal_view_test.dart +++ b/packages/flterm/test/widgets/terminal_view_test.dart @@ -1457,6 +1457,9 @@ void main() { scrollController: scrollController, autofocus: true, showKeyboard: false, + gestureSettings: const TerminalGestureSettings( + touchMouseTracking: TouchMouseTracking.tapAndScroll, + ), width: 400, height: 320, ), @@ -1467,17 +1470,24 @@ void main() { output.clear(); final topLeft = tester.getTopLeft(find.byType(TerminalView)); - final gesture = await tester.startGesture( + await tester.dragFrom( topLeft + const Offset(120, 240), + const Offset(0, -120), kind: kind, ); - await gesture.moveTo(topLeft + const Offset(120, 120)); await tester.pump(); + + expect( + utf8.decode( + Uint8List.fromList(output.expand((bytes) => bytes).toList()), + ), + isNot(contains('\x1b[<0;')), + ); + expect(controller.hasSelection, isFalse); output.clear(); scrollController.jumpTo(scrollController.offset + 160); await tester.pump(); - await gesture.up(); final reports = utf8 .decode( From 15095de440381455b5391a359714586cad605199 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 15:09:47 +0800 Subject: [PATCH 10/15] feat(flterm): normalize layout-aware keyboard input --- .github/workflows/checks.yml | 65 ++++ packages/flterm/CHANGELOG.md | 4 + packages/flterm/README.md | 39 +++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + packages/flterm/example/macos/Podfile | 2 +- .../macos/Runner.xcodeproj/project.pbxproj | 6 +- packages/flterm/lib/flterm.dart | 4 + packages/flterm/lib/src/foundation.dart | 1 + .../lib/src/foundation/platform_map.dart | 49 ++- .../lib/src/foundation/terminal_config.dart | 12 + .../foundation/terminal_keyboard_event.dart | 88 +++++ .../widgets/keyboard_event_normalizer.dart | 187 +++++++++++ .../src/widgets/native_keyboard_metadata.dart | 223 +++++++++++++ .../lib/src/widgets/terminal_controller.dart | 10 +- .../src/widgets/terminal_controller_impl.dart | 140 +++++--- packages/flterm/linux/CMakeLists.txt | 19 ++ packages/flterm/linux/flterm_plugin.cc | 190 +++++++++++ .../linux/include/flterm/flterm_plugin.h | 26 ++ packages/flterm/macos/flterm.podspec | 15 + packages/flterm/macos/flterm/Package.swift | 20 ++ .../flterm/Sources/flterm/FltermPlugin.swift | 156 +++++++++ packages/flterm/pubspec.yaml | 10 + .../test/foundation/platform_map_test.dart | 32 +- .../test/foundation/terminal_config_test.dart | 6 + .../terminal_keyboard_event_test.dart | 52 +++ .../keyboard_event_normalizer_test.dart | 140 ++++++++ .../native_keyboard_metadata_test.dart | 117 +++++++ .../widgets/terminal_controller_test.dart | 12 + .../widgets/terminal_view_binding_test.dart | 85 ++++- packages/flterm/windows/CMakeLists.txt | 27 ++ packages/flterm/windows/flterm_plugin.cpp | 308 ++++++++++++++++++ packages/flterm/windows/flterm_plugin.h | 47 +++ .../flterm/windows/flterm_plugin_c_api.cpp | 12 + .../include/flterm/flterm_plugin_c_api.h | 23 ++ 34 files changed, 2067 insertions(+), 62 deletions(-) create mode 100644 packages/flterm/lib/src/foundation/terminal_keyboard_event.dart create mode 100644 packages/flterm/lib/src/widgets/keyboard_event_normalizer.dart create mode 100644 packages/flterm/lib/src/widgets/native_keyboard_metadata.dart create mode 100644 packages/flterm/linux/CMakeLists.txt create mode 100644 packages/flterm/linux/flterm_plugin.cc create mode 100644 packages/flterm/linux/include/flterm/flterm_plugin.h create mode 100644 packages/flterm/macos/flterm.podspec create mode 100644 packages/flterm/macos/flterm/Package.swift create mode 100644 packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift create mode 100644 packages/flterm/test/foundation/terminal_keyboard_event_test.dart create mode 100644 packages/flterm/test/widgets/keyboard_event_normalizer_test.dart create mode 100644 packages/flterm/test/widgets/native_keyboard_metadata_test.dart create mode 100644 packages/flterm/windows/CMakeLists.txt create mode 100644 packages/flterm/windows/flterm_plugin.cpp create mode 100644 packages/flterm/windows/flterm_plugin.h create mode 100644 packages/flterm/windows/flterm_plugin_c_api.cpp create mode 100644 packages/flterm/windows/include/flterm/flterm_plugin_c_api.h diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8977e324..6c3c26a8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -309,6 +309,70 @@ jobs: working-directory: packages/flterm run: very_good test + build-flterm-desktop: + name: build-flterm-${{ matrix.platform }} + needs: [analyze-flterm, changes] + if: needs.changes.outputs.flterm == 'true' + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + include: + - platform: linux + runner: ubuntu-latest + - platform: macos + runner: macos-15 + - platform: windows + runner: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-tags: false + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + channel: stable + cache: true + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: ${{ env.ZIG_VERSION }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.pub-cache + key: pub-flterm-desktop-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }} + restore-keys: pub-flterm-desktop-${{ runner.os }}- + - name: install Linux build dependencies + if: matrix.platform == 'linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev ninja-build + - name: resolve ghostty source + id: ghostty + run: | + COMMIT=$(cat packages/libghostty/ghostty.version) + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: ghostty-cache + with: + path: ghostty + 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 ghostty.tar.gz + - name: build desktop smoke app + run: | + SMOKE_DIR="$RUNNER_TEMP/flterm-smoke" + flutter create --platforms=${{ matrix.platform }} --project-name=flterm_smoke "$SMOKE_DIR" + cd "$SMOKE_DIR" + flutter pub add flterm --path "$GITHUB_WORKSPACE/packages/flterm" + flutter build ${{ matrix.platform }} --debug + test-ptyx-dart: name: test-ptyx-dart-${{ matrix.os }} needs: [analyze-ptyx, changes] @@ -437,6 +501,7 @@ jobs: - test-libghostty-native - test-libghostty-wasm - test-flterm + - build-flterm-desktop - test-ptyx-dart - test-ptyx-rust runs-on: ubuntu-latest diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index 17736b73..0423be24 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -8,6 +8,10 @@ write-only clipboard requests from libghostty to terminal embedders. - **Tracked touch policy**: `TerminalGestureSettings.touchMouseTracking` can preserve terminal taps while routing touch and stylus drags to scrolling. +- **Layout-aware keyboard input**: desktop companions provide active-layout + unshifted characters, consumed modifiers, lock/side state, and dead-key + detection. `TerminalKeyEventNormalizer` supports custom runners, and + `TerminalConfig.optionAsAlt` configures macOS Option behavior. ## 0.0.4 diff --git a/packages/flterm/README.md b/packages/flterm/README.md index 13dcb7e3..8c2b1f7b 100644 --- a/packages/flterm/README.md +++ b/packages/flterm/README.md @@ -92,6 +92,45 @@ controller.paste('hello'); controller.clear(); ``` +## Keyboard input + +flterm keeps platform key translation separate from terminal protocol +encoding. On Linux, macOS, and Windows, a native companion enriches Flutter's +physical key event with the active layout's unshifted codepoint, consumed +modifiers, lock and modifier-side state, and dead-key information. Dead keys +and IME preedit stay on Flutter's text-input path; committed input is sent to +the terminal separately. + +On mobile and web, Flutter does not expose all of that native metadata. flterm +uses the produced character, pressed and locked keys, previously observed +unmodified characters, and a physical-key fallback. Basic input and terminal +shortcuts still work, but consumed modifiers and layout translation can be +less precise than on desktop. + +Custom runners can replace or enrich the normalized event at the controller +boundary: + +```dart +final controller = TerminalController( + keyEventNormalizer: (flutterEvent, fallback) { + return fallback.copyWith( + // Metadata captured before the runner normalizes its native event. + unshiftedCodepoint: nativeUnshiftedCodepoint(flutterEvent), + consumedMods: nativeConsumedMods(flutterEvent), + ); + }, +); +``` + +On macOS, Option participates in keyboard layout translation by default. It +can instead act as terminal Alt on either or both sides: + +```dart +final controller = TerminalController( + config: const TerminalConfig(optionAsAlt: OptionAsAlt.true$), +); +``` + Links are configured on the view. Built-in detection covers OSC 8 metadata, text URLs, and file paths. diff --git a/packages/flterm/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/flterm/example/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817a..07d7e398 100644 --- a/packages/flterm/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/flterm/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import flterm func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FltermPlugin.register(with: registry.registrar(forPlugin: "FltermPlugin")) } diff --git a/packages/flterm/example/macos/Podfile b/packages/flterm/example/macos/Podfile index ff5ddb3b..167132a2 100644 --- a/packages/flterm/example/macos/Podfile +++ b/packages/flterm/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/packages/flterm/example/macos/Runner.xcodeproj/project.pbxproj b/packages/flterm/example/macos/Runner.xcodeproj/project.pbxproj index e0184a50..ea057d13 100644 --- a/packages/flterm/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/flterm/example/macos/Runner.xcodeproj/project.pbxproj @@ -539,7 +539,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -621,7 +621,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -671,7 +671,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/packages/flterm/lib/flterm.dart b/packages/flterm/lib/flterm.dart index 2f3e0a67..699b9b3a 100644 --- a/packages/flterm/lib/flterm.dart +++ b/packages/flterm/lib/flterm.dart @@ -13,8 +13,10 @@ export 'package:libghostty/libghostty.dart' FormatterExtra, FormatterFormat, Key, + KeyAction, Mods, MouseTracking, + OptionAsAlt, PointTag, Position, Scrollbar, @@ -39,6 +41,8 @@ export 'src/foundation/terminal_gesture_settings.dart' TerminalGestureSettings, TerminalSelectionShape, TouchMouseTracking; +export 'src/foundation/terminal_keyboard_event.dart' + show TerminalKeyEventNormalizer, TerminalKeyboardEvent; export 'src/foundation/terminal_theme.dart' show CursorTheme, diff --git a/packages/flterm/lib/src/foundation.dart b/packages/flterm/lib/src/foundation.dart index 28a580d3..b463b1b2 100644 --- a/packages/flterm/lib/src/foundation.dart +++ b/packages/flterm/lib/src/foundation.dart @@ -7,4 +7,5 @@ export 'foundation/input_types.dart'; export 'foundation/platform_map.dart'; export 'foundation/terminal_config.dart'; export 'foundation/terminal_gesture_settings.dart'; +export 'foundation/terminal_keyboard_event.dart'; export 'foundation/terminal_theme.dart'; diff --git a/packages/flterm/lib/src/foundation/platform_map.dart b/packages/flterm/lib/src/foundation/platform_map.dart index f59f0e87..8313a04d 100644 --- a/packages/flterm/lib/src/foundation/platform_map.dart +++ b/packages/flterm/lib/src/foundation/platform_map.dart @@ -1,5 +1,5 @@ import 'package:flutter/services.dart' show PhysicalKeyboardKey; -import 'package:libghostty/libghostty.dart' show Key; +import 'package:libghostty/libghostty.dart' show Key, Mods; final Map _codepointToKey = { 0x20: Key.space, @@ -197,6 +197,31 @@ final Map _keyToCodepoint = { Key.quote: 0x27, Key.semicolon: 0x3b, Key.slash: 0x2f, + Key.space: 0x20, +}; + +const _shiftedCodepoints = { + 0x60: 0x7e, + 0x31: 0x21, + 0x32: 0x40, + 0x33: 0x23, + 0x34: 0x24, + 0x35: 0x25, + 0x36: 0x5e, + 0x37: 0x26, + 0x38: 0x2a, + 0x39: 0x28, + 0x30: 0x29, + 0x2d: 0x5f, + 0x3d: 0x2b, + 0x5b: 0x7b, + 0x5d: 0x7d, + 0x5c: 0x7c, + 0x3b: 0x3a, + 0x27: 0x22, + 0x2c: 0x3c, + 0x2e: 0x3e, + 0x2f: 0x3f, }; /// Maps a Unicode [codepoint] to the corresponding libghostty [Key]. @@ -220,3 +245,25 @@ Key keyFromPhysical(PhysicalKeyboardKey physical) { /// Used by the key encoder to determine the unshifted codepoint that /// libghostty expects for keyboard input encoding. int unshiftedCodepointForKey(Key key) => _keyToCodepoint[key] ?? 0; + +/// Returns the US-layout codepoint produced by a programmatic key press. +int codepointForKey(Key key, Mods mods) { + final codepoint = unshiftedCodepointForKey(key); + if (codepoint >= 0x61 && codepoint <= 0x7A) { + return mods.hasShift != mods.hasCapsLock ? codepoint - 0x20 : codepoint; + } + return mods.hasShift ? _shiftedCodepoints[codepoint] ?? codepoint : codepoint; +} + +/// Returns modifiers consumed by the US-layout programmatic translation. +Mods consumedModsForKey(Key key, Mods mods) { + final unshifted = unshiftedCodepointForKey(key); + var consumed = const Mods.none(); + if (unshifted >= 0x61 && unshifted <= 0x7A) { + if (mods.hasShift) consumed = consumed | const Mods.shift(); + if (mods.hasCapsLock) consumed = consumed | const Mods.capsLock(); + } else if (mods.hasShift && codepointForKey(key, mods) != unshifted) { + consumed = consumed | const Mods.shift(); + } + return consumed; +} diff --git a/packages/flterm/lib/src/foundation/terminal_config.dart b/packages/flterm/lib/src/foundation/terminal_config.dart index 5dbb5450..7cb02229 100644 --- a/packages/flterm/lib/src/foundation/terminal_config.dart +++ b/packages/flterm/lib/src/foundation/terminal_config.dart @@ -99,6 +99,13 @@ class TerminalConfig { /// addition to Kitty graphics. final bool glyphProtocol; + /// Whether macOS Option keys act as terminal Alt modifiers. + /// + /// By default Option participates in the active keyboard layout. Set a side + /// or both sides to Alt when terminal shortcuts should take precedence over + /// Option-produced text. + final OptionAsAlt optionAsAlt; + /// Initial cursor shape. Terminal programs can override via DECSCUSR. final CursorShape cursorStyle; @@ -144,6 +151,7 @@ class TerminalConfig { this.rows = 24, this.cursorBlink, this.glyphProtocol = false, + this.optionAsAlt = .false$, this.apcBufferLimit = defaultApcBufferLimit, this.enquiryResponse = '', this.modes = defaultModes, @@ -170,6 +178,7 @@ class TerminalConfig { kittyImageStorageLimit, apcBufferLimit, glyphProtocol, + optionAsAlt, cursorStyle, cursorBlink, .hashAllUnordered(modes.entries.map((e) => .hash(e.key, e.value))), @@ -189,6 +198,7 @@ class TerminalConfig { kittyImageStorageLimit == other.kittyImageStorageLimit && apcBufferLimit == other.apcBufferLimit && glyphProtocol == other.glyphProtocol && + optionAsAlt == other.optionAsAlt && cursorStyle == other.cursorStyle && cursorBlink == other.cursorBlink && mapEquals(modes, other.modes) && @@ -205,6 +215,7 @@ class TerminalConfig { int? kittyImageStorageLimit, int? apcBufferLimit, bool? glyphProtocol, + OptionAsAlt? optionAsAlt, CursorShape? cursorStyle, bool? cursorBlink, Map? modes, @@ -221,6 +232,7 @@ class TerminalConfig { kittyImageStorageLimit ?? this.kittyImageStorageLimit, apcBufferLimit: apcBufferLimit ?? this.apcBufferLimit, glyphProtocol: glyphProtocol ?? this.glyphProtocol, + optionAsAlt: optionAsAlt ?? this.optionAsAlt, cursorStyle: cursorStyle ?? this.cursorStyle, cursorBlink: cursorBlink ?? this.cursorBlink, modes: modes ?? this.modes, diff --git a/packages/flterm/lib/src/foundation/terminal_keyboard_event.dart b/packages/flterm/lib/src/foundation/terminal_keyboard_event.dart new file mode 100644 index 00000000..37ce161a --- /dev/null +++ b/packages/flterm/lib/src/foundation/terminal_keyboard_event.dart @@ -0,0 +1,88 @@ +import 'package:flutter/foundation.dart' show immutable; +import 'package:flutter/services.dart' show KeyEvent; +import 'package:libghostty/libghostty.dart' show Key, KeyAction, Mods; + +/// A keyboard event normalized for terminal protocol encoding. +/// +/// Unlike Flutter's [KeyEvent], this includes the active layout's unshifted +/// codepoint and the modifiers consumed while producing [text]. +@immutable +final class TerminalKeyboardEvent { + /// The physical, layout-independent key. + final Key key; + + /// Whether the key was pressed, repeated, or released. + final KeyAction action; + + /// Modifier and lock state at the time of the event. + final Mods mods; + + /// Modifiers used by the active layout to produce [text]. + final Mods consumedMods; + + /// Text produced by the active keyboard layout, if any. + final String? text; + + /// The active layout's Unicode codepoint for this key without modifiers. + /// + /// Zero means that the platform could not provide a single codepoint. + final int unshiftedCodepoint; + + /// Whether the event belongs to an active dead-key or IME composition. + final bool composing; + + /// Whether platform text input should handle this event instead of the + /// terminal key encoder. + final bool deferToTextInput; + + const TerminalKeyboardEvent({ + required this.key, + required this.action, + required this.mods, + this.consumedMods = const Mods.none(), + this.text, + this.unshiftedCodepoint = 0, + this.composing = false, + this.deferToTextInput = false, + }) : assert( + unshiftedCodepoint >= 0 && + unshiftedCodepoint <= 0x10FFFF && + (unshiftedCodepoint < 0xD800 || unshiftedCodepoint > 0xDFFF), + 'unshiftedCodepoint must be a valid Unicode scalar or zero', + ); + + /// Returns a copy with selected fields replaced. + TerminalKeyboardEvent copyWith({ + Key? key, + KeyAction? action, + Mods? mods, + Mods? consumedMods, + String? text, + bool clearText = false, + int? unshiftedCodepoint, + bool? composing, + bool? deferToTextInput, + }) { + return TerminalKeyboardEvent( + key: key ?? this.key, + action: action ?? this.action, + mods: mods ?? this.mods, + consumedMods: consumedMods ?? this.consumedMods, + text: clearText ? null : text ?? this.text, + unshiftedCodepoint: unshiftedCodepoint ?? this.unshiftedCodepoint, + composing: composing ?? this.composing, + deferToTextInput: deferToTextInput ?? this.deferToTextInput, + ); + } +} + +/// Overrides or enriches flterm's normalized keyboard event. +/// +/// [fallback] contains all information available from Flutter and flterm's +/// native desktop companion. Custom runners can replace fields with metadata +/// captured before Flutter normalizes the platform key event. +typedef TerminalKeyEventNormalizer = + TerminalKeyboardEvent Function( + KeyEvent event, + TerminalKeyboardEvent fallback, + ); diff --git a/packages/flterm/lib/src/widgets/keyboard_event_normalizer.dart b/packages/flterm/lib/src/widgets/keyboard_event_normalizer.dart new file mode 100644 index 00000000..1e46679b --- /dev/null +++ b/packages/flterm/lib/src/widgets/keyboard_event_normalizer.dart @@ -0,0 +1,187 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform; +import 'package:flutter/services.dart'; +import 'package:libghostty/libghostty.dart' + show Key, KeyAction, Mods, OptionAsAlt; + +import '../foundation.dart'; +import 'native_keyboard_metadata.dart'; + +final class KeyboardEventNormalizer { + final _unshiftedByPhysicalKey = {}; + final NativeKeyboardMetadata? Function(KeyEvent) _nativeMetadataForEvent; + + KeyboardEventNormalizer({ + NativeKeyboardMetadata? Function(KeyEvent)? nativeMetadataForEvent, + }) : _nativeMetadataForEvent = + nativeMetadataForEvent ?? + NativeKeyboardMetadataStore.instance.forEvent { + if (nativeMetadataForEvent == null) { + NativeKeyboardMetadataStore.instance.ensureInitialized(); + } + } + + TerminalKeyboardEvent normalize( + KeyEvent event, { + required Key key, + required KeyAction action, + required Mods mods, + required String? character, + required bool composing, + required OptionAsAlt optionAsAlt, + }) { + final native = _nativeMetadataForEvent(event); + final effectiveMods = native == null ? mods : native.mods | mods; + var unshiftedCodepoint = native?.unshiftedCodepoint ?? 0; + + if (unshiftedCodepoint == 0) { + unshiftedCodepoint = _fallbackUnshiftedCodepoint( + event, + key: key, + mods: effectiveMods, + character: character, + ); + } else { + _unshiftedByPhysicalKey[event.physicalKey] = unshiftedCodepoint; + } + + var text = character; + final optionActsAsAlt = _optionActsAsAlt(effectiveMods, optionAsAlt); + var consumedMods = native?.consumedMods ?? const Mods.none(); + consumedMods = + consumedMods | + _fallbackConsumedMods( + text, + unshiftedCodepoint: unshiftedCodepoint, + mods: effectiveMods, + ); + if (optionActsAsAlt) { + text = + native?.textWithoutAlt ?? + _textForCodepoint(unshiftedCodepoint) ?? + text; + consumedMods = _withoutAlt(consumedMods); + } + final deadKey = (native?.deadKey ?? false) && !optionActsAsAlt; + + return TerminalKeyboardEvent( + key: key, + action: action, + mods: effectiveMods, + consumedMods: consumedMods, + text: text, + unshiftedCodepoint: unshiftedCodepoint, + composing: composing || deadKey, + deferToTextInput: deadKey, + ); + } + + int _fallbackUnshiftedCodepoint( + KeyEvent event, { + required Key key, + required Mods mods, + required String? character, + }) { + final unmodified = + !mods.hasShift && + !mods.hasCtrl && + !mods.hasAlt && + !mods.hasSuper && + !mods.hasCapsLock; + if (unmodified) { + final codepoint = _singleCodepoint(character); + if (codepoint != 0) { + _unshiftedByPhysicalKey[event.physicalKey] = codepoint; + return codepoint; + } + } + + final cached = _unshiftedByPhysicalKey[event.physicalKey]; + if (cached != null) return cached; + + final logical = _singleCodepoint(event.logicalKey.keyLabel); + if (logical != 0) { + final lowered = _singleCodepoint(event.logicalKey.keyLabel.toLowerCase()); + return lowered == 0 ? logical : lowered; + } + + return unshiftedCodepointForKey(key); + } + + Mods _fallbackConsumedMods( + String? character, { + required int unshiftedCodepoint, + required Mods mods, + }) { + if (character == null || unshiftedCodepoint == 0) { + return const Mods.none(); + } + final codepoint = _singleCodepoint(character); + if (codepoint == 0) return const Mods.none(); + + var consumed = const Mods.none(); + if (mods.hasShift && codepoint != unshiftedCodepoint) { + consumed = consumed | const Mods.shift(); + } + if (mods.hasCapsLock && codepoint != unshiftedCodepoint) { + consumed = consumed | const Mods.capsLock(); + } + + final keyboard = HardwareKeyboard.instance; + final altGraph = keyboard.logicalKeysPressed.contains( + LogicalKeyboardKey.altGraph, + ); + final rightAlt = keyboard.physicalKeysPressed.contains( + PhysicalKeyboardKey.altRight, + ); + if (defaultTargetPlatform != TargetPlatform.macOS && + mods.hasAlt && + (altGraph || (rightAlt && mods.hasCtrl))) { + consumed = consumed | const Mods.alt(); + if (mods.hasCtrl) consumed = consumed | const Mods.ctrl(); + } else if (defaultTargetPlatform == TargetPlatform.macOS && + mods.hasAlt && + !mods.hasCtrl && + !mods.hasSuper && + codepoint != unshiftedCodepoint) { + consumed = consumed | const Mods.alt(); + } + return consumed; + } + + bool _optionActsAsAlt(Mods mods, OptionAsAlt option) { + if (defaultTargetPlatform != TargetPlatform.macOS || !mods.hasAlt) { + return false; + } + return switch (option) { + .false$ => false, + .true$ => true, + .left => !mods.isAltRight, + .right => mods.isAltRight, + }; + } + + Mods _withoutAlt(Mods mods) { + if (!mods.hasAlt) return mods; + return mods ^ + const Mods.alt() ^ + (mods.isAltRight ? const Mods.altSide() : const Mods.none()); + } + + int _singleCodepoint(String? value) { + if (value == null || value.isEmpty) return 0; + final runes = value.runes.iterator; + if (!runes.moveNext()) return 0; + final codepoint = runes.current; + return runes.moveNext() ? 0 : codepoint; + } + + String? _textForCodepoint(int codepoint) { + if (codepoint <= 0 || + codepoint > 0x10FFFF || + (codepoint >= 0xD800 && codepoint <= 0xDFFF)) { + return null; + } + return String.fromCharCode(codepoint); + } +} diff --git a/packages/flterm/lib/src/widgets/native_keyboard_metadata.dart b/packages/flterm/lib/src/widgets/native_keyboard_metadata.dart new file mode 100644 index 00000000..4dab3766 --- /dev/null +++ b/packages/flterm/lib/src/widgets/native_keyboard_metadata.dart @@ -0,0 +1,223 @@ +// Flutter's public KeyEvent omits native layout metadata that terminal +// protocols need. Desktop plugins send a companion record before Flutter's +// own key messages; the deprecated raw event is used only to correlate that +// record with the KeyEvent object delivered to Focus. +// ignore_for_file: deprecated_member_use + +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:libghostty/libghostty.dart' show Mods; + +final class NativeKeyboardMetadata { + final Mods mods; + final Mods consumedMods; + final int unshiftedCodepoint; + final String? textWithoutAlt; + final bool deadKey; + + const NativeKeyboardMetadata({ + required this.mods, + required this.consumedMods, + required this.unshiftedCodepoint, + required this.textWithoutAlt, + required this.deadKey, + }); +} + +final class NativeKeyboardMetadataStore { + static final instance = NativeKeyboardMetadataStore._(); + static const _channelName = 'dev.flterm/native_keyboard'; + static const _queueLimit = 64; + + final _nativeEvents = ListQueue<_NativeKeyboardRecord>(); + final _pairedEvents = ListQueue<_PairedKeyboardRecord>(); + final _metadata = Expando(); + var _initialized = false; + + NativeKeyboardMetadataStore._(); + + void ensureInitialized() { + if (_initialized || kIsWeb || !_isDesktopPlatform) return; + _initialized = true; + const BasicMessageChannel( + _channelName, + StandardMessageCodec(), + ).setMessageHandler(_handleMessage); + RawKeyboard.instance.addListener(_handleRawEvent); + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + } + + NativeKeyboardMetadata? forEvent(KeyEvent event) => _metadata[event]; + + bool get _isDesktopPlatform => switch (defaultTargetPlatform) { + .linux || .macOS || .windows => true, + .android || .fuchsia || .iOS => false, + }; + + bool _handleKeyEvent(KeyEvent event) { + if (event.synthesized) return false; + final down = event is! KeyUpEvent; + final records = _pairedEvents.toList(); + final index = records.lastIndexWhere( + (record) => + record.down == down && record.physicalKey == event.physicalKey, + ); + if (index < 0) return false; + final record = records[index]; + _pairedEvents.removeWhere( + (candidate) => + candidate.down == down && candidate.physicalKey == event.physicalKey, + ); + _metadata[event] = record.metadata; + return false; + } + + Future _handleMessage(Object? message) async { + final record = _NativeKeyboardRecord.fromMessage(message); + if (record == null) return null; + if (record.eventTime != 0 && _nativeEvents.any(record.sameNativeEvent)) { + return null; + } + _nativeEvents.addLast(record); + while (_nativeEvents.length > _queueLimit) { + _nativeEvents.removeFirst(); + } + return null; + } + + void _handleRawEvent(RawKeyEvent event) { + final down = event is RawKeyDownEvent; + final records = _nativeEvents.toList(); + final index = records.lastIndexWhere( + (record) => record.down == down && record.matches(event.data), + ); + if (index < 0) return; + final record = records[index]; + _nativeEvents.removeWhere( + (candidate) => candidate.down == down && candidate.matches(event.data), + ); + _pairedEvents.addLast( + _PairedKeyboardRecord( + physicalKey: event.physicalKey, + down: down, + metadata: record.metadata, + ), + ); + while (_pairedEvents.length > _queueLimit) { + _pairedEvents.removeFirst(); + } + } +} + +final class _NativeKeyboardRecord { + final String platform; + final int scanCode; + final int keyCode; + final bool down; + final int eventTime; + final NativeKeyboardMetadata metadata; + + const _NativeKeyboardRecord({ + required this.platform, + required this.scanCode, + required this.keyCode, + required this.down, + required this.eventTime, + required this.metadata, + }); + + static _NativeKeyboardRecord? fromMessage(Object? message) { + if (message is! Map) return null; + final platform = message['platform']; + final scanCode = message['scanCode']; + final keyCode = message['keyCode']; + final down = message['down']; + final eventTime = message['eventTime']; + final mods = message['mods']; + final consumedMods = message['consumedMods']; + final unshiftedCodepoint = message['unshiftedCodepoint']; + final textWithoutAlt = message['textWithoutAlt']; + final deadKey = message['deadKey']; + if (platform is! String || + !const {'linux', 'macos', 'windows'}.contains(platform) || + scanCode is! int || + scanCode < 0 || + keyCode is! int || + keyCode < 0 || + down is! bool || + eventTime is! int || + eventTime < 0 || + mods is! int || + consumedMods is! int || + unshiftedCodepoint is! int || + unshiftedCodepoint < 0 || + unshiftedCodepoint > 0x10FFFF || + (unshiftedCodepoint >= 0xD800 && unshiftedCodepoint <= 0xDFFF) || + (textWithoutAlt != null && textWithoutAlt is! String) || + deadKey is! bool) { + return null; + } + return _NativeKeyboardRecord( + platform: platform, + scanCode: scanCode, + keyCode: keyCode, + down: down, + eventTime: eventTime, + metadata: NativeKeyboardMetadata( + mods: _modsFromBits(mods), + consumedMods: _modsFromBits(consumedMods), + unshiftedCodepoint: unshiftedCodepoint, + textWithoutAlt: textWithoutAlt as String?, + deadKey: deadKey, + ), + ); + } + + bool matches(RawKeyEventData data) { + return switch ((platform, data)) { + ('linux', final RawKeyEventDataLinux event) => + event.scanCode == scanCode && event.keyCode == keyCode, + ('macos', final RawKeyEventDataMacOs event) => event.keyCode == keyCode, + ('windows', final RawKeyEventDataWindows event) => + event.scanCode == scanCode && event.keyCode == keyCode, + _ => false, + }; + } + + bool sameNativeEvent(_NativeKeyboardRecord other) { + return platform == other.platform && + scanCode == other.scanCode && + keyCode == other.keyCode && + down == other.down && + eventTime == other.eventTime; + } +} + +final class _PairedKeyboardRecord { + final PhysicalKeyboardKey physicalKey; + final bool down; + final NativeKeyboardMetadata metadata; + + const _PairedKeyboardRecord({ + required this.physicalKey, + required this.down, + required this.metadata, + }); +} + +Mods _modsFromBits(int bits) { + var mods = const Mods.none(); + if (bits & (1 << 0) != 0) mods = mods | const Mods.shift(); + if (bits & (1 << 1) != 0) mods = mods | const Mods.ctrl(); + if (bits & (1 << 2) != 0) mods = mods | const Mods.alt(); + if (bits & (1 << 3) != 0) mods = mods | const Mods.superKey(); + if (bits & (1 << 4) != 0) mods = mods | const Mods.capsLock(); + if (bits & (1 << 5) != 0) mods = mods | const Mods.numLock(); + if (bits & (1 << 6) != 0) mods = mods | const Mods.shiftSide(); + if (bits & (1 << 7) != 0) mods = mods | const Mods.ctrlSide(); + if (bits & (1 << 8) != 0) mods = mods | const Mods.altSide(); + if (bits & (1 << 9) != 0) mods = mods | const Mods.superSide(); + return mods; +} diff --git a/packages/flterm/lib/src/widgets/terminal_controller.dart b/packages/flterm/lib/src/widgets/terminal_controller.dart index 9215f79c..41e1241f 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller.dart @@ -50,7 +50,15 @@ abstract class TerminalController extends ChangeNotifier { /// /// The terminal is created immediately with dimensions and scrollback /// from [config]. Disposed when the controller is disposed. - factory TerminalController({TerminalConfig config}) = TerminalControllerImpl; + /// + /// [keyEventNormalizer] can enrich or replace the normalized keyboard event + /// before terminal protocol encoding. flterm already supplies native layout + /// metadata on desktop when its plugin is registered; custom runners can use + /// this callback for metadata captured in their own event pipeline. + factory TerminalController({ + TerminalConfig config, + TerminalKeyEventNormalizer? keyEventNormalizer, + }) = TerminalControllerImpl; @internal TerminalController.base(); diff --git a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart index c1aacd09..4b228e12 100644 --- a/packages/flterm/lib/src/widgets/terminal_controller_impl.dart +++ b/packages/flterm/lib/src/widgets/terminal_controller_impl.dart @@ -9,6 +9,7 @@ import 'package:meta/meta.dart'; import '../foundation.dart'; import '../rendering/kitty_png_decoder.dart'; +import 'keyboard_event_normalizer.dart'; import 'selection_gesture_driver.dart'; import 'terminal_controller.dart'; import 'terminal_input_client.dart'; @@ -31,11 +32,13 @@ class TerminalControllerImpl extends TerminalController @override final Terminal terminal; final _keyEncoder = KeyEncoder(); + final _keyboardNormalizer = KeyboardEventNormalizer(); final _mouseEncoder = MouseEncoder(); late final SelectionGestureDriver _selectionGesture; final vt.KeyEvent _keyEvent; final MouseEvent _mouseEvent; final TerminalInputClient _textInput; + final TerminalKeyEventNormalizer? keyEventNormalizer; TerminalConfig _config; TerminalScreen _activeScreen = .primary; @@ -60,18 +63,21 @@ class TerminalControllerImpl extends TerminalController ScrollController? _scrollController; var _lastCols = 0; var _lastRows = 0; - - TerminalControllerImpl({TerminalConfig config = const TerminalConfig()}) - : _config = config, - _keyEvent = vt.KeyEvent(), - _mouseEvent = MouseEvent(), - _textInput = TerminalInputClient(), - terminal = Terminal( - cols: config.cols, - rows: config.rows, - maxScrollback: config.scrollbackLimit, - ), - super.base() { + final _textInputPhysicalKeys = {}; + + TerminalControllerImpl({ + TerminalConfig config = const TerminalConfig(), + this.keyEventNormalizer, + }) : _config = config, + _keyEvent = vt.KeyEvent(), + _mouseEvent = MouseEvent(), + _textInput = TerminalInputClient(), + terminal = Terminal( + cols: config.cols, + rows: config.rows, + maxScrollback: config.scrollbackLimit, + ), + super.base() { _lastCols = config.cols; _lastRows = config.rows; _selectionGesture = SelectionGestureDriver(terminal); @@ -277,37 +283,56 @@ class TerminalControllerImpl extends TerminalController if (action == null) return .ignored; + if (action == .release && + _textInputPhysicalKeys.remove(event.physicalKey)) { + return .skipRemainingHandlers; + } + if (_shouldForwardCompositionKeyToTextInput) { + if (action == .press || action == .repeat) { + _textInputPhysicalKeys.add(event.physicalKey); + } return .skipRemainingHandlers; } - final unshiftedCodepoint = unshiftedCodepointForKey(key); final mods = _currentMods(); final character = _encoderCharacter(event.character); - final consumedMods = _consumedModsFor( - character, - unshiftedCodepoint: unshiftedCodepoint, + var normalized = _keyboardNormalizer.normalize( + event, + key: key, + action: action, mods: mods, + character: character, + composing: _hasActiveComposition, + optionAsAlt: _config.optionAsAlt, ); + normalized = keyEventNormalizer?.call(event, normalized) ?? normalized; + if (normalized.deferToTextInput) { + if (action == .press || action == .repeat) { + _textInputPhysicalKeys.add(event.physicalKey); + } + return .skipRemainingHandlers; + } + if (action == .press) _textInputPhysicalKeys.remove(event.physicalKey); _keyEvent - ..key = key - ..mods = mods - ..action = action - ..utf8 = character - ..consumedMods = consumedMods - ..unshiftedCodepoint = unshiftedCodepoint - ..composing = _hasActiveComposition; - - _keyEncoder.sync(terminal); + ..key = normalized.key + ..mods = normalized.mods + ..action = normalized.action + ..utf8 = _encoderCharacter(normalized.text) + ..consumedMods = normalized.consumedMods + ..unshiftedCodepoint = normalized.unshiftedCodepoint + ..composing = normalized.composing; + + _syncKeyEncoder(); final result = _keyEncoder.encode(_keyEvent); if (result.isEmpty) return _hasActiveComposition ? .handled : .ignored; if (_shouldRouteKeyThroughTextInput( - action: action, - character: character, + action: normalized.action, + character: normalized.text, encoded: result, - mods: mods, + mods: normalized.mods, )) { _onTextInput(); return .skipRemainingHandlers; @@ -315,9 +340,9 @@ class TerminalControllerImpl extends TerminalController clearVirtualMods(); final forwardToPlatformIme = _consumeCommittedCompositionEditKey( - key, - action, - mods, + normalized.key, + normalized.action, + normalized.mods, ); _emitOutput(utf8.encode(result)); _onTextInput(); @@ -645,25 +670,6 @@ class TerminalControllerImpl extends TerminalController return _textInput.consumeCommittedCompositionEdit(); } - Mods _consumedModsFor( - String? character, { - required int unshiftedCodepoint, - required Mods mods, - }) { - // Flutter does not expose consumed modifiers, so this fallback only - // accounts for Shift producing a different single-codepoint character. - if (!mods.hasShift || character == null || unshiftedCodepoint == 0) { - return const .none(); - } - - final codepoints = character.runes.iterator; - if (!codepoints.moveNext()) return const .none(); - final codepoint = codepoints.current; - if (codepoints.moveNext()) return const .none(); - if (codepoint == unshiftedCodepoint) return const .none(); - return const .shift(); - } - Mods _currentMods() { var mods = _virtualMods; final keyboard = HardwareKeyboard.instance; @@ -671,6 +677,26 @@ class TerminalControllerImpl extends TerminalController if (keyboard.isControlPressed) mods = mods | const .ctrl(); if (keyboard.isAltPressed) mods = mods | const .alt(); if (keyboard.isMetaPressed) mods = mods | const .superKey(); + final pressed = keyboard.physicalKeysPressed; + if (pressed.contains(PhysicalKeyboardKey.shiftRight)) { + mods = mods | const .shiftSide(); + } + if (pressed.contains(PhysicalKeyboardKey.controlRight)) { + mods = mods | const .ctrlSide(); + } + if (pressed.contains(PhysicalKeyboardKey.altRight)) { + mods = mods | const .altSide(); + } + if (pressed.contains(PhysicalKeyboardKey.metaRight)) { + mods = mods | const .superSide(); + } + final locks = keyboard.lockModesEnabled; + if (locks.contains(KeyboardLockMode.capsLock)) { + mods = mods | const .capsLock(); + } + if (locks.contains(KeyboardLockMode.numLock)) { + mods = mods | const .numLock(); + } return mods; } @@ -692,20 +718,26 @@ class TerminalControllerImpl extends TerminalController } String _encodeKeyPress(vt.Key key, {Mods mods = const .none()}) { - final codepoint = unshiftedCodepointForKey(key); + final unshiftedCodepoint = unshiftedCodepointForKey(key); + final codepoint = codepointForKey(key, mods); _keyEvent ..key = key ..mods = mods ..action = .press - ..consumedMods = const .none() - ..unshiftedCodepoint = codepoint + ..consumedMods = consumedModsForKey(key, mods) + ..unshiftedCodepoint = unshiftedCodepoint ..utf8 = codepoint > 0 ? String.fromCharCode(codepoint) : null ..composing = false; - _keyEncoder.sync(terminal); + _syncKeyEncoder(); return _keyEncoder.encode(_keyEvent); } + void _syncKeyEncoder() { + _keyEncoder.sync(terminal); + _keyEncoder.setOptionAsAlt(_config.optionAsAlt); + } + void _emitOutput(Uint8List bytes) => onOutput?.call(bytes); void _ensureGridSize() { diff --git a/packages/flterm/linux/CMakeLists.txt b/packages/flterm/linux/CMakeLists.txt new file mode 100644 index 00000000..17d3330b --- /dev/null +++ b/packages/flterm/linux/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.10) + +set(PROJECT_NAME "flterm") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "flterm_plugin") + +add_library(${PLUGIN_NAME} SHARED + "flterm_plugin.cc" +) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter PkgConfig::GTK) + +set(flterm_bundled_libraries "" PARENT_SCOPE) diff --git a/packages/flterm/linux/flterm_plugin.cc b/packages/flterm/linux/flterm_plugin.cc new file mode 100644 index 00000000..dfa3ca5d --- /dev/null +++ b/packages/flterm/linux/flterm_plugin.cc @@ -0,0 +1,190 @@ +#include "include/flterm/flterm_plugin.h" + +#include +#include +#include + +#define FLTERM_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), flterm_plugin_get_type(), FltermPlugin)) + +namespace { + +constexpr char kChannelName[] = "dev.flterm/native_keyboard"; + +enum ModBits { + kShift = 1 << 0, + kControl = 1 << 1, + kAlt = 1 << 2, + kSuper = 1 << 3, + kCapsLock = 1 << 4, + kNumLock = 1 << 5, + kShiftSide = 1 << 6, + kControlSide = 1 << 7, + kAltSide = 1 << 8, + kSuperSide = 1 << 9, +}; + +int64_t ModifiersFromGdk(guint state, bool level3_is_alt = false) { + int64_t result = 0; + if ((state & GDK_SHIFT_MASK) != 0) result |= kShift; + if ((state & GDK_CONTROL_MASK) != 0) result |= kControl; + if ((state & GDK_MOD1_MASK) != 0) result |= kAlt; + if (level3_is_alt && (state & GDK_MOD5_MASK) != 0) result |= kAlt; + if ((state & (GDK_SUPER_MASK | GDK_META_MASK)) != 0) result |= kSuper; + if ((state & GDK_LOCK_MASK) != 0) result |= kCapsLock; + if ((state & GDK_MOD2_MASK) != 0) result |= kNumLock; + return result; +} + +bool IsModifierKey(guint keyval) { + switch (keyval) { + case GDK_KEY_Shift_L: + case GDK_KEY_Shift_R: + case GDK_KEY_Control_L: + case GDK_KEY_Control_R: + case GDK_KEY_Alt_L: + case GDK_KEY_Alt_R: + case GDK_KEY_Meta_L: + case GDK_KEY_Meta_R: + case GDK_KEY_Super_L: + case GDK_KEY_Super_R: + case GDK_KEY_Hyper_L: + case GDK_KEY_Hyper_R: + case GDK_KEY_Caps_Lock: + case GDK_KEY_Num_Lock: + case GDK_KEY_ISO_Level3_Shift: + case GDK_KEY_Mode_switch: + return true; + default: + return false; + } +} + +bool IsDeadKey(guint keyval) { + const gchar* name = gdk_keyval_name(keyval); + return name != nullptr && g_str_has_prefix(name, "dead_"); +} + +void Set(FlValue* map, const char* key, FlValue* value) { + fl_value_set_string_take(map, key, value); +} + +} // namespace + +struct _FltermPlugin { + GObject parent_instance; + FlBasicMessageChannel* channel; + FlView* view; + gulong key_press_handler; + gulong key_release_handler; +}; + +G_DEFINE_TYPE(FltermPlugin, flterm_plugin, g_object_get_type()) + +static gboolean SendKeyEvent(FltermPlugin* self, + GdkEventKey* event, + gboolean down) { + if (IsModifierKey(event->keyval)) return FALSE; + + GdkKeymap* keymap = gdk_keymap_get_for_display( + gtk_widget_get_display(GTK_WIDGET(self->view))); + + guint translated_keyval = 0; + gint effective_group = 0; + gint level = 0; + GdkModifierType consumed = static_cast(0); + gdk_keymap_translate_keyboard_state( + keymap, event->hardware_keycode, + static_cast(event->state), event->group, + &translated_keyval, &effective_group, &level, &consumed); + + const GdkKeymapKey unshifted_key = { + event->hardware_keycode, + static_cast(event->group), + 0, + }; + const guint unshifted_keyval = gdk_keymap_lookup_key(keymap, &unshifted_key); + gunichar unshifted = gdk_keyval_to_unicode(unshifted_keyval); + if (unshifted < 0x20 || unshifted == 0x7F) unshifted = 0; + + g_autoptr(FlValue) message = fl_value_new_map(); + Set(message, "platform", fl_value_new_string("linux")); + Set(message, "scanCode", fl_value_new_int(event->hardware_keycode)); + Set(message, "keyCode", fl_value_new_int(event->keyval)); + Set(message, "down", fl_value_new_bool(down)); + Set(message, "eventTime", fl_value_new_int(event->time)); + Set(message, "mods", + fl_value_new_int(ModifiersFromGdk(event->state, true))); + Set(message, "consumedMods", + fl_value_new_int( + ModifiersFromGdk(consumed & event->state, true))); + Set(message, "unshiftedCodepoint", fl_value_new_int(unshifted)); + Set(message, "textWithoutAlt", fl_value_new_null()); + Set(message, "deadKey", + fl_value_new_bool(down && IsDeadKey(event->keyval))); + fl_basic_message_channel_send(self->channel, message, nullptr, nullptr, + nullptr); + return FALSE; +} + +static gboolean KeyPressCallback(GtkWidget*, + GdkEventKey* event, + gpointer user_data) { + return SendKeyEvent(FLTERM_PLUGIN(user_data), event, TRUE); +} + +static gboolean KeyReleaseCallback(GtkWidget*, + GdkEventKey* event, + gpointer user_data) { + return SendKeyEvent(FLTERM_PLUGIN(user_data), event, FALSE); +} + +static void flterm_plugin_dispose(GObject* object) { + FltermPlugin* self = FLTERM_PLUGIN(object); + if (self->view != nullptr) { + if (self->key_press_handler != 0) { + g_signal_handler_disconnect(self->view, self->key_press_handler); + } + if (self->key_release_handler != 0) { + g_signal_handler_disconnect(self->view, self->key_release_handler); + } + g_object_remove_weak_pointer(G_OBJECT(self->view), + reinterpret_cast(&self->view)); + self->view = nullptr; + } + g_clear_object(&self->channel); + G_OBJECT_CLASS(flterm_plugin_parent_class)->dispose(object); +} + +static void flterm_plugin_class_init(FltermPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = flterm_plugin_dispose; +} + +static void flterm_plugin_init(FltermPlugin* self) { + self->channel = nullptr; + self->view = nullptr; + self->key_press_handler = 0; + self->key_release_handler = 0; +} + +void flterm_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + FltermPlugin* plugin = FLTERM_PLUGIN( + g_object_new(flterm_plugin_get_type(), nullptr)); + g_autoptr(FlStandardMessageCodec) codec = fl_standard_message_codec_new(); + plugin->channel = fl_basic_message_channel_new( + fl_plugin_registrar_get_messenger(registrar), kChannelName, + FL_MESSAGE_CODEC(codec)); + plugin->view = fl_plugin_registrar_get_view(registrar); + if (plugin->view == nullptr) { + g_object_unref(plugin); + return; + } + g_object_add_weak_pointer(G_OBJECT(plugin->view), + reinterpret_cast(&plugin->view)); + plugin->key_press_handler = g_signal_connect( + plugin->view, "key-press-event", G_CALLBACK(KeyPressCallback), plugin); + plugin->key_release_handler = g_signal_connect( + plugin->view, "key-release-event", G_CALLBACK(KeyReleaseCallback), plugin); + g_object_set_data_full(G_OBJECT(plugin->view), "dev.flterm.plugin", plugin, + g_object_unref); +} diff --git a/packages/flterm/linux/include/flterm/flterm_plugin.h b/packages/flterm/linux/include/flterm/flterm_plugin.h new file mode 100644 index 00000000..af2ad0c7 --- /dev/null +++ b/packages/flterm/linux/include/flterm/flterm_plugin.h @@ -0,0 +1,26 @@ +#ifndef FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _FltermPlugin FltermPlugin; +typedef struct { + GObjectClass parent_class; +} FltermPluginClass; + +FLUTTER_PLUGIN_EXPORT GType flterm_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void flterm_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ diff --git a/packages/flterm/macos/flterm.podspec b/packages/flterm/macos/flterm.podspec new file mode 100644 index 00000000..e9ef34b1 --- /dev/null +++ b/packages/flterm/macos/flterm.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'flterm' + s.version = '0.0.4' + s.summary = 'Flutter terminal widget on top of Ghostty.' + s.description = 'Native keyboard metadata companion for flterm.' + s.homepage = 'https://github.com/elias8/libghostty' + s.license = { :file => '../LICENSE' } + s.author = { 'libghostty contributors' => 'opensource@example.invalid' } + s.source = { :path => '.' } + s.source_files = 'flterm/Sources/flterm/**/*' + s.dependency 'FlutterMacOS' + s.platform = :osx, '12.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.9' +end diff --git a/packages/flterm/macos/flterm/Package.swift b/packages/flterm/macos/flterm/Package.swift new file mode 100644 index 00000000..611fe5a2 --- /dev/null +++ b/packages/flterm/macos/flterm/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "flterm", + platforms: [.macOS("12.0")], + products: [.library(name: "flterm", targets: ["flterm"])], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework") + ], + targets: [ + .target( + name: "flterm", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework") + ] + ) + ] +) diff --git a/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift b/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift new file mode 100644 index 00000000..5294b639 --- /dev/null +++ b/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift @@ -0,0 +1,156 @@ +import Carbon +import Cocoa +import FlutterMacOS + +public final class FltermPlugin: NSObject, FlutterPlugin { + private static let channelName = "dev.flterm/native_keyboard" + + private let channel: FlutterBasicMessageChannel + private weak var view: NSView? + private var monitor: Any? + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = FltermPlugin( + messenger: registrar.messenger, + view: registrar.view) + registrar.publish(instance) + } + + private init(messenger: FlutterBinaryMessenger, view: NSView?) { + channel = FlutterBasicMessageChannel( + name: Self.channelName, + binaryMessenger: messenger, + codec: FlutterStandardMessageCodec.sharedInstance()) + self.view = view + super.init() + monitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .keyUp] + ) { [weak self] event in + self?.handle(event) + return event + } + } + + deinit { + if let monitor { + NSEvent.removeMonitor(monitor) + } + } + + private func handle(_ event: NSEvent) { + guard let view, + let window = view.window, + event.window === window else { + return + } + + let unmodified = terminalText(event.characters(byApplyingModifiers: [])) + let textWithoutAlt = terminalText( + event.characters( + byApplyingModifiers: event.modifierFlags.subtracting(.option))) + channel.sendMessage([ + "platform": "macos", + "scanCode": Int(event.keyCode), + "keyCode": Int(event.keyCode), + "down": event.type == .keyDown, + "eventTime": Int((event.timestamp * 1_000_000).rounded()), + "mods": modifierBits(event.modifierFlags), + "consumedMods": consumedModifierBits(event), + "unshiftedCodepoint": singleScalar(unmodified), + "textWithoutAlt": textWithoutAlt ?? NSNull(), + "deadKey": event.type == .keyDown && isDeadKey(event), + ]) + } + + private func consumedModifierBits(_ event: NSEvent) -> Int { + guard let produced = terminalText(event.characters) else { return 0 } + var result = 0 + let candidates: [(NSEvent.ModifierFlags, Int)] = [ + (.shift, 1 << 0), + (.option, 1 << 2), + (.capsLock, 1 << 4), + ] + for (flag, bit) in candidates where event.modifierFlags.contains(flag) { + let without = terminalText( + event.characters( + byApplyingModifiers: event.modifierFlags.subtracting(flag))) + if without != produced { + result |= bit + } + } + return result + } + + private func isDeadKey(_ event: NSEvent) -> Bool { + guard let unmanagedSource = TISCopyCurrentKeyboardLayoutInputSource() else { + return (event.characters?.isEmpty ?? true) && + !(event.characters(byApplyingModifiers: [])?.isEmpty ?? true) + } + let source = unmanagedSource.takeRetainedValue() + guard let rawLayout = TISGetInputSourceProperty( + source, + kTISPropertyUnicodeKeyLayoutData) else { + return (event.characters?.isEmpty ?? true) && + !(event.characters(byApplyingModifiers: [])?.isEmpty ?? true) + } + + let layoutData = unsafeBitCast(rawLayout, to: CFData.self) + guard let bytes = CFDataGetBytePtr(layoutData) else { return false } + let layout = UnsafeRawPointer(bytes) + .assumingMemoryBound(to: UCKeyboardLayout.self) + var deadKeyState: UInt32 = 0 + var length: UniCharCount = 0 + var characters = [UniChar](repeating: 0, count: 4) + let modifiers = UInt32( + (event.modifierFlags.rawValue >> 16) & 0xFF) + let status = characters.withUnsafeMutableBufferPointer { buffer in + UCKeyTranslate( + layout, + event.keyCode, + UInt16(kUCKeyActionDown), + modifiers, + UInt32(LMGetKbdType()), + OptionBits(0), + &deadKeyState, + UniCharCount(buffer.count), + &length, + buffer.baseAddress!) + } + return status == noErr && deadKeyState != 0 + } + + private func modifierBits(_ flags: NSEvent.ModifierFlags) -> Int { + var result = 0 + if flags.contains(.shift) { result |= 1 << 0 } + if flags.contains(.control) { result |= 1 << 1 } + if flags.contains(.option) { result |= 1 << 2 } + if flags.contains(.command) { result |= 1 << 3 } + if flags.contains(.capsLock) { result |= 1 << 4 } + + let raw = flags.rawValue + if raw & 0x04 != 0 { result |= 1 << 6 } + if raw & 0x2000 != 0 { result |= 1 << 7 } + if raw & 0x40 != 0 { result |= 1 << 8 } + if raw & 0x10 != 0 { result |= 1 << 9 } + return result + } + + private func singleScalar(_ value: String?) -> Int { + guard let value else { return 0 } + let scalars = value.unicodeScalars + guard scalars.count == 1, let scalar = scalars.first else { return 0 } + return Int(scalar.value) + } + + private func terminalText(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + let scalars = value.unicodeScalars + if scalars.count == 1, let codepoint = scalars.first?.value { + if codepoint < 0x20 || codepoint == 0x7F || + (codepoint >= 0xF700 && codepoint <= 0xF8FF) { + return nil + } + } + return value + } +} diff --git a/packages/flterm/pubspec.yaml b/packages/flterm/pubspec.yaml index b5b48371..34b5d684 100644 --- a/packages/flterm/pubspec.yaml +++ b/packages/flterm/pubspec.yaml @@ -33,6 +33,16 @@ dependencies: libghostty: ^0.0.11 meta: ^1.18.0 +flutter: + plugin: + platforms: + linux: + pluginClass: FltermPlugin + macos: + pluginClass: FltermPlugin + windows: + pluginClass: FltermPluginCApi + dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/flterm/test/foundation/platform_map_test.dart b/packages/flterm/test/foundation/platform_map_test.dart index 4d0e1342..4bdc1632 100644 --- a/packages/flterm/test/foundation/platform_map_test.dart +++ b/packages/flterm/test/foundation/platform_map_test.dart @@ -1,7 +1,7 @@ import 'package:flterm/src/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:libghostty/libghostty.dart' show Key; +import 'package:libghostty/libghostty.dart' show Key, Mods; void main() { group('PlatformMap', () { @@ -78,6 +78,7 @@ void main() { expect(unshiftedCodepointForKey(Key.bracketLeft), 0x5b); expect(unshiftedCodepointForKey(Key.backslash), 0x5c); expect(unshiftedCodepointForKey(Key.slash), 0x2f); + expect(unshiftedCodepointForKey(Key.space), 0x20); }); test('returns zero for non-character keys', () { @@ -86,5 +87,34 @@ void main() { expect(unshiftedCodepointForKey(Key.f1), 0); }); }); + + group('programmatic key translation', () { + test('applies US shift and caps lock translation', () { + expect(codepointForKey(Key.a, const Mods.shift()), 0x41); + expect(codepointForKey(Key.a, const Mods.capsLock()), 0x41); + expect( + codepointForKey(Key.a, const Mods.shift() | const Mods.capsLock()), + 0x61, + ); + expect(codepointForKey(Key.digit1, const Mods.shift()), 0x21); + expect(codepointForKey(Key.space, const Mods.none()), 0x20); + }); + + test('reports modifiers consumed by translation', () { + expect(consumedModsForKey(Key.a, const Mods.shift()).hasShift, isTrue); + expect( + consumedModsForKey(Key.a, const Mods.capsLock()).hasCapsLock, + isTrue, + ); + expect( + consumedModsForKey(Key.digit1, const Mods.shift()).hasShift, + isTrue, + ); + expect( + consumedModsForKey(Key.space, const Mods.shift()).isEmpty, + isTrue, + ); + }); + }); }); } diff --git a/packages/flterm/test/foundation/terminal_config_test.dart b/packages/flterm/test/foundation/terminal_config_test.dart index e3754310..a5854ab1 100644 --- a/packages/flterm/test/foundation/terminal_config_test.dart +++ b/packages/flterm/test/foundation/terminal_config_test.dart @@ -14,6 +14,7 @@ void main() { expect(config.cursorStyle, CursorShape.block); expect(config.cursorBlink, isNull); expect(config.glyphProtocol, isFalse); + expect(config.optionAsAlt, OptionAsAlt.false$); expect(config.apcBufferLimit, TerminalConfig.defaultApcBufferLimit); expect(config.scrollToBottom, ScrollToBottom.onKeystroke); expect(config.selectionClearOnTyping, isTrue); @@ -60,12 +61,14 @@ void main() { scrollbackLimit: 99999, apcBufferLimit: 1024, glyphProtocol: true, + optionAsAlt: OptionAsAlt.right, cursorBlink: false, ); expect(updated.scrollbackLimit, 99999); expect(updated.apcBufferLimit, 1024); expect(updated.glyphProtocol, isTrue); + expect(updated.optionAsAlt, OptionAsAlt.right); expect(updated.cursorBlink, isFalse); expect(updated.cols, config.cols); }); @@ -104,6 +107,9 @@ void main() { const glyphProtocol = TerminalConfig(glyphProtocol: true); expect(a, isNot(equals(glyphProtocol))); + + const optionAsAlt = TerminalConfig(optionAsAlt: OptionAsAlt.true$); + expect(a, isNot(equals(optionAsAlt))); }); test('ignores map order', () { diff --git a/packages/flterm/test/foundation/terminal_keyboard_event_test.dart b/packages/flterm/test/foundation/terminal_keyboard_event_test.dart new file mode 100644 index 00000000..85cf8e3c --- /dev/null +++ b/packages/flterm/test/foundation/terminal_keyboard_event_test.dart @@ -0,0 +1,52 @@ +import 'package:flterm/src/foundation/terminal_keyboard_event.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart'; + +void main() { + group('TerminalKeyboardEvent', () { + test('stores normalized terminal input', () { + const event = TerminalKeyboardEvent( + key: Key.a, + action: KeyAction.press, + mods: Mods.shift(), + consumedMods: Mods.shift(), + text: 'A', + unshiftedCodepoint: 0x61, + composing: true, + deferToTextInput: true, + ); + + expect(event.key, Key.a); + expect(event.action, KeyAction.press); + expect(event.mods.hasShift, isTrue); + expect(event.consumedMods.hasShift, isTrue); + expect(event.text, 'A'); + expect(event.unshiftedCodepoint, 0x61); + expect(event.composing, isTrue); + expect(event.deferToTextInput, isTrue); + }); + + test('copyWith replaces and clears fields', () { + const event = TerminalKeyboardEvent( + key: Key.a, + action: KeyAction.press, + mods: Mods.none(), + text: 'a', + unshiftedCodepoint: 0x61, + ); + + final copy = event.copyWith( + key: Key.b, + action: KeyAction.repeat, + clearText: true, + unshiftedCodepoint: 0x62, + ); + + expect(copy.key, Key.b); + expect(copy.action, KeyAction.repeat); + expect(copy.text, isNull); + expect(copy.unshiftedCodepoint, 0x62); + expect(copy.mods.isEmpty, isTrue); + }); + }); +} diff --git a/packages/flterm/test/widgets/keyboard_event_normalizer_test.dart b/packages/flterm/test/widgets/keyboard_event_normalizer_test.dart new file mode 100644 index 00000000..e2cb15d2 --- /dev/null +++ b/packages/flterm/test/widgets/keyboard_event_normalizer_test.dart @@ -0,0 +1,140 @@ +import 'package:flterm/src/widgets/keyboard_event_normalizer.dart'; +import 'package:flterm/src/widgets/native_keyboard_metadata.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libghostty/libghostty.dart' hide KeyEvent; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const keyEvent = KeyDownEvent( + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + character: 'A', + timeStamp: Duration.zero, + ); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + test('uses native layout translation and consumed modifiers', () { + final normalizer = KeyboardEventNormalizer( + nativeMetadataForEvent: (_) => const NativeKeyboardMetadata( + mods: Mods.shift(), + consumedMods: Mods.shift(), + unshiftedCodepoint: 0x71, + textWithoutAlt: null, + deadKey: false, + ), + ); + + final result = normalizer.normalize( + keyEvent, + key: Key.a, + action: KeyAction.press, + mods: const Mods.shift(), + character: 'A', + composing: false, + optionAsAlt: OptionAsAlt.false$, + ); + + expect(result.unshiftedCodepoint, 0x71); + expect(result.consumedMods.hasShift, isTrue); + expect(result.text, 'A'); + expect(result.deferToTextInput, isFalse); + }); + + test('option as alt preserves other translation modifiers', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final normalizer = KeyboardEventNormalizer( + nativeMetadataForEvent: (_) => const NativeKeyboardMetadata( + mods: Mods.none(), + consumedMods: Mods.none(), + unshiftedCodepoint: 0x32, + textWithoutAlt: '@', + deadKey: true, + ), + ); + + final result = normalizer.normalize( + keyEvent, + key: Key.digit2, + action: KeyAction.press, + mods: const Mods.shift() | const Mods.alt(), + character: '€', + composing: false, + optionAsAlt: OptionAsAlt.true$, + ); + + expect(result.text, '@'); + expect(result.mods.hasAlt, isTrue); + expect(result.consumedMods.hasAlt, isFalse); + expect(result.consumedMods.hasShift, isTrue); + expect(result.composing, isFalse); + expect(result.deferToTextInput, isFalse); + }); + + test('dead key defers to platform text input', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final normalizer = KeyboardEventNormalizer( + nativeMetadataForEvent: (_) => const NativeKeyboardMetadata( + mods: Mods.alt(), + consumedMods: Mods.alt(), + unshiftedCodepoint: 0x65, + textWithoutAlt: 'e', + deadKey: true, + ), + ); + + final result = normalizer.normalize( + keyEvent, + key: Key.e, + action: KeyAction.press, + mods: const Mods.alt(), + character: null, + composing: false, + optionAsAlt: OptionAsAlt.false$, + ); + + expect(result.composing, isTrue); + expect(result.deferToTextInput, isTrue); + }); + + test('option as alt honors the configured side', () { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final normalizer = KeyboardEventNormalizer( + nativeMetadataForEvent: (_) => const NativeKeyboardMetadata( + mods: Mods.none(), + consumedMods: Mods.none(), + unshiftedCodepoint: 0x61, + textWithoutAlt: 'a', + deadKey: false, + ), + ); + + final left = normalizer.normalize( + keyEvent, + key: Key.a, + action: KeyAction.press, + mods: const Mods.alt(), + character: 'å', + composing: false, + optionAsAlt: OptionAsAlt.right, + ); + final right = normalizer.normalize( + keyEvent, + key: Key.a, + action: KeyAction.press, + mods: const Mods.alt() | const Mods.altSide(), + character: 'å', + composing: false, + optionAsAlt: OptionAsAlt.right, + ); + + expect(left.text, 'å'); + expect(right.text, 'a'); + }); +} diff --git a/packages/flterm/test/widgets/native_keyboard_metadata_test.dart b/packages/flterm/test/widgets/native_keyboard_metadata_test.dart new file mode 100644 index 00000000..39a4f2e6 --- /dev/null +++ b/packages/flterm/test/widgets/native_keyboard_metadata_test.dart @@ -0,0 +1,117 @@ +import 'package:flterm/src/widgets/native_keyboard_metadata.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('newest native record wins after an unmatched redispatch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final store = NativeKeyboardMetadataStore.instance..ensureInitialized(); + final received = []; + bool capture(KeyEvent event) { + if (event.physicalKey == PhysicalKeyboardKey.keyA) { + final metadata = store.forEvent(event); + if (metadata != null) received.add(metadata); + } + return false; + } + + HardwareKeyboard.instance.addHandler(capture); + addTearDown(() => HardwareKeyboard.instance.removeHandler(capture)); + + final keyData = KeyEventSimulator.getKeyData( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + character: 'a', + ); + + Future sendMetadata({ + required int eventTime, + required int unshiftedCodepoint, + bool down = true, + }) async { + final message = { + 'platform': 'linux', + 'scanCode': keyData['scanCode']! as int, + 'keyCode': keyData['keyCode']! as int, + 'down': down, + 'eventTime': eventTime, + 'mods': 0, + 'consumedMods': 0, + 'unshiftedCodepoint': unshiftedCodepoint, + 'textWithoutAlt': null, + 'deadKey': false, + }; + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'dev.flterm/native_keyboard', + const StandardMessageCodec().encodeMessage(message), + (_) {}, + ); + } + + await sendMetadata(eventTime: 1, unshiftedCodepoint: 0x71); + await simulateKeyDownEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + character: 'a', + ); + await simulateKeyUpEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + ); + + // A native redispatch can arrive without a second Flutter key event. + await sendMetadata(eventTime: 1, unshiftedCodepoint: 0x71); + await sendMetadata(eventTime: 2, unshiftedCodepoint: 0x7A); + await simulateKeyDownEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + character: 'a', + ); + await simulateKeyUpEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + ); + + // A raw key-up without a regularized KeyEvent can also leave a stale + // second-stage record. The next complete tap must use newer metadata. + await sendMetadata(eventTime: 3, unshiftedCodepoint: 0x78, down: false); + final orphanUp = {...keyData, 'type': 'keyup'}; + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.keyEvent.name, + SystemChannels.keyEvent.codec.encodeMessage(orphanUp), + (_) {}, + ); + await sendMetadata(eventTime: 4, unshiftedCodepoint: 0x62); + await simulateKeyDownEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + character: 'a', + ); + await sendMetadata(eventTime: 5, unshiftedCodepoint: 0x63, down: false); + await simulateKeyUpEvent( + LogicalKeyboardKey.keyA, + platform: 'linux', + physicalKey: PhysicalKeyboardKey.keyA, + ); + + debugDefaultTargetPlatformOverride = null; + expect(received, hasLength(4)); + expect(received.first.unshiftedCodepoint, 0x71); + expect(received[1].unshiftedCodepoint, 0x7A); + expect(received[2].unshiftedCodepoint, 0x62); + expect(received.last.unshiftedCodepoint, 0x63); + }); +} diff --git a/packages/flterm/test/widgets/terminal_controller_test.dart b/packages/flterm/test/widgets/terminal_controller_test.dart index bd232c5f..91a20209 100644 --- a/packages/flterm/test/widgets/terminal_controller_test.dart +++ b/packages/flterm/test/widgets/terminal_controller_test.dart @@ -104,6 +104,18 @@ void main() { expect(utf8.decode(output.first), 'a'); }); + test('encodes shifted and space key output', () { + final output = []; + controller.onOutput = output.add; + + controller + ..sendKey(Key.a, mods: const Mods.shift()) + ..sendKey(Key.digit1, mods: const Mods.shift()) + ..sendKey(Key.space); + + expect(output.map(utf8.decode), ['A', '!', ' ']); + }); + test('ignores missing output callback', () { expect(() => controller.sendKey(Key.a), returnsNormally); }); diff --git a/packages/flterm/test/widgets/terminal_view_binding_test.dart b/packages/flterm/test/widgets/terminal_view_binding_test.dart index 89deef97..6fa66ce9 100644 --- a/packages/flterm/test/widgets/terminal_view_binding_test.dart +++ b/packages/flterm/test/widgets/terminal_view_binding_test.dart @@ -7,7 +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'; +import 'package:flutter/widgets.dart' hide Key; import 'package:flutter_test/flutter_test.dart'; import 'package:libghostty/libghostty.dart' hide KeyEvent; @@ -200,6 +200,89 @@ void main() { }); group('handleKeyEvent', () { + test('applies a custom normalized key event', () { + controller.dispose(); + TerminalKeyboardEvent? fallback; + controller = TerminalControllerImpl( + keyEventNormalizer: (event, normalized) { + fallback = normalized; + return normalized.copyWith( + key: Key.b, + text: 'b', + unshiftedCodepoint: 0x62, + ); + }, + ); + binding = controller as TerminalViewBinding; + final output = []; + controller.onOutput = output.add; + + final result = binding.handleKeyEvent( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + character: 'a', + timeStamp: Duration.zero, + ), + ); + + expect(result, KeyEventResult.handled); + expect(fallback?.key, Key.a); + expect(fallback?.text, 'a'); + expect(fallback?.unshiftedCodepoint, 0x61); + expect(utf8.decode(output.single), 'b'); + }); + + test('custom normalizer can defer a key to platform text input', () { + controller.dispose(); + controller = TerminalControllerImpl( + keyEventNormalizer: (event, normalized) => + normalized.copyWith(deferToTextInput: true), + ); + binding = controller as TerminalViewBinding; + final output = []; + controller.onOutput = output.add; + + final result = binding.handleKeyEvent( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.quote, + logicalKey: LogicalKeyboardKey.quoteSingle, + timeStamp: Duration.zero, + ), + ); + + expect(result, KeyEventResult.skipRemainingHandlers); + expect(output, isEmpty); + }); + + test('defers the release paired with a text input key', () { + controller.dispose(); + controller = TerminalControllerImpl( + keyEventNormalizer: (event, normalized) => event is KeyDownEvent + ? normalized.copyWith(deferToTextInput: true) + : normalized, + ); + binding = controller as TerminalViewBinding; + + final down = binding.handleKeyEvent( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.quote, + logicalKey: LogicalKeyboardKey.quoteSingle, + timeStamp: Duration.zero, + ), + ); + final up = binding.handleKeyEvent( + const KeyUpEvent( + physicalKey: PhysicalKeyboardKey.quote, + logicalKey: LogicalKeyboardKey.quoteSingle, + timeStamp: Duration.zero, + ), + ); + + expect(down, KeyEventResult.skipRemainingHandlers); + expect(up, KeyEventResult.skipRemainingHandlers); + }); + test('returns handled and emits output for printable key', () { final output = []; controller.onOutput = output.add; diff --git a/packages/flterm/windows/CMakeLists.txt b/packages/flterm/windows/CMakeLists.txt new file mode 100644 index 00000000..a716d846 --- /dev/null +++ b/packages/flterm/windows/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.14) + +set(PROJECT_NAME "flterm") +project(${PROJECT_NAME} LANGUAGES CXX) +cmake_policy(VERSION 3.14...3.25) + +set(PLUGIN_NAME "flterm_plugin") + +add_library(${PLUGIN_NAME} SHARED + "include/flterm/flterm_plugin_c_api.h" + "flterm_plugin.cpp" + "flterm_plugin.h" + "flterm_plugin_c_api.cpp" +) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE + flutter + flutter_wrapper_plugin + Comctl32.lib +) + +set(flterm_bundled_libraries "" PARENT_SCOPE) diff --git a/packages/flterm/windows/flterm_plugin.cpp b/packages/flterm/windows/flterm_plugin.cpp new file mode 100644 index 00000000..650ce0a9 --- /dev/null +++ b/packages/flterm/windows/flterm_plugin.cpp @@ -0,0 +1,308 @@ +#include +#include + +#include "flterm_plugin.h" + +#include + +#include +#include +#include +#include + +namespace flterm { +namespace { + +constexpr char kChannelName[] = "dev.flterm/native_keyboard"; +constexpr UINT_PTR kSubclassId = 0x464C544D; +constexpr int kExtendedScanCode = 0xE000; +constexpr UINT kTranslateWithoutChangingState = 1 << 2; + +enum ModBits { + kShift = 1 << 0, + kControl = 1 << 1, + kAlt = 1 << 2, + kSuper = 1 << 3, + kCapsLock = 1 << 4, + kNumLock = 1 << 5, + kShiftSide = 1 << 6, + kControlSide = 1 << 7, + kAltSide = 1 << 8, + kSuperSide = 1 << 9, +}; + +bool IsDown(const std::array& state, int key) { + return (state[key] & 0x80) != 0; +} + +bool IsToggled(const std::array& state, int key) { + return (state[key] & 0x01) != 0; +} + +uint16_t ResolveKeyCode(WPARAM wparam, bool extended, uint8_t scan_code) { + switch (wparam) { + case VK_SHIFT: + return static_cast( + MapVirtualKey(scan_code, MAPVK_VSC_TO_VK_EX)); + case VK_CONTROL: + return extended ? VK_RCONTROL : VK_LCONTROL; + case VK_MENU: + return extended ? VK_RMENU : VK_LMENU; + default: + return static_cast(wparam); + } +} + +bool IsModifierKey(uint16_t key_code) { + switch (key_code) { + case VK_LSHIFT: + case VK_RSHIFT: + case VK_LCONTROL: + case VK_RCONTROL: + case VK_LMENU: + case VK_RMENU: + case VK_LWIN: + case VK_RWIN: + case VK_CAPITAL: + case VK_NUMLOCK: + case VK_SCROLL: + return true; + default: + return false; + } +} + +int64_t ModifierBits(const std::array& state) { + int64_t result = 0; + if (IsDown(state, VK_LSHIFT) || IsDown(state, VK_RSHIFT)) result |= kShift; + if (IsDown(state, VK_LCONTROL) || IsDown(state, VK_RCONTROL)) { + result |= kControl; + } + if (IsDown(state, VK_LMENU) || IsDown(state, VK_RMENU)) result |= kAlt; + if (IsDown(state, VK_LWIN) || IsDown(state, VK_RWIN)) result |= kSuper; + if (IsToggled(state, VK_CAPITAL)) result |= kCapsLock; + if (IsToggled(state, VK_NUMLOCK)) result |= kNumLock; + if (IsDown(state, VK_RSHIFT)) result |= kShiftSide; + if (IsDown(state, VK_RCONTROL)) result |= kControlSide; + if (IsDown(state, VK_RMENU)) result |= kAltSide; + if (IsDown(state, VK_RWIN)) result |= kSuperSide; + return result; +} + +struct Translation { + int result = 0; + std::wstring text; +}; + +Translation Translate(uint16_t key_code, + uint8_t scan_code, + const std::array& state, + HKL layout) { + std::array buffer{}; + const int result = ToUnicodeEx( + key_code, scan_code, state.data(), buffer.data(), + static_cast(buffer.size()), kTranslateWithoutChangingState, layout); + Translation translation; + translation.result = result; + if (result > 0) { + translation.text.assign(buffer.data(), result); + } + return translation; +} + +void ClearKey(std::array* state, int key) { + (*state)[key] = 0; +} + +void ClearShift(std::array* state) { + ClearKey(state, VK_SHIFT); + ClearKey(state, VK_LSHIFT); + ClearKey(state, VK_RSHIFT); +} + +void ClearControl(std::array* state) { + ClearKey(state, VK_CONTROL); + ClearKey(state, VK_LCONTROL); + ClearKey(state, VK_RCONTROL); +} + +void ClearAlt(std::array* state) { + ClearKey(state, VK_MENU); + ClearKey(state, VK_LMENU); + ClearKey(state, VK_RMENU); +} + +bool IsPrintable(const std::wstring& text) { + if (text.empty()) return false; + for (wchar_t value : text) { + if (value < 0x20 || value == 0x7F) return false; + } + return true; +} + +int64_t ConsumedModifierBits(uint16_t key_code, + uint8_t scan_code, + const std::array& state, + HKL layout, + const Translation& produced) { + if (produced.result <= 0 || !IsPrintable(produced.text)) return 0; + int64_t result = 0; + + if (IsDown(state, VK_LSHIFT) || IsDown(state, VK_RSHIFT)) { + auto without = state; + ClearShift(&without); + if (Translate(key_code, scan_code, without, layout).text != produced.text) { + result |= kShift; + } + } + if (IsToggled(state, VK_CAPITAL)) { + auto without = state; + without[VK_CAPITAL] &= 0xFE; + if (Translate(key_code, scan_code, without, layout).text != produced.text) { + result |= kCapsLock; + } + } + if (IsDown(state, VK_RMENU) && + (IsDown(state, VK_LCONTROL) || IsDown(state, VK_RCONTROL))) { + auto without = state; + ClearControl(&without); + ClearAlt(&without); + if (Translate(key_code, scan_code, without, layout).text != produced.text) { + result |= kControl | kAlt; + } + } + return result; +} + +uint32_t SingleCodePoint(const std::wstring& text) { + if (text.size() == 1) { + const uint32_t value = text[0]; + if (value < 0x20 || value == 0x7F || + (value >= 0xD800 && value <= 0xDFFF)) { + return 0; + } + return value; + } + if (text.size() != 2) return 0; + const uint32_t high = text[0]; + const uint32_t low = text[1]; + if (high < 0xD800 || high > 0xDBFF || low < 0xDC00 || low > 0xDFFF) { + return 0; + } + return 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00); +} + +} // namespace + +void FltermPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + registrar->AddPlugin(std::make_unique(registrar)); +} + +FltermPlugin::FltermPlugin(flutter::PluginRegistrarWindows* registrar) + : registrar_(registrar), + channel_(std::make_unique< + flutter::BasicMessageChannel>( + registrar->messenger(), kChannelName, + &flutter::StandardMessageCodec::GetInstance())) { + window_proc_id_ = registrar_->RegisterTopLevelWindowProcDelegate( + [this](HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + return HandleWindowMessage(window, message, wparam, lparam); + }); + if (auto* view = registrar_->GetView()) { + view_window_ = view->GetNativeWindow(); + SetWindowSubclass(view_window_, WindowSubclassProc, kSubclassId, + reinterpret_cast(this)); + } +} + +FltermPlugin::~FltermPlugin() { + if (view_window_ != nullptr) { + RemoveWindowSubclass(view_window_, WindowSubclassProc, kSubclassId); + } + if (window_proc_id_ >= 0) { + registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_id_); + } +} + +LRESULT CALLBACK FltermPlugin::WindowSubclassProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam, + UINT_PTR, + DWORD_PTR reference_data) { + auto* plugin = reinterpret_cast(reference_data); + plugin->HandleWindowMessage(window, message, wparam, lparam); + return DefSubclassProc(window, message, wparam, lparam); +} + +std::optional FltermPlugin::HandleWindowMessage(HWND, + UINT message, + WPARAM wparam, + LPARAM lparam) { + switch (message) { + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + case WM_KEYUP: + case WM_SYSKEYUP: + SendKeyboardMetadata(message, wparam, lparam); + break; + default: + break; + } + return std::nullopt; +} + +void FltermPlugin::SendKeyboardMetadata(UINT message, + WPARAM wparam, + LPARAM lparam) { + const bool down = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; + const uint8_t scan_code = static_cast((lparam >> 16) & 0xFF); + const bool extended = ((lparam >> 24) & 0x01) != 0; + const uint16_t key_code = ResolveKeyCode(wparam, extended, scan_code); + if (wparam == VK_PACKET || IsModifierKey(key_code)) return; + + std::array state{}; + if (!GetKeyboardState(state.data())) return; + const HKL layout = GetKeyboardLayout(0); + const Translation produced = Translate(key_code, scan_code, state, layout); + + auto unshifted_state = state; + ClearShift(&unshifted_state); + ClearControl(&unshifted_state); + ClearAlt(&unshifted_state); + ClearKey(&unshifted_state, VK_LWIN); + ClearKey(&unshifted_state, VK_RWIN); + unshifted_state[VK_CAPITAL] &= 0xFE; + const Translation unshifted = + Translate(key_code, scan_code, unshifted_state, layout); + + flutter::EncodableMap message_map{ + {flutter::EncodableValue("platform"), + flutter::EncodableValue("windows")}, + {flutter::EncodableValue("scanCode"), + flutter::EncodableValue(scan_code | + (extended ? kExtendedScanCode : 0))}, + {flutter::EncodableValue("keyCode"), + flutter::EncodableValue(static_cast(key_code))}, + {flutter::EncodableValue("down"), flutter::EncodableValue(down)}, + {flutter::EncodableValue("eventTime"), + flutter::EncodableValue( + static_cast(static_cast(GetMessageTime())))}, + {flutter::EncodableValue("mods"), + flutter::EncodableValue(ModifierBits(state))}, + {flutter::EncodableValue("consumedMods"), + flutter::EncodableValue(ConsumedModifierBits( + key_code, scan_code, state, layout, produced))}, + {flutter::EncodableValue("unshiftedCodepoint"), + flutter::EncodableValue( + static_cast(SingleCodePoint(unshifted.text)))}, + {flutter::EncodableValue("textWithoutAlt"), + flutter::EncodableValue()}, + {flutter::EncodableValue("deadKey"), + flutter::EncodableValue(down && produced.result < 0)}, + }; + channel_->Send(flutter::EncodableValue(std::move(message_map))); +} + +} // namespace flterm diff --git a/packages/flterm/windows/flterm_plugin.h b/packages/flterm/windows/flterm_plugin.h new file mode 100644 index 00000000..ec5f9d15 --- /dev/null +++ b/packages/flterm/windows/flterm_plugin.h @@ -0,0 +1,47 @@ +#ifndef FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ + +#include +#include +#include + +#include +#include + +namespace flterm { + +class FltermPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar); + + explicit FltermPlugin(flutter::PluginRegistrarWindows* registrar); + ~FltermPlugin() override; + + FltermPlugin(const FltermPlugin&) = delete; + FltermPlugin& operator=(const FltermPlugin&) = delete; + + private: + static LRESULT CALLBACK WindowSubclassProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam, + UINT_PTR subclass_id, + DWORD_PTR reference_data); + + std::optional HandleWindowMessage(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam); + void SendKeyboardMetadata(UINT message, WPARAM wparam, LPARAM lparam); + + flutter::PluginRegistrarWindows* registrar_; + std::unique_ptr> + channel_; + HWND view_window_ = nullptr; + int window_proc_id_ = -1; +}; + +} // namespace flterm + +#endif // FLUTTER_PLUGIN_FLTERM_PLUGIN_H_ diff --git a/packages/flterm/windows/flterm_plugin_c_api.cpp b/packages/flterm/windows/flterm_plugin_c_api.cpp new file mode 100644 index 00000000..1e8876eb --- /dev/null +++ b/packages/flterm/windows/flterm_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/flterm/flterm_plugin_c_api.h" + +#include + +#include "flterm_plugin.h" + +void FltermPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + flterm::FltermPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/packages/flterm/windows/include/flterm/flterm_plugin_c_api.h b/packages/flterm/windows/include/flterm/flterm_plugin_c_api.h new file mode 100644 index 00000000..5546f8b0 --- /dev/null +++ b/packages/flterm/windows/include/flterm/flterm_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_FLTERM_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_FLTERM_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void FltermPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_FLTERM_PLUGIN_C_API_H_ From 433c50a43cfe435dad3e760f7858126125733e75 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 15:31:45 +0800 Subject: [PATCH 11/15] fix(flterm): match Carbon Swift integer types --- .../flterm/macos/flterm/Sources/flterm/FltermPlugin.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift b/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift index 5294b639..c0789117 100644 --- a/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift +++ b/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift @@ -99,7 +99,7 @@ public final class FltermPlugin: NSObject, FlutterPlugin { let layout = UnsafeRawPointer(bytes) .assumingMemoryBound(to: UCKeyboardLayout.self) var deadKeyState: UInt32 = 0 - var length: UniCharCount = 0 + var length = 0 var characters = [UniChar](repeating: 0, count: 4) let modifiers = UInt32( (event.modifierFlags.rawValue >> 16) & 0xFF) @@ -112,7 +112,7 @@ public final class FltermPlugin: NSObject, FlutterPlugin { UInt32(LMGetKbdType()), OptionBits(0), &deadKeyState, - UniCharCount(buffer.count), + buffer.count, &length, buffer.baseAddress!) } From f8f756e419f2097ba5d80e2f777e7b4f8d7ff0d6 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 20:28:10 +0800 Subject: [PATCH 12/15] fix(libghostty): serialize source cache population --- packages/libghostty/CHANGELOG.md | 2 + .../lib/src/hook/ghostty_source.dart | 141 ++++++++++++------ .../lib/src/hook/library_provider.dart | 69 +++++---- .../test/hook/ghostty_source_test.dart | 37 ++++- .../test/hook/helpers/test_server.dart | 19 ++- 5 files changed, 188 insertions(+), 80 deletions(-) diff --git a/packages/libghostty/CHANGELOG.md b/packages/libghostty/CHANGELOG.md index 5c268445..9cf6a50a 100644 --- a/packages/libghostty/CHANGELOG.md +++ b/packages/libghostty/CHANGELOG.md @@ -14,6 +14,8 @@ 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. +- **Concurrent source builds**: native-asset hooks serialize shared Ghostty + source cache population across isolates and processes. - **Embedded tagged builds**: source compilation passes Ghostty's own version explicitly instead of inheriting Git tags from an embedding repository. diff --git a/packages/libghostty/lib/src/hook/ghostty_source.dart b/packages/libghostty/lib/src/hook/ghostty_source.dart index b126b0c6..404b76ac 100644 --- a/packages/libghostty/lib/src/hook/ghostty_source.dart +++ b/packages/libghostty/lib/src/hook/ghostty_source.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:crypto/crypto.dart'; @@ -10,6 +11,44 @@ const _patchMarkerName = '.libghostty-patch-key'; final _semanticVersion = RegExp( r'^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$', ); +final _sourceCacheLocks = >{}; + +/// Runs [action] while holding the lock for a shared Ghostty source cache. +Future withGhosttySourceCacheLock( + Uri cacheBase, + String cacheKey, + Future Function() action, +) async { + final lockFile = File.fromUri( + cacheBase.resolve('.ghostty-source-$cacheKey.lock'), + ); + lockFile.parent.createSync(recursive: true); + + final previous = _sourceCacheLocks[lockFile.path]; + final completer = Completer(); + final current = completer.future; + _sourceCacheLocks[lockFile.path] = current; + if (previous != null) await previous; + + RandomAccessFile? handle; + var locked = false; + try { + handle = await lockFile.open(mode: FileMode.append); + await handle.lock(FileLock.blockingExclusive); + locked = true; + return await action(); + } finally { + try { + if (locked) await handle!.unlock(); + await handle?.close(); + } finally { + if (identical(_sourceCacheLocks[lockFile.path], current)) { + unawaited(_sourceCacheLocks.remove(lockFile.path)); + } + completer.complete(); + } + } +} /// Reads the Ghostty application version without consulting Git metadata. String ghosttySourceVersion(Directory source) { @@ -101,68 +140,76 @@ Future downloadSource( }) async { final commit = pinnedCommit(packageRoot); final cacheKey = ghosttySourceCacheKey(packageRoot); + final resolvedTarballUrl = + tarballUrl ?? '$_defaultTarballBase/$commit.tar.gz'; final cacheDir = Directory.fromUri( cacheBase.resolve('ghostty-source-$cacheKey/'), ); final patchMarker = File.fromUri(cacheDir.uri.resolve(_patchMarkerName)); - if (cacheDir.existsSync()) { - if (patchMarker.existsSync() && - patchMarker.readAsStringSync() == cacheKey) { - return cacheDir; - } - cacheDir.deleteSync(recursive: true); + if (cacheDir.existsSync() && + patchMarker.existsSync() && + patchMarker.readAsStringSync() == cacheKey) { + return cacheDir; } - tarballUrl ??= '$_defaultTarballBase/$commit.tar.gz'; + return withGhosttySourceCacheLock(cacheBase, cacheKey, () async { + if (cacheDir.existsSync()) { + if (patchMarker.existsSync() && + patchMarker.readAsStringSync() == cacheKey) { + return cacheDir; + } + cacheDir.deleteSync(recursive: true); + } - final tarball = File.fromUri(cacheBase.resolve('$commit.tar.gz')); - tarball.parent.createSync(recursive: true); + final tarball = File.fromUri(cacheBase.resolve('$cacheKey.tar.gz')); + tarball.parent.createSync(recursive: true); - final httpClient = HttpClient(); - try { - final request = await httpClient.getUrl(Uri.parse(tarballUrl)); - final response = await request.close(); - if (response.statusCode != 200) { + final httpClient = HttpClient(); + try { + final request = await httpClient.getUrl(Uri.parse(resolvedTarballUrl)); + final response = await request.close(); + if (response.statusCode != 200) { + throw Exception( + 'Failed to download Ghostty source: HTTP ${response.statusCode}. ' + 'Check your network connection or set ' + '$ghosttySrcEnvKey to a local checkout.', + ); + } + final sink = tarball.openWrite(); + await response.pipe(sink); + } finally { + httpClient.close(); + } + + cacheDir.createSync(recursive: true); + final extractResult = Process.runSync('tar', [ + 'xzf', + tarball.path, + '-C', + cacheDir.path, + '--strip-components=1', + ]); + if (extractResult.exitCode != 0) { + cacheDir.deleteSync(recursive: true); + tarball.deleteSync(); throw Exception( - 'Failed to download Ghostty source: HTTP ${response.statusCode}. ' - 'Check your network connection or set ' - '$ghosttySrcEnvKey to a local checkout.', + 'Failed to extract Ghostty source: ${extractResult.stderr}', ); } - final sink = tarball.openWrite(); - await response.pipe(sink); - } finally { - httpClient.close(); - } - cacheDir.createSync(recursive: true); - final extractResult = Process.runSync('tar', [ - 'xzf', - tarball.path, - '-C', - cacheDir.path, - '--strip-components=1', - ]); - 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; + } - try { - applyGhosttyPatches(cacheDir, packageRoot); - patchMarker.writeAsStringSync(cacheKey); - } on Object { - cacheDir.deleteSync(recursive: true); tarball.deleteSync(); - rethrow; - } - - tarball.deleteSync(); - return cacheDir; + return cacheDir; + }); } /// Reads the pinned Ghostty commit from `ghostty.version` at [packageRoot]. diff --git a/packages/libghostty/lib/src/hook/library_provider.dart b/packages/libghostty/lib/src/hook/library_provider.dart index 1a357123..00409bbf 100644 --- a/packages/libghostty/lib/src/hook/library_provider.dart +++ b/packages/libghostty/lib/src/hook/library_provider.dart @@ -161,36 +161,49 @@ final class CompileFromSource extends LibraryProvider { cacheDir.uri.resolve('.libghostty-patch-key'), ); - if (!cacheDir.existsSync() || - !patchMarker.existsSync() || - patchMarker.readAsStringSync() != cacheKey) { - if (cacheDir.existsSync()) cacheDir.deleteSync(recursive: true); - cacheDir.createSync(recursive: true); - - final result = Process.runSync('git', [ - 'clone', - '--depth', - '1', - '--branch', - commit, - 'https://github.com/ghostty-org/ghostty.git', - '.', - ], workingDirectory: cacheDir.path); - - if (result.exitCode != 0) { - 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; - } + if (cacheDir.existsSync() && + patchMarker.existsSync() && + patchMarker.readAsStringSync() == cacheKey) { + return cacheDir; } - return cacheDir; + return withGhosttySourceCacheLock( + input.outputDirectoryShared, + 'git-$cacheKey', + () async { + if (cacheDir.existsSync() && + patchMarker.existsSync() && + patchMarker.readAsStringSync() == cacheKey) { + return cacheDir; + } + if (cacheDir.existsSync()) cacheDir.deleteSync(recursive: true); + cacheDir.createSync(recursive: true); + + final result = Process.runSync('git', [ + 'clone', + '--depth', + '1', + '--branch', + commit, + 'https://github.com/ghostty-org/ghostty.git', + '.', + ], workingDirectory: cacheDir.path); + + if (result.exitCode != 0) { + 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; + }, + ); } Future _resolveSource() async { diff --git a/packages/libghostty/test/hook/ghostty_source_test.dart b/packages/libghostty/test/hook/ghostty_source_test.dart index eb7de107..e9a71315 100644 --- a/packages/libghostty/test/hook/ghostty_source_test.dart +++ b/packages/libghostty/test/hook/ghostty_source_test.dart @@ -253,6 +253,39 @@ void main() { ); }); + test('serializes concurrent cache population', () async { + final contentDir = Directory('${tmpDir.path}/content')..createSync(); + File('${contentDir.path}/marker.txt').writeAsStringSync('ready'); + + final tarball = File('${tmpDir.path}/test.tar.gz'); + Process.runSync('tar', ['czf', tarball.path, '-C', contentDir.path, '.']); + + final serverDir = Directory('${tmpDir.path}/server')..createSync(); + tarball.copySync('${serverDir.path}/source.tar.gz'); + + final server = await TestServer.start(serverDir); + addTearDown(server.close); + + final cacheBase = Uri.directory('${tmpDir.path}/cache/'); + final tarballUrl = '${server.baseUrl}/source.tar.gz'; + final results = await Future.wait([ + downloadSource( + cacheBase, + packageRoot: packageRoot, + tarballUrl: tarballUrl, + ), + downloadSource( + cacheBase, + packageRoot: packageRoot, + tarballUrl: tarballUrl, + ), + ]); + + expect(results[1].path, results[0].path); + expect(File('${results[0].path}/marker.txt').readAsStringSync(), 'ready'); + expect(server.requestCount, 1); + }); + test('throws on HTTP error with actionable message', () async { final serverDir = Directory('${tmpDir.path}/empty_server')..createSync(); final server = await TestServer.start(serverDir); @@ -322,7 +355,9 @@ void main() { ); final commit = pinnedCommit(packageRoot); - final tarballInCache = File.fromUri(cacheBase.resolve('$commit.tar.gz')); + final tarballInCache = File.fromUri( + cacheBase.resolve('${commit.substring(0, 12)}-none.tar.gz'), + ); expect(tarballInCache.existsSync(), isFalse); }); }); diff --git a/packages/libghostty/test/hook/helpers/test_server.dart b/packages/libghostty/test/hook/helpers/test_server.dart index c4bd6d5c..eb005a0d 100644 --- a/packages/libghostty/test/hook/helpers/test_server.dart +++ b/packages/libghostty/test/hook/helpers/test_server.dart @@ -6,16 +6,27 @@ import 'package:shelf_static/shelf_static.dart'; class TestServer { final Uri baseUrl; final HttpServer _server; + final List _requests; Future? _closeFuture; - TestServer._(this._server, this.baseUrl); + TestServer._(this._server, this.baseUrl, this._requests); + + int get requestCount => _requests.length; Future close() => _closeFuture ??= _server.close(); static Future start(Directory directory) async { - final handler = createStaticHandler(directory.path); - final server = await io.serve(handler, 'localhost', 0); + final staticHandler = createStaticHandler(directory.path); + final requests = []; + final server = await io.serve( + (request) { + requests.add(null); + return staticHandler(request); + }, + 'localhost', + 0, + ); final baseUrl = Uri.parse('http://localhost:${server.port}'); - return TestServer._(server, baseUrl); + return TestServer._(server, baseUrl, requests); } } From bd55c09c8407ffcdbc79240d6230fbe936d9f475 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 21:04:00 +0800 Subject: [PATCH 13/15] fix(libghostty): preserve patch line endings --- .gitattributes | 2 + packages/libghostty/CHANGELOG.md | 2 - .../lib/src/hook/ghostty_source.dart | 141 ++++++------------ .../lib/src/hook/library_provider.dart | 69 ++++----- .../test/hook/ghostty_source_test.dart | 37 +---- .../test/hook/helpers/test_server.dart | 19 +-- 6 files changed, 82 insertions(+), 188 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1a95de09 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +.gitattributes text eol=lf +*.patch text eol=lf diff --git a/packages/libghostty/CHANGELOG.md b/packages/libghostty/CHANGELOG.md index 9cf6a50a..5c268445 100644 --- a/packages/libghostty/CHANGELOG.md +++ b/packages/libghostty/CHANGELOG.md @@ -14,8 +14,6 @@ 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. -- **Concurrent source builds**: native-asset hooks serialize shared Ghostty - source cache population across isolates and processes. - **Embedded tagged builds**: source compilation passes Ghostty's own version explicitly instead of inheriting Git tags from an embedding repository. diff --git a/packages/libghostty/lib/src/hook/ghostty_source.dart b/packages/libghostty/lib/src/hook/ghostty_source.dart index 404b76ac..b126b0c6 100644 --- a/packages/libghostty/lib/src/hook/ghostty_source.dart +++ b/packages/libghostty/lib/src/hook/ghostty_source.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:crypto/crypto.dart'; @@ -11,44 +10,6 @@ const _patchMarkerName = '.libghostty-patch-key'; final _semanticVersion = RegExp( r'^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$', ); -final _sourceCacheLocks = >{}; - -/// Runs [action] while holding the lock for a shared Ghostty source cache. -Future withGhosttySourceCacheLock( - Uri cacheBase, - String cacheKey, - Future Function() action, -) async { - final lockFile = File.fromUri( - cacheBase.resolve('.ghostty-source-$cacheKey.lock'), - ); - lockFile.parent.createSync(recursive: true); - - final previous = _sourceCacheLocks[lockFile.path]; - final completer = Completer(); - final current = completer.future; - _sourceCacheLocks[lockFile.path] = current; - if (previous != null) await previous; - - RandomAccessFile? handle; - var locked = false; - try { - handle = await lockFile.open(mode: FileMode.append); - await handle.lock(FileLock.blockingExclusive); - locked = true; - return await action(); - } finally { - try { - if (locked) await handle!.unlock(); - await handle?.close(); - } finally { - if (identical(_sourceCacheLocks[lockFile.path], current)) { - unawaited(_sourceCacheLocks.remove(lockFile.path)); - } - completer.complete(); - } - } -} /// Reads the Ghostty application version without consulting Git metadata. String ghosttySourceVersion(Directory source) { @@ -140,76 +101,68 @@ Future downloadSource( }) async { final commit = pinnedCommit(packageRoot); final cacheKey = ghosttySourceCacheKey(packageRoot); - final resolvedTarballUrl = - tarballUrl ?? '$_defaultTarballBase/$commit.tar.gz'; final cacheDir = Directory.fromUri( cacheBase.resolve('ghostty-source-$cacheKey/'), ); final patchMarker = File.fromUri(cacheDir.uri.resolve(_patchMarkerName)); - if (cacheDir.existsSync() && - patchMarker.existsSync() && - patchMarker.readAsStringSync() == cacheKey) { - return cacheDir; + if (cacheDir.existsSync()) { + if (patchMarker.existsSync() && + patchMarker.readAsStringSync() == cacheKey) { + return cacheDir; + } + cacheDir.deleteSync(recursive: true); } - return withGhosttySourceCacheLock(cacheBase, cacheKey, () async { - if (cacheDir.existsSync()) { - if (patchMarker.existsSync() && - patchMarker.readAsStringSync() == cacheKey) { - return cacheDir; - } - cacheDir.deleteSync(recursive: true); - } + tarballUrl ??= '$_defaultTarballBase/$commit.tar.gz'; - final tarball = File.fromUri(cacheBase.resolve('$cacheKey.tar.gz')); - tarball.parent.createSync(recursive: true); + final tarball = File.fromUri(cacheBase.resolve('$commit.tar.gz')); + tarball.parent.createSync(recursive: true); - final httpClient = HttpClient(); - try { - final request = await httpClient.getUrl(Uri.parse(resolvedTarballUrl)); - final response = await request.close(); - if (response.statusCode != 200) { - throw Exception( - 'Failed to download Ghostty source: HTTP ${response.statusCode}. ' - 'Check your network connection or set ' - '$ghosttySrcEnvKey to a local checkout.', - ); - } - final sink = tarball.openWrite(); - await response.pipe(sink); - } finally { - httpClient.close(); - } - - cacheDir.createSync(recursive: true); - final extractResult = Process.runSync('tar', [ - 'xzf', - tarball.path, - '-C', - cacheDir.path, - '--strip-components=1', - ]); - if (extractResult.exitCode != 0) { - cacheDir.deleteSync(recursive: true); - tarball.deleteSync(); + final httpClient = HttpClient(); + try { + final request = await httpClient.getUrl(Uri.parse(tarballUrl)); + final response = await request.close(); + if (response.statusCode != 200) { throw Exception( - 'Failed to extract Ghostty source: ${extractResult.stderr}', + 'Failed to download Ghostty source: HTTP ${response.statusCode}. ' + 'Check your network connection or set ' + '$ghosttySrcEnvKey to a local checkout.', ); } + final sink = tarball.openWrite(); + await response.pipe(sink); + } finally { + httpClient.close(); + } - try { - applyGhosttyPatches(cacheDir, packageRoot); - patchMarker.writeAsStringSync(cacheKey); - } on Object { - cacheDir.deleteSync(recursive: true); - tarball.deleteSync(); - rethrow; - } + cacheDir.createSync(recursive: true); + final extractResult = Process.runSync('tar', [ + 'xzf', + tarball.path, + '-C', + cacheDir.path, + '--strip-components=1', + ]); + 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; - }); + return cacheDir; } /// Reads the pinned Ghostty commit from `ghostty.version` at [packageRoot]. diff --git a/packages/libghostty/lib/src/hook/library_provider.dart b/packages/libghostty/lib/src/hook/library_provider.dart index 00409bbf..1a357123 100644 --- a/packages/libghostty/lib/src/hook/library_provider.dart +++ b/packages/libghostty/lib/src/hook/library_provider.dart @@ -161,49 +161,36 @@ final class CompileFromSource extends LibraryProvider { cacheDir.uri.resolve('.libghostty-patch-key'), ); - if (cacheDir.existsSync() && - patchMarker.existsSync() && - patchMarker.readAsStringSync() == cacheKey) { - return cacheDir; + if (!cacheDir.existsSync() || + !patchMarker.existsSync() || + patchMarker.readAsStringSync() != cacheKey) { + if (cacheDir.existsSync()) cacheDir.deleteSync(recursive: true); + cacheDir.createSync(recursive: true); + + final result = Process.runSync('git', [ + 'clone', + '--depth', + '1', + '--branch', + commit, + 'https://github.com/ghostty-org/ghostty.git', + '.', + ], workingDirectory: cacheDir.path); + + if (result.exitCode != 0) { + 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 withGhosttySourceCacheLock( - input.outputDirectoryShared, - 'git-$cacheKey', - () async { - if (cacheDir.existsSync() && - patchMarker.existsSync() && - patchMarker.readAsStringSync() == cacheKey) { - return cacheDir; - } - if (cacheDir.existsSync()) cacheDir.deleteSync(recursive: true); - cacheDir.createSync(recursive: true); - - final result = Process.runSync('git', [ - 'clone', - '--depth', - '1', - '--branch', - commit, - 'https://github.com/ghostty-org/ghostty.git', - '.', - ], workingDirectory: cacheDir.path); - - if (result.exitCode != 0) { - 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; - }, - ); + return cacheDir; } Future _resolveSource() async { diff --git a/packages/libghostty/test/hook/ghostty_source_test.dart b/packages/libghostty/test/hook/ghostty_source_test.dart index e9a71315..eb7de107 100644 --- a/packages/libghostty/test/hook/ghostty_source_test.dart +++ b/packages/libghostty/test/hook/ghostty_source_test.dart @@ -253,39 +253,6 @@ void main() { ); }); - test('serializes concurrent cache population', () async { - final contentDir = Directory('${tmpDir.path}/content')..createSync(); - File('${contentDir.path}/marker.txt').writeAsStringSync('ready'); - - final tarball = File('${tmpDir.path}/test.tar.gz'); - Process.runSync('tar', ['czf', tarball.path, '-C', contentDir.path, '.']); - - final serverDir = Directory('${tmpDir.path}/server')..createSync(); - tarball.copySync('${serverDir.path}/source.tar.gz'); - - final server = await TestServer.start(serverDir); - addTearDown(server.close); - - final cacheBase = Uri.directory('${tmpDir.path}/cache/'); - final tarballUrl = '${server.baseUrl}/source.tar.gz'; - final results = await Future.wait([ - downloadSource( - cacheBase, - packageRoot: packageRoot, - tarballUrl: tarballUrl, - ), - downloadSource( - cacheBase, - packageRoot: packageRoot, - tarballUrl: tarballUrl, - ), - ]); - - expect(results[1].path, results[0].path); - expect(File('${results[0].path}/marker.txt').readAsStringSync(), 'ready'); - expect(server.requestCount, 1); - }); - test('throws on HTTP error with actionable message', () async { final serverDir = Directory('${tmpDir.path}/empty_server')..createSync(); final server = await TestServer.start(serverDir); @@ -355,9 +322,7 @@ void main() { ); final commit = pinnedCommit(packageRoot); - final tarballInCache = File.fromUri( - cacheBase.resolve('${commit.substring(0, 12)}-none.tar.gz'), - ); + final tarballInCache = File.fromUri(cacheBase.resolve('$commit.tar.gz')); expect(tarballInCache.existsSync(), isFalse); }); }); diff --git a/packages/libghostty/test/hook/helpers/test_server.dart b/packages/libghostty/test/hook/helpers/test_server.dart index eb005a0d..c4bd6d5c 100644 --- a/packages/libghostty/test/hook/helpers/test_server.dart +++ b/packages/libghostty/test/hook/helpers/test_server.dart @@ -6,27 +6,16 @@ import 'package:shelf_static/shelf_static.dart'; class TestServer { final Uri baseUrl; final HttpServer _server; - final List _requests; Future? _closeFuture; - TestServer._(this._server, this.baseUrl, this._requests); - - int get requestCount => _requests.length; + TestServer._(this._server, this.baseUrl); Future close() => _closeFuture ??= _server.close(); static Future start(Directory directory) async { - final staticHandler = createStaticHandler(directory.path); - final requests = []; - final server = await io.serve( - (request) { - requests.add(null); - return staticHandler(request); - }, - 'localhost', - 0, - ); + final handler = createStaticHandler(directory.path); + final server = await io.serve(handler, 'localhost', 0); final baseUrl = Uri.parse('http://localhost:${server.port}'); - return TestServer._(server, baseUrl, requests); + return TestServer._(server, baseUrl); } } From 1341797fc17503dcc539fc8951f67124f6492fac Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 21:32:40 +0800 Subject: [PATCH 14/15] fix(libghostty): retry Windows Zig helper race --- packages/libghostty/CHANGELOG.md | 2 ++ .../lib/src/hook/library_provider.dart | 35 +++++++++++++------ .../test/hook/library_provider_test.dart | 18 ++++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/libghostty/CHANGELOG.md b/packages/libghostty/CHANGELOG.md index 5c268445..df9416d9 100644 --- a/packages/libghostty/CHANGELOG.md +++ b/packages/libghostty/CHANGELOG.md @@ -16,6 +16,8 @@ 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. +- **Windows source builds**: each native-asset output uses an isolated local + Zig cache and retries the transient generated-helper scanner race. ## 0.0.11 diff --git a/packages/libghostty/lib/src/hook/library_provider.dart b/packages/libghostty/lib/src/hook/library_provider.dart index 1a357123..74b4cfc1 100644 --- a/packages/libghostty/lib/src/hook/library_provider.dart +++ b/packages/libghostty/lib/src/hook/library_provider.dart @@ -15,6 +15,10 @@ String libraryExtension(OS os) => switch (os) { _ => 'so', }; +/// Whether Zig hit the transient Windows scanner race for a generated helper. +bool isTransientWindowsZigFailure(String stderr) => + stderr.contains('uucode_build_tables') && stderr.contains('FileNotFound'); + /// Environment variable for local Ghostty source path. const ghosttySrcEnvKey = 'GHOSTTY_SRC'; @@ -98,6 +102,7 @@ final class CompileFromSource extends LibraryProvider { final ios = os == OS.iOS ? input.config.code.iOS.targetSdk : null; final installDir = target.parent.parent.uri; + final localCacheDir = Directory.fromUri(installDir.resolve('.zig-cache/')); final zig = zigTarget(os, arch, iOSSdk: ios); final zigArgs = [ @@ -107,24 +112,32 @@ final class CompileFromSource extends LibraryProvider { Directory.fromUri(installDir).path, '--release=fast', '-Dversion-string=${ghosttySourceVersion(sourceDir)}', + '--cache-dir', + localCacheDir.path, '--global-cache-dir', _zigCacheDir(sourceDir), if (os != .current || arch != .current) '-Dtarget=$zig', if (ios == .iPhoneSimulator && arch == .arm64) '-Dcpu=apple_a17', ]; - final result = Process.runSync( - 'zig', - zigArgs, - workingDirectory: sourceDir.path, - ); - - if (result.exitCode != 0) { - throw Exception( - 'Zig compilation failed (exit code ${result.exitCode}):\n' - 'stdout: ${result.stdout}\n' - 'stderr: ${result.stderr}', + late ProcessResult result; + for (var attempt = 1; attempt <= 3; attempt++) { + result = Process.runSync( + 'zig', + zigArgs, + workingDirectory: sourceDir.path, ); + if (result.exitCode == 0) break; + if (os != .windows || + !isTransientWindowsZigFailure(result.stderr.toString()) || + attempt == 3) { + throw Exception( + 'Zig compilation failed (exit code ${result.exitCode}):\n' + 'stdout: ${result.stdout}\n' + 'stderr: ${result.stderr}', + ); + } + sleep(Duration(seconds: attempt * 2)); } final srcDir = os == .windows ? 'bin' : 'lib'; diff --git a/packages/libghostty/test/hook/library_provider_test.dart b/packages/libghostty/test/hook/library_provider_test.dart index 4841f538..ec146ea7 100644 --- a/packages/libghostty/test/hook/library_provider_test.dart +++ b/packages/libghostty/test/hook/library_provider_test.dart @@ -41,6 +41,24 @@ void main() { expect(libraryExtension(OS.android), 'so'); }); }); + + group('isTransientWindowsZigFailure', () { + test('recognizes the generated helper scanner race', () { + expect( + isTransientWindowsZigFailure( + 'failed to spawn uucode_build_tables.exe: FileNotFound', + ), + isTrue, + ); + }); + + test('rejects unrelated Zig failures', () { + expect( + isTransientWindowsZigFailure('src/main.zig:1:1: error: invalid'), + isFalse, + ); + }); + }); }); } From 9e0b2afc5e04040b15f6dd11cfd07ad6dfc74470 Mon Sep 17 00:00:00 2001 From: Adon Metcalfe Date: Wed, 15 Jul 2026 22:10:01 +0800 Subject: [PATCH 15/15] fix(flterm): encode nullable macOS text metadata --- packages/flterm/CHANGELOG.md | 5 +++++ .../flterm/macos/flterm/Sources/flterm/FltermPlugin.swift | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/flterm/CHANGELOG.md b/packages/flterm/CHANGELOG.md index 0423be24..decf01ab 100644 --- a/packages/flterm/CHANGELOG.md +++ b/packages/flterm/CHANGELOG.md @@ -13,6 +13,11 @@ detection. `TerminalKeyEventNormalizer` supports custom runners, and `TerminalConfig.optionAsAlt` configures macOS Option behavior. +### Fixed + +- **macOS keyboard metadata**: nullable Option-free text is encoded with an + explicitly typed standard-codec null value so release builds compile. + ## 0.0.4 ### Breaking diff --git a/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift b/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift index c0789117..e185fa0c 100644 --- a/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift +++ b/packages/flterm/macos/flterm/Sources/flterm/FltermPlugin.swift @@ -48,6 +48,12 @@ public final class FltermPlugin: NSObject, FlutterPlugin { let textWithoutAlt = terminalText( event.characters( byApplyingModifiers: event.modifierFlags.subtracting(.option))) + let encodedTextWithoutAlt: Any + if let textWithoutAlt { + encodedTextWithoutAlt = textWithoutAlt + } else { + encodedTextWithoutAlt = NSNull() + } channel.sendMessage([ "platform": "macos", "scanCode": Int(event.keyCode), @@ -57,7 +63,7 @@ public final class FltermPlugin: NSObject, FlutterPlugin { "mods": modifierBits(event.modifierFlags), "consumedMods": consumedModifierBits(event), "unshiftedCodepoint": singleScalar(unmodified), - "textWithoutAlt": textWithoutAlt ?? NSNull(), + "textWithoutAlt": encodedTextWithoutAlt, "deadKey": event.type == .keyDown && isDeadKey(event), ]) }