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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions pkgs/vte/ghostty_vte_flutter/lib/src/terminal_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ class GhosttyTerminalView extends StatefulWidget {
this.onCopySelection,
this.onPasteRequest,
this.onOpenHyperlink,
this.onHyperlinkHover,
this.onCellMetricsChanged,
this.onZoomUpdate,
this.onZoomEnd,
Expand Down Expand Up @@ -419,6 +420,19 @@ class GhosttyTerminalView extends StatefulWidget {
/// Callback used when the user activates a hyperlink inside the terminal.
final Future<void> 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<String?>? 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)
Expand Down Expand Up @@ -629,6 +643,42 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {

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) {
Expand Down Expand Up @@ -812,6 +862,9 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
_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) {
Expand Down Expand Up @@ -2324,7 +2377,18 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
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;
}

Expand All @@ -2349,7 +2413,7 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
: 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);
Expand Down Expand Up @@ -2536,6 +2600,7 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
return;
}
setState(() {});
_notifyHoverChanged();
}

Future<void> _openHyperlink(String uri) async {
Expand Down Expand Up @@ -3328,6 +3393,7 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
session: _selectionSession,
)) {
setState(() {});
_notifyHoverChanged();
}
},
onHover: (event) {
Expand All @@ -3345,6 +3411,7 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
GhosttyTerminalSelection
>(session: _selectionSession)) {
setState(() {});
_notifyHoverChanged();
}
_sendMouseEvent(
GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_MOTION,
Expand Down
107 changes: 107 additions & 0 deletions pkgs/vte/ghostty_vte_flutter/test/terminal_view_hover_cost_test.dart
Original file line number Diff line number Diff line change
@@ -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<int> 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.',
);
});
}
Loading
Loading