nandi/frqpublic Fork 0
4925e54
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

The arrow goes to the message

A reply chip's arrow highlighted the message it points at and left the
view where it was. The core has marked that row with `scrollHere` since
the port, and `Jump to present` has counted up a `scrollToBottom` tick
for just as long; the renderer read neither prop. Both are handled now.

`scrollHere` needs a built element — `ensureVisible` cannot run during
the build that asks for it — so the row carries a GlobalKey and the
scroll happens after the frame. Two keys on one widget is not a thing,
so they nest: the ValueKey keeps the element across rebuilds and the
GlobalKey is how the frame finds it afterwards. It lands a fifth of the
way down rather than against the ceiling, so the answered message is
read with what came after it.

The renderer then tells the core it has arrived, and the core takes the
target off. A `jumpTo` left set would scroll back to that row on every
frame, which is scrolling taken away from the reader — the highlight
stays, since that is the part that says "this is the one you asked for".

`scrollToBottom` is a tick rather than a flag for the same reason: a
count that goes up needs nobody to clear it. A reverse scroll holds the
present at offset zero, which is why jumping to it is not
maxScrollExtent.

Both tests took a correction. The first asserted the offset moved with
the view already at the present, where the target is on screen and
`ensureVisible` is right to do nothing; and a single timed pump advances
the clock without running the scroll animation out, so the offset came
back unchanged as though the handler were missing. `pumpAndSettle`, from
a position away from the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T17:56:49-07:00 Browse files
4925e54 parent: 91b0e1e
modified flutter/lib/nim_renderer.dart +56 -4
@@ -60,6 +60,19 @@ class _NimAppState extends State<NimApp> {
6060 // switching rooms is a different backlog at a different offset.
6161 final _scrollers = <String, ScrollController>{};
6262
63+ // Where a "go to that message" is pointing, for the one frame it is
64+ // pointing there. The core marks the row with `scrollHere`, this finds it
65+ // after the frame is laid out — `ensureVisible` needs a built element, so
66+ // it cannot happen during the build that asks for it — and then tells the
67+ // core it has arrived, which takes the mark off. Leaving it on would pin
68+ // the view to that row and take scrolling away from the reader.
69+ final _jumpKey = GlobalKey();
70+
71+ // The last `scrollToBottom` tick acted on, per scroll. The core counts up
72+ // when "Jump to present" is pressed; an unchanged count is a frame where
73+ // nobody asked to be moved.
74+ final _bottomTicks = <String, int>{};
75+
6376 @override
6477 void initState() {
6578 super.initState();
@@ -230,7 +243,28 @@ class _NimAppState extends State<NimApp> {
230243 // holds per element is at stake: text controllers, scroll offsets, and
231244 // the selectables a live text selection is made of.
232245 final key = n.prop('key', '');
233- final w = _buildNode(n, axis);
246+ var w = _buildNode(n, axis);
247+
248+ // The row a reply's arrow is aiming at. Two keys on one widget is not a
249+ // thing, so they nest: the ValueKey keeps the element across rebuilds,
250+ // and the GlobalKey is how this frame finds it afterwards.
251+ if (n.prop('scrollHere', false)) {
252+ w = KeyedSubtree(key: _jumpKey, child: w);
253+ WidgetsBinding.instance.addPostFrameCallback((_) {
254+ final ctx = _jumpKey.currentContext;
255+ if (ctx == null || !mounted) return;
256+ Scrollable.ensureVisible(
257+ ctx,
258+ duration: const Duration(milliseconds: 250),
259+ curve: Curves.easeOut,
260+ // A little down from the top, so the message that was replied to
261+ // is read with what came after it rather than alone against the
262+ // ceiling.
263+ alignment: 0.2,
264+ );
265+ _send('jump.done');
266+ });
267+ }
234268 return key.isEmpty ? w : KeyedSubtree(key: ValueKey(key), child: w);
235269 }
236270
@@ -640,12 +674,30 @@ class _NimAppState extends State<NimApp> {
640674 case 'scroll':
641675 {
642676 // Both ends of the same scroll, named so they are the same one.
643- final c = _scrollers.putIfAbsent(
644- n.prop('scrollKey', 'scroll'), ScrollController.new);
677+ final scrollKey = n.prop('scrollKey', 'scroll');
678+ final c = _scrollers.putIfAbsent(scrollKey, ScrollController.new);
679+ final stick = n.prop('stickToBottom', false);
680+
681+ // "Jump to present": a tick that goes up, rather than a flag that
682+ // would have to be cleared. A reverse scroll holds the present at
683+ // offset zero, which is why this is not maxScrollExtent.
684+ final tick = n.prop('scrollToBottom', 0);
685+ if (_bottomTicks[scrollKey] != tick) {
686+ _bottomTicks[scrollKey] = tick;
687+ WidgetsBinding.instance.addPostFrameCallback((_) {
688+ if (!c.hasClients) return;
689+ c.animateTo(
690+ stick ? c.position.minScrollExtent
691+ : c.position.maxScrollExtent,
692+ duration: const Duration(milliseconds: 250),
693+ curve: Curves.easeOut,
694+ );
695+ });
696+ }
645697 Widget body = SingleChildScrollView(
646698 controller: c,
647699 // The backlog reads from the bottom; a settings list from the top.
648- reverse: n.prop('stickToBottom', false),
700+ reverse: stick,
649701 child: Column(
650702 crossAxisAlignment: CrossAxisAlignment.start,
651703 children: _spaced(kids, spacing, vertical: true)),
@@ -60,6 +60,19 @@ class _NimAppState extends State<NimApp> {
60 // switching rooms is a different backlog at a different offset.60 // switching rooms is a different backlog at a different offset.
61 final _scrollers = <String, ScrollController>{};61 final _scrollers = <String, ScrollController>{};
62 62
63+ // Where a "go to that message" is pointing, for the one frame it is
64+ // pointing there. The core marks the row with `scrollHere`, this finds it
65+ // after the frame is laid out — `ensureVisible` needs a built element, so
66+ // it cannot happen during the build that asks for it — and then tells the
67+ // core it has arrived, which takes the mark off. Leaving it on would pin
68+ // the view to that row and take scrolling away from the reader.
69+ final _jumpKey = GlobalKey();
70+
71+ // The last `scrollToBottom` tick acted on, per scroll. The core counts up
72+ // when "Jump to present" is pressed; an unchanged count is a frame where
73+ // nobody asked to be moved.
74+ final _bottomTicks = <String, int>{};
75+
63 @override76 @override
64 void initState() {77 void initState() {
65 super.initState();78 super.initState();
@@ -230,7 +243,28 @@ class _NimAppState extends State<NimApp> {
230 // holds per element is at stake: text controllers, scroll offsets, and243 // holds per element is at stake: text controllers, scroll offsets, and
231 // the selectables a live text selection is made of.244 // the selectables a live text selection is made of.
232 final key = n.prop('key', '');245 final key = n.prop('key', '');
233- final w = _buildNode(n, axis);246+ var w = _buildNode(n, axis);
247+
248+ // The row a reply's arrow is aiming at. Two keys on one widget is not a
249+ // thing, so they nest: the ValueKey keeps the element across rebuilds,
250+ // and the GlobalKey is how this frame finds it afterwards.
251+ if (n.prop('scrollHere', false)) {
252+ w = KeyedSubtree(key: _jumpKey, child: w);
253+ WidgetsBinding.instance.addPostFrameCallback((_) {
254+ final ctx = _jumpKey.currentContext;
255+ if (ctx == null || !mounted) return;
256+ Scrollable.ensureVisible(
257+ ctx,
258+ duration: const Duration(milliseconds: 250),
259+ curve: Curves.easeOut,
260+ // A little down from the top, so the message that was replied to
261+ // is read with what came after it rather than alone against the
262+ // ceiling.
263+ alignment: 0.2,
264+ );
265+ _send('jump.done');
266+ });
267+ }
234 return key.isEmpty ? w : KeyedSubtree(key: ValueKey(key), child: w);268 return key.isEmpty ? w : KeyedSubtree(key: ValueKey(key), child: w);
235 }269 }
236 270
@@ -640,12 +674,30 @@ class _NimAppState extends State<NimApp> {
640 case 'scroll':674 case 'scroll':
641 {675 {
642 // Both ends of the same scroll, named so they are the same one.676 // Both ends of the same scroll, named so they are the same one.
643- final c = _scrollers.putIfAbsent(677+ final scrollKey = n.prop('scrollKey', 'scroll');
644- n.prop('scrollKey', 'scroll'), ScrollController.new);678+ final c = _scrollers.putIfAbsent(scrollKey, ScrollController.new);
679+ final stick = n.prop('stickToBottom', false);
680+
681+ // "Jump to present": a tick that goes up, rather than a flag that
682+ // would have to be cleared. A reverse scroll holds the present at
683+ // offset zero, which is why this is not maxScrollExtent.
684+ final tick = n.prop('scrollToBottom', 0);
685+ if (_bottomTicks[scrollKey] != tick) {
686+ _bottomTicks[scrollKey] = tick;
687+ WidgetsBinding.instance.addPostFrameCallback((_) {
688+ if (!c.hasClients) return;
689+ c.animateTo(
690+ stick ? c.position.minScrollExtent
691+ : c.position.maxScrollExtent,
692+ duration: const Duration(milliseconds: 250),
693+ curve: Curves.easeOut,
694+ );
695+ });
696+ }
645 Widget body = SingleChildScrollView(697 Widget body = SingleChildScrollView(
646 controller: c,698 controller: c,
647 // The backlog reads from the bottom; a settings list from the top.699 // The backlog reads from the bottom; a settings list from the top.
648- reverse: n.prop('stickToBottom', false),700+ reverse: stick,
649 child: Column(701 child: Column(
650 crossAxisAlignment: CrossAxisAlignment.start,702 crossAxisAlignment: CrossAxisAlignment.start,
651 children: _spaced(kids, spacing, vertical: true)),703 children: _spaced(kids, spacing, vertical: true)),
modified flutter/test/nim_layout_test.dart +45 -0
@@ -241,6 +241,51 @@ void main() {
241241 });
242242 });
243243
244+ group('going to a message', () {
245+ // The core has always marked the row a reply points at with
246+ // `scrollHere`, and the renderer ignored the prop — so the arrow on a
247+ // reply chip highlighted the message and left the view where it was.
248+ testWidgets('the target is scrolled into view', (tester) async {
249+ core.demoUi();
250+ // Short enough that the backlog does not fit, or a scroll has nothing
251+ // to do and the target is already on screen — which is what the first
252+ // version of this test proved: the offset stayed at zero because
253+ // `ensureVisible` was right not to move.
254+ await layOut(tester, const Size(700, 260));
255+ final c = tester
256+ .widget<Scrollable>(find.byType(Scrollable).first)
257+ .controller!;
258+
259+ // Away from the present, where the answered message is not.
260+ c.jumpTo(c.position.maxScrollExtent);
261+ await tester.pump();
262+ final before = c.offset;
263+ expect(before, greaterThan(0.0), reason: 'nothing to scroll here');
264+
265+ core.dispatch('goto:5');
266+ await tester.pump(const Duration(milliseconds: 150));
267+ // Settled, not a timed pump: one pump of 400ms advances the clock but
268+ // does not run the scroll animation out, and the offset comes back
269+ // unchanged as though nothing had happened.
270+ await tester.pumpAndSettle();
271+ expectLaidOut(tester, 'the backlog after going to a message');
272+ expect(c.offset, lessThan(before), reason: 'the view did not move');
273+ });
274+
275+ testWidgets('and the core is told, so the view is not pinned there',
276+ (tester) async {
277+ // A `jumpTo` left set would scroll back to that row on every frame,
278+ // which is scrolling taken away from the reader.
279+ core.demoUi();
280+ await layOut(tester, const Size(700, 260));
281+ await tester.tap(find.text('').first);
282+ await tester.pumpAndSettle();
283+ expectLaidOut(tester, 'the backlog after the arrow');
284+ expect(core.dispatchFrame('noop').json.contains('"scrollHere":true'),
285+ isFalse, reason: 'the core still thinks it has somewhere to go');
286+ });
287+ });
288+
244289 group('identity', () {
245290 // Duplicate keys among siblings are an error Flutter throws at build
246291 // time, so this is mostly a guard on the tree the core emits: every
@@ -241,6 +241,51 @@ void main() {
241 });241 });
242 });242 });
243 243
244+ group('going to a message', () {
245+ // The core has always marked the row a reply points at with
246+ // `scrollHere`, and the renderer ignored the prop — so the arrow on a
247+ // reply chip highlighted the message and left the view where it was.
248+ testWidgets('the target is scrolled into view', (tester) async {
249+ core.demoUi();
250+ // Short enough that the backlog does not fit, or a scroll has nothing
251+ // to do and the target is already on screen — which is what the first
252+ // version of this test proved: the offset stayed at zero because
253+ // `ensureVisible` was right not to move.
254+ await layOut(tester, const Size(700, 260));
255+ final c = tester
256+ .widget<Scrollable>(find.byType(Scrollable).first)
257+ .controller!;
258+
259+ // Away from the present, where the answered message is not.
260+ c.jumpTo(c.position.maxScrollExtent);
261+ await tester.pump();
262+ final before = c.offset;
263+ expect(before, greaterThan(0.0), reason: 'nothing to scroll here');
264+
265+ core.dispatch('goto:5');
266+ await tester.pump(const Duration(milliseconds: 150));
267+ // Settled, not a timed pump: one pump of 400ms advances the clock but
268+ // does not run the scroll animation out, and the offset comes back
269+ // unchanged as though nothing had happened.
270+ await tester.pumpAndSettle();
271+ expectLaidOut(tester, 'the backlog after going to a message');
272+ expect(c.offset, lessThan(before), reason: 'the view did not move');
273+ });
274+
275+ testWidgets('and the core is told, so the view is not pinned there',
276+ (tester) async {
277+ // A `jumpTo` left set would scroll back to that row on every frame,
278+ // which is scrolling taken away from the reader.
279+ core.demoUi();
280+ await layOut(tester, const Size(700, 260));
281+ await tester.tap(find.text('').first);
282+ await tester.pumpAndSettle();
283+ expectLaidOut(tester, 'the backlog after the arrow');
284+ expect(core.dispatchFrame('noop').json.contains('"scrollHere":true'),
285+ isFalse, reason: 'the core still thinks it has somewhere to go');
286+ });
287+ });
288+
244 group('identity', () {289 group('identity', () {
245 // Duplicate keys among siblings are an error Flutter throws at build290 // Duplicate keys among siblings are an error Flutter throws at build
246 // time, so this is mostly a guard on the tree the core emits: every291 // time, so this is mostly a guard on the tree the core emits: every
modified nim/src/frq/reducer.nim +7 -0
@@ -503,6 +503,13 @@ proc dispatch*(event: JsonNode) =
503503 app.jumpTo = arg
504504 app.highlight = arg
505505
506+ of "jump.done":
507+ # The renderer, saying it has scrolled there. The target is taken off
508+ # again because a `jumpTo` that stayed set would pin the view to that
509+ # message and take scrolling away from the reader — the highlight stays,
510+ # since that is what says "this is the one you asked for".
511+ app.jumpTo = ""
512+
506513 of "overview.goto":
507514 # The overview is the one place that moves the reader without their having
508515 # asked to leave where they were, so it is the one place that owes them
@@ -503,6 +503,13 @@ proc dispatch*(event: JsonNode) =
503 app.jumpTo = arg503 app.jumpTo = arg
504 app.highlight = arg504 app.highlight = arg
505 505
506+ of "jump.done":
507+ # The renderer, saying it has scrolled there. The target is taken off
508+ # again because a `jumpTo` that stayed set would pin the view to that
509+ # message and take scrolling away from the reader — the highlight stays,
510+ # since that is what says "this is the one you asked for".
511+ app.jumpTo = ""
512+
506 of "overview.goto":513 of "overview.goto":
507 # The overview is the one place that moves the reader without their having514 # The overview is the one place that moves the reader without their having
508 # asked to leave where they were, so it is the one place that owes them515 # asked to leave where they were, so it is the one place that owes them