diff --git a/lib/src/components/auto_scroll_controller.dart b/lib/src/components/auto_scroll_controller.dart index c3485c6..be12640 100644 --- a/lib/src/components/auto_scroll_controller.dart +++ b/lib/src/components/auto_scroll_controller.dart @@ -42,25 +42,13 @@ class AutoScrollController extends ScrollController { axisDirection: axisDirection, ); - // Auto-scroll if we were near bottom and content grew + // Auto-scroll if we were near bottom and content grew. + // + // This must be a silent layout-time correction. Deferring to a post-frame + // jump lets one frame paint with the old offset when content wraps onto a + // new line, which shows up as a visible flicker in chat/log panels. if (_isAutoScrollEnabled && wasNearBottom && contentGrew) { - // Schedule scroll to end after the frame - try { - TerminalBinding.instance.addPostFrameCallback((_) { - if (isReversed) { - scrollToStart(); // In reverse mode, scroll to start (offset 0) - } else { - scrollToEnd(); // In normal mode, scroll to end - } - }); - } catch (e) { - // In test environment or when binding is not available, scroll immediately - if (isReversed) { - scrollToStart(); - } else { - scrollToEnd(); - } - } + correctOffset(isReversed ? minScrollExtent : maxScrollExtent); } _previousMaxScrollExtent = maxScrollExtent; diff --git a/lib/src/components/scroll_controller.dart b/lib/src/components/scroll_controller.dart index 01a8c57..eb09801 100644 --- a/lib/src/components/scroll_controller.dart +++ b/lib/src/components/scroll_controller.dart @@ -90,6 +90,16 @@ class ScrollController extends ChangeNotifier { notifyListeners(); } + /// Corrects the scroll position during layout without notifying listeners. + /// + /// Scrollable render objects can call this while updating metrics to keep + /// paint offsets consistent in the same frame. This mirrors Flutter's + /// layout-time pixel correction and avoids scheduling a second frame for + /// purely metric-driven adjustments. + void correctOffset(double value) { + _offset = value.clamp(minScrollExtent, maxScrollExtent); + } + /// Scrolls by the given delta. void scrollBy(double delta) { jumpTo(offset + delta); diff --git a/test/layout/auto_scroll_controller_test.dart b/test/layout/auto_scroll_controller_test.dart index 7288040..2c9a933 100644 --- a/test/layout/auto_scroll_controller_test.dart +++ b/test/layout/auto_scroll_controller_test.dart @@ -60,6 +60,48 @@ void main() { ); }); + test('auto-scrolls in the same frame when an item wraps', () async { + await testNocterm( + 'auto-scroll same frame on wrap', + (tester) async { + final scrollController = AutoScrollController(); + final items = [ + 'Message 1', + 'Message 2', + 'Message 3', + 'tail', + ]; + + Component buildList() { + return ListView.builder( + controller: scrollController, + itemCount: items.length, + itemBuilder: (context, index) { + return Text(items[index]); + }, + ); + } + + await tester.pumpComponent(buildList()); + expect(scrollController.offset, 0); + expect(scrollController.isAutoScrollEnabled, isTrue); + + items[3] = 'alpha beta gamma delta epsilon zeta needle'; + + await tester.pumpComponent(buildList()); + + expect(scrollController.atEnd, isTrue); + expect( + tester.terminalState.getText(), + contains('needle'), + reason: 'The first painted frame after wrapping should already ' + 'be pinned to the new bottom.', + ); + }, + size: Size(20, 4), + ); + }); + test('disables auto-scroll when user scrolls up', () async { await testNocterm( 'disable auto-scroll on manual scroll',