nandi/frqpublic Fork 0
a4be36c261319bd0b54b42b9e4a829c7080bb6d0
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 · 698 lines · 26.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
The data model, in Nim d333b6f nandi yesterday50 @override
51 void initState() {
52 super.initState();
53 // 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 yesterday54 // callback into Dart. At ~70µs a render a 100ms timer costs nothing.
The data model, in Nim d333b6f nandi yesterday55 _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday56 // Compared as the JSON Nim already produced, and decoded only when it
57 // differs. Stringifying both trees to answer "did anything change" was
58 // ~1MB of string churn per poll in a busy room, ten times a second, for
59 // an answer that is almost always no.
60 final next = core.pollIfChanged(_frame.json);
61 if (next != null) setState(() => _frame = next);
The data model, in Nim d333b6f nandi yesterday62 });
63 }
64
65 void _send(String id, [String value = '']) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday66 if (id.isEmpty) return;
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday67 setState(() => _frame = core.dispatchFrame(id, value));
The data model, in Nim d333b6f nandi yesterday68 if (id == 'send') _focus['draft']?.requestFocus();
69 }
70
71 @override
72 void dispose() {
73 _poll?.cancel();
74 for (final c in _controllers.values) {
75 c.dispose();
76 }
77 for (final f in _focus.values) {
78 f.dispose();
79 }
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday80 for (final r in _linkTaps.values) {
81 r.dispose();
82 }
The data model, in Nim d333b6f nandi yesterday83 super.dispose();
84 }
85
86 @override
87 Widget build(BuildContext context) => MaterialApp(
88 title: 'frq',
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday89 debugShowCheckedModeBanner: false,
90 theme: ThemeData(
91 useMaterial3: true,
92 brightness: Brightness.dark,
93 scaffoldBackgroundColor: t.bg,
94 colorScheme: const ColorScheme.dark(
95 primary: t.accent,
96 onPrimary: t.onAccent,
97 surface: t.bg,
98 onSurface: t.onBg,
99 error: t.destructive,
100 ),
The data model, in Nim d333b6f nandi yesterday101 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday102 home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))),
The data model, in Nim d333b6f nandi yesterday103 );
104
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday105 // ---------------------------------------------------------------- helpers
106
107 TextStyle _style(double size, Color color) =>
108 TextStyle(fontSize: size, color: color, height: 1.35);
109
The pencil was drawn by a text font 1cb40af nandi 17h ago110 /// The style for a widget whose whole content is an emoji glyph.
111 ///
112 /// Naming the colour emoji font is not belt and braces: a glyph like ✏️ is
113 /// U+270F plus U+FE0F, and the variation selector is a *request* for emoji
114 /// presentation, not a guarantee. DejaVu Sans claims U+270F, so ordinary
115 /// fallback stops there and draws the monochrome pencil the text era had —
116 /// while 🙂, which no text font covers, falls all the way through to the
117 /// emoji font and looks right. That is why only some of the chips were
118 /// wrong.
119 ///
120 /// A family list rather than one name, because the font that has them
121 /// differs by platform, and a name nothing matches costs nothing.
122 static const List<String> _emojiFonts = <String>[
123 'Noto Color Emoji', // Linux, Android
124 'Apple Color Emoji', // macOS, iOS
125 'Segoe UI Emoji', // Windows
126 ];
127
128 TextStyle _emojiStyle(double size) => TextStyle(
129 fontSize: size,
130 fontFamily: _emojiFonts.first,
131 fontFamilyFallback: _emojiFonts,
132 );
133
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday134 double _d(dynamic v, double fallback) =>
135 v is num ? v.toDouble() : fallback;
136
137 /// Gaps between children, as real widgets rather than a `spacing:` — the
138 /// same layout on every Flutter version this might be built against.
139 List<Widget> _spaced(List<Widget> kids, double gap, {required bool vertical}) {
140 if (gap <= 0 || kids.length < 2) return kids;
141 final out = <Widget>[];
142 for (var i = 0; i < kids.length; i++) {
143 if (i > 0) {
144 out.add(vertical ? SizedBox(height: gap) : SizedBox(width: gap));
145 }
146 out.add(kids[i]);
147 }
148 return out;
149 }
150
151 /// A source that may be a bundled asset, a file on disk, or a URL — the
152 /// three the screens hand over, named apart by an `asset:` prefix so they
153 /// stay one property.
154 ImageProvider? _imageProvider(String src) {
155 if (src.isEmpty) return null;
156 if (src.startsWith('asset:')) return AssetImage(src.substring(6));
157 if (src.startsWith('http://') || src.startsWith('https://')) {
158 return NetworkImage(src);
159 }
160 return FileImage(File(src));
161 }
162
163 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {
164 if (onClick.isEmpty) return child;
165 return InkWell(
166 onTap: () => _send(onClick),
167 borderRadius: radius,
168 child: child,
169 );
170 }
171
172 // ------------------------------------------------------------------ build
173
Expanded only where a Flex can hold it f1e99b4 nandi yesterday174 /// The axis of the widget a node is being built *into*, because `Expanded`
175 /// is only legal inside a Flex and there is no way to ask Flutter after the
176 /// fact.
177 ///
178 /// Getting this wrong is what "Cannot hit test a render box that has never
179 /// been laid out" means, in a pile: an `Expanded` inside a `Wrap` fails the
180 /// layout, and every box under it is then asked to hit-test without ever
181 /// having been laid out. The chats screen did exactly that — two unsized
182 /// entries in an `hbox`, which is a Wrap.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday183 /// What an unsized entry or a stranded scroll falls back to.
184 ///
185 /// Both are only reachable when the tree has put one outside a Flex, which
186 /// is a tree bug rather than a rendering choice. The numbers exist so that
187 /// bug renders as something a person can see and a test can catch, not so
188 /// that it renders correctly.
189 static const _unsizedEntry = 320.0;
190 static const _strandedScroll = 400.0;
191
Expanded only where a Flex can hold it f1e99b4 nandi yesterday192 static const _noAxis = '';
193 static const _row = 'row';
194 static const _column = 'column';
195
196 Widget _build(core.UiNode n, [String axis = _noAxis]) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday197 final spacing = _d(n.props['spacing'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday198 final flex = axis == _row || axis == _column;
199
200 // What this node's own children are being built into.
201 final childAxis = switch (n.tag) {
202 'page' || 'vbox' || 'card' || 'scroll' || 'dialog' => _column,
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday203 // Wrapping unless the row says otherwise. Flipping this default was
204 // tried and reverted: only 4 of 15 `hbox` call sites state `wrap` at
205 // all, so the other 11 became Rows and overflowed — the tree's habit is
206 // to wrap, and the default has to match it.
Expanded only where a Flex can hold it f1e99b4 nandi yesterday207 'hbox' => n.prop('wrap', true) ? _noAxis : _row,
208 _ => _noAxis,
209 };
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday210 // A paragraph's children are spans, not widgets — building them as
211 // widgets and throwing them away is what the `inline` special case did.
212 final kids = n.tag == 'paragraph'
213 ? const <Widget>[]
214 : n.children.map((c) => _build(c, childAxis)).toList();
215
216 // One rule for "take the remaining main-axis extent", stated by the node
217 // that expands. It used to be three: a vbox prop, a scroll with no
218 // height, and a row peering at its children's props to infer it.
219 Widget expanded(Widget w) =>
220 (n.prop('expand', false) && flex) ? Expanded(child: w) : w;
The data model, in Nim d333b6f nandi yesterday221
222 switch (n.tag) {
223 case 'page':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday224 return SingleChildScrollView(
225 child: Center(
226 child: ConstrainedBox(
227 constraints:
228 BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)),
229 child: Padding(
230 padding: const EdgeInsets.all(t.spaceM),
231 child: Column(
232 crossAxisAlignment: CrossAxisAlignment.start,
233 children: _spaced(kids, spacing, vertical: true)),
234 ),
The data model, in Nim d333b6f nandi yesterday235 ),
236 ),
237 );
238
239 case 'vbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday240 {
241 Widget col = Column(
242 crossAxisAlignment: CrossAxisAlignment.start,
243 mainAxisSize: MainAxisSize.min,
244 children: _spaced(kids, spacing, vertical: true),
245 );
246 col = _margins(n, col);
247 final w = _d(n.props['widthRequest'], 0);
248 if (w > 0) col = SizedBox(width: w, child: col);
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday249 return expanded(col);
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday250 }
The data model, in Nim d333b6f nandi yesterday251
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday252 // 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 yesterday253 // unbounded width, so a long URL or a long word can never wrap — it
254 // overflows, the layout fails, and every box under it is then hit-tested
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday255 // having never been laid out. Spans in one RichText wrap properly.
256 case 'paragraph':
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday257 return Text.rich(
258 TextSpan(children: n.children.map(_span).toList()),
259 softWrap: true,
260 );
261
The data model, in Nim d333b6f nandi yesterday262 case 'hbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday263 {
264 // Wrap and not Row: `:hbox` in the screens means "these go together
265 // across", not "these fit". The head row of the chat screen asks for
266 // more than 360 points has, and a Row answers that with an overflow
267 // rather than a second line.
268 final wrapping = n.prop('wrap', true);
269 final align = n.prop('align', 'center');
270 if (!wrapping) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday271 // An expanding row stretches on its cross axis, which is where a
272 // child's height comes from — Expanded in a Row is about width.
273 // Stretch needs a bounded height, and `expanded()` below is what
274 // gives the row one; without it the stretch resolves to infinity.
275 final fills = n.prop('expand', false);
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday276 final row = Row(
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday277 crossAxisAlignment: fills
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday278 ? CrossAxisAlignment.stretch
279 : (align == 'end'
280 ? CrossAxisAlignment.end
281 : CrossAxisAlignment.center),
282 children: _spaced(kids, spacing, vertical: false),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday283 );
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday284 return expanded(_margins(n, row));
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday285 }
286 return _margins(
287 n,
288 Wrap(
289 spacing: spacing,
290 runSpacing: spacing,
291 crossAxisAlignment: align == 'end'
292 ? WrapCrossAlignment.end
293 : WrapCrossAlignment.center,
294 children: kids,
The data model, in Nim d333b6f nandi yesterday295 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday296 );
297 }
The data model, in Nim d333b6f nandi yesterday298
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday299 // Container::Card in the Clojure: padding 12, fills its width.
The data model, in Nim d333b6f nandi yesterday300 case 'card':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday301 return Container(
302 width: double.infinity,
303 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
304 padding: const EdgeInsets.all(t.spaceXs),
305 decoration: BoxDecoration(
306 color: t.card,
307 borderRadius: BorderRadius.circular(t.radiusS),
The data model, in Nim d333b6f nandi yesterday308 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday309 child: Column(
310 crossAxisAlignment: CrossAxisAlignment.start,
311 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
312 vertical: true)),
The data model, in Nim d333b6f nandi yesterday313 );
314
315 case 'title':
316 return Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday317 style: _style(t.textTitle3, t.onBg)
318 .copyWith(fontWeight: FontWeight.bold));
The data model, in Nim d333b6f nandi yesterday319
320 case 'title-2':
321 return Padding(
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday322 padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs),
The data model, in Nim d333b6f nandi yesterday323 child: Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday324 style: _style(t.textTitle4, t.onBg)
325 .copyWith(fontWeight: FontWeight.w600)),
The data model, in Nim d333b6f nandi yesterday326 );
327
328 case 'label':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday329 return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg));
The data model, in Nim d333b6f nandi yesterday330
331 case 'dim-label':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday332 return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim));
333
334 /// Prose, as opposed to a label: this is what a message is, and it
335 /// wraps. Kept apart from `label` because a wrapping label in a row
336 /// lays out against the row's width rather than the column's.
337 case 'text':
338 return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));
339
340 case 'link':
341 return _wrapTap(
342 n.prop('onClick', ''),
343 Text(
344 n.prop('label', ''),
345 style: _style(t.textBody, t.accent)
346 .copyWith(decoration: TextDecoration.underline,
347 decorationColor: t.accent),
348 ),
349 );
350
351 case 'separator':
352 return const Divider(height: 1, thickness: 1, color: t.divider);
353
354 case 'spacer':
355 {
356 final s = _d(n.props['size'], t.spaceXxs);
357 return SizedBox(width: s, height: s);
358 }
The data model, in Nim d333b6f nandi yesterday359
360 case 'spinner':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday361 return Row(
362 mainAxisSize: MainAxisSize.min,
363 children: [
364 const SizedBox(
365 width: 16,
366 height: 16,
367 child: CircularProgressIndicator(
368 strokeWidth: 2, color: t.accent)),
369 if (n.prop('label', '').isNotEmpty) ...[
370 const SizedBox(width: t.spaceXxs),
371 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
372 ],
373 ],
374 );
375
376 /// A dot that says whether the thing is live, and the words beside it.
377 case 'status':
378 return Row(
379 mainAxisSize: MainAxisSize.min,
380 children: [
381 Container(
382 width: 8,
383 height: 8,
384 decoration: BoxDecoration(
385 color: n.prop('live', false) ? t.success : t.dim,
386 borderRadius: BorderRadius.circular(t.radiusXs),
387 ),
388 ),
389 const SizedBox(width: 6),
390 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
391 ],
392 );
The data model, in Nim d333b6f nandi yesterday393
394 case 'button':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday395 {
396 final onClick = n.prop('onClick', '');
397 final kind = n.prop('kind', 'default');
398 final label = Text(n.prop('label', ''));
399 if (kind == 'primary') {
400 return FilledButton(
401 onPressed: () => _send(onClick), child: label);
402 }
A face that opens someone 9bb81a1 nandi 19h ago403 // A sender's name: a way in to who someone is, but it sits in the
404 // middle of a line and must not look like a control. Text that
405 // takes a press, with no chrome at all.
406 if (kind == 'plain') {
407 return InkWell(
408 onTap: () => _send(onClick),
409 child: Text(n.prop('label', ''),
410 style: _style(t.textBody, t.onBg)),
411 );
412 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday413 if (kind == 'destructive') {
414 return FilledButton(
415 style: FilledButton.styleFrom(
416 backgroundColor: t.destructive,
417 foregroundColor: t.onDestructive),
418 onPressed: () => _send(onClick),
419 child: label,
420 );
421 }
422 return OutlinedButton(onPressed: () => _send(onClick), child: label);
423 }
The data model, in Nim d333b6f nandi yesterday424
425 case 'checkbutton':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday426 {
427 // The label is part of the target. 20 logical pixels is a fine tick
428 // on a desktop pointer and a miss on a thumb, so the whole row taps.
429 final onToggled = n.prop('onToggled', '');
430 return InkWell(
431 onTap: () => _send(onToggled),
432 child: Row(
433 mainAxisSize: MainAxisSize.min,
434 children: [
435 Checkbox(
436 value: n.prop('active', false),
437 onChanged: (_) => _send(onToggled),
438 ),
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday439 // Flexible, because the label is prose and the row is as wide
440 // as the window: "Hide join/part messages" beside a checkbox
441 // overflows a phone otherwise.
442 Flexible(
443 child: Text(n.prop('label', ''),
444 style: _style(t.textBody, t.onBg)),
445 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday446 ],
447 ),
448 );
449 }
450
451 case 'emoji':
452 return _wrapTap(
453 n.prop('onClick', ''),
454 Text(n.prop('glyph', n.prop('emoji', '')),
The pencil was drawn by a text font 1cb40af nandi 17h ago455 style: _emojiStyle(_d(n.props['size'], 16))),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday456 );
457
458 /// A reaction pill: the glyph, and the tally beside it where there is
459 /// one to show. The same shape whether it is a reaction under a message,
460 /// a swatch in the picker, or a chip on the sender's row — which is the
461 /// point: what you press to react and what appears once you have should
462 /// look like one family.
463 ///
464 /// A count of zero is no count. The picker passes 0 for every swatch,
465 /// and a grid of little grey zeroes is noise where a reader is scanning
466 /// for a face. `mine` is the accent, because the only thing a pill has
467 /// to say at a glance is whether pressing it again takes yours off.
468 case 'reaction':
469 {
470 final size = _d(n.props['size'], 14);
471 final count = n.prop('count', 0);
472 final mine = n.prop('mine', false);
473 final pad = (0.25 * size).clamp(2.0, 8.0);
474 return _wrapTap(
475 n.prop('onClick', ''),
476 Container(
477 padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2),
478 decoration: BoxDecoration(
479 color: mine ? t.accent : t.component,
480 borderRadius: BorderRadius.circular(t.radiusS),
481 ),
482 child: Row(
483 mainAxisSize: MainAxisSize.min,
484 children: [
The pencil was drawn by a text font 1cb40af nandi 17h ago485 Text(n.prop('emoji', ''), style: _emojiStyle(size)),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday486 if (count > 0) ...[
487 const SizedBox(width: 4),
488 Text('$count',
489 style: _style(t.textCaption,
490 mine ? t.onAccent : t.dim)),
491 ],
492 ],
493 ),
494 ),
495 );
496 }
497
498 /// A face is a way in to who someone is, so it takes the press that
499 /// opens their profile. A picture that will not load is a face that
500 /// stays its initial and nothing else.
501 case 'avatar':
502 {
503 final size = _d(n.props['size'], 32);
504 final provider = _imageProvider(n.prop('url', ''));
505 final fallback = n.prop('fallback', '');
506 final face = CircleAvatar(
507 radius: size / 2,
508 backgroundColor: t.component,
509 backgroundImage: provider,
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday510 onBackgroundImageError: provider == null ? null : (_, _) {},
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday511 child: provider == null
512 ? Text(
513 fallback.isNotEmpty
514 ? fallback.substring(0, 1).toUpperCase()
515 : '?',
516 style: _style(t.textBody, t.onBg))
517 : null,
518 );
519 final onClick = n.prop('onClick', '');
520 if (onClick.isEmpty) return face;
521 return InkWell(
522 onTap: () => _send(onClick),
523 customBorder: const CircleBorder(),
524 child: face,
525 );
526 }
527
528 case 'image':
529 {
530 final provider = _imageProvider(n.prop('src', ''));
531 if (provider == null) return const SizedBox.shrink();
532 final maxW = _d(n.props['maxWidth'], 0);
533 final maxH = _d(n.props['maxHeight'], 0);
534 Widget img = Image(
535 image: provider,
536 fit: BoxFit.contain,
537 // A half-written cache file, or one deleted under us: the decoder
538 // throws during the build, and an exception in a build is a red
539 // screen for the whole conversation rather than a gap where one
540 // picture was.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday541 errorBuilder: (_, _, _) => const SizedBox.shrink(),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday542 );
543 if (maxW > 0 || maxH > 0) {
544 img = ConstrainedBox(
545 constraints: BoxConstraints(
546 maxWidth: maxW > 0 ? maxW : double.infinity,
547 maxHeight: maxH > 0 ? maxH : double.infinity,
548 ),
549 child: img,
550 );
551 }
552 return _wrapTap(n.prop('onClick', ''), img);
553 }
The data model, in Nim d333b6f nandi yesterday554
555 case 'entry':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday556 {
557 final key = n.prop('key', '');
558 final value = n.prop('text', '');
559 final c = _controllers.putIfAbsent(
560 key, () => TextEditingController(text: value));
561 // Only when it actually differs: assigning unconditionally moves the
562 // caret to the end on every keystroke.
563 if (c.text != value) {
564 c.value = c.value.copyWith(
565 text: value,
566 selection: TextSelection.collapsed(offset: value.length),
567 );
568 }
569 final field = TextField(
570 controller: c,
571 focusNode: _focus.putIfAbsent(key, FocusNode.new),
572 style: _style(t.textBody, t.onBg),
573 decoration: InputDecoration(
574 hintText: n.prop('placeholder', ''),
575 hintStyle: _style(t.textBody, t.dim),
576 isDense: true,
577 filled: true,
578 fillColor: t.component,
579 border: OutlineInputBorder(
580 borderRadius: BorderRadius.circular(t.radiusS),
581 borderSide: BorderSide.none,
582 ),
583 ),
584 onChanged: (v) => _send(n.prop('onChange', ''), v),
585 onSubmitted: (_) => _send(n.prop('onSubmit', '')),
586 );
587 final w = _d(n.props['widthRequest'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday588 if (w > 0) return SizedBox(width: w, child: field);
589 // No width asked for: take the rest of the row where there is a row
590 // to take it from, and otherwise a definite width. NOT Expanded
591 // unconditionally — a TextField has no intrinsic width, so in a Wrap
592 // it is both illegal and unmeasurable, and that combination is what
593 // took the whole screen down rather than one field.
594 return axis == _row
595 ? Expanded(child: field)
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday596 : SizedBox(width: _unsizedEntry, child: field);
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday597 }
598
599 case 'scroll':
600 {
601 Widget body = SingleChildScrollView(
602 // The backlog reads from the bottom; a settings list from the top.
603 reverse: n.prop('stickToBottom', false),
604 child: Column(
605 crossAxisAlignment: CrossAxisAlignment.start,
606 children: _spaced(kids, spacing, vertical: true)),
The data model, in Nim d333b6f nandi yesterday607 );
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday608 body = Scrollbar(child: body);
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday609 // A scroll takes what the column has left. Outside a Flex there is
610 // nothing to take, and the tree is malformed — `_strandedScroll` is
611 // a visible size rather than a correct one, so the layout tests see
612 // a screen instead of an exception.
613 return flex
614 ? Expanded(child: body)
615 : const SizedBox(height: _strandedScroll);
The data model, in Nim d333b6f nandi yesterday616 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday617
618 /// A panel over the screen rather than a screen of its own.
619 case 'dialog':
620 return Card(
621 color: t.cardComponent,
622 child: Padding(
623 padding: const EdgeInsets.all(t.spaceS),
624 child: Column(
625 mainAxisSize: MainAxisSize.min,
626 crossAxisAlignment: CrossAxisAlignment.start,
627 children: [
628 if (n.prop('title', '').isNotEmpty)
629 Padding(
630 padding: const EdgeInsets.only(bottom: t.spaceXxs),
631 child: Text(n.prop('title', ''),
632 style: _style(t.textTitle4, t.onCard)
633 .copyWith(fontWeight: FontWeight.w600)),
634 ),
635 ..._spaced(kids, spacing, vertical: true),
636 ],
637 ),
The data model, in Nim d333b6f nandi yesterday638 ),
639 );
640
641 default:
642 // An unknown tag paints as itself rather than crashing or vanishing.
643 // 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 yesterday644 // is what makes the boundary pleasant to work across.
The data model, in Nim d333b6f nandi yesterday645 return Container(
646 padding: const EdgeInsets.all(4),
647 color: Colors.orange.withValues(alpha: 0.3),
648 child: Text('?${n.tag}'),
649 );
650 }
651 }
652
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday653 /// One node of an inline paragraph, as a span.
654 ///
655 /// Only `text` and `link` appear here — they are the only things `runNodes`
656 /// emits — and anything else falls back to its plain text so an unexpected
657 /// tag degrades to something readable rather than vanishing.
658 InlineSpan _span(core.UiNode n) {
659 switch (n.tag) {
660 case 'link':
661 final url = n.prop('url', n.prop('label', ''));
662 final onClick = n.prop('onClick', '');
663 return TextSpan(
664 text: n.prop('label', ''),
665 style: _style(t.textBody, t.accent)
666 .copyWith(decoration: TextDecoration.underline,
667 decorationColor: t.accent),
668 recognizer: onClick.isEmpty
669 ? null
670 : (_linkTaps[url] ??= TapGestureRecognizer()
671 ..onTap = () => _send(onClick)),
672 );
673 case 'text':
674 return TextSpan(
675 text: n.prop('text', ''), style: _style(t.textBody, t.onBg));
676 default:
677 return TextSpan(
678 text: n.prop('label', n.prop('text', '')),
679 style: _style(t.textBody, t.onBg));
680 }
681 }
682
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday683 /// `margin` and its four sides — the props the
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday684 /// screens use to buy air without a wrapper each time.
685 Widget _margins(core.UiNode n, Widget child) {
686 final all = _d(n.props['margin'], 0);
687 final top = _d(n.props['marginTop'], all);
688 final bottom = _d(n.props['marginBottom'], all);
689 final right = _d(n.props['marginRight'], all);
690 final left = _d(n.props['marginLeft'], all);
691 if (top == 0 && bottom == 0 && right == 0 && left == 0) return child;
692 return Padding(
693 padding: EdgeInsets.only(
694 top: top, bottom: bottom, right: right, left: left),
695 child: child,
696 );
The data model, in Nim d333b6f nandi yesterday697 }
698}