nandi/frqpublic Fork 0
bd10e816d10d09c3dbe1256c1b7c661d8e190ee6
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 · 908 lines · 36.0 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';
14
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday15import 'package:flutter/gestures.dart';
16
The data model, in Nim d333b6f nandi yesterday17import 'package:flutter/material.dart';
18import 'package:frq_core/frq_core.dart' as core;
A web version, from the same core 23846db nandi 13h ago19import 'src/host.dart' as host;
The data model, in Nim d333b6f nandi yesterday20
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 yesterday50 // 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
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago63 // The last size reported to the core, so a rebuild that changed nothing
64 // does not dispatch.
65 int _reportedW = 0;
66 int _reportedH = 0;
67
The arrow goes to the message 4925e54 nandi 16h ago68 // Where a "go to that message" is pointing, for the one frame it is
69 // pointing there. The core marks the row with `scrollHere`, this finds it
70 // after the frame is laid out — `ensureVisible` needs a built element, so
71 // it cannot happen during the build that asks for it — and then tells the
72 // core it has arrived, which takes the mark off. Leaving it on would pin
73 // the view to that row and take scrolling away from the reader.
74 final _jumpKey = GlobalKey();
75
76 // The last `scrollToBottom` tick acted on, per scroll. The core counts up
77 // when "Jump to present" is pressed; an unchanged count is a frame where
78 // nobody asked to be moved.
79 final _bottomTicks = <String, int>{};
80
Jump to present, which was never on screen ecb440f nandi 15h ago81 // Whether each scroll is at the present, as last reported to the core.
82 // Only the changes are sent: a notification arrives per pixel of a drag,
83 // and the core has one question, not a thousand.
84 final _atPresent = <String, bool>{};
85
The data model, in Nim d333b6f nandi yesterday86 @override
87 void initState() {
88 super.initState();
89 // 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 yesterday90 // callback into Dart. At ~70µs a render a 100ms timer costs nothing.
The data model, in Nim d333b6f nandi yesterday91 _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday92 // Compared as the JSON Nim already produced, and decoded only when it
93 // differs. Stringifying both trees to answer "did anything change" was
94 // ~1MB of string churn per poll in a busy room, ten times a second, for
95 // an answer that is almost always no.
96 final next = core.pollIfChanged(_frame.json);
97 if (next != null) setState(() => _frame = next);
The data model, in Nim d333b6f nandi yesterday98 });
99 }
100
101 void _send(String id, [String value = '']) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday102 if (id.isEmpty) return;
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday103 setState(() => _frame = core.dispatchFrame(id, value));
The data model, in Nim d333b6f nandi yesterday104 if (id == 'send') _focus['draft']?.requestFocus();
105 }
106
107 @override
108 void dispose() {
109 _poll?.cancel();
110 for (final c in _controllers.values) {
111 c.dispose();
112 }
113 for (final f in _focus.values) {
114 f.dispose();
115 }
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday116 for (final r in _linkTaps.values) {
117 r.dispose();
118 }
Both ends of a scroll, on the same controller 5594a62 nandi yesterday119 for (final c in _scrollers.values) {
120 c.dispose();
121 }
The data model, in Nim d333b6f nandi yesterday122 super.dispose();
123 }
124
125 @override
126 Widget build(BuildContext context) => MaterialApp(
127 title: 'frq',
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday128 debugShowCheckedModeBanner: false,
129 theme: ThemeData(
130 useMaterial3: true,
131 brightness: Brightness.dark,
132 scaffoldBackgroundColor: t.bg,
133 colorScheme: const ColorScheme.dark(
134 primary: t.accent,
135 onPrimary: t.onAccent,
136 surface: t.bg,
137 onSurface: t.onBg,
138 error: t.destructive,
139 ),
The data model, in Nim d333b6f nandi yesterday140 ),
Text can be selected ec74784 nandi yesterday141 // Everything inside one SelectionArea, so a message can be selected
142 // and copied — and so can a nick, a timestamp, or a line of an error.
143 // Per-widget `SelectableText` was the alternative and is worse: it
144 // selects within one widget only, so a two-line answer and the name
145 // above it cannot be dragged across, which is most of what anyone
146 // wants to copy out of a chat.
147 //
148 // Taps still arrive: a selection starts on a drag, and the buttons,
149 // faces and reaction pills under here keep their gestures.
150 home: Scaffold(
151 backgroundColor: t.bg,
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago152 // The core decides what a window this size can hold — whether the
153 // room list rides beside the conversation, whether there is a back
154 // button — and it cannot measure one. A window is the host's, like
155 // a socket or a clock, so the host says.
156 //
157 // From the constraints rather than MediaQuery: this is the space
158 // the tree is actually given, which is what the decision is about.
159 // Reported after the frame, because a dispatch is a setState and a
160 // setState during build is an error.
161 body: SafeArea(
162 child: LayoutBuilder(
163 builder: (context, constraints) {
164 final w = constraints.maxWidth.round();
165 final h = constraints.maxHeight.round();
166 if (w != _reportedW || h != _reportedH) {
167 _reportedW = w;
168 _reportedH = h;
169 WidgetsBinding.instance.addPostFrameCallback((_) {
170 if (mounted) _send('window.size', '${w}x$h');
171 });
172 }
173 return SelectionArea(child: _build(_tree));
174 },
175 ),
176 ),
Text can be selected ec74784 nandi yesterday177 ),
The data model, in Nim d333b6f nandi yesterday178 );
179
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday180 // ---------------------------------------------------------------- helpers
181
182 TextStyle _style(double size, Color color) =>
183 TextStyle(fontSize: size, color: color, height: 1.35);
184
The pencil was drawn by a text font 1cb40af nandi yesterday185 /// The style for a widget whose whole content is an emoji glyph.
186 ///
187 /// Naming the colour emoji font is not belt and braces: a glyph like ✏️ is
188 /// U+270F plus U+FE0F, and the variation selector is a *request* for emoji
189 /// presentation, not a guarantee. DejaVu Sans claims U+270F, so ordinary
190 /// fallback stops there and draws the monochrome pencil the text era had —
191 /// while 🙂, which no text font covers, falls all the way through to the
192 /// emoji font and looks right. That is why only some of the chips were
193 /// wrong.
194 ///
195 /// A family list rather than one name, because the font that has them
196 /// differs by platform, and a name nothing matches costs nothing.
197 TextStyle _emojiStyle(double size) => TextStyle(
198 fontSize: size,
A web version, from the same core 23846db nandi 13h ago199 fontFamily: host.emojiFonts.isEmpty ? null : host.emojiFonts.first,
200 fontFamilyFallback: host.emojiFonts.isEmpty ? null : host.emojiFonts,
The pencil was drawn by a text font 1cb40af nandi yesterday201 );
202
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday203 double _d(dynamic v, double fallback) =>
204 v is num ? v.toDouble() : fallback;
205
206 /// Gaps between children, as real widgets rather than a `spacing:` — the
207 /// same layout on every Flutter version this might be built against.
208 List<Widget> _spaced(List<Widget> kids, double gap, {required bool vertical}) {
209 if (gap <= 0 || kids.length < 2) return kids;
210 final out = <Widget>[];
211 for (var i = 0; i < kids.length; i++) {
212 if (i > 0) {
213 out.add(vertical ? SizedBox(height: gap) : SizedBox(width: gap));
214 }
215 out.add(kids[i]);
216 }
217 return out;
218 }
219
220 /// A source that may be a bundled asset, a file on disk, or a URL — the
221 /// three the screens hand over, named apart by an `asset:` prefix so they
222 /// stay one property.
223 ImageProvider? _imageProvider(String src) {
224 if (src.isEmpty) return null;
225 if (src.startsWith('asset:')) return AssetImage(src.substring(6));
226 if (src.startsWith('http://') || src.startsWith('https://')) {
227 return NetworkImage(src);
228 }
A web version, from the same core 23846db nandi 13h ago229 return host.localImage(src);
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday230 }
231
232 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {
233 if (onClick.isEmpty) return child;
234 return InkWell(
235 onTap: () => _send(onClick),
236 borderRadius: radius,
237 child: child,
238 );
239 }
240
241 // ------------------------------------------------------------------ build
242
Expanded only where a Flex can hold it f1e99b4 nandi yesterday243 /// The axis of the widget a node is being built *into*, because `Expanded`
244 /// is only legal inside a Flex and there is no way to ask Flutter after the
245 /// fact.
246 ///
247 /// Getting this wrong is what "Cannot hit test a render box that has never
248 /// been laid out" means, in a pile: an `Expanded` inside a `Wrap` fails the
249 /// layout, and every box under it is then asked to hit-test without ever
250 /// having been laid out. The chats screen did exactly that — two unsized
251 /// entries in an `hbox`, which is a Wrap.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday252 /// What an unsized entry or a stranded scroll falls back to.
253 ///
254 /// Both are only reachable when the tree has put one outside a Flex, which
255 /// is a tree bug rather than a rendering choice. The numbers exist so that
256 /// bug renders as something a person can see and a test can catch, not so
257 /// that it renders correctly.
258 static const _unsizedEntry = 320.0;
259 static const _strandedScroll = 400.0;
260
Expanded only where a Flex can hold it f1e99b4 nandi yesterday261 static const _noAxis = '';
262 static const _row = 'row';
263 static const _column = 'column';
264
265 Widget _build(core.UiNode n, [String axis = _noAxis]) {
A row is named for its message, not its place 4083860 nandi 23h ago266 // A node that names itself keeps its element across rebuilds.
267 //
268 // The tree is rebuilt wholesale from the core, so Flutter matches
269 // children by position unless something says otherwise — and a position
270 // is not an identity when a line can arrive above. Everything Flutter
271 // holds per element is at stake: text controllers, scroll offsets, and
272 // the selectables a live text selection is made of.
273 final key = n.prop('key', '');
The arrow goes to the message 4925e54 nandi 16h ago274 var w = _buildNode(n, axis);
275
276 // The row a reply's arrow is aiming at. Two keys on one widget is not a
277 // thing, so they nest: the ValueKey keeps the element across rebuilds,
278 // and the GlobalKey is how this frame finds it afterwards.
279 if (n.prop('scrollHere', false)) {
280 w = KeyedSubtree(key: _jumpKey, child: w);
281 WidgetsBinding.instance.addPostFrameCallback((_) {
282 final ctx = _jumpKey.currentContext;
283 if (ctx == null || !mounted) return;
284 Scrollable.ensureVisible(
285 ctx,
286 duration: const Duration(milliseconds: 250),
287 curve: Curves.easeOut,
288 // A little down from the top, so the message that was replied to
289 // is read with what came after it rather than alone against the
290 // ceiling.
291 alignment: 0.2,
292 );
293 _send('jump.done');
294 });
295 }
A row is named for its message, not its place 4083860 nandi 23h ago296 return key.isEmpty ? w : KeyedSubtree(key: ValueKey(key), child: w);
297 }
298
299 Widget _buildNode(core.UiNode n, String axis) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday300 final spacing = _d(n.props['spacing'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday301 final flex = axis == _row || axis == _column;
302
303 // What this node's own children are being built into.
304 final childAxis = switch (n.tag) {
305 'page' || 'vbox' || 'card' || 'scroll' || 'dialog' => _column,
Jump to present, which was never on screen ecb440f nandi 15h ago306 // A stack's children are laid out by the stack, not by a flex: an
307 // `Expanded` among them is illegal, so they must not think they are
308 // in one.
309 'overlay' => _noAxis,
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday310 // Wrapping unless the row says otherwise. Flipping this default was
311 // tried and reverted: only 4 of 15 `hbox` call sites state `wrap` at
312 // all, so the other 11 became Rows and overflowed — the tree's habit is
313 // to wrap, and the default has to match it.
Expanded only where a Flex can hold it f1e99b4 nandi yesterday314 'hbox' => n.prop('wrap', true) ? _noAxis : _row,
315 _ => _noAxis,
316 };
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday317 // A paragraph's children are spans, not widgets — building them as
318 // widgets and throwing them away is what the `inline` special case did.
319 final kids = n.tag == 'paragraph'
320 ? const <Widget>[]
321 : n.children.map((c) => _build(c, childAxis)).toList();
322
323 // One rule for "take the remaining main-axis extent", stated by the node
324 // that expands. It used to be three: a vbox prop, a scroll with no
325 // height, and a row peering at its children's props to infer it.
326 Widget expanded(Widget w) =>
327 (n.prop('expand', false) && flex) ? Expanded(child: w) : w;
The data model, in Nim d333b6f nandi yesterday328
329 switch (n.tag) {
330 case 'page':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday331 return SingleChildScrollView(
332 child: Center(
333 child: ConstrainedBox(
334 constraints:
335 BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)),
336 child: Padding(
337 padding: const EdgeInsets.all(t.spaceM),
338 child: Column(
339 crossAxisAlignment: CrossAxisAlignment.start,
340 children: _spaced(kids, spacing, vertical: true)),
341 ),
The data model, in Nim d333b6f nandi yesterday342 ),
343 ),
344 );
345
Jump to present, which was never on screen ecb440f nandi 15h ago346 /// A node with others floating over it — the backlog, with the button
347 /// that takes you back to the present sitting on top of it.
348 ///
349 /// The floating children are given no height of their own, which is
350 /// the whole point: a control that belongs to the backlog should not
351 /// take a row away from it, and on a short window that row is what
352 /// makes the screen overflow.
353 case 'overlay':
354 {
355 final base = kids.isNotEmpty ? kids.first : const SizedBox.shrink();
356 final over = kids.skip(1).toList();
357 return expanded(Stack(
358 children: [
359 Positioned.fill(child: base),
360 for (final o in over)
361 Positioned(
362 left: 0,
363 right: 0,
364 bottom: t.spaceS,
365 child: Align(alignment: Alignment.bottomCenter, child: o),
366 ),
367 ],
368 ));
369 }
370
The data model, in Nim d333b6f nandi yesterday371 case 'vbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday372 {
373 Widget col = Column(
374 crossAxisAlignment: CrossAxisAlignment.start,
375 mainAxisSize: MainAxisSize.min,
376 children: _spaced(kids, spacing, vertical: true),
377 );
378 col = _margins(n, col);
379 final w = _d(n.props['widthRequest'], 0);
380 if (w > 0) col = SizedBox(width: w, child: col);
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday381 return expanded(col);
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday382 }
The data model, in Nim d333b6f nandi yesterday383
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday384 // 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 yesterday385 // unbounded width, so a long URL or a long word can never wrap — it
386 // overflows, the layout fails, and every box under it is then hit-tested
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday387 // having never been laid out. Spans in one RichText wrap properly.
388 case 'paragraph':
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday389 return Text.rich(
390 TextSpan(children: n.children.map(_span).toList()),
391 softWrap: true,
392 );
393
The data model, in Nim d333b6f nandi yesterday394 case 'hbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday395 {
396 // Wrap and not Row: `:hbox` in the screens means "these go together
397 // across", not "these fit". The head row of the chat screen asks for
398 // more than 360 points has, and a Row answers that with an overflow
399 // rather than a second line.
400 final wrapping = n.prop('wrap', true);
401 final align = n.prop('align', 'center');
402 if (!wrapping) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday403 // An expanding row stretches on its cross axis, which is where a
404 // child's height comes from — Expanded in a Row is about width.
405 // Stretch needs a bounded height, and `expanded()` below is what
406 // gives the row one; without it the stretch resolves to infinity.
407 final fills = n.prop('expand', false);
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday408 final row = Row(
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday409 crossAxisAlignment: fills
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday410 ? CrossAxisAlignment.stretch
411 : (align == 'end'
412 ? CrossAxisAlignment.end
413 : CrossAxisAlignment.center),
414 children: _spaced(kids, spacing, vertical: false),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday415 );
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday416 return expanded(_margins(n, row));
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday417 }
418 return _margins(
419 n,
420 Wrap(
421 spacing: spacing,
422 runSpacing: spacing,
423 crossAxisAlignment: align == 'end'
424 ? WrapCrossAlignment.end
425 : WrapCrossAlignment.center,
426 children: kids,
The data model, in Nim d333b6f nandi yesterday427 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday428 );
429 }
The data model, in Nim d333b6f nandi yesterday430
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday431 // Container::Card in the Clojure: padding 12, fills its width.
The data model, in Nim d333b6f nandi yesterday432 case 'card':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday433 return Container(
434 width: double.infinity,
435 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
436 padding: const EdgeInsets.all(t.spaceXs),
437 decoration: BoxDecoration(
438 color: t.card,
439 borderRadius: BorderRadius.circular(t.radiusS),
The data model, in Nim d333b6f nandi yesterday440 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday441 child: Column(
442 crossAxisAlignment: CrossAxisAlignment.start,
443 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
444 vertical: true)),
The data model, in Nim d333b6f nandi yesterday445 );
446
447 case 'title':
448 return Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday449 style: _style(t.textTitle3, t.onBg)
450 .copyWith(fontWeight: FontWeight.bold));
The data model, in Nim d333b6f nandi yesterday451
452 case 'title-2':
453 return Padding(
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday454 padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs),
The data model, in Nim d333b6f nandi yesterday455 child: Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday456 style: _style(t.textTitle4, t.onBg)
457 .copyWith(fontWeight: FontWeight.w600)),
The data model, in Nim d333b6f nandi yesterday458 );
459
460 case 'label':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday461 return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg));
The data model, in Nim d333b6f nandi yesterday462
463 case 'dim-label':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday464 return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim));
465
466 /// Prose, as opposed to a label: this is what a message is, and it
467 /// wraps. Kept apart from `label` because a wrapping label in a row
468 /// lays out against the row's width rather than the column's.
469 case 'text':
470 return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));
471
472 case 'link':
473 return _wrapTap(
474 n.prop('onClick', ''),
475 Text(
476 n.prop('label', ''),
477 style: _style(t.textBody, t.accent)
478 .copyWith(decoration: TextDecoration.underline,
479 decorationColor: t.accent),
480 ),
481 );
482
483 case 'separator':
484 return const Divider(height: 1, thickness: 1, color: t.divider);
485
486 case 'spacer':
487 {
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago488 // A gap that takes whatever is left, when it says so. That is what
489 // carries a row's last children to its far edge: `align: end` on a
490 // row cannot, because a Row with no slack has nothing to align.
491 if (n.prop('expand', false) && flex) {
492 return const Spacer();
493 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday494 final s = _d(n.props['size'], t.spaceXxs);
495 return SizedBox(width: s, height: s);
496 }
The data model, in Nim d333b6f nandi yesterday497
498 case 'spinner':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday499 return Row(
500 mainAxisSize: MainAxisSize.min,
501 children: [
502 const SizedBox(
503 width: 16,
504 height: 16,
505 child: CircularProgressIndicator(
506 strokeWidth: 2, color: t.accent)),
507 if (n.prop('label', '').isNotEmpty) ...[
508 const SizedBox(width: t.spaceXxs),
509 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
510 ],
511 ],
512 );
513
514 /// A dot that says whether the thing is live, and the words beside it.
515 case 'status':
516 return Row(
517 mainAxisSize: MainAxisSize.min,
518 children: [
519 Container(
520 width: 8,
521 height: 8,
522 decoration: BoxDecoration(
523 color: n.prop('live', false) ? t.success : t.dim,
524 borderRadius: BorderRadius.circular(t.radiusXs),
525 ),
526 ),
527 const SizedBox(width: 6),
528 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
529 ],
530 );
The data model, in Nim d333b6f nandi yesterday531
532 case 'button':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday533 {
534 final onClick = n.prop('onClick', '');
535 final kind = n.prop('kind', 'default');
536 final label = Text(n.prop('label', ''));
537 if (kind == 'primary') {
538 return FilledButton(
539 onPressed: () => _send(onClick), child: label);
540 }
A face that opens someone 9bb81a1 nandi yesterday541 // A sender's name: a way in to who someone is, but it sits in the
542 // middle of a line and must not look like a control. Text that
543 // takes a press, with no chrome at all.
544 if (kind == 'plain') {
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago545 final plain = InkWell(
A face that opens someone 9bb81a1 nandi yesterday546 onTap: () => _send(onClick),
547 child: Text(n.prop('label', ''),
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago548 maxLines: 1,
549 overflow: TextOverflow.ellipsis,
A face that opens someone 9bb81a1 nandi yesterday550 style: _style(t.textBody, t.onBg)),
551 );
A window with two edges, and something against each of them 0e7cd32 nandi 12h ago552 // `Flexible` and not `Expanded`: a name takes the width it needs
553 // and gives the rest back, but on a row too narrow for everything
554 // it is the part that should shrink. A handle is long, and the
555 // time and the chips beside it are not negotiable — so without
556 // this the sender's row overflowed by however much the name was
557 // over, which on a phone was most handles.
558 return (n.prop('expand', false) && flex)
559 ? Flexible(child: plain)
560 : plain;
A face that opens someone 9bb81a1 nandi yesterday561 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday562 if (kind == 'destructive') {
563 return FilledButton(
564 style: FilledButton.styleFrom(
565 backgroundColor: t.destructive,
566 foregroundColor: t.onDestructive),
567 onPressed: () => _send(onClick),
568 child: label,
569 );
570 }
571 return OutlinedButton(onPressed: () => _send(onClick), child: label);
572 }
The data model, in Nim d333b6f nandi yesterday573
574 case 'checkbutton':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday575 {
576 // The label is part of the target. 20 logical pixels is a fine tick
577 // on a desktop pointer and a miss on a thumb, so the whole row taps.
578 final onToggled = n.prop('onToggled', '');
579 return InkWell(
580 onTap: () => _send(onToggled),
581 child: Row(
582 mainAxisSize: MainAxisSize.min,
583 children: [
584 Checkbox(
585 value: n.prop('active', false),
586 onChanged: (_) => _send(onToggled),
587 ),
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday588 // Flexible, because the label is prose and the row is as wide
589 // as the window: "Hide join/part messages" beside a checkbox
590 // overflows a phone otherwise.
591 Flexible(
592 child: Text(n.prop('label', ''),
593 style: _style(t.textBody, t.onBg)),
594 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday595 ],
596 ),
597 );
598 }
599
600 case 'emoji':
601 return _wrapTap(
602 n.prop('onClick', ''),
603 Text(n.prop('glyph', n.prop('emoji', '')),
The pencil was drawn by a text font 1cb40af nandi yesterday604 style: _emojiStyle(_d(n.props['size'], 16))),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday605 );
606
607 /// A reaction pill: the glyph, and the tally beside it where there is
608 /// one to show. The same shape whether it is a reaction under a message,
609 /// a swatch in the picker, or a chip on the sender's row — which is the
610 /// point: what you press to react and what appears once you have should
611 /// look like one family.
612 ///
613 /// A count of zero is no count. The picker passes 0 for every swatch,
614 /// and a grid of little grey zeroes is noise where a reader is scanning
615 /// for a face. `mine` is the accent, because the only thing a pill has
616 /// to say at a glance is whether pressing it again takes yours off.
617 case 'reaction':
618 {
619 final size = _d(n.props['size'], 14);
620 final count = n.prop('count', 0);
621 final mine = n.prop('mine', false);
622 final pad = (0.25 * size).clamp(2.0, 8.0);
623 return _wrapTap(
624 n.prop('onClick', ''),
625 Container(
626 padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2),
627 decoration: BoxDecoration(
628 color: mine ? t.accent : t.component,
629 borderRadius: BorderRadius.circular(t.radiusS),
630 ),
631 child: Row(
632 mainAxisSize: MainAxisSize.min,
633 children: [
The pencil was drawn by a text font 1cb40af nandi yesterday634 Text(n.prop('emoji', ''), style: _emojiStyle(size)),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday635 if (count > 0) ...[
636 const SizedBox(width: 4),
637 Text('$count',
638 style: _style(t.textCaption,
639 mine ? t.onAccent : t.dim)),
640 ],
641 ],
642 ),
643 ),
644 );
645 }
646
647 /// A face is a way in to who someone is, so it takes the press that
648 /// opens their profile. A picture that will not load is a face that
649 /// stays its initial and nothing else.
650 case 'avatar':
651 {
652 final size = _d(n.props['size'], 32);
A web version, from the same core 23846db nandi 13h ago653 final url = n.prop('url', '');
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday654 final fallback = n.prop('fallback', '');
A web version, from the same core 23846db nandi 13h ago655 final initial = Text(
656 fallback.isNotEmpty ? fallback.substring(0, 1).toUpperCase() : '?',
657 style: _style(t.textBody, t.onBg));
658 // Through the host rather than as a `backgroundImage`: on the web a
659 // face is an <img> the browser fetches, which is the only kind CORS
660 // lets through, and an element cannot be a decoration.
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday661 final face = CircleAvatar(
662 radius: size / 2,
663 backgroundColor: t.component,
A web version, from the same core 23846db nandi 13h ago664 child: url.isEmpty
665 ? initial
666 : ClipOval(
667 child: SizedBox(
668 width: size,
669 height: size,
670 child: host.networkImage(url,
671 fit: BoxFit.cover, onError: () => initial),
672 ),
673 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday674 );
675 final onClick = n.prop('onClick', '');
676 if (onClick.isEmpty) return face;
677 return InkWell(
678 onTap: () => _send(onClick),
679 customBorder: const CircleBorder(),
680 child: face,
681 );
682 }
683
684 case 'image':
685 {
A web version, from the same core 23846db nandi 13h ago686 final src = n.prop('src', '');
687 if (src.isEmpty) return const SizedBox.shrink();
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday688 final maxW = _d(n.props['maxWidth'], 0);
689 final maxH = _d(n.props['maxHeight'], 0);
A web version, from the same core 23846db nandi 13h ago690 // A half-written cache file, or one deleted under us: the decoder
691 // throws during the build, and an exception in a build is a red
692 // screen for the whole conversation rather than a gap where one
693 // picture was.
694 Widget img;
695 if (src.startsWith('http://') || src.startsWith('https://')) {
696 img = host.networkImage(src);
697 } else {
698 final provider = _imageProvider(src);
699 if (provider == null) return const SizedBox.shrink();
700 img = Image(
701 image: provider,
702 fit: BoxFit.contain,
703 errorBuilder: (_, _, _) => const SizedBox.shrink(),
704 );
705 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday706 if (maxW > 0 || maxH > 0) {
707 img = ConstrainedBox(
708 constraints: BoxConstraints(
709 maxWidth: maxW > 0 ? maxW : double.infinity,
710 maxHeight: maxH > 0 ? maxH : double.infinity,
711 ),
712 child: img,
713 );
714 }
715 return _wrapTap(n.prop('onClick', ''), img);
716 }
The data model, in Nim d333b6f nandi yesterday717
718 case 'entry':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday719 {
720 final key = n.prop('key', '');
721 final value = n.prop('text', '');
722 final c = _controllers.putIfAbsent(
723 key, () => TextEditingController(text: value));
724 // Only when it actually differs: assigning unconditionally moves the
725 // caret to the end on every keystroke.
726 if (c.text != value) {
727 c.value = c.value.copyWith(
728 text: value,
729 selection: TextSelection.collapsed(offset: value.length),
730 );
731 }
732 final field = TextField(
733 controller: c,
734 focusNode: _focus.putIfAbsent(key, FocusNode.new),
735 style: _style(t.textBody, t.onBg),
736 decoration: InputDecoration(
737 hintText: n.prop('placeholder', ''),
738 hintStyle: _style(t.textBody, t.dim),
739 isDense: true,
740 filled: true,
741 fillColor: t.component,
742 border: OutlineInputBorder(
743 borderRadius: BorderRadius.circular(t.radiusS),
744 borderSide: BorderSide.none,
745 ),
746 ),
747 onChanged: (v) => _send(n.prop('onChange', ''), v),
748 onSubmitted: (_) => _send(n.prop('onSubmit', '')),
749 );
750 final w = _d(n.props['widthRequest'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday751 if (w > 0) return SizedBox(width: w, child: field);
752 // No width asked for: take the rest of the row where there is a row
753 // to take it from, and otherwise a definite width. NOT Expanded
754 // unconditionally — a TextField has no intrinsic width, so in a Wrap
755 // it is both illegal and unmeasurable, and that combination is what
756 // took the whole screen down rather than one field.
757 return axis == _row
758 ? Expanded(child: field)
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday759 : SizedBox(width: _unsizedEntry, child: field);
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday760 }
761
762 case 'scroll':
763 {
Both ends of a scroll, on the same controller 5594a62 nandi yesterday764 // Both ends of the same scroll, named so they are the same one.
The arrow goes to the message 4925e54 nandi 16h ago765 final scrollKey = n.prop('scrollKey', 'scroll');
766 final c = _scrollers.putIfAbsent(scrollKey, ScrollController.new);
767 final stick = n.prop('stickToBottom', false);
768
769 // "Jump to present": a tick that goes up, rather than a flag that
770 // would have to be cleared. A reverse scroll holds the present at
771 // offset zero, which is why this is not maxScrollExtent.
772 final tick = n.prop('scrollToBottom', 0);
773 if (_bottomTicks[scrollKey] != tick) {
774 _bottomTicks[scrollKey] = tick;
775 WidgetsBinding.instance.addPostFrameCallback((_) {
776 if (!c.hasClients) return;
777 c.animateTo(
778 stick ? c.position.minScrollExtent
779 : c.position.maxScrollExtent,
780 duration: const Duration(milliseconds: 250),
781 curve: Curves.easeOut,
782 );
783 });
784 }
Jump to present, which was never on screen ecb440f nandi 15h ago785 // How far from the present counts as having left it. Enough that
786 // the last line being taller than the gap does not toggle this on
787 // its own, and little enough that a nudge upward and back does not
788 // leave the button on screen.
789 const away = 120.0;
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday790 Widget body = SingleChildScrollView(
Both ends of a scroll, on the same controller 5594a62 nandi yesterday791 controller: c,
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday792 // The backlog reads from the bottom; a settings list from the top.
The arrow goes to the message 4925e54 nandi 16h ago793 reverse: stick,
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday794 child: Column(
795 crossAxisAlignment: CrossAxisAlignment.start,
796 children: _spaced(kids, spacing, vertical: true)),
The data model, in Nim d333b6f nandi yesterday797 );
Both ends of a scroll, on the same controller 5594a62 nandi yesterday798 body = Scrollbar(controller: c, child: body);
Jump to present, which was never on screen ecb440f nandi 15h ago799
800 // Only the backlog reports this. A settings list has no present to
801 // be at, and telling the core about one would put the chat
802 // screen's button on the wrong screen's scrolling.
803 if (stick) {
804 body = NotificationListener<ScrollNotification>(
805 onNotification: (note) {
806 if (note.depth != 0) return false;
807 final m = note.metrics;
808 // Reversed, so the present is the zero end.
809 final here = m.pixels <= m.minScrollExtent + away;
810 if (_atPresent[scrollKey] != here) {
811 _atPresent[scrollKey] = here;
812 _send(here ? 'present.back' : 'present.left');
813 }
814 return false;
815 },
816 child: body,
817 );
818 }
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday819 // A scroll takes what the column has left. Outside a Flex there is
820 // nothing to take, and the tree is malformed — `_strandedScroll` is
821 // a visible size rather than a correct one, so the layout tests see
822 // a screen instead of an exception.
823 return flex
824 ? Expanded(child: body)
825 : const SizedBox(height: _strandedScroll);
The data model, in Nim d333b6f nandi yesterday826 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday827
828 /// A panel over the screen rather than a screen of its own.
829 case 'dialog':
830 return Card(
831 color: t.cardComponent,
832 child: Padding(
833 padding: const EdgeInsets.all(t.spaceS),
834 child: Column(
835 mainAxisSize: MainAxisSize.min,
836 crossAxisAlignment: CrossAxisAlignment.start,
837 children: [
838 if (n.prop('title', '').isNotEmpty)
839 Padding(
840 padding: const EdgeInsets.only(bottom: t.spaceXxs),
841 child: Text(n.prop('title', ''),
842 style: _style(t.textTitle4, t.onCard)
843 .copyWith(fontWeight: FontWeight.w600)),
844 ),
845 ..._spaced(kids, spacing, vertical: true),
846 ],
847 ),
The data model, in Nim d333b6f nandi yesterday848 ),
849 );
850
851 default:
852 // An unknown tag paints as itself rather than crashing or vanishing.
853 // 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 yesterday854 // is what makes the boundary pleasant to work across.
The data model, in Nim d333b6f nandi yesterday855 return Container(
856 padding: const EdgeInsets.all(4),
857 color: Colors.orange.withValues(alpha: 0.3),
858 child: Text('?${n.tag}'),
859 );
860 }
861 }
862
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday863 /// One node of an inline paragraph, as a span.
864 ///
865 /// Only `text` and `link` appear here — they are the only things `runNodes`
866 /// emits — and anything else falls back to its plain text so an unexpected
867 /// tag degrades to something readable rather than vanishing.
868 InlineSpan _span(core.UiNode n) {
869 switch (n.tag) {
870 case 'link':
871 final url = n.prop('url', n.prop('label', ''));
872 final onClick = n.prop('onClick', '');
873 return TextSpan(
874 text: n.prop('label', ''),
875 style: _style(t.textBody, t.accent)
876 .copyWith(decoration: TextDecoration.underline,
877 decorationColor: t.accent),
878 recognizer: onClick.isEmpty
879 ? null
880 : (_linkTaps[url] ??= TapGestureRecognizer()
881 ..onTap = () => _send(onClick)),
882 );
883 case 'text':
884 return TextSpan(
885 text: n.prop('text', ''), style: _style(t.textBody, t.onBg));
886 default:
887 return TextSpan(
888 text: n.prop('label', n.prop('text', '')),
889 style: _style(t.textBody, t.onBg));
890 }
891 }
892
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday893 /// `margin` and its four sides — the props the
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday894 /// screens use to buy air without a wrapper each time.
895 Widget _margins(core.UiNode n, Widget child) {
896 final all = _d(n.props['margin'], 0);
897 final top = _d(n.props['marginTop'], all);
898 final bottom = _d(n.props['marginBottom'], all);
899 final right = _d(n.props['marginRight'], all);
900 final left = _d(n.props['marginLeft'], all);
901 if (top == 0 && bottom == 0 && right == 0 && left == 0) return child;
902 return Padding(
903 padding: EdgeInsets.only(
904 top: top, bottom: bottom, right: right, left: left),
905 child: child,
906 );
The data model, in Nim d333b6f nandi yesterday907 }
908}