nandi/frqpublic Fork 0
4083860
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.

A row is named for its message, not its place

Two things, both about a tree that is rebuilt wholesale on every frame.

The member list is no longer remembered. It was, as of this morning,
along with the join/part and room-list toggles — but on a narrow window
the people panel is not beside the conversation, it *is* the pane. So
having asked for it once, every launch opened a room and showed a list
of names instead of the room. It is a way of looking at the moment you
are in, like the overview, and those start off.

And message rows were keyed by their index. A line arriving above, or
the join/part filter coming off, renumbers every row under it — and a
key that renumbers is a row Flutter tears down and builds again, losing
everything it held for it. The renderer now honours the `key` the core
has always put on these nodes, and the rows carry the message's own id.

That is also the best I can do about `ConcurrentModificationError ...
during a scheduler callback` out of SelectableRegion, which is a
framework bug in handling selectables that change during a live
selection: fewer of them change now. I could not reproduce it — three
attempts, driving a held drag against tree changes from the poll, from a
second pointer and from a send, all of which passed. The test that did
land is the one about element identity, and it took two tries to make it
discriminate: an arriving line lands at the bottom and renumbers
nothing, so the first version passed against both keyings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T10:47:59-07:00 Browse files
4083860 parent: abb8e36
modified flutter/lib/nim_renderer.dart +13 -0
@@ -222,6 +222,19 @@ class _NimAppState extends State<NimApp> {
222222 static const _column = 'column';
223223
224224 Widget _build(core.UiNode n, [String axis = _noAxis]) {
225+ // A node that names itself keeps its element across rebuilds.
226+ //
227+ // The tree is rebuilt wholesale from the core, so Flutter matches
228+ // children by position unless something says otherwise — and a position
229+ // is not an identity when a line can arrive above. Everything Flutter
230+ // holds per element is at stake: text controllers, scroll offsets, and
231+ // the selectables a live text selection is made of.
232+ final key = n.prop('key', '');
233+ final w = _buildNode(n, axis);
234+ return key.isEmpty ? w : KeyedSubtree(key: ValueKey(key), child: w);
235+ }
236+
237+ Widget _buildNode(core.UiNode n, String axis) {
225238 final spacing = _d(n.props['spacing'], 0);
226239 final flex = axis == _row || axis == _column;
227240
@@ -222,6 +222,19 @@ class _NimAppState extends State<NimApp> {
222 static const _column = 'column';222 static const _column = 'column';
223 223
224 Widget _build(core.UiNode n, [String axis = _noAxis]) {224 Widget _build(core.UiNode n, [String axis = _noAxis]) {
225+ // A node that names itself keeps its element across rebuilds.
226+ //
227+ // The tree is rebuilt wholesale from the core, so Flutter matches
228+ // children by position unless something says otherwise — and a position
229+ // is not an identity when a line can arrive above. Everything Flutter
230+ // holds per element is at stake: text controllers, scroll offsets, and
231+ // the selectables a live text selection is made of.
232+ final key = n.prop('key', '');
233+ final w = _buildNode(n, axis);
234+ return key.isEmpty ? w : KeyedSubtree(key: ValueKey(key), child: w);
235+ }
236+
237+ Widget _buildNode(core.UiNode n, String axis) {
225 final spacing = _d(n.props['spacing'], 0);238 final spacing = _d(n.props['spacing'], 0);
226 final flex = axis == _row || axis == _column;239 final flex = axis == _row || axis == _column;
227 240
modified flutter/test/nim_layout_test.dart +36 -0
@@ -241,6 +241,42 @@ void main() {
241241 });
242242 });
243243
244+ group('identity', () {
245+ // 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: every
247+ // message row names itself now, and two rows must never name themselves
248+ // the same thing.
249+ testWidgets('every screen builds with the keys the core gives it',
250+ (tester) async {
251+ for (final screen in ['chat', 'chats', 'discover', 'settings']) {
252+ core.demoUi();
253+ if (screen != 'chat') core.dispatch('screen.$screen');
254+ await layOut(tester, sizes['desktop']!);
255+ expectLaidOut(tester, '$screen with keys');
256+ }
257+ });
258+
259+ testWidgets('a row keeps its element when one above it goes away',
260+ (tester) async {
261+ // Hiding the joins and parts takes the system line off the top of the
262+ // demo backlog, which renumbers every row under it. Keyed by position
263+ // that is a teardown and rebuild of all of them; keyed by the message
264+ // it is one row leaving.
265+ //
266+ // An arriving line would not show this — it lands at the bottom and
267+ // renumbers nothing, which is how the first version of this test
268+ // passed against both.
269+ core.demoUi();
270+ await layOut(tester, sizes['desktop']!);
271+ final before = tester.element(find.text('alice').first);
272+ core.dispatch('join-part.toggle');
273+ await tester.pump(const Duration(milliseconds: 150));
274+ expectLaidOut(tester, 'the backlog with the system lines hidden');
275+ expect(tester.element(find.text('alice').first), same(before),
276+ reason: 'the row was torn down rather than kept');
277+ });
278+ });
279+
244280 group('emoji', () {
245281 // ✏️ is U+270F plus a variation selector asking for emoji presentation,
246282 // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome
@@ -241,6 +241,42 @@ void main() {
241 });241 });
242 });242 });
243 243
244+ group('identity', () {
245+ // 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: every
247+ // message row names itself now, and two rows must never name themselves
248+ // the same thing.
249+ testWidgets('every screen builds with the keys the core gives it',
250+ (tester) async {
251+ for (final screen in ['chat', 'chats', 'discover', 'settings']) {
252+ core.demoUi();
253+ if (screen != 'chat') core.dispatch('screen.$screen');
254+ await layOut(tester, sizes['desktop']!);
255+ expectLaidOut(tester, '$screen with keys');
256+ }
257+ });
258+
259+ testWidgets('a row keeps its element when one above it goes away',
260+ (tester) async {
261+ // Hiding the joins and parts takes the system line off the top of the
262+ // demo backlog, which renumbers every row under it. Keyed by position
263+ // that is a teardown and rebuild of all of them; keyed by the message
264+ // it is one row leaving.
265+ //
266+ // An arriving line would not show this — it lands at the bottom and
267+ // renumbers nothing, which is how the first version of this test
268+ // passed against both.
269+ core.demoUi();
270+ await layOut(tester, sizes['desktop']!);
271+ final before = tester.element(find.text('alice').first);
272+ core.dispatch('join-part.toggle');
273+ await tester.pump(const Duration(milliseconds: 150));
274+ expectLaidOut(tester, 'the backlog with the system lines hidden');
275+ expect(tester.element(find.text('alice').first), same(before),
276+ reason: 'the row was torn down rather than kept');
277+ });
278+ });
279+
244 group('emoji', () {280 group('emoji', () {
245 // ✏️ is U+270F plus a variation selector asking for emoji presentation,281 // ✏️ is U+270F plus a variation selector asking for emoji presentation,
246 // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome282 // and DejaVu Sans claims U+270F — so ordinary fallback draws a monochrome
modified nim/src/frq/reducer.nim +12 -8
@@ -88,18 +88,22 @@ proc restoreRooms() =
8888 trace("store", $saved.len & " rooms remembered")
8989
9090 proc restorePrefs() =
91- ## The display toggles. Three booleans, and every one of them is the
92- ## reader's answer to a question this client has no business re-asking on
93- ## every run — whether the comings and goings are worth seeing, whether the
94- ## member list is up, whether the room list is out of the way.
91+ ## The display toggles that are a standing answer rather than a passing
92+ ## one: whether the comings and goings are worth seeing, and whether the
93+ ## room list is out of the way.
94+ ##
95+ ## The member list is NOT among them, though it was for a day. On a narrow
96+ ## window the people panel is not beside the conversation, it *is* the
97+ ## pane — so a saved "on" meant opening a room and being shown a list of
98+ ## names instead of the room, every launch, having asked for it once. It is
99+ ## a way of looking at the moment you are in, like the overview, and those
100+ ## start off.
95101 let prefs = loadPrefs()
96102 app.hideJoinPart = prefs.getOrDefault("hideJoinPart", app.hideJoinPart)
97- app.showUsers = prefs.getOrDefault("showUsers", app.showUsers)
98103 app.hideChatList = prefs.getOrDefault("hideChatList", app.hideChatList)
99104
100105 proc rememberPrefs() =
101106 discard savePrefs({"hideJoinPart": app.hideJoinPart,
102- "showUsers": app.showUsers,
103107 "hideChatList": app.hideChatList}.toTable)
104108
105109 var
@@ -386,15 +390,15 @@ proc dispatch*(event: JsonNode) =
386390 of "search.clear": app.search = ""
387391
388392 # ---------------------------------------------------------- chat chrome
389- # The three that outlive the run are written as they are pressed. There is
393+ # The two that outlive the run are written as they are pressed. There is
390394 # no Save on this screen and no moment that is obviously the last one — the
391395 # window closes when it closes.
392396 of "chat-list.toggle":
393397 app.hideChatList = not app.hideChatList
394398 rememberPrefs()
395399 of "users.toggle":
400+ # Not remembered; see `restorePrefs`.
396401 app.showUsers = not app.showUsers
397- rememberPrefs()
398402 of "overview.toggle":
399403 # Not kept: the overview is a way of looking at the moment you are in
400404 # rather than a preference, and a client that reopened into it would be
@@ -88,18 +88,22 @@ proc restoreRooms() =
88 trace("store", $saved.len & " rooms remembered")88 trace("store", $saved.len & " rooms remembered")
89 89
90 proc restorePrefs() =90 proc restorePrefs() =
91- ## The display toggles. Three booleans, and every one of them is the91+ ## The display toggles that are a standing answer rather than a passing
92- ## reader's answer to a question this client has no business re-asking on92+ ## one: whether the comings and goings are worth seeing, and whether the
93- ## every run — whether the comings and goings are worth seeing, whether the93+ ## room list is out of the way.
94- ## member list is up, whether the room list is out of the way.94+ ##
95+ ## The member list is NOT among them, though it was for a day. On a narrow
96+ ## window the people panel is not beside the conversation, it *is* the
97+ ## pane — so a saved "on" meant opening a room and being shown a list of
98+ ## names instead of the room, every launch, having asked for it once. It is
99+ ## a way of looking at the moment you are in, like the overview, and those
100+ ## start off.
95 let prefs = loadPrefs()101 let prefs = loadPrefs()
96 app.hideJoinPart = prefs.getOrDefault("hideJoinPart", app.hideJoinPart)102 app.hideJoinPart = prefs.getOrDefault("hideJoinPart", app.hideJoinPart)
97- app.showUsers = prefs.getOrDefault("showUsers", app.showUsers)
98 app.hideChatList = prefs.getOrDefault("hideChatList", app.hideChatList)103 app.hideChatList = prefs.getOrDefault("hideChatList", app.hideChatList)
99 104
100 proc rememberPrefs() =105 proc rememberPrefs() =
101 discard savePrefs({"hideJoinPart": app.hideJoinPart,106 discard savePrefs({"hideJoinPart": app.hideJoinPart,
102- "showUsers": app.showUsers,
103 "hideChatList": app.hideChatList}.toTable)107 "hideChatList": app.hideChatList}.toTable)
104 108
105 var109 var
@@ -386,15 +390,15 @@ proc dispatch*(event: JsonNode) =
386 of "search.clear": app.search = ""390 of "search.clear": app.search = ""
387 391
388 # ---------------------------------------------------------- chat chrome392 # ---------------------------------------------------------- chat chrome
389- # The three that outlive the run are written as they are pressed. There is393+ # The two that outlive the run are written as they are pressed. There is
390 # no Save on this screen and no moment that is obviously the last one — the394 # no Save on this screen and no moment that is obviously the last one — the
391 # window closes when it closes.395 # window closes when it closes.
392 of "chat-list.toggle":396 of "chat-list.toggle":
393 app.hideChatList = not app.hideChatList397 app.hideChatList = not app.hideChatList
394 rememberPrefs()398 rememberPrefs()
395 of "users.toggle":399 of "users.toggle":
400+ # Not remembered; see `restorePrefs`.
396 app.showUsers = not app.showUsers401 app.showUsers = not app.showUsers
397- rememberPrefs()
398 of "overview.toggle":402 of "overview.toggle":
399 # Not kept: the overview is a way of looking at the moment you are in403 # Not kept: the overview is a way of looking at the moment you are in
400 # rather than a preference, and a client that reopened into it would be404 # rather than a preference, and a client that reopened into it would be
modified nim/src/frq/screens/chat.nim +8 -2
@@ -210,7 +210,13 @@ proc messageRow(s: State, room: Room, i: int, m: Message): Node =
210210 ## on the sender's row, which a headerless line has nowhere to put.
211211 let rid = rowId(m)
212212 let highlit = rid.len > 0 and rid == s.highlight
213- n("vbox", %*{"key": $i, "spacing": 2, "margin": 0, "marginRight": 10,
213+ # Keyed by the message and not by its position. A line arriving, or the
214+ # join/part filter coming off, renumbers every row under it — and a key
215+ # that renumbers is a row the renderer tears down and builds again, taking
216+ # with it whatever state Flutter held for it. A live text selection is the
217+ # loudest thing that state has been.
218+ n("vbox", %*{"key": (if rid.len > 0: rid else: "row-" & $i),
219+ "spacing": 2, "margin": 0, "marginRight": 10,
214220 "marginTop": 10,
215221 "scrollHere": rid.len > 0 and rid == s.jumpTo},
216222 @[messageBody(s, room, m, highlit)])
@@ -237,7 +243,7 @@ proc messageRows*(s: State, room: Room, messages: seq[Message]): seq[Node] =
237243 if m.at > 0:
238244 let d = day(m.at)
239245 if d != prevDay:
240- result.add daySeparator("day-" & $i, dayLabel(m.at))
246+ result.add daySeparator("day-" & d, dayLabel(m.at))
241247 prevDay = d
242248 result.add messageRow(s, room, i, m)
243249
@@ -210,7 +210,13 @@ proc messageRow(s: State, room: Room, i: int, m: Message): Node =
210 ## on the sender's row, which a headerless line has nowhere to put.210 ## on the sender's row, which a headerless line has nowhere to put.
211 let rid = rowId(m)211 let rid = rowId(m)
212 let highlit = rid.len > 0 and rid == s.highlight212 let highlit = rid.len > 0 and rid == s.highlight
213- n("vbox", %*{"key": $i, "spacing": 2, "margin": 0, "marginRight": 10,213+ # Keyed by the message and not by its position. A line arriving, or the
214+ # join/part filter coming off, renumbers every row under it — and a key
215+ # that renumbers is a row the renderer tears down and builds again, taking
216+ # with it whatever state Flutter held for it. A live text selection is the
217+ # loudest thing that state has been.
218+ n("vbox", %*{"key": (if rid.len > 0: rid else: "row-" & $i),
219+ "spacing": 2, "margin": 0, "marginRight": 10,
214 "marginTop": 10,220 "marginTop": 10,
215 "scrollHere": rid.len > 0 and rid == s.jumpTo},221 "scrollHere": rid.len > 0 and rid == s.jumpTo},
216 @[messageBody(s, room, m, highlit)])222 @[messageBody(s, room, m, highlit)])
@@ -237,7 +243,7 @@ proc messageRows*(s: State, room: Room, messages: seq[Message]): seq[Node] =
237 if m.at > 0:243 if m.at > 0:
238 let d = day(m.at)244 let d = day(m.at)
239 if d != prevDay:245 if d != prevDay:
240- result.add daySeparator("day-" & $i, dayLabel(m.at))246+ result.add daySeparator("day-" & d, dayLabel(m.at))
241 prevDay = d247 prevDay = d
242 result.add messageRow(s, room, i, m)248 result.add messageRow(s, room, i, m)
243 249
modified nim/tests/tstore.nim +13 -2
@@ -109,15 +109,26 @@ suite "the rooms file":
109109
110110 suite "the prefs file":
111111 setup: clean()
112- test "the three display toggles outlive the run":
112+ test "the display toggles outlive the run":
113113 restore()
114114 let wasJoinPart = app.hideJoinPart
115115 dispatch(%*{"id": "join-part.toggle"})
116- dispatch(%*{"id": "users.toggle"})
116+ dispatch(%*{"id": "chat-list.toggle"})
117117 app = initState()
118118 restore()
119119 check app.hideJoinPart == not wasJoinPart
120+ check app.hideChatList
121+
122+ test "but the member list does not":
123+ # On a narrow window the people panel is the pane rather than something
124+ # beside it, so a remembered "on" opens a room and shows a list of names
125+ # instead of the room.
126+ restore()
127+ dispatch(%*{"id": "users.toggle"})
120128 check app.showUsers
129+ app = initState()
130+ restore()
131+ check not app.showUsers
121132 test "the overview is not one of them":
122133 # It is a way of looking at the moment you are in rather than a
123134 # preference, and reopening into it would answer a question nobody asked
@@ -109,15 +109,26 @@ suite "the rooms file":
109 109
110 suite "the prefs file":110 suite "the prefs file":
111 setup: clean()111 setup: clean()
112- test "the three display toggles outlive the run":112+ test "the display toggles outlive the run":
113 restore()113 restore()
114 let wasJoinPart = app.hideJoinPart114 let wasJoinPart = app.hideJoinPart
115 dispatch(%*{"id": "join-part.toggle"})115 dispatch(%*{"id": "join-part.toggle"})
116- dispatch(%*{"id": "users.toggle"})116+ dispatch(%*{"id": "chat-list.toggle"})
117 app = initState()117 app = initState()
118 restore()118 restore()
119 check app.hideJoinPart == not wasJoinPart119 check app.hideJoinPart == not wasJoinPart
120+ check app.hideChatList
121+
122+ test "but the member list does not":
123+ # On a narrow window the people panel is the pane rather than something
124+ # beside it, so a remembered "on" opens a room and shows a list of names
125+ # instead of the room.
126+ restore()
127+ dispatch(%*{"id": "users.toggle"})
120 check app.showUsers128 check app.showUsers
129+ app = initState()
130+ restore()
131+ check not app.showUsers
121 test "the overview is not one of them":132 test "the overview is not one of them":
122 # It is a way of looking at the moment you are in rather than a133 # It is a way of looking at the moment you are in rather than a
123 # preference, and reopening into it would answer a question nobody asked134 # preference, and reopening into it would answer a question nobody asked