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 c9a1b7d..060e02b 100644 --- a/pkgs/vte/ghostty_vte_flutter/lib/src/terminal_view.dart +++ b/pkgs/vte/ghostty_vte_flutter/lib/src/terminal_view.dart @@ -531,13 +531,15 @@ class _GhosttyTerminalViewState extends State { int _pendingSerialTapCount = 0; PointerDeviceKind _lastPointerKind = PointerDeviceKind.mouse; - /// The OSC 8 URI a touch tap landed on, held from the lift that suppressed - /// its forwarded click until [_handleTapUp] opens it. + /// The OSC 8 URI the pointer now down landed on, held from the press that + /// suppressed its forwarded click until [_handleTapUp] opens it. /// - /// Carries the URI rather than a flag so the open uses the cell the finger + /// Armed for mouse and touch alike — see [_armTapHyperlink]. + /// + /// Carries the URI rather than a flag so the open uses the cell the pointer /// LANDED on: a tap may drift within `kTouchSlop`, and re-resolving against /// the release point can miss the link by a cell. - String? _touchTapHyperlinkUri; + String? _tapHyperlinkUri; /// Whether the pointer now down was pressed with the mouse-reporting bypass /// modifier held, so it drives this widget instead of the program. @@ -1106,6 +1108,60 @@ class _GhosttyTerminalViewState extends State { ); } + /// The URI of an *explicit* OSC 8 link under [localPosition], or null. + /// + /// Deliberately excludes the bare-URL match [_resolveHyperlinkUriAt] falls + /// back to: every caller uses this to decide whether to take a click away + /// from a program that has the mouse, and a regex guess about visible text + /// is not grounds for that. + String? _explicitHyperlinkAt( + Offset localPosition, + Size size, + _TerminalMetrics metrics, + ) { + final cell = _positionForOffset(localPosition, size, metrics); + if (cell == null) { + return null; + } + return widget.controller.hyperlinkUriAt(cell); + } + + /// Arms [_tapHyperlinkUri] when a primary press lands on an explicit OSC 8 + /// link the program has the mouse for, so [_handleTapUp] opens it and + /// [_sendMouseEvent] withholds the whole click. + /// + /// OSC 8 is the program declaring these exact cells ARE a link, which is a + /// more specific statement of intent than its claim on the mouse — so the + /// collision is confined to programs contradicting their own markup. Without + /// this a link under a full-screen agent is reachable only by a Shift chord + /// that nothing on screen discloses, while the cell still paints as a link. + /// + /// The whole gesture goes, not just the click: a drag off a link cell sends + /// the program nothing rather than a release it has no press for. Same trade + /// the Shift bypass already makes, and drag-select is unavailable under mouse + /// reporting either way, so nothing reachable is lost. + void _armTapHyperlink( + PointerDownEvent event, + Size size, + _TerminalMetrics metrics, + ) { + _tapHyperlinkUri = null; + // Shift already routes the whole gesture here, drag-select included; + // arming on top of it would turn that drag into a click. + if (_lastPointerBypassedTerminalMouse) { + return; + } + if (!_terminalMouseReportingCapturesPointerKind(event.kind)) { + return; + } + // Primary only: a program that has the mouse still owns its right- and + // middle-click menus, on a link cell as much as anywhere else. + if (event.buttons != kPrimaryButton) { + return; + } + _tapHyperlinkUri = _explicitHyperlinkAt(event.localPosition, size, metrics); + } + String? _resolveHyperlinkUriAt(GhosttyTerminalCellPosition position) { // OSC 8 first. The engine grid is the only place an explicit link's URI // survives — neither snapshot carries it — and an explicit link must win @@ -2052,11 +2108,11 @@ class _GhosttyTerminalViewState extends State { if (!_terminalMouseReportingCapturesPointerKind(event.kind)) { return; } - // Suppress the whole press-motion-release span of a bypassed gesture, so - // the program is never handed half of a click. Bare hover carries no - // button and is not part of that span, so motion tracking survives a - // bypassed click. - if (_lastPointerBypassedTerminalMouse && + // Suppress the whole press-motion-release span of a gesture this widget + // has taken — bypassed, or landed on an explicit link — so the program is + // never handed half of a click. Bare hover carries no button and is not + // part of that span, so motion tracking survives either. + if ((_lastPointerBypassedTerminalMouse || _tapHyperlinkUri != null) && (action == GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS || action == GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_RELEASE || event.buttons != 0)) { @@ -2674,7 +2730,7 @@ class _GhosttyTerminalViewState extends State { // Cleared ahead of the guards: a tap whose gesture never reached // `_handleTapUp` (it lost the arena) would otherwise leave its URI armed // for whatever the next tap turns out to be. - _touchTapHyperlinkUri = null; + _tapHyperlinkUri = null; if (!_terminalMouseReportingCapturesPointerKind(PointerDeviceKind.touch)) { return; } @@ -2753,19 +2809,13 @@ class _GhosttyTerminalViewState extends State { !_terminalMouseReportingCapturesPointerKind(PointerDeviceKind.touch)) { return; } - // An OSC 8 link is the program declaring these exact cells ARE a link, - // which is a more specific statement of intent than holding the mouse — and - // on touch there is no modifier to bypass with, so without this a link - // under a full-screen agent is unreachable. Explicit markup only: the - // bare-URL match is a guess about visible text, and stealing a TUI's clicks - // on a guess is a hole nobody can explain. - final downCell = _positionForOffset(downPosition, size, metrics); - if (downCell != null) { - final uri = widget.controller.hyperlinkUriAt(downCell); - if (uri != null) { - _touchTapHyperlinkUri = uri; - return; - } + // Same trade as [_armTapHyperlink], one gesture later: touch cannot decide + // this at press, because until the finger lifts the gesture may still turn + // out to be a scroll or a long-press selection. + final uri = _explicitHyperlinkAt(downPosition, size, metrics); + if (uri != null) { + _tapHyperlinkUri = uri; + return; } // Deferred click: press + release at the down cell (where the finger // landed), not the release cell — a tap may drift within `kTouchSlop`. @@ -2812,8 +2862,8 @@ class _GhosttyTerminalViewState extends State { void _handleTapUp(Offset localPosition, Size size, _TerminalMetrics metrics) { widget.onTapTerminal?.call(); _requestTerminalFocus(); - if (_touchTapHyperlinkUri case final uri?) { - _touchTapHyperlinkUri = null; + if (_tapHyperlinkUri case final uri?) { + _tapHyperlinkUri = null; unawaited(_openHyperlink(uri)); return; } @@ -3281,11 +3331,15 @@ class _GhosttyTerminalViewState extends State { } }, onHover: (event) { - // Under mouse reporting the affordance follows the bypass, or - // Shift+click would be an invisible shortcut: no cursor change, + // Under mouse reporting the affordance has to track what a click + // would actually do, or it lies: an explicit OSC 8 cell opens on + // a plain click, everything else only under the Shift bypass — + // which without this would be a shortcut with no cursor change, // no underline, nothing saying a link is reachable at all. if (!_terminalMouseReportingEnabled || - HardwareKeyboard.instance.isShiftPressed) { + HardwareKeyboard.instance.isShiftPressed || + _explicitHyperlinkAt(event.localPosition, size, metrics) != + null) { _updateHoveredHyperlink(event.localPosition, size, metrics); } else if (ghosttyTerminalClearHoveredLink< GhosttyTerminalSelection @@ -3320,6 +3374,8 @@ class _GhosttyTerminalViewState extends State { // the gesture to a pinch, which resets whatever they tracked. _pinchZoomPointerDown(event); } else { + // Before the PRESS: arming is what suppresses it. + _armTapHyperlink(event, size, metrics); _sendMouseEvent( GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS, event, 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 6d16c9d..4f79d46 100644 --- a/pkgs/vte/ghostty_vte_flutter/test/terminal_view_test.dart +++ b/pkgs/vte/ghostty_vte_flutter/test/terminal_view_test.dart @@ -2243,13 +2243,19 @@ void main() { expect(openedUri, uri); }); - // Under mouse reporting the program owns the pointer, so a plain click on a - // link has to keep reaching the program — Shift is the escape hatch, and it - // must not leave the program holding half a click. + // Under mouse reporting the program owns the pointer, with one carve-out: + // cells it marked up as an explicit OSC 8 link. Everything else — bare-URL + // matches, ordinary cells, non-primary buttons — keeps reaching it, and + // neither path may leave it holding half a click. Future clickLinkUnderMouseReporting( WidgetTester tester, _RecordingTerminalController controller, { required bool holdShift, + String output = + '\x1B]8;;https://github.com/antgrid-ai/antgrid/pull/13\x07' + 'antgrid-ai/antgrid#13\x1B]8;;\x07', + int col = 5, + int buttons = kPrimaryButton, }) async { String? openedUri; controller.terminal.setMode(VtModes.normalMouse, true); @@ -2275,20 +2281,23 @@ void main() { ); await tester.pumpAndSettle(); - const uri = 'https://github.com/antgrid-ai/antgrid/pull/13'; - controller.appendDebugOutput(']8;;$uriantgrid-ai/antgrid#13]8;;'); + controller.appendDebugOutput(output); await tester.pumpAndSettle(); final (:charWidth, :linePixels, :padding) = _measureTestMetrics(); final target = Offset( - (padding + (5 * charWidth) + (charWidth ~/ 2)).toDouble(), + (padding + (col * charWidth) + (charWidth ~/ 2)).toDouble(), (padding + (linePixels ~/ 2)).toDouble(), ); if (holdShift) { await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); } - final gesture = await _startMouseGesture(tester, target); + final gesture = await _startMouseGesture( + tester, + target, + buttons: buttons, + ); await tester.pump(const Duration(milliseconds: 40)); await gesture.up(); await tester.pumpAndSettle(); @@ -2298,31 +2307,242 @@ void main() { return openedUri; } - testWidgets( - 'a plain click under mouse reporting still drives the program', - (tester) async { - if (!hasNativeTerminal) { - return; - } + testWidgets('a plain click under mouse reporting opens an OSC 8 link', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } - final controller = _RecordingTerminalController(); - addTearDown(controller.dispose); + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); - final opened = await clickLinkUnderMouseReporting( - tester, - controller, - holdShift: false, - ); + final opened = await clickLinkUnderMouseReporting( + tester, + controller, + holdShift: false, + ); - expect(opened, isNull); - expect( - controller.mouseEvents.map((event) => event.action), - contains(GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS), - ); - }, - ); + expect(opened, 'https://github.com/antgrid-ai/antgrid/pull/13'); + // Both halves withheld: a release with no press leaves the program + // tracking a button that is not down. + final actions = controller.mouseEvents.map((event) => event.action); + expect( + actions, + isNot(contains(GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS)), + ); + expect( + actions, + isNot(contains(GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_RELEASE)), + ); + }); + + testWidgets('a plain click beside the link still drives the program', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); + + final opened = await clickLinkUnderMouseReporting( + tester, + controller, + holdShift: false, + // Two leading spaces, so column 0 is outside the linked cells. + output: + ' \x1B]8;;https://github.com/antgrid-ai/antgrid/pull/13\x07' + 'antgrid-ai/antgrid#13\x1B]8;;\x07', + col: 0, + ); + + expect(opened, isNull); + expect( + controller.mouseEvents.map((event) => event.action), + contains(GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS), + ); + }); - testWidgets('Shift+click under mouse reporting opens the link instead', ( + testWidgets('a right click on the link still drives the program', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); + + final opened = await clickLinkUnderMouseReporting( + tester, + controller, + holdShift: false, + buttons: kSecondaryButton, + ); + + expect(opened, isNull); + expect( + controller.mouseEvents.map((event) => event.action), + contains(GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS), + ); + }); + + testWidgets('a plain click on a bare URL stays with the program', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); + + final opened = await clickLinkUnderMouseReporting( + tester, + controller, + holdShift: false, + // No OSC 8 anywhere: a regex guess about visible text is not grounds + // for taking a click off a program that asked for the mouse. + output: 'see https://example.com/docs for more', + col: 6, + ); + + expect(opened, isNull); + expect( + controller.mouseEvents.map((event) => event.action), + contains(GhosttyMouseAction.GHOSTTY_MOUSE_ACTION_PRESS), + ); + }); + + // The affordance has to agree with the click, or the cell paints as a link + // and nothing about hovering it says whether that means anything. + Future hoverCursorUnderMouseReporting( + WidgetTester tester, + _RecordingTerminalController controller, { + required String output, + required int col, + }) async { + controller.terminal.setMode(VtModes.normalMouse, true); + controller.terminal.setMode(VtModes.sgrMouse, true); + + 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(); + + controller.appendDebugOutput(output); + 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 + (col * charWidth) + (charWidth ~/ 2)).toDouble(), + (padding + (linePixels ~/ 2)).toDouble(), + ), + ); + await tester.pumpAndSettle(); + + return tester + .widget( + find + .descendant( + of: find.byType(GhosttyTerminalView), + matching: find.byType(MouseRegion), + ) + .first, + ) + .cursor; + } + + testWidgets('hovering an OSC 8 link under mouse reporting marks it', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); + + final cursor = await hoverCursorUnderMouseReporting( + tester, + controller, + output: + '\x1B]8;;https://github.com/antgrid-ai/antgrid/pull/13\x07' + 'antgrid-ai/antgrid#13\x1B]8;;\x07', + col: 5, + ); + + expect(cursor, SystemMouseCursors.click); + }); + + testWidgets('hovering a bare URL under mouse reporting does not', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); + + // A plain click here goes to the program, so the pointer must not + // advertise otherwise. Shift is what makes this one reachable. + final cursor = await hoverCursorUnderMouseReporting( + tester, + controller, + output: 'see https://example.com/docs for more', + col: 6, + ); + + expect(cursor, SystemMouseCursors.text); + }); + + testWidgets('Shift+click under mouse reporting opens a bare URL too', ( + tester, + ) async { + if (!hasNativeTerminal) { + return; + } + + final controller = _RecordingTerminalController(); + addTearDown(controller.dispose); + + // What the bypass is still for now that explicit links open on their + // own: reaching a match the program never marked up. + final opened = await clickLinkUnderMouseReporting( + tester, + controller, + holdShift: true, + output: 'see https://example.com/docs for more', + col: 6, + ); + + expect(opened, 'https://example.com/docs'); + }); + + testWidgets('Shift+click under mouse reporting opens the link as well', ( tester, ) async { if (!hasNativeTerminal) { @@ -4528,9 +4748,13 @@ void main() { Future _startMouseGesture( WidgetTester tester, - Offset offset, -) async { - final gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + Offset offset, { + int buttons = kPrimaryButton, +}) async { + final gesture = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + buttons: buttons, + ); await gesture.down(offset); return gesture; }