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

nim_renderer.dart · 854 lines · 33.3 KBDart Blame HistoryRaw
The data model, in Nim d333b6f nandi yesterday1/// The renderer: a Nim widget tree, walked into Flutter widgets.
2///
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday3/// This knows the tag vocabulary and nothing else — no screens, no state, no
4/// idea what "connect" means. Nim decides what the screen is; this decides
5/// what a `vbox` looks like.
The data model, in Nim d333b6f nandi yesterday6///
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday7/// The measure of whether the split is honest is how boring this file is. If a
8/// feature ever needs a change here AND in Nim, the boundary is in the wrong
9/// place. The treatments are `flutter/src/frq/hiccup.cljd`'s, so a tree from
10/// Nim paints the way the same tree painted under ClojureDart.
11library;
12
The data model, in Nim d333b6f nandi yesterday13import 'dart:async';
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday14import 'dart:io';
The data model, in Nim d333b6f nandi yesterday15
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday16import 'package:flutter/gestures.dart';
17
The data model, in Nim d333b6f nandi yesterday18import 'package:flutter/material.dart';
19import 'package:frq_core/frq_core.dart' as core;
20
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday21import 'nim_theme.dart' as t;
22
The data model, in Nim d333b6f nandi yesterday23class NimApp extends StatefulWidget {
24 const NimApp({super.key});
25 @override
26 State<NimApp> createState() => _NimAppState();
27}
28
29class _NimAppState extends State<NimApp> {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday30 late core.UiFrame _frame = core.renderFrame();
31 core.UiNode get _tree => _frame.tree;
The data model, in Nim d333b6f nandi yesterday32 Timer? _poll;
33
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday34 // One controller and one focus node per keyed entry, kept across rebuilds.
35 //
36 // This is why `:key` is on every entry in both the Clojure and the Nim: a
37 // controller identified by position instead of name meant the host field and
38 // the port field shared one and both showed the port. The focus node is the
39 // same bug one layer up — the field is rebuilt from a fresh tree on every
40 // keystroke, so without a node held per key the caret goes nowhere after the
41 // first line.
42 final _controllers = <String, TextEditingController>{};
43 final _focus = <String, FocusNode>{};
44
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday45 // One tap recogniser per link URL, kept across rebuilds and disposed with
46 // the state. A recogniser made during build and dropped on the next frame
47 // leaks, and this tree is rebuilt on every keystroke.
48 final _linkTaps = <String, TapGestureRecognizer>{};
49
Both ends of a scroll, on the same controller 5594a62 nandi 23h ago50 // One ScrollController per `scrollKey`, for the same reason the entries
51 // have one per key — and for a second reason of its own. A `Scrollbar` with
52 // no controller of its own asks the PrimaryScrollController, and a
53 // SingleChildScrollView is only primary on mobile: on a desktop the two
54 // ends looked at different controllers, so the first wheel event over any
55 // scroll threw "The Scrollbar's ScrollController has no ScrollPosition
56 // attached" and went on throwing it. Naming the controller joins them.
57 //
58 // Two positions must never share one, which is what makes `scrollKey` a
59 // requirement rather than a nicety — the chat screen's is per room, since
60 // switching rooms is a different backlog at a different offset.
61 final _scrollers = <String, ScrollController>{};
62
The arrow goes to the message 4925e54 nandi 14h ago63 // 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
Jump to present, which was never on screen ecb440f nandi 12h ago76 // Whether each scroll is at the present, as last reported to the core.
77 // Only the changes are sent: a notification arrives per pixel of a drag,
78 // and the core has one question, not a thousand.
79 final _atPresent = <String, bool>{};
80
The data model, in Nim d333b6f nandi yesterday81 @override
82 void initState() {
83 super.initState();
84 // Polling, because the socket lives on a Nim thread and there is no
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday85 // callback into Dart. At ~70µs a render a 100ms timer costs nothing.
The data model, in Nim d333b6f nandi yesterday86 _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday87 // Compared as the JSON Nim already produced, and decoded only when it
88 // differs. Stringifying both trees to answer "did anything change" was
89 // ~1MB of string churn per poll in a busy room, ten times a second, for
90 // an answer that is almost always no.
91 final next = core.pollIfChanged(_frame.json);
92 if (next != null) setState(() => _frame = next);
The data model, in Nim d333b6f nandi yesterday93 });
94 }
95
96 void _send(String id, [String value = '']) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday97 if (id.isEmpty) return;
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday98 setState(() => _frame = core.dispatchFrame(id, value));
The data model, in Nim d333b6f nandi yesterday99 if (id == 'send') _focus['draft']?.requestFocus();
100 }
101
102 @override
103 void dispose() {
104 _poll?.cancel();
105 for (final c in _controllers.values) {
106 c.dispose();
107 }
108 for (final f in _focus.values) {
109 f.dispose();
110 }
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday111 for (final r in _linkTaps.values) {
112 r.dispose();
113 }
Both ends of a scroll, on the same controller 5594a62 nandi 23h ago114 for (final c in _scrollers.values) {
115 c.dispose();
116 }
The data model, in Nim d333b6f nandi yesterday117 super.dispose();
118 }
119
120 @override
121 Widget build(BuildContext context) => MaterialApp(
122 title: 'frq',
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday123 debugShowCheckedModeBanner: false,
124 theme: ThemeData(
125 useMaterial3: true,
126 brightness: Brightness.dark,
127 scaffoldBackgroundColor: t.bg,
128 colorScheme: const ColorScheme.dark(
129 primary: t.accent,
130 onPrimary: t.onAccent,
131 surface: t.bg,
132 onSurface: t.onBg,
133 error: t.destructive,
134 ),
The data model, in Nim d333b6f nandi yesterday135 ),
Text can be selected ec74784 nandi 22h ago136 // Everything inside one SelectionArea, so a message can be selected
137 // and copied — and so can a nick, a timestamp, or a line of an error.
138 // Per-widget `SelectableText` was the alternative and is worse: it
139 // selects within one widget only, so a two-line answer and the name
140 // above it cannot be dragged across, which is most of what anyone
141 // wants to copy out of a chat.
142 //
143 // Taps still arrive: a selection starts on a drag, and the buttons,
144 // faces and reaction pills under here keep their gestures.
145 home: Scaffold(
146 backgroundColor: t.bg,
147 body: SafeArea(child: SelectionArea(child: _build(_tree))),
148 ),
The data model, in Nim d333b6f nandi yesterday149 );
150
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday151 // ---------------------------------------------------------------- helpers
152
153 TextStyle _style(double size, Color color) =>
154 TextStyle(fontSize: size, color: color, height: 1.35);
155
The pencil was drawn by a text font 1cb40af nandi 23h ago156 /// The style for a widget whose whole content is an emoji glyph.
157 ///
158 /// Naming the colour emoji font is not belt and braces: a glyph like ✏️ is
159 /// U+270F plus U+FE0F, and the variation selector is a *request* for emoji
160 /// presentation, not a guarantee. DejaVu Sans claims U+270F, so ordinary
161 /// fallback stops there and draws the monochrome pencil the text era had —
162 /// while 🙂, which no text font covers, falls all the way through to the
163 /// emoji font and looks right. That is why only some of the chips were
164 /// wrong.
165 ///
166 /// A family list rather than one name, because the font that has them
167 /// differs by platform, and a name nothing matches costs nothing.
168 static const List<String> _emojiFonts = <String>[
169 'Noto Color Emoji', // Linux, Android
170 'Apple Color Emoji', // macOS, iOS
171 'Segoe UI Emoji', // Windows
172 ];
173
174 TextStyle _emojiStyle(double size) => TextStyle(
175 fontSize: size,
176 fontFamily: _emojiFonts.first,
177 fontFamilyFallback: _emojiFonts,
178 );
179
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday180 double _d(dynamic v, double fallback) =>
181 v is num ? v.toDouble() : fallback;
182
183 /// Gaps between children, as real widgets rather than a `spacing:` — the
184 /// same layout on every Flutter version this might be built against.
185 List<Widget> _spaced(List<Widget> kids, double gap, {required bool vertical}) {
186 if (gap <= 0 || kids.length < 2) return kids;
187 final out = <Widget>[];
188 for (var i = 0; i < kids.length; i++) {
189 if (i > 0) {
190 out.add(vertical ? SizedBox(height: gap) : SizedBox(width: gap));
191 }
192 out.add(kids[i]);
193 }
194 return out;
195 }
196
197 /// A source that may be a bundled asset, a file on disk, or a URL — the
198 /// three the screens hand over, named apart by an `asset:` prefix so they
199 /// stay one property.
200 ImageProvider? _imageProvider(String src) {
201 if (src.isEmpty) return null;
202 if (src.startsWith('asset:')) return AssetImage(src.substring(6));
203 if (src.startsWith('http://') || src.startsWith('https://')) {
204 return NetworkImage(src);
205 }
206 return FileImage(File(src));
207 }
208
209 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {
210 if (onClick.isEmpty) return child;
211 return InkWell(
212 onTap: () => _send(onClick),
213 borderRadius: radius,
214 child: child,
215 );
216 }
217
218 // ------------------------------------------------------------------ build
219
Expanded only where a Flex can hold it f1e99b4 nandi yesterday220 /// The axis of the widget a node is being built *into*, because `Expanded`
221 /// is only legal inside a Flex and there is no way to ask Flutter after the
222 /// fact.
223 ///
224 /// Getting this wrong is what "Cannot hit test a render box that has never
225 /// been laid out" means, in a pile: an `Expanded` inside a `Wrap` fails the
226 /// layout, and every box under it is then asked to hit-test without ever
227 /// having been laid out. The chats screen did exactly that — two unsized
228 /// entries in an `hbox`, which is a Wrap.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday229 /// What an unsized entry or a stranded scroll falls back to.
230 ///
231 /// Both are only reachable when the tree has put one outside a Flex, which
232 /// is a tree bug rather than a rendering choice. The numbers exist so that
233 /// bug renders as something a person can see and a test can catch, not so
234 /// that it renders correctly.
235 static const _unsizedEntry = 320.0;
236 static const _strandedScroll = 400.0;
237
Expanded only where a Flex can hold it f1e99b4 nandi yesterday238 static const _noAxis = '';
239 static const _row = 'row';
240 static const _column = 'column';
241
242 Widget _build(core.UiNode n, [String axis = _noAxis]) {
A row is named for its message, not its place 4083860 nandi 21h ago243 // A node that names itself keeps its element across rebuilds.
244 //
245 // The tree is rebuilt wholesale from the core, so Flutter matches
246 // children by position unless something says otherwise — and a position
247 // is not an identity when a line can arrive above. Everything Flutter
248 // holds per element is at stake: text controllers, scroll offsets, and
249 // the selectables a live text selection is made of.
250 final key = n.prop('key', '');
The arrow goes to the message 4925e54 nandi 14h ago251 var w = _buildNode(n, axis);
252
253 // The row a reply's arrow is aiming at. Two keys on one widget is not a
254 // thing, so they nest: the ValueKey keeps the element across rebuilds,
255 // and the GlobalKey is how this frame finds it afterwards.
256 if (n.prop('scrollHere', false)) {
257 w = KeyedSubtree(key: _jumpKey, child: w);
258 WidgetsBinding.instance.addPostFrameCallback((_) {
259 final ctx = _jumpKey.currentContext;
260 if (ctx == null || !mounted) return;
261 Scrollable.ensureVisible(
262 ctx,
263 duration: const Duration(milliseconds: 250),
264 curve: Curves.easeOut,
265 // A little down from the top, so the message that was replied to
266 // is read with what came after it rather than alone against the
267 // ceiling.
268 alignment: 0.2,
269 );
270 _send('jump.done');
271 });
272 }
A row is named for its message, not its place 4083860 nandi 21h ago273 return key.isEmpty ? w : KeyedSubtree(key: ValueKey(key), child: w);
274 }
275
276 Widget _buildNode(core.UiNode n, String axis) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday277 final spacing = _d(n.props['spacing'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday278 final flex = axis == _row || axis == _column;
279
280 // What this node's own children are being built into.
281 final childAxis = switch (n.tag) {
282 'page' || 'vbox' || 'card' || 'scroll' || 'dialog' => _column,
Jump to present, which was never on screen ecb440f nandi 12h ago283 // A stack's children are laid out by the stack, not by a flex: an
284 // `Expanded` among them is illegal, so they must not think they are
285 // in one.
286 'overlay' => _noAxis,
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday287 // Wrapping unless the row says otherwise. Flipping this default was
288 // tried and reverted: only 4 of 15 `hbox` call sites state `wrap` at
289 // all, so the other 11 became Rows and overflowed — the tree's habit is
290 // to wrap, and the default has to match it.
Expanded only where a Flex can hold it f1e99b4 nandi yesterday291 'hbox' => n.prop('wrap', true) ? _noAxis : _row,
292 _ => _noAxis,
293 };
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday294 // A paragraph's children are spans, not widgets — building them as
295 // widgets and throwing them away is what the `inline` special case did.
296 final kids = n.tag == 'paragraph'
297 ? const <Widget>[]
298 : n.children.map((c) => _build(c, childAxis)).toList();
299
300 // One rule for "take the remaining main-axis extent", stated by the node
301 // that expands. It used to be three: a vbox prop, a scroll with no
302 // height, and a row peering at its children's props to infer it.
303 Widget expanded(Widget w) =>
304 (n.prop('expand', false) && flex) ? Expanded(child: w) : w;
The data model, in Nim d333b6f nandi yesterday305
306 switch (n.tag) {
307 case 'page':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday308 return SingleChildScrollView(
309 child: Center(
310 child: ConstrainedBox(
311 constraints:
312 BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)),
313 child: Padding(
314 padding: const EdgeInsets.all(t.spaceM),
315 child: Column(
316 crossAxisAlignment: CrossAxisAlignment.start,
317 children: _spaced(kids, spacing, vertical: true)),
318 ),
The data model, in Nim d333b6f nandi yesterday319 ),
320 ),
321 );
322
Jump to present, which was never on screen ecb440f nandi 12h ago323 /// A node with others floating over it — the backlog, with the button
324 /// that takes you back to the present sitting on top of it.
325 ///
326 /// The floating children are given no height of their own, which is
327 /// the whole point: a control that belongs to the backlog should not
328 /// take a row away from it, and on a short window that row is what
329 /// makes the screen overflow.
330 case 'overlay':
331 {
332 final base = kids.isNotEmpty ? kids.first : const SizedBox.shrink();
333 final over = kids.skip(1).toList();
334 return expanded(Stack(
335 children: [
336 Positioned.fill(child: base),
337 for (final o in over)
338 Positioned(
339 left: 0,
340 right: 0,
341 bottom: t.spaceS,
342 child: Align(alignment: Alignment.bottomCenter, child: o),
343 ),
344 ],
345 ));
346 }
347
The data model, in Nim d333b6f nandi yesterday348 case 'vbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday349 {
350 Widget col = Column(
351 crossAxisAlignment: CrossAxisAlignment.start,
352 mainAxisSize: MainAxisSize.min,
353 children: _spaced(kids, spacing, vertical: true),
354 );
355 col = _margins(n, col);
356 final w = _d(n.props['widthRequest'], 0);
357 if (w > 0) col = SizedBox(width: w, child: col);
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday358 return expanded(col);
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday359 }
The data model, in Nim d333b6f nandi yesterday360
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday361 // Prose with links in it. NOT a Wrap: children of a Wrap are given
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday362 // unbounded width, so a long URL or a long word can never wrap — it
363 // overflows, the layout fails, and every box under it is then hit-tested
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday364 // having never been laid out. Spans in one RichText wrap properly.
365 case 'paragraph':
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday366 return Text.rich(
367 TextSpan(children: n.children.map(_span).toList()),
368 softWrap: true,
369 );
370
The data model, in Nim d333b6f nandi yesterday371 case 'hbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday372 {
373 // Wrap and not Row: `:hbox` in the screens means "these go together
374 // across", not "these fit". The head row of the chat screen asks for
375 // more than 360 points has, and a Row answers that with an overflow
376 // rather than a second line.
377 final wrapping = n.prop('wrap', true);
378 final align = n.prop('align', 'center');
379 if (!wrapping) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday380 // An expanding row stretches on its cross axis, which is where a
381 // child's height comes from — Expanded in a Row is about width.
382 // Stretch needs a bounded height, and `expanded()` below is what
383 // gives the row one; without it the stretch resolves to infinity.
384 final fills = n.prop('expand', false);
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday385 final row = Row(
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday386 crossAxisAlignment: fills
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday387 ? CrossAxisAlignment.stretch
388 : (align == 'end'
389 ? CrossAxisAlignment.end
390 : CrossAxisAlignment.center),
391 children: _spaced(kids, spacing, vertical: false),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday392 );
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday393 return expanded(_margins(n, row));
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday394 }
395 return _margins(
396 n,
397 Wrap(
398 spacing: spacing,
399 runSpacing: spacing,
400 crossAxisAlignment: align == 'end'
401 ? WrapCrossAlignment.end
402 : WrapCrossAlignment.center,
403 children: kids,
The data model, in Nim d333b6f nandi yesterday404 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday405 );
406 }
The data model, in Nim d333b6f nandi yesterday407
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday408 // Container::Card in the Clojure: padding 12, fills its width.
The data model, in Nim d333b6f nandi yesterday409 case 'card':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday410 return Container(
411 width: double.infinity,
412 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
413 padding: const EdgeInsets.all(t.spaceXs),
414 decoration: BoxDecoration(
415 color: t.card,
416 borderRadius: BorderRadius.circular(t.radiusS),
The data model, in Nim d333b6f nandi yesterday417 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday418 child: Column(
419 crossAxisAlignment: CrossAxisAlignment.start,
420 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
421 vertical: true)),
The data model, in Nim d333b6f nandi yesterday422 );
423
424 case 'title':
425 return Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday426 style: _style(t.textTitle3, t.onBg)
427 .copyWith(fontWeight: FontWeight.bold));
The data model, in Nim d333b6f nandi yesterday428
429 case 'title-2':
430 return Padding(
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday431 padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs),
The data model, in Nim d333b6f nandi yesterday432 child: Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday433 style: _style(t.textTitle4, t.onBg)
434 .copyWith(fontWeight: FontWeight.w600)),
The data model, in Nim d333b6f nandi yesterday435 );
436
437 case 'label':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday438 return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg));
The data model, in Nim d333b6f nandi yesterday439
440 case 'dim-label':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday441 return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim));
442
443 /// Prose, as opposed to a label: this is what a message is, and it
444 /// wraps. Kept apart from `label` because a wrapping label in a row
445 /// lays out against the row's width rather than the column's.
446 case 'text':
447 return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));
448
449 case 'link':
450 return _wrapTap(
451 n.prop('onClick', ''),
452 Text(
453 n.prop('label', ''),
454 style: _style(t.textBody, t.accent)
455 .copyWith(decoration: TextDecoration.underline,
456 decorationColor: t.accent),
457 ),
458 );
459
460 case 'separator':
461 return const Divider(height: 1, thickness: 1, color: t.divider);
462
463 case 'spacer':
464 {
465 final s = _d(n.props['size'], t.spaceXxs);
466 return SizedBox(width: s, height: s);
467 }
The data model, in Nim d333b6f nandi yesterday468
469 case 'spinner':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday470 return Row(
471 mainAxisSize: MainAxisSize.min,
472 children: [
473 const SizedBox(
474 width: 16,
475 height: 16,
476 child: CircularProgressIndicator(
477 strokeWidth: 2, color: t.accent)),
478 if (n.prop('label', '').isNotEmpty) ...[
479 const SizedBox(width: t.spaceXxs),
480 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
481 ],
482 ],
483 );
484
485 /// A dot that says whether the thing is live, and the words beside it.
486 case 'status':
487 return Row(
488 mainAxisSize: MainAxisSize.min,
489 children: [
490 Container(
491 width: 8,
492 height: 8,
493 decoration: BoxDecoration(
494 color: n.prop('live', false) ? t.success : t.dim,
495 borderRadius: BorderRadius.circular(t.radiusXs),
496 ),
497 ),
498 const SizedBox(width: 6),
499 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
500 ],
501 );
The data model, in Nim d333b6f nandi yesterday502
503 case 'button':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday504 {
505 final onClick = n.prop('onClick', '');
506 final kind = n.prop('kind', 'default');
507 final label = Text(n.prop('label', ''));
508 if (kind == 'primary') {
509 return FilledButton(
510 onPressed: () => _send(onClick), child: label);
511 }
A face that opens someone 9bb81a1 nandi yesterday512 // A sender's name: a way in to who someone is, but it sits in the
513 // middle of a line and must not look like a control. Text that
514 // takes a press, with no chrome at all.
515 if (kind == 'plain') {
516 return InkWell(
517 onTap: () => _send(onClick),
518 child: Text(n.prop('label', ''),
519 style: _style(t.textBody, t.onBg)),
520 );
521 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday522 if (kind == 'destructive') {
523 return FilledButton(
524 style: FilledButton.styleFrom(
525 backgroundColor: t.destructive,
526 foregroundColor: t.onDestructive),
527 onPressed: () => _send(onClick),
528 child: label,
529 );
530 }
531 return OutlinedButton(onPressed: () => _send(onClick), child: label);
532 }
The data model, in Nim d333b6f nandi yesterday533
534 case 'checkbutton':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday535 {
536 // The label is part of the target. 20 logical pixels is a fine tick
537 // on a desktop pointer and a miss on a thumb, so the whole row taps.
538 final onToggled = n.prop('onToggled', '');
539 return InkWell(
540 onTap: () => _send(onToggled),
541 child: Row(
542 mainAxisSize: MainAxisSize.min,
543 children: [
544 Checkbox(
545 value: n.prop('active', false),
546 onChanged: (_) => _send(onToggled),
547 ),
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday548 // Flexible, because the label is prose and the row is as wide
549 // as the window: "Hide join/part messages" beside a checkbox
550 // overflows a phone otherwise.
551 Flexible(
552 child: Text(n.prop('label', ''),
553 style: _style(t.textBody, t.onBg)),
554 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday555 ],
556 ),
557 );
558 }
559
560 case 'emoji':
561 return _wrapTap(
562 n.prop('onClick', ''),
563 Text(n.prop('glyph', n.prop('emoji', '')),
The pencil was drawn by a text font 1cb40af nandi 23h ago564 style: _emojiStyle(_d(n.props['size'], 16))),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday565 );
566
567 /// A reaction pill: the glyph, and the tally beside it where there is
568 /// one to show. The same shape whether it is a reaction under a message,
569 /// a swatch in the picker, or a chip on the sender's row — which is the
570 /// point: what you press to react and what appears once you have should
571 /// look like one family.
572 ///
573 /// A count of zero is no count. The picker passes 0 for every swatch,
574 /// and a grid of little grey zeroes is noise where a reader is scanning
575 /// for a face. `mine` is the accent, because the only thing a pill has
576 /// to say at a glance is whether pressing it again takes yours off.
577 case 'reaction':
578 {
579 final size = _d(n.props['size'], 14);
580 final count = n.prop('count', 0);
581 final mine = n.prop('mine', false);
582 final pad = (0.25 * size).clamp(2.0, 8.0);
583 return _wrapTap(
584 n.prop('onClick', ''),
585 Container(
586 padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2),
587 decoration: BoxDecoration(
588 color: mine ? t.accent : t.component,
589 borderRadius: BorderRadius.circular(t.radiusS),
590 ),
591 child: Row(
592 mainAxisSize: MainAxisSize.min,
593 children: [
The pencil was drawn by a text font 1cb40af nandi 23h ago594 Text(n.prop('emoji', ''), style: _emojiStyle(size)),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday595 if (count > 0) ...[
596 const SizedBox(width: 4),
597 Text('$count',
598 style: _style(t.textCaption,
599 mine ? t.onAccent : t.dim)),
600 ],
601 ],
602 ),
603 ),
604 );
605 }
606
607 /// A face is a way in to who someone is, so it takes the press that
608 /// opens their profile. A picture that will not load is a face that
609 /// stays its initial and nothing else.
610 case 'avatar':
611 {
612 final size = _d(n.props['size'], 32);
613 final provider = _imageProvider(n.prop('url', ''));
614 final fallback = n.prop('fallback', '');
615 final face = CircleAvatar(
616 radius: size / 2,
617 backgroundColor: t.component,
618 backgroundImage: provider,
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday619 onBackgroundImageError: provider == null ? null : (_, _) {},
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday620 child: provider == null
621 ? Text(
622 fallback.isNotEmpty
623 ? fallback.substring(0, 1).toUpperCase()
624 : '?',
625 style: _style(t.textBody, t.onBg))
626 : null,
627 );
628 final onClick = n.prop('onClick', '');
629 if (onClick.isEmpty) return face;
630 return InkWell(
631 onTap: () => _send(onClick),
632 customBorder: const CircleBorder(),
633 child: face,
634 );
635 }
636
637 case 'image':
638 {
639 final provider = _imageProvider(n.prop('src', ''));
640 if (provider == null) return const SizedBox.shrink();
641 final maxW = _d(n.props['maxWidth'], 0);
642 final maxH = _d(n.props['maxHeight'], 0);
643 Widget img = Image(
644 image: provider,
645 fit: BoxFit.contain,
646 // A half-written cache file, or one deleted under us: the decoder
647 // throws during the build, and an exception in a build is a red
648 // screen for the whole conversation rather than a gap where one
649 // picture was.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday650 errorBuilder: (_, _, _) => const SizedBox.shrink(),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday651 );
652 if (maxW > 0 || maxH > 0) {
653 img = ConstrainedBox(
654 constraints: BoxConstraints(
655 maxWidth: maxW > 0 ? maxW : double.infinity,
656 maxHeight: maxH > 0 ? maxH : double.infinity,
657 ),
658 child: img,
659 );
660 }
661 return _wrapTap(n.prop('onClick', ''), img);
662 }
The data model, in Nim d333b6f nandi yesterday663
664 case 'entry':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday665 {
666 final key = n.prop('key', '');
667 final value = n.prop('text', '');
668 final c = _controllers.putIfAbsent(
669 key, () => TextEditingController(text: value));
670 // Only when it actually differs: assigning unconditionally moves the
671 // caret to the end on every keystroke.
672 if (c.text != value) {
673 c.value = c.value.copyWith(
674 text: value,
675 selection: TextSelection.collapsed(offset: value.length),
676 );
677 }
678 final field = TextField(
679 controller: c,
680 focusNode: _focus.putIfAbsent(key, FocusNode.new),
681 style: _style(t.textBody, t.onBg),
682 decoration: InputDecoration(
683 hintText: n.prop('placeholder', ''),
684 hintStyle: _style(t.textBody, t.dim),
685 isDense: true,
686 filled: true,
687 fillColor: t.component,
688 border: OutlineInputBorder(
689 borderRadius: BorderRadius.circular(t.radiusS),
690 borderSide: BorderSide.none,
691 ),
692 ),
693 onChanged: (v) => _send(n.prop('onChange', ''), v),
694 onSubmitted: (_) => _send(n.prop('onSubmit', '')),
695 );
696 final w = _d(n.props['widthRequest'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday697 if (w > 0) return SizedBox(width: w, child: field);
698 // No width asked for: take the rest of the row where there is a row
699 // to take it from, and otherwise a definite width. NOT Expanded
700 // unconditionally — a TextField has no intrinsic width, so in a Wrap
701 // it is both illegal and unmeasurable, and that combination is what
702 // took the whole screen down rather than one field.
703 return axis == _row
704 ? Expanded(child: field)
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday705 : SizedBox(width: _unsizedEntry, child: field);
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday706 }
707
708 case 'scroll':
709 {
Both ends of a scroll, on the same controller 5594a62 nandi 23h ago710 // Both ends of the same scroll, named so they are the same one.
The arrow goes to the message 4925e54 nandi 14h ago711 final scrollKey = n.prop('scrollKey', 'scroll');
712 final c = _scrollers.putIfAbsent(scrollKey, ScrollController.new);
713 final stick = n.prop('stickToBottom', false);
714
715 // "Jump to present": a tick that goes up, rather than a flag that
716 // would have to be cleared. A reverse scroll holds the present at
717 // offset zero, which is why this is not maxScrollExtent.
718 final tick = n.prop('scrollToBottom', 0);
719 if (_bottomTicks[scrollKey] != tick) {
720 _bottomTicks[scrollKey] = tick;
721 WidgetsBinding.instance.addPostFrameCallback((_) {
722 if (!c.hasClients) return;
723 c.animateTo(
724 stick ? c.position.minScrollExtent
725 : c.position.maxScrollExtent,
726 duration: const Duration(milliseconds: 250),
727 curve: Curves.easeOut,
728 );
729 });
730 }
Jump to present, which was never on screen ecb440f nandi 12h ago731 // How far from the present counts as having left it. Enough that
732 // the last line being taller than the gap does not toggle this on
733 // its own, and little enough that a nudge upward and back does not
734 // leave the button on screen.
735 const away = 120.0;
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday736 Widget body = SingleChildScrollView(
Both ends of a scroll, on the same controller 5594a62 nandi 23h ago737 controller: c,
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday738 // The backlog reads from the bottom; a settings list from the top.
The arrow goes to the message 4925e54 nandi 14h ago739 reverse: stick,
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday740 child: Column(
741 crossAxisAlignment: CrossAxisAlignment.start,
742 children: _spaced(kids, spacing, vertical: true)),
The data model, in Nim d333b6f nandi yesterday743 );
Both ends of a scroll, on the same controller 5594a62 nandi 23h ago744 body = Scrollbar(controller: c, child: body);
Jump to present, which was never on screen ecb440f nandi 12h ago745
746 // Only the backlog reports this. A settings list has no present to
747 // be at, and telling the core about one would put the chat
748 // screen's button on the wrong screen's scrolling.
749 if (stick) {
750 body = NotificationListener<ScrollNotification>(
751 onNotification: (note) {
752 if (note.depth != 0) return false;
753 final m = note.metrics;
754 // Reversed, so the present is the zero end.
755 final here = m.pixels <= m.minScrollExtent + away;
756 if (_atPresent[scrollKey] != here) {
757 _atPresent[scrollKey] = here;
758 _send(here ? 'present.back' : 'present.left');
759 }
760 return false;
761 },
762 child: body,
763 );
764 }
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday765 // A scroll takes what the column has left. Outside a Flex there is
766 // nothing to take, and the tree is malformed — `_strandedScroll` is
767 // a visible size rather than a correct one, so the layout tests see
768 // a screen instead of an exception.
769 return flex
770 ? Expanded(child: body)
771 : const SizedBox(height: _strandedScroll);
The data model, in Nim d333b6f nandi yesterday772 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday773
774 /// A panel over the screen rather than a screen of its own.
775 case 'dialog':
776 return Card(
777 color: t.cardComponent,
778 child: Padding(
779 padding: const EdgeInsets.all(t.spaceS),
780 child: Column(
781 mainAxisSize: MainAxisSize.min,
782 crossAxisAlignment: CrossAxisAlignment.start,
783 children: [
784 if (n.prop('title', '').isNotEmpty)
785 Padding(
786 padding: const EdgeInsets.only(bottom: t.spaceXxs),
787 child: Text(n.prop('title', ''),
788 style: _style(t.textTitle4, t.onCard)
789 .copyWith(fontWeight: FontWeight.w600)),
790 ),
791 ..._spaced(kids, spacing, vertical: true),
792 ],
793 ),
The data model, in Nim d333b6f nandi yesterday794 ),
795 );
796
797 default:
798 // An unknown tag paints as itself rather than crashing or vanishing.
799 // Nim can add one and see it before this file has heard of it, which
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday800 // is what makes the boundary pleasant to work across.
The data model, in Nim d333b6f nandi yesterday801 return Container(
802 padding: const EdgeInsets.all(4),
803 color: Colors.orange.withValues(alpha: 0.3),
804 child: Text('?${n.tag}'),
805 );
806 }
807 }
808
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday809 /// One node of an inline paragraph, as a span.
810 ///
811 /// Only `text` and `link` appear here — they are the only things `runNodes`
812 /// emits — and anything else falls back to its plain text so an unexpected
813 /// tag degrades to something readable rather than vanishing.
814 InlineSpan _span(core.UiNode n) {
815 switch (n.tag) {
816 case 'link':
817 final url = n.prop('url', n.prop('label', ''));
818 final onClick = n.prop('onClick', '');
819 return TextSpan(
820 text: n.prop('label', ''),
821 style: _style(t.textBody, t.accent)
822 .copyWith(decoration: TextDecoration.underline,
823 decorationColor: t.accent),
824 recognizer: onClick.isEmpty
825 ? null
826 : (_linkTaps[url] ??= TapGestureRecognizer()
827 ..onTap = () => _send(onClick)),
828 );
829 case 'text':
830 return TextSpan(
831 text: n.prop('text', ''), style: _style(t.textBody, t.onBg));
832 default:
833 return TextSpan(
834 text: n.prop('label', n.prop('text', '')),
835 style: _style(t.textBody, t.onBg));
836 }
837 }
838
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday839 /// `margin` and its four sides — the props the
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday840 /// screens use to buy air without a wrapper each time.
841 Widget _margins(core.UiNode n, Widget child) {
842 final all = _d(n.props['margin'], 0);
843 final top = _d(n.props['marginTop'], all);
844 final bottom = _d(n.props['marginBottom'], all);
845 final right = _d(n.props['marginRight'], all);
846 final left = _d(n.props['marginLeft'], all);
847 if (top == 0 && bottom == 0 && right == 0 && left == 0) return child;
848 return Padding(
849 padding: EdgeInsets.only(
850 top: top, bottom: bottom, right: right, left: left),
851 child: child,
852 );
The data model, in Nim d333b6f nandi yesterday853 }
854}