nandi/frqpublic Fork 0
main
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 · 743 lines · 28.6 KBDart Blame HistoryRaw
The data model, in Nim d333b6f nandi 15h ago1/// The renderer: a Nim widget tree, walked into Flutter widgets.
2///
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago3/// 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 15h ago6///
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago7/// 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 15h ago13import 'dart:async';
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago14import 'dart:io';
The data model, in Nim d333b6f nandi 15h ago15
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 12h ago16import 'package:flutter/gestures.dart';
17
The data model, in Nim d333b6f nandi 15h ago18import '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 14h ago21import 'nim_theme.dart' as t;
22
The data model, in Nim d333b6f nandi 15h ago23class 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 12h ago30 late core.UiFrame _frame = core.renderFrame();
31 core.UiNode get _tree => _frame.tree;
The data model, in Nim d333b6f nandi 15h ago32 Timer? _poll;
33
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago34 // 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 12h ago45 // 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 3h 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 data model, in Nim d333b6f nandi 15h ago63 @override
64 void initState() {
65 super.initState();
66 // 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 14h ago67 // callback into Dart. At ~70µs a render a 100ms timer costs nothing.
The data model, in Nim d333b6f nandi 15h ago68 _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago69 // Compared as the JSON Nim already produced, and decoded only when it
70 // differs. Stringifying both trees to answer "did anything change" was
71 // ~1MB of string churn per poll in a busy room, ten times a second, for
72 // an answer that is almost always no.
73 final next = core.pollIfChanged(_frame.json);
74 if (next != null) setState(() => _frame = next);
The data model, in Nim d333b6f nandi 15h ago75 });
76 }
77
78 void _send(String id, [String value = '']) {
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago79 if (id.isEmpty) return;
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago80 setState(() => _frame = core.dispatchFrame(id, value));
The data model, in Nim d333b6f nandi 15h ago81 if (id == 'send') _focus['draft']?.requestFocus();
82 }
83
84 @override
85 void dispose() {
86 _poll?.cancel();
87 for (final c in _controllers.values) {
88 c.dispose();
89 }
90 for (final f in _focus.values) {
91 f.dispose();
92 }
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 12h ago93 for (final r in _linkTaps.values) {
94 r.dispose();
95 }
Both ends of a scroll, on the same controller 5594a62 nandi 3h ago96 for (final c in _scrollers.values) {
97 c.dispose();
98 }
The data model, in Nim d333b6f nandi 15h ago99 super.dispose();
100 }
101
102 @override
103 Widget build(BuildContext context) => MaterialApp(
104 title: 'frq',
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago105 debugShowCheckedModeBanner: false,
106 theme: ThemeData(
107 useMaterial3: true,
108 brightness: Brightness.dark,
109 scaffoldBackgroundColor: t.bg,
110 colorScheme: const ColorScheme.dark(
111 primary: t.accent,
112 onPrimary: t.onAccent,
113 surface: t.bg,
114 onSurface: t.onBg,
115 error: t.destructive,
116 ),
The data model, in Nim d333b6f nandi 15h ago117 ),
Text can be selected ec74784 nandi 2h ago118 // Everything inside one SelectionArea, so a message can be selected
119 // and copied — and so can a nick, a timestamp, or a line of an error.
120 // Per-widget `SelectableText` was the alternative and is worse: it
121 // selects within one widget only, so a two-line answer and the name
122 // above it cannot be dragged across, which is most of what anyone
123 // wants to copy out of a chat.
124 //
125 // Taps still arrive: a selection starts on a drag, and the buttons,
126 // faces and reaction pills under here keep their gestures.
127 home: Scaffold(
128 backgroundColor: t.bg,
129 body: SafeArea(child: SelectionArea(child: _build(_tree))),
130 ),
The data model, in Nim d333b6f nandi 15h ago131 );
132
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago133 // ---------------------------------------------------------------- helpers
134
135 TextStyle _style(double size, Color color) =>
136 TextStyle(fontSize: size, color: color, height: 1.35);
137
The pencil was drawn by a text font 1cb40af nandi 3h ago138 /// The style for a widget whose whole content is an emoji glyph.
139 ///
140 /// Naming the colour emoji font is not belt and braces: a glyph like ✏️ is
141 /// U+270F plus U+FE0F, and the variation selector is a *request* for emoji
142 /// presentation, not a guarantee. DejaVu Sans claims U+270F, so ordinary
143 /// fallback stops there and draws the monochrome pencil the text era had —
144 /// while 🙂, which no text font covers, falls all the way through to the
145 /// emoji font and looks right. That is why only some of the chips were
146 /// wrong.
147 ///
148 /// A family list rather than one name, because the font that has them
149 /// differs by platform, and a name nothing matches costs nothing.
150 static const List<String> _emojiFonts = <String>[
151 'Noto Color Emoji', // Linux, Android
152 'Apple Color Emoji', // macOS, iOS
153 'Segoe UI Emoji', // Windows
154 ];
155
156 TextStyle _emojiStyle(double size) => TextStyle(
157 fontSize: size,
158 fontFamily: _emojiFonts.first,
159 fontFamilyFallback: _emojiFonts,
160 );
161
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago162 double _d(dynamic v, double fallback) =>
163 v is num ? v.toDouble() : fallback;
164
165 /// Gaps between children, as real widgets rather than a `spacing:` — the
166 /// same layout on every Flutter version this might be built against.
167 List<Widget> _spaced(List<Widget> kids, double gap, {required bool vertical}) {
168 if (gap <= 0 || kids.length < 2) return kids;
169 final out = <Widget>[];
170 for (var i = 0; i < kids.length; i++) {
171 if (i > 0) {
172 out.add(vertical ? SizedBox(height: gap) : SizedBox(width: gap));
173 }
174 out.add(kids[i]);
175 }
176 return out;
177 }
178
179 /// A source that may be a bundled asset, a file on disk, or a URL — the
180 /// three the screens hand over, named apart by an `asset:` prefix so they
181 /// stay one property.
182 ImageProvider? _imageProvider(String src) {
183 if (src.isEmpty) return null;
184 if (src.startsWith('asset:')) return AssetImage(src.substring(6));
185 if (src.startsWith('http://') || src.startsWith('https://')) {
186 return NetworkImage(src);
187 }
188 return FileImage(File(src));
189 }
190
191 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {
192 if (onClick.isEmpty) return child;
193 return InkWell(
194 onTap: () => _send(onClick),
195 borderRadius: radius,
196 child: child,
197 );
198 }
199
200 // ------------------------------------------------------------------ build
201
Expanded only where a Flex can hold it f1e99b4 nandi 13h ago202 /// The axis of the widget a node is being built *into*, because `Expanded`
203 /// is only legal inside a Flex and there is no way to ask Flutter after the
204 /// fact.
205 ///
206 /// Getting this wrong is what "Cannot hit test a render box that has never
207 /// been laid out" means, in a pile: an `Expanded` inside a `Wrap` fails the
208 /// layout, and every box under it is then asked to hit-test without ever
209 /// having been laid out. The chats screen did exactly that — two unsized
210 /// entries in an `hbox`, which is a Wrap.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago211 /// What an unsized entry or a stranded scroll falls back to.
212 ///
213 /// Both are only reachable when the tree has put one outside a Flex, which
214 /// is a tree bug rather than a rendering choice. The numbers exist so that
215 /// bug renders as something a person can see and a test can catch, not so
216 /// that it renders correctly.
217 static const _unsizedEntry = 320.0;
218 static const _strandedScroll = 400.0;
219
Expanded only where a Flex can hold it f1e99b4 nandi 13h ago220 static const _noAxis = '';
221 static const _row = 'row';
222 static const _column = 'column';
223
224 Widget _build(core.UiNode n, [String axis = _noAxis]) {
A row is named for its message, not its place 4083860 nandi 1h ago225 // A node that names itself keeps its element across rebuilds.
226 //
227 // The tree is rebuilt wholesale from the core, so Flutter matches
228 // children by position unless something says otherwise — and a position
229 // is not an identity when a line can arrive above. Everything Flutter
230 // holds per element is at stake: text controllers, scroll offsets, and
231 // the selectables a live text selection is made of.
232 final key = n.prop('key', '');
233 final w = _buildNode(n, axis);
234 return key.isEmpty ? w : KeyedSubtree(key: ValueKey(key), child: w);
235 }
236
237 Widget _buildNode(core.UiNode n, String axis) {
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago238 final spacing = _d(n.props['spacing'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi 13h ago239 final flex = axis == _row || axis == _column;
240
241 // What this node's own children are being built into.
242 final childAxis = switch (n.tag) {
243 'page' || 'vbox' || 'card' || 'scroll' || 'dialog' => _column,
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago244 // Wrapping unless the row says otherwise. Flipping this default was
245 // tried and reverted: only 4 of 15 `hbox` call sites state `wrap` at
246 // all, so the other 11 became Rows and overflowed — the tree's habit is
247 // to wrap, and the default has to match it.
Expanded only where a Flex can hold it f1e99b4 nandi 13h ago248 'hbox' => n.prop('wrap', true) ? _noAxis : _row,
249 _ => _noAxis,
250 };
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago251 // A paragraph's children are spans, not widgets — building them as
252 // widgets and throwing them away is what the `inline` special case did.
253 final kids = n.tag == 'paragraph'
254 ? const <Widget>[]
255 : n.children.map((c) => _build(c, childAxis)).toList();
256
257 // One rule for "take the remaining main-axis extent", stated by the node
258 // that expands. It used to be three: a vbox prop, a scroll with no
259 // height, and a row peering at its children's props to infer it.
260 Widget expanded(Widget w) =>
261 (n.prop('expand', false) && flex) ? Expanded(child: w) : w;
The data model, in Nim d333b6f nandi 15h ago262
263 switch (n.tag) {
264 case 'page':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago265 return SingleChildScrollView(
266 child: Center(
267 child: ConstrainedBox(
268 constraints:
269 BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)),
270 child: Padding(
271 padding: const EdgeInsets.all(t.spaceM),
272 child: Column(
273 crossAxisAlignment: CrossAxisAlignment.start,
274 children: _spaced(kids, spacing, vertical: true)),
275 ),
The data model, in Nim d333b6f nandi 15h ago276 ),
277 ),
278 );
279
280 case 'vbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago281 {
282 Widget col = Column(
283 crossAxisAlignment: CrossAxisAlignment.start,
284 mainAxisSize: MainAxisSize.min,
285 children: _spaced(kids, spacing, vertical: true),
286 );
287 col = _margins(n, col);
288 final w = _d(n.props['widthRequest'], 0);
289 if (w > 0) col = SizedBox(width: w, child: col);
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago290 return expanded(col);
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago291 }
The data model, in Nim d333b6f nandi 15h ago292
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago293 // 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 12h ago294 // unbounded width, so a long URL or a long word can never wrap — it
295 // overflows, the layout fails, and every box under it is then hit-tested
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago296 // having never been laid out. Spans in one RichText wrap properly.
297 case 'paragraph':
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 12h ago298 return Text.rich(
299 TextSpan(children: n.children.map(_span).toList()),
300 softWrap: true,
301 );
302
The data model, in Nim d333b6f nandi 15h ago303 case 'hbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago304 {
305 // Wrap and not Row: `:hbox` in the screens means "these go together
306 // across", not "these fit". The head row of the chat screen asks for
307 // more than 360 points has, and a Row answers that with an overflow
308 // rather than a second line.
309 final wrapping = n.prop('wrap', true);
310 final align = n.prop('align', 'center');
311 if (!wrapping) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago312 // An expanding row stretches on its cross axis, which is where a
313 // child's height comes from — Expanded in a Row is about width.
314 // Stretch needs a bounded height, and `expanded()` below is what
315 // gives the row one; without it the stretch resolves to infinity.
316 final fills = n.prop('expand', false);
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 12h ago317 final row = Row(
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago318 crossAxisAlignment: fills
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 12h ago319 ? CrossAxisAlignment.stretch
320 : (align == 'end'
321 ? CrossAxisAlignment.end
322 : CrossAxisAlignment.center),
323 children: _spaced(kids, spacing, vertical: false),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago324 );
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago325 return expanded(_margins(n, row));
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago326 }
327 return _margins(
328 n,
329 Wrap(
330 spacing: spacing,
331 runSpacing: spacing,
332 crossAxisAlignment: align == 'end'
333 ? WrapCrossAlignment.end
334 : WrapCrossAlignment.center,
335 children: kids,
The data model, in Nim d333b6f nandi 15h ago336 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago337 );
338 }
The data model, in Nim d333b6f nandi 15h ago339
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago340 // Container::Card in the Clojure: padding 12, fills its width.
The data model, in Nim d333b6f nandi 15h ago341 case 'card':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago342 return Container(
343 width: double.infinity,
344 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
345 padding: const EdgeInsets.all(t.spaceXs),
346 decoration: BoxDecoration(
347 color: t.card,
348 borderRadius: BorderRadius.circular(t.radiusS),
The data model, in Nim d333b6f nandi 15h ago349 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago350 child: Column(
351 crossAxisAlignment: CrossAxisAlignment.start,
352 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
353 vertical: true)),
The data model, in Nim d333b6f nandi 15h ago354 );
355
356 case 'title':
357 return Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago358 style: _style(t.textTitle3, t.onBg)
359 .copyWith(fontWeight: FontWeight.bold));
The data model, in Nim d333b6f nandi 15h ago360
361 case 'title-2':
362 return Padding(
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago363 padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs),
The data model, in Nim d333b6f nandi 15h ago364 child: Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago365 style: _style(t.textTitle4, t.onBg)
366 .copyWith(fontWeight: FontWeight.w600)),
The data model, in Nim d333b6f nandi 15h ago367 );
368
369 case 'label':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago370 return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg));
The data model, in Nim d333b6f nandi 15h ago371
372 case 'dim-label':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago373 return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim));
374
375 /// Prose, as opposed to a label: this is what a message is, and it
376 /// wraps. Kept apart from `label` because a wrapping label in a row
377 /// lays out against the row's width rather than the column's.
378 case 'text':
379 return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));
380
381 case 'link':
382 return _wrapTap(
383 n.prop('onClick', ''),
384 Text(
385 n.prop('label', ''),
386 style: _style(t.textBody, t.accent)
387 .copyWith(decoration: TextDecoration.underline,
388 decorationColor: t.accent),
389 ),
390 );
391
392 case 'separator':
393 return const Divider(height: 1, thickness: 1, color: t.divider);
394
395 case 'spacer':
396 {
397 final s = _d(n.props['size'], t.spaceXxs);
398 return SizedBox(width: s, height: s);
399 }
The data model, in Nim d333b6f nandi 15h ago400
401 case 'spinner':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago402 return Row(
403 mainAxisSize: MainAxisSize.min,
404 children: [
405 const SizedBox(
406 width: 16,
407 height: 16,
408 child: CircularProgressIndicator(
409 strokeWidth: 2, color: t.accent)),
410 if (n.prop('label', '').isNotEmpty) ...[
411 const SizedBox(width: t.spaceXxs),
412 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
413 ],
414 ],
415 );
416
417 /// A dot that says whether the thing is live, and the words beside it.
418 case 'status':
419 return Row(
420 mainAxisSize: MainAxisSize.min,
421 children: [
422 Container(
423 width: 8,
424 height: 8,
425 decoration: BoxDecoration(
426 color: n.prop('live', false) ? t.success : t.dim,
427 borderRadius: BorderRadius.circular(t.radiusXs),
428 ),
429 ),
430 const SizedBox(width: 6),
431 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
432 ],
433 );
The data model, in Nim d333b6f nandi 15h ago434
435 case 'button':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago436 {
437 final onClick = n.prop('onClick', '');
438 final kind = n.prop('kind', 'default');
439 final label = Text(n.prop('label', ''));
440 if (kind == 'primary') {
441 return FilledButton(
442 onPressed: () => _send(onClick), child: label);
443 }
A face that opens someone 9bb81a1 nandi 5h ago444 // A sender's name: a way in to who someone is, but it sits in the
445 // middle of a line and must not look like a control. Text that
446 // takes a press, with no chrome at all.
447 if (kind == 'plain') {
448 return InkWell(
449 onTap: () => _send(onClick),
450 child: Text(n.prop('label', ''),
451 style: _style(t.textBody, t.onBg)),
452 );
453 }
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago454 if (kind == 'destructive') {
455 return FilledButton(
456 style: FilledButton.styleFrom(
457 backgroundColor: t.destructive,
458 foregroundColor: t.onDestructive),
459 onPressed: () => _send(onClick),
460 child: label,
461 );
462 }
463 return OutlinedButton(onPressed: () => _send(onClick), child: label);
464 }
The data model, in Nim d333b6f nandi 15h ago465
466 case 'checkbutton':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago467 {
468 // The label is part of the target. 20 logical pixels is a fine tick
469 // on a desktop pointer and a miss on a thumb, so the whole row taps.
470 final onToggled = n.prop('onToggled', '');
471 return InkWell(
472 onTap: () => _send(onToggled),
473 child: Row(
474 mainAxisSize: MainAxisSize.min,
475 children: [
476 Checkbox(
477 value: n.prop('active', false),
478 onChanged: (_) => _send(onToggled),
479 ),
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 12h ago480 // Flexible, because the label is prose and the row is as wide
481 // as the window: "Hide join/part messages" beside a checkbox
482 // overflows a phone otherwise.
483 Flexible(
484 child: Text(n.prop('label', ''),
485 style: _style(t.textBody, t.onBg)),
486 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago487 ],
488 ),
489 );
490 }
491
492 case 'emoji':
493 return _wrapTap(
494 n.prop('onClick', ''),
495 Text(n.prop('glyph', n.prop('emoji', '')),
The pencil was drawn by a text font 1cb40af nandi 3h ago496 style: _emojiStyle(_d(n.props['size'], 16))),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago497 );
498
499 /// A reaction pill: the glyph, and the tally beside it where there is
500 /// one to show. The same shape whether it is a reaction under a message,
501 /// a swatch in the picker, or a chip on the sender's row — which is the
502 /// point: what you press to react and what appears once you have should
503 /// look like one family.
504 ///
505 /// A count of zero is no count. The picker passes 0 for every swatch,
506 /// and a grid of little grey zeroes is noise where a reader is scanning
507 /// for a face. `mine` is the accent, because the only thing a pill has
508 /// to say at a glance is whether pressing it again takes yours off.
509 case 'reaction':
510 {
511 final size = _d(n.props['size'], 14);
512 final count = n.prop('count', 0);
513 final mine = n.prop('mine', false);
514 final pad = (0.25 * size).clamp(2.0, 8.0);
515 return _wrapTap(
516 n.prop('onClick', ''),
517 Container(
518 padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2),
519 decoration: BoxDecoration(
520 color: mine ? t.accent : t.component,
521 borderRadius: BorderRadius.circular(t.radiusS),
522 ),
523 child: Row(
524 mainAxisSize: MainAxisSize.min,
525 children: [
The pencil was drawn by a text font 1cb40af nandi 3h ago526 Text(n.prop('emoji', ''), style: _emojiStyle(size)),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago527 if (count > 0) ...[
528 const SizedBox(width: 4),
529 Text('$count',
530 style: _style(t.textCaption,
531 mine ? t.onAccent : t.dim)),
532 ],
533 ],
534 ),
535 ),
536 );
537 }
538
539 /// A face is a way in to who someone is, so it takes the press that
540 /// opens their profile. A picture that will not load is a face that
541 /// stays its initial and nothing else.
542 case 'avatar':
543 {
544 final size = _d(n.props['size'], 32);
545 final provider = _imageProvider(n.prop('url', ''));
546 final fallback = n.prop('fallback', '');
547 final face = CircleAvatar(
548 radius: size / 2,
549 backgroundColor: t.component,
550 backgroundImage: provider,
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago551 onBackgroundImageError: provider == null ? null : (_, _) {},
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago552 child: provider == null
553 ? Text(
554 fallback.isNotEmpty
555 ? fallback.substring(0, 1).toUpperCase()
556 : '?',
557 style: _style(t.textBody, t.onBg))
558 : null,
559 );
560 final onClick = n.prop('onClick', '');
561 if (onClick.isEmpty) return face;
562 return InkWell(
563 onTap: () => _send(onClick),
564 customBorder: const CircleBorder(),
565 child: face,
566 );
567 }
568
569 case 'image':
570 {
571 final provider = _imageProvider(n.prop('src', ''));
572 if (provider == null) return const SizedBox.shrink();
573 final maxW = _d(n.props['maxWidth'], 0);
574 final maxH = _d(n.props['maxHeight'], 0);
575 Widget img = Image(
576 image: provider,
577 fit: BoxFit.contain,
578 // A half-written cache file, or one deleted under us: the decoder
579 // throws during the build, and an exception in a build is a red
580 // screen for the whole conversation rather than a gap where one
581 // picture was.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago582 errorBuilder: (_, _, _) => const SizedBox.shrink(),
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago583 );
584 if (maxW > 0 || maxH > 0) {
585 img = ConstrainedBox(
586 constraints: BoxConstraints(
587 maxWidth: maxW > 0 ? maxW : double.infinity,
588 maxHeight: maxH > 0 ? maxH : double.infinity,
589 ),
590 child: img,
591 );
592 }
593 return _wrapTap(n.prop('onClick', ''), img);
594 }
The data model, in Nim d333b6f nandi 15h ago595
596 case 'entry':
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago597 {
598 final key = n.prop('key', '');
599 final value = n.prop('text', '');
600 final c = _controllers.putIfAbsent(
601 key, () => TextEditingController(text: value));
602 // Only when it actually differs: assigning unconditionally moves the
603 // caret to the end on every keystroke.
604 if (c.text != value) {
605 c.value = c.value.copyWith(
606 text: value,
607 selection: TextSelection.collapsed(offset: value.length),
608 );
609 }
610 final field = TextField(
611 controller: c,
612 focusNode: _focus.putIfAbsent(key, FocusNode.new),
613 style: _style(t.textBody, t.onBg),
614 decoration: InputDecoration(
615 hintText: n.prop('placeholder', ''),
616 hintStyle: _style(t.textBody, t.dim),
617 isDense: true,
618 filled: true,
619 fillColor: t.component,
620 border: OutlineInputBorder(
621 borderRadius: BorderRadius.circular(t.radiusS),
622 borderSide: BorderSide.none,
623 ),
624 ),
625 onChanged: (v) => _send(n.prop('onChange', ''), v),
626 onSubmitted: (_) => _send(n.prop('onSubmit', '')),
627 );
628 final w = _d(n.props['widthRequest'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi 13h ago629 if (w > 0) return SizedBox(width: w, child: field);
630 // No width asked for: take the rest of the row where there is a row
631 // to take it from, and otherwise a definite width. NOT Expanded
632 // unconditionally — a TextField has no intrinsic width, so in a Wrap
633 // it is both illegal and unmeasurable, and that combination is what
634 // took the whole screen down rather than one field.
635 return axis == _row
636 ? Expanded(child: field)
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago637 : SizedBox(width: _unsizedEntry, child: field);
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago638 }
639
640 case 'scroll':
641 {
Both ends of a scroll, on the same controller 5594a62 nandi 3h ago642 // Both ends of the same scroll, named so they are the same one.
643 final c = _scrollers.putIfAbsent(
644 n.prop('scrollKey', 'scroll'), ScrollController.new);
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago645 Widget body = SingleChildScrollView(
Both ends of a scroll, on the same controller 5594a62 nandi 3h ago646 controller: c,
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago647 // The backlog reads from the bottom; a settings list from the top.
648 reverse: n.prop('stickToBottom', false),
649 child: Column(
650 crossAxisAlignment: CrossAxisAlignment.start,
651 children: _spaced(kids, spacing, vertical: true)),
The data model, in Nim d333b6f nandi 15h ago652 );
Both ends of a scroll, on the same controller 5594a62 nandi 3h ago653 body = Scrollbar(controller: c, child: body);
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago654 // A scroll takes what the column has left. Outside a Flex there is
655 // nothing to take, and the tree is malformed — `_strandedScroll` is
656 // a visible size rather than a correct one, so the layout tests see
657 // a screen instead of an exception.
658 return flex
659 ? Expanded(child: body)
660 : const SizedBox(height: _strandedScroll);
The data model, in Nim d333b6f nandi 15h ago661 }
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago662
663 /// A panel over the screen rather than a screen of its own.
664 case 'dialog':
665 return Card(
666 color: t.cardComponent,
667 child: Padding(
668 padding: const EdgeInsets.all(t.spaceS),
669 child: Column(
670 mainAxisSize: MainAxisSize.min,
671 crossAxisAlignment: CrossAxisAlignment.start,
672 children: [
673 if (n.prop('title', '').isNotEmpty)
674 Padding(
675 padding: const EdgeInsets.only(bottom: t.spaceXxs),
676 child: Text(n.prop('title', ''),
677 style: _style(t.textTitle4, t.onCard)
678 .copyWith(fontWeight: FontWeight.w600)),
679 ),
680 ..._spaced(kids, spacing, vertical: true),
681 ],
682 ),
The data model, in Nim d333b6f nandi 15h ago683 ),
684 );
685
686 default:
687 // An unknown tag paints as itself rather than crashing or vanishing.
688 // 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 14h ago689 // is what makes the boundary pleasant to work across.
The data model, in Nim d333b6f nandi 15h ago690 return Container(
691 padding: const EdgeInsets.all(4),
692 color: Colors.orange.withValues(alpha: 0.3),
693 child: Text('?${n.tag}'),
694 );
695 }
696 }
697
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 12h ago698 /// One node of an inline paragraph, as a span.
699 ///
700 /// Only `text` and `link` appear here — they are the only things `runNodes`
701 /// emits — and anything else falls back to its plain text so an unexpected
702 /// tag degrades to something readable rather than vanishing.
703 InlineSpan _span(core.UiNode n) {
704 switch (n.tag) {
705 case 'link':
706 final url = n.prop('url', n.prop('label', ''));
707 final onClick = n.prop('onClick', '');
708 return TextSpan(
709 text: n.prop('label', ''),
710 style: _style(t.textBody, t.accent)
711 .copyWith(decoration: TextDecoration.underline,
712 decorationColor: t.accent),
713 recognizer: onClick.isEmpty
714 ? null
715 : (_linkTaps[url] ??= TapGestureRecognizer()
716 ..onTap = () => _send(onClick)),
717 );
718 case 'text':
719 return TextSpan(
720 text: n.prop('text', ''), style: _style(t.textBody, t.onBg));
721 default:
722 return TextSpan(
723 text: n.prop('label', n.prop('text', '')),
724 style: _style(t.textBody, t.onBg));
725 }
726 }
727
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 12h ago728 /// `margin` and its four sides — the props the
The renderer, and a transport bug that took four tries 58e6c97 nandi 14h ago729 /// screens use to buy air without a wrapper each time.
730 Widget _margins(core.UiNode n, Widget child) {
731 final all = _d(n.props['margin'], 0);
732 final top = _d(n.props['marginTop'], all);
733 final bottom = _d(n.props['marginBottom'], all);
734 final right = _d(n.props['marginRight'], all);
735 final left = _d(n.props['marginLeft'], all);
736 if (top == 0 && bottom == 0 && right == 0 && left == 0) return child;
737 return Padding(
738 padding: EdgeInsets.only(
739 top: top, bottom: bottom, right: right, left: left),
740 child: child,
741 );
The data model, in Nim d333b6f nandi 15h ago742 }
743}