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
50 changes: 45 additions & 5 deletions pkgs/vte/ghostty_vte_flutter/lib/src/terminal_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,15 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
/// Latest offset-from-bottom awaiting a single post-frame engine drive.
int? _pendingEngineOffset;

/// Latest engine offset awaiting a post-frame Flutter scroll-layer sync.
/// A null value also cancels an already scheduled callback when a newer
/// local scroll takes ownership of the viewport.
int? _pendingFlutterOffset;

/// Prevents an engine-originated [ScrollController.jumpTo] from being sent
/// straight back to the engine as a new user scroll.
bool _isSyncingFlutterViewport = false;

/// True while the view is synchronously driving the engine viewport in
/// response to its OWN scroll. `scrollViewportToOffsetFromBottom` notifies
/// listeners (`_markDirty`), which re-enters `_onControllerChanged`; without
Expand Down Expand Up @@ -852,6 +861,8 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
void didUpdateWidget(covariant GhosttyTerminalView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
_pendingEngineOffset = null;
_pendingFlutterOffset = null;
oldWidget.controller.removeListener(_onControllerChanged);
widget.controller.addListener(_onControllerChanged);
_lastReportedCols = -1;
Expand Down Expand Up @@ -966,6 +977,11 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
/// scrollbar is authoritative because column changes can reflow rows and
/// alter the offset-from-bottom even when the user did not scroll.
void _syncScrollOffsetFromEngine() {
// A local scroll has already been accepted but has not reached the engine
// yet. Its newer intent wins over notifications emitted in the meantime.
if (_pendingEngineOffset != null) {
return;
}
final bar = widget.controller.viewportScrollbar;
if (bar == null) {
return;
Expand All @@ -978,16 +994,28 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
if (!_scrollController.hasClients || _lastMeasuredLinePixels <= 0) {
return;
}
final alreadyScheduled = _pendingFlutterOffset != null;
_pendingFlutterOffset = next;
if (alreadyScheduled) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
final target = _pendingFlutterOffset;
_pendingFlutterOffset = null;
if (!mounted || target == null || !_scrollController.hasClients) {
return;
}
final clamped = (next * _lastMeasuredLinePixels).clamp(
final clamped = (target * _lastMeasuredLinePixels).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
if ((_scrollController.offset - clamped).abs() >= 0.5) {
_scrollController.jumpTo(clamped);
_isSyncingFlutterViewport = true;
try {
_scrollController.jumpTo(clamped);
} finally {
_isSyncingFlutterViewport = false;
}
}
});
}
Expand All @@ -996,6 +1024,11 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
if (!mounted || _lastMeasuredLinePixels <= 0) {
return;
}
if (!_isSyncingFlutterViewport) {
// Invalidate an older engine-to-Flutter callback before it can move the
// scroll layer back underneath this newer local scroll.
_pendingFlutterOffset = null;
}
final nextOffsetLines = (_scrollController.offset / _lastMeasuredLinePixels)
.round();
if (nextOffsetLines == _scrollOffsetLines) {
Expand All @@ -1004,7 +1037,9 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
setState(() {
_scrollOffsetLines = nextOffsetLines;
});
_driveEngineViewport(nextOffsetLines);
if (!_isSyncingFlutterViewport) {
_driveEngineViewport(nextOffsetLines);
}
}

bool _resetScrollOffsetToBottom() {
Expand Down Expand Up @@ -3220,6 +3255,7 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
_TerminalMetrics metrics, {
_TerminalSelectionGranularity granularity =
_TerminalSelectionGranularity.line,
bool autoScrollWhileHeld = false,
}) {
// See `_beginSelection`: a slow pinch can sit inside long-press slop long
// enough for the recognizer to fire — swallow it while the pinch is live.
Expand Down Expand Up @@ -3264,7 +3300,9 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
fallbackLocalPosition: localPosition,
);
}
_syncAutoScroll(localPosition, size, metrics);
if (autoScrollWhileHeld) {
_syncAutoScroll(localPosition, size, metrics);
}
}

Widget _buildPointerGestureLayer({
Expand All @@ -3288,6 +3326,7 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
size,
metrics,
granularity: _TerminalSelectionGranularity.visualRow,
autoScrollWhileHeld: true,
);
},
onLongPressMoveUpdate: (details) =>
Expand Down Expand Up @@ -3341,6 +3380,7 @@ class _GhosttyTerminalViewState extends State<GhosttyTerminalView> {
size,
metrics,
granularity: _TerminalSelectionGranularity.visualRow,
autoScrollWhileHeld: true,
);
} else {
_beginLineSelection(details.localPosition, size, metrics);
Expand Down
92 changes: 86 additions & 6 deletions pkgs/vte/ghostty_vte_flutter/test/terminal_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1689,7 +1689,8 @@ void main() {
await tester.tapAt(firstRowTarget);
await tester.pumpAndSettle();

expect(currentContent?.text, 'Line 0');
expect(currentContent, isNotNull);
final liveBottomRowText = currentContent?.text;

final scrollbar = await tester.startGesture(const Offset(595, 48));
await scrollbar.moveTo(const Offset(595, 320));
Expand All @@ -1704,7 +1705,7 @@ void main() {
await tester.pumpAndSettle();

expect(currentContent, isNotNull);
expect(currentContent?.text, isNot('Line 0'));
expect(currentContent?.text, isNot(liveBottomRowText));
});

testWidgets(
Expand Down Expand Up @@ -1783,7 +1784,8 @@ void main() {
await tester.pump(const Duration(milliseconds: 40));
await tester.tapAt(firstRowTarget);
await tester.pumpAndSettle();
expect(currentContent?.text, 'Line 0');
expect(currentContent, isNotNull);
final liveBottomRowText = currentContent?.text;

scrollController.jumpTo(300);
await tester.pumpAndSettle();
Expand All @@ -1796,7 +1798,78 @@ void main() {
await tester.pumpAndSettle();

expect(currentContent, isNotNull);
expect(currentContent?.text, isNot('Line 0'));
expect(currentContent?.text, isNot(liveBottomRowText));
});

testWidgets('completed desktop triple-click does not keep auto-scrolling', (
tester,
) async {
if (!hasNativeTerminal) {
return;
}

final scrollController = ScrollController();
addTearDown(scrollController.dispose);
controller.appendDebugOutput(
List<String>.generate(120, (index) => 'Line $index').join('\r\n'),
);

await tester.pumpWidget(
buildView(
showHeader: false,
autofocus: true,
scrollController: scrollController,
),
);
await tester.pumpAndSettle();

scrollController.jumpTo(300);
await tester.pumpAndSettle();
final before = controller.viewportScrollbar!;
final beforeOffsetFromBottom =
before.total - before.length - before.offset;

const firstRowTarget = Offset(30, 24);
await tester.tapAt(firstRowTarget);
await tester.pump(const Duration(milliseconds: 40));
await tester.tapAt(firstRowTarget);
await tester.pump(const Duration(milliseconds: 40));
await tester.tapAt(firstRowTarget);
await tester.pumpAndSettle();

final after = controller.viewportScrollbar!;
expect(after.total - after.length - after.offset, beforeOffsetFromBottom);
expect(scrollController.offset, 300);
});

testWidgets('new output cannot overwrite a pending local viewport scroll', (
tester,
) async {
if (!hasNativeTerminal) {
return;
}

final scrollController = ScrollController();
addTearDown(scrollController.dispose);
controller.appendDebugOutput(
List<String>.generate(120, (index) => 'Line $index').join('\r\n'),
);

await tester.pumpWidget(
buildView(showHeader: false, scrollController: scrollController),
);
await tester.pumpAndSettle();

final observedOffsets = <double>[];
scrollController.addListener(
() => observedOffsets.add(scrollController.offset),
);
scrollController.jumpTo(300);
controller.appendDebugOutput('\r\nTail');
await tester.pumpAndSettle();

expect(observedOffsets, isNot(contains(0.0)));
expect(scrollController.offset, 300);
});

testWidgets('a trackpad two-finger scroll drives a program holding the '
Expand Down Expand Up @@ -2006,8 +2079,15 @@ void main() {
controller.appendDebugOutput('\r\nTail');
await tester.pumpAndSettle();

// Without auto-follow the viewport stays put.
expect(scrollController.offset, preservedOffset);
// Without auto-follow the same transcript row stays visible. Since the
// Flutter scroll layer measures from the live bottom, its coordinate
// grows when a new row is appended below that held viewport.
final bar = controller.viewportScrollbar!;
final expectedOffset =
(bar.total - bar.length - bar.offset) *
_measureTestMetrics().linePixels;
expect(scrollController.offset, expectedOffset);
expect(scrollController.offset, greaterThan(preservedOffset));

// The rendered first row must still show the same content as before the
// new output arrived — the viewport did not move.
Expand Down
Loading