diff --git a/pkgs/vte/ghostty_vte_flutter/lib/src/terminal_view.dart b/pkgs/vte/ghostty_vte_flutter/lib/src/terminal_view.dart index 060e02b..78e7591 100644 --- a/pkgs/vte/ghostty_vte_flutter/lib/src/terminal_view.dart +++ b/pkgs/vte/ghostty_vte_flutter/lib/src/terminal_view.dart @@ -215,6 +215,7 @@ class GhosttyTerminalView extends StatefulWidget { this.onCopySelection, this.onPasteRequest, this.onOpenHyperlink, + this.onHyperlinkHover, this.onCellMetricsChanged, this.onZoomUpdate, this.onZoomEnd, @@ -419,6 +420,19 @@ class GhosttyTerminalView extends StatefulWidget { /// Callback used when the user activates a hyperlink inside the terminal. final Future Function(String uri)? onOpenHyperlink; + /// Reports the URI under the pointer, or null when it leaves every link. + /// + /// Fires only on a real change, so a host may rebuild on it freely — moving + /// within one link is silent. This is the only way to show a destination + /// before it is opened: OSC 8 lets a link's text disagree with its target, + /// and the view itself paints the underline but never the URI. + /// + /// Deliberately not fired from `dispose`: a host that rebuilt on it there + /// would be setting state while the tree is being torn down. A controller + /// swap does fire (through the session reset), which is the case that would + /// otherwise strand a stale preview. + final ValueChanged? onHyperlinkHover; + /// Reports the exact (unrounded) cell metrics — character advance width and /// line height in logical pixels — whenever they are recomputed. Hosts use /// this to size a foreign grid to an exact pixel extent (cols × charWidth) @@ -629,6 +643,42 @@ class _GhosttyTerminalViewState extends State { GhosttyTerminalSelection? get _selection => _selectionSession.selection; String? get _hoveredHyperlink => _selectionSession.hoveredHyperlink; + + /// Last value handed to [GhosttyTerminalView.onHyperlinkHover]. + /// + /// The session already dedupes its own state, but the notify sites are + /// several and a controller swap resets the session behind them — so the + /// latch is what keeps "fires only on a real change" true from the host's + /// side rather than each caller's. + String? _reportedHoverUri; + + /// Reports a hover change to the host, at most once per distinct URI. + /// + /// [afterFrame] is required from `didUpdateWidget`, which runs mid-build: a + /// host that rebuilds on this callback would otherwise be setting state + /// during build. Pointer-driven changes are already outside that phase. + void _notifyHoverChanged({bool afterFrame = false}) { + final uri = _hoveredHyperlink; + if (uri == _reportedHoverUri) { + return; + } + _reportedHoverUri = uri; + final callback = widget.onHyperlinkHover; + if (callback == null) { + return; + } + if (!afterFrame) { + callback(uri); + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + callback(uri); + }); + } + int? get _lineSelectionAnchorRow => _selectionSession.lineSelectionAnchorRow; void _recordSerialTapDown(SerialTapDownDetails details) { @@ -812,6 +862,9 @@ class _GhosttyTerminalViewState extends State { _selectionHandleDragEdge = null; _lastSelectionHandleDragPosition = null; _selectionSession.reset(); + // The pointer has not moved, but what is under it belongs to a terminal + // that is gone — leaving a host's preview showing the old link. + _notifyHoverChanged(afterFrame: true); _autoScrollSession.reset(); } if (oldWidget.focusNode != widget.focusNode) { @@ -2324,7 +2377,18 @@ class _GhosttyTerminalViewState extends State { bool clampToViewport = false, }) { final viewport = _viewportFor(size, metrics); - if (widget.controller.snapshot.lines.isEmpty) { + // `_scrollableLineCount`, never `snapshot`: reading the snapshot settles the + // formatter, which is three full-buffer passes plus a re-parse of the styled + // output — ~19ms on a 1800-line scrollback, measured. Under + // `GhosttyTerminalRendererMode.renderState` the painter never reads it, so + // nothing else pays that cost and asking here put it on every hover and + // every selection-drag motion over a terminal that is still producing + // output. The engine's own row total answers the same question for free, + // and falls back to the snapshot only where there is no engine geometry to + // ask (web, and before the terminal exists) — where the formatter is what + // renders anyway. + final lineCount = _scrollableLineCount(); + if (lineCount <= 0) { return null; } @@ -2349,7 +2413,7 @@ class _GhosttyTerminalViewState extends State { : localPosition.dy; final lineIndex = ((resolvedY - viewport.contentTop) / metrics.linePixels) .floor(); - final maxRow = math.max(0, _scrollableLineCount() - 1); + final maxRow = math.max(0, lineCount - 1); final row = (viewport.startLine + lineIndex).clamp(0, maxRow).toInt(); final col = ((resolvedX - effPadding.left) / metrics.charWidth).floor(); final maxCol = math.max(0, widget.controller.cols - 1); @@ -2536,6 +2600,7 @@ class _GhosttyTerminalViewState extends State { return; } setState(() {}); + _notifyHoverChanged(); } Future _openHyperlink(String uri) async { @@ -3328,6 +3393,7 @@ class _GhosttyTerminalViewState extends State { session: _selectionSession, )) { setState(() {}); + _notifyHoverChanged(); } }, onHover: (event) { @@ -3345,6 +3411,7 @@ class _GhosttyTerminalViewState extends State { GhosttyTerminalSelection >(session: _selectionSession)) { setState(() {}); + _notifyHoverChanged(); } _sendMouseEvent( GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_MOTION, diff --git a/pkgs/vte/ghostty_vte_flutter/test/terminal_view_hover_cost_test.dart b/pkgs/vte/ghostty_vte_flutter/test/terminal_view_hover_cost_test.dart new file mode 100644 index 0000000..babe4e5 --- /dev/null +++ b/pkgs/vte/ghostty_vte_flutter/test/terminal_view_hover_cost_test.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ghostty_vte_flutter/ghostty_vte_flutter.dart'; + +import 'support/native_terminal.dart'; + +/// Hovering must not settle the styled formatter. +/// +/// The formatter is three full-buffer passes plus a re-parse of the styled +/// output — tens of milliseconds on a real agent scrollback — and under +/// `renderState` the painter never reads it, so whatever asks for it pays the +/// whole cost alone. `_positionForOffset` used to ask, which put that rebuild +/// on every hover event and every selection-drag motion over a terminal that +/// was still producing output: the exact case the hyperlink affordance exists +/// for. +/// +/// Timed rather than counted because the staleness flag is private to the +/// controller, and compared against the same loop WITHOUT the pointer move +/// rather than against a fixed millisecond bound. Both loops take the same +/// output and repaint the same frames, so the only difference between them is +/// the hover — which is what makes the ratio hold on any machine where an +/// absolute bound would drift with it. +void main() { + testWidgets('hovering does not rebuild the transcript', (tester) async { + if (!hasNativeTerminal) { + return; + } + final controller = GhosttyTerminalController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 600, + height: 400, + child: GhosttyTerminalView( + controller: controller, + autofocus: true, + showHeader: false, + onOpenHyperlink: (uri) async {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // What a full-screen agent does in its first frame: take the mouse. + controller.appendOutputBytes(utf8.encode('\x1B[?1000h\x1B[?1006h')); + + final scrollback = StringBuffer(); + for (var i = 0; i < 3000; i++) { + scrollback.write( + '\x1B[32m[$i]\x1B[0m agent output with a ' + '\x1B]8;;https://example.com/$i\x07link\x1B]8;;\x07 in it\r\n', + ); + } + controller.appendOutputBytes(utf8.encode(scrollback.toString())); + await tester.pumpAndSettle(); + + final gesture = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + + const rounds = 40; + + // Output between frames is what marks the transcript stale, and it is the + // normal state of an agent session — a hover with nothing new since the + // last one finds it already settled and never showed this cost at all. + Future run({required bool movePointer, int count = rounds}) async { + final stopwatch = Stopwatch()..start(); + for (var i = 0; i < count; i++) { + controller.appendOutputBytes(utf8.encode('.')); + if (movePointer) { + await gesture.moveTo(Offset(40 + (i % 7).toDouble(), 30)); + } + await tester.pump(); + } + stopwatch.stop(); + return stopwatch.elapsedMilliseconds; + } + + // Warm first and discard, so neither measurement carries the one-time cost + // of the paths the other will then find warm. + await run(movePointer: true, count: 5); + // The control repaints the same frames from the same output; it just does + // not move the pointer. Whatever separates the two IS the hover. + final still = await run(movePointer: false); + final hovering = await run(movePointer: true); + + expect( + hovering, + lessThan(still * 2 + 50), + reason: + '$rounds frames took ${hovering}ms with the pointer moving against ' + '${still}ms with it still — hovering is rebuilding the transcript ' + 'again.', + ); + }); +} diff --git a/pkgs/vte/ghostty_vte_flutter/test/terminal_view_test.dart b/pkgs/vte/ghostty_vte_flutter/test/terminal_view_test.dart index 4f79d46..dbe5e9e 100644 --- a/pkgs/vte/ghostty_vte_flutter/test/terminal_view_test.dart +++ b/pkgs/vte/ghostty_vte_flutter/test/terminal_view_test.dart @@ -2519,6 +2519,196 @@ void main() { expect(cursor, SystemMouseCursors.text); }); + // The cursor says "a link is here"; only the host can say where it goes. + testWidgets('onHyperlinkHover reports entering and leaving a link', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); + controller.terminal.setMode(VtModes.normalMouse, true); + controller.terminal.setMode(VtModes.sgrMouse, true); + + final reported = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 600, + height: 400, + child: GhosttyTerminalView( + controller: controller, + autofocus: true, + showHeader: false, + onHyperlinkHover: reported.add, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + const uri = 'https://github.com/antgrid-ai/antgrid/pull/13'; + // Two leading spaces, so column 0 is outside the linked cells. + controller.appendDebugOutput( + ' \x1B]8;;$uri\x07antgrid-ai/antgrid#13\x1B]8;;\x07', + ); + await tester.pumpAndSettle(); + + final (:charWidth, :linePixels, :padding) = _measureTestMetrics(); + Offset at(int col) => Offset( + (padding + (col * charWidth) + (charWidth ~/ 2)).toDouble(), + (padding + (linePixels ~/ 2)).toDouble(), + ); + + final gesture = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(at(4)); + await tester.pumpAndSettle(); + expect(reported, [uri]); + + // Still inside the same link: a host may rebuild on this callback, so + // moving across it must stay silent. + await gesture.moveTo(at(6)); + await tester.pumpAndSettle(); + expect(reported, [uri]); + + await gesture.moveTo(at(0)); + await tester.pumpAndSettle(); + expect(reported, [uri, null]); + }); + + testWidgets('onHyperlinkHover reports null when the pointer leaves', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); + + final reported = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + SizedBox( + width: 600, + height: 200, + child: GhosttyTerminalView( + controller: controller, + autofocus: true, + showHeader: false, + onHyperlinkHover: reported.add, + ), + ), + const SizedBox(width: 600, height: 200), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + const uri = 'https://github.com/antgrid-ai/antgrid/pull/13'; + controller.appendDebugOutput( + '\x1B]8;;$uri\x07antgrid-ai/antgrid#13\x1B]8;;\x07', + ); + await tester.pumpAndSettle(); + + final (:charWidth, :linePixels, :padding) = _measureTestMetrics(); + final gesture = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo( + Offset( + (padding + (5 * charWidth) + (charWidth ~/ 2)).toDouble(), + (padding + (linePixels ~/ 2)).toDouble(), + ), + ); + await tester.pumpAndSettle(); + expect(reported, [uri]); + + // Out of the view entirely, into the sibling below it. + await gesture.moveTo(const Offset(300, 300)); + await tester.pumpAndSettle(); + expect(reported, [uri, null]); + }); + + testWidgets('onHyperlinkHover clears when the controller is swapped', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final first = _RecordingTerminalController(); + addTearDown(first.dispose); + final second = _RecordingTerminalController(); + addTearDown(second.dispose); + + final reported = []; + Widget build(GhosttyTerminalController controller) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: 600, + height: 400, + child: GhosttyTerminalView( + controller: controller, + autofocus: true, + showHeader: false, + onHyperlinkHover: reported.add, + ), + ), + ), + ); + + await tester.pumpWidget(build(first)); + await tester.pumpAndSettle(); + + const uri = 'https://github.com/antgrid-ai/antgrid/pull/13'; + first.appendDebugOutput( + '\x1B]8;;$uri\x07antgrid-ai/antgrid#13\x1B]8;;\x07', + ); + await tester.pumpAndSettle(); + + final (:charWidth, :linePixels, :padding) = _measureTestMetrics(); + final gesture = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo( + Offset( + (padding + (5 * charWidth) + (charWidth ~/ 2)).toDouble(), + (padding + (linePixels ~/ 2)).toDouble(), + ), + ); + await tester.pumpAndSettle(); + expect(reported, [uri]); + + // The pointer never moves: without the swap reporting for itself, the + // host is left showing a link from a terminal that is gone. + await tester.pumpWidget(build(second)); + await tester.pumpAndSettle(); + expect(reported, [uri, null]); + }); + testWidgets('Shift+click under mouse reporting opens a bare URL too', ( tester, ) async {