nandi/frqpublic Fork 0
32aed8bf4abecda2b10c8997d0b5bf2e6567b70a
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 · 655 lines · 24.3 KBDart Blame HistoryRaw
The data model, in Nim d333b6f nandi 19h ago1/// The renderer: a Nim widget tree, walked into Flutter widgets.
2///
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h 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 19h ago6///
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h 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 19h ago13import 'dart:async';
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago14import 'dart:io';
The data model, in Nim d333b6f nandi 19h ago15
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago16import 'package:flutter/gestures.dart';
17
The data model, in Nim d333b6f nandi 19h 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 18h ago21import 'nim_theme.dart' as t;
22
The data model, in Nim d333b6f nandi 19h ago23class NimApp extends StatefulWidget {
24 const NimApp({super.key});
25 @override
26 State<NimApp> createState() => _NimAppState();
27}
28
29class _NimAppState extends State<NimApp> {
30 late core.UiNode _tree = core.render();
31 Timer? _poll;
32
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago33 // One controller and one focus node per keyed entry, kept across rebuilds.
34 //
35 // This is why `:key` is on every entry in both the Clojure and the Nim: a
36 // controller identified by position instead of name meant the host field and
37 // the port field shared one and both showed the port. The focus node is the
38 // same bug one layer up — the field is rebuilt from a fresh tree on every
39 // keystroke, so without a node held per key the caret goes nowhere after the
40 // first line.
41 final _controllers = <String, TextEditingController>{};
42 final _focus = <String, FocusNode>{};
43
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago44 // One tap recogniser per link URL, kept across rebuilds and disposed with
45 // the state. A recogniser made during build and dropped on the next frame
46 // leaks, and this tree is rebuilt on every keystroke.
47 final _linkTaps = <String, TapGestureRecognizer>{};
48
The data model, in Nim d333b6f nandi 19h ago49 @override
50 void initState() {
51 super.initState();
52 // 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 18h ago53 // callback into Dart. At ~70µs a render a 100ms timer costs nothing.
The data model, in Nim d333b6f nandi 19h ago54 _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago55 final next = core.poll();
56 if (next.toString() != _tree.toString()) {
57 setState(() => _tree = next);
The data model, in Nim d333b6f nandi 19h ago58 }
59 });
60 }
61
62 void _send(String id, [String value = '']) {
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago63 if (id.isEmpty) return;
The data model, in Nim d333b6f nandi 19h ago64 setState(() => _tree = core.dispatch(id, value));
65 if (id == 'send') _focus['draft']?.requestFocus();
66 }
67
68 @override
69 void dispose() {
70 _poll?.cancel();
71 for (final c in _controllers.values) {
72 c.dispose();
73 }
74 for (final f in _focus.values) {
75 f.dispose();
76 }
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago77 for (final r in _linkTaps.values) {
78 r.dispose();
79 }
The data model, in Nim d333b6f nandi 19h ago80 super.dispose();
81 }
82
83 @override
84 Widget build(BuildContext context) => MaterialApp(
85 title: 'frq',
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago86 debugShowCheckedModeBanner: false,
87 theme: ThemeData(
88 useMaterial3: true,
89 brightness: Brightness.dark,
90 scaffoldBackgroundColor: t.bg,
91 colorScheme: const ColorScheme.dark(
92 primary: t.accent,
93 onPrimary: t.onAccent,
94 surface: t.bg,
95 onSurface: t.onBg,
96 error: t.destructive,
97 ),
The data model, in Nim d333b6f nandi 19h ago98 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago99 home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))),
The data model, in Nim d333b6f nandi 19h ago100 );
101
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago102 // ---------------------------------------------------------------- helpers
103
104 TextStyle _style(double size, Color color) =>
105 TextStyle(fontSize: size, color: color, height: 1.35);
106
107 double _d(dynamic v, double fallback) =>
108 v is num ? v.toDouble() : fallback;
109
110 /// Gaps between children, as real widgets rather than a `spacing:` — the
111 /// same layout on every Flutter version this might be built against.
112 List<Widget> _spaced(List<Widget> kids, double gap, {required bool vertical}) {
113 if (gap <= 0 || kids.length < 2) return kids;
114 final out = <Widget>[];
115 for (var i = 0; i < kids.length; i++) {
116 if (i > 0) {
117 out.add(vertical ? SizedBox(height: gap) : SizedBox(width: gap));
118 }
119 out.add(kids[i]);
120 }
121 return out;
122 }
123
124 /// A source that may be a bundled asset, a file on disk, or a URL — the
125 /// three the screens hand over, named apart by an `asset:` prefix so they
126 /// stay one property.
127 ImageProvider? _imageProvider(String src) {
128 if (src.isEmpty) return null;
129 if (src.startsWith('asset:')) return AssetImage(src.substring(6));
130 if (src.startsWith('http://') || src.startsWith('https://')) {
131 return NetworkImage(src);
132 }
133 return FileImage(File(src));
134 }
135
136 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {
137 if (onClick.isEmpty) return child;
138 return InkWell(
139 onTap: () => _send(onClick),
140 borderRadius: radius,
141 child: child,
142 );
143 }
144
145 // ------------------------------------------------------------------ build
146
Expanded only where a Flex can hold it f1e99b4 nandi 16h ago147 /// The axis of the widget a node is being built *into*, because `Expanded`
148 /// is only legal inside a Flex and there is no way to ask Flutter after the
149 /// fact.
150 ///
151 /// Getting this wrong is what "Cannot hit test a render box that has never
152 /// been laid out" means, in a pile: an `Expanded` inside a `Wrap` fails the
153 /// layout, and every box under it is then asked to hit-test without ever
154 /// having been laid out. The chats screen did exactly that — two unsized
155 /// entries in an `hbox`, which is a Wrap.
156 static const _noAxis = '';
157 static const _row = 'row';
158 static const _column = 'column';
159
160 Widget _build(core.UiNode n, [String axis = _noAxis]) {
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago161 final spacing = _d(n.props['spacing'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi 16h ago162 final flex = axis == _row || axis == _column;
163
164 // What this node's own children are being built into.
165 final childAxis = switch (n.tag) {
166 'page' || 'vbox' || 'card' || 'scroll' || 'dialog' => _column,
167 'hbox' => n.prop('wrap', true) ? _noAxis : _row,
168 _ => _noAxis,
169 };
170 final kids = n.children.map((c) => _build(c, childAxis)).toList();
The data model, in Nim d333b6f nandi 19h ago171
172 switch (n.tag) {
173 case 'page':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago174 return SingleChildScrollView(
175 child: Center(
176 child: ConstrainedBox(
177 constraints:
178 BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)),
179 child: Padding(
180 padding: const EdgeInsets.all(t.spaceM),
181 child: Column(
182 crossAxisAlignment: CrossAxisAlignment.start,
183 children: _spaced(kids, spacing, vertical: true)),
184 ),
The data model, in Nim d333b6f nandi 19h ago185 ),
186 ),
187 );
188
189 case 'vbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago190 {
191 Widget col = Column(
192 crossAxisAlignment: CrossAxisAlignment.start,
193 mainAxisSize: MainAxisSize.min,
194 children: _spaced(kids, spacing, vertical: true),
195 );
196 col = _margins(n, col);
197 final w = _d(n.props['widthRequest'], 0);
198 if (w > 0) col = SizedBox(width: w, child: col);
199 // `fillHeight` is what keeps the compose bar at the bottom instead
Expanded only where a Flex can hold it f1e99b4 nandi 16h ago200 // of wherever the backlog happens to end — but only a Flex can be
201 // told to expand into.
202 return (n.prop('fillHeight', false) && flex)
203 ? Expanded(child: col)
204 : col;
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago205 }
The data model, in Nim d333b6f nandi 19h ago206
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago207 // A paragraph: the words and the links of one message, wrapping as text
208 // rather than as boxes.
209 //
210 // NOT a Wrap, which is what this was. Children of a Wrap are given
211 // unbounded width, so a long URL or a long word can never wrap — it
212 // overflows, the layout fails, and every box under it is then hit-tested
213 // having never been laid out. That is the pile of "Cannot hit test"
214 // errors the chat screen produced. Spans in one RichText wrap the way
215 // the Clojure's `:inline` row always meant.
216 case 'hbox' when n.prop('inline', false):
217 return Text.rich(
218 TextSpan(children: n.children.map(_span).toList()),
219 softWrap: true,
220 );
221
The data model, in Nim d333b6f nandi 19h ago222 case 'hbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago223 {
224 // Wrap and not Row: `:hbox` in the screens means "these go together
225 // across", not "these fit". The head row of the chat screen asks for
226 // more than 360 points has, and a Row answers that with an overflow
227 // rather than a second line.
228 final wrapping = n.prop('wrap', true);
229 final align = n.prop('align', 'center');
230 if (!wrapping) {
Expanded only where a Flex can hold it f1e99b4 nandi 16h ago231 // A child asking to fill the height gets it from the row's cross
232 // axis, not from an Expanded — Expanded in a Row is about width.
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago233 //
234 // But stretch needs a bounded height to stretch to, and a Row in a
235 // Column has none of its own: it is as tall as its tallest child.
236 // So the row that holds a filling child has to take the column's
237 // remaining height itself, or the stretch resolves to infinity and
238 // the assertion reads `BoxConstraints forces an infinite height`.
Expanded only where a Flex can hold it f1e99b4 nandi 16h ago239 final stretches =
240 n.children.any((c) => c.prop('fillHeight', false));
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago241 final row = Row(
242 crossAxisAlignment: stretches
243 ? CrossAxisAlignment.stretch
244 : (align == 'end'
245 ? CrossAxisAlignment.end
246 : CrossAxisAlignment.center),
247 children: _spaced(kids, spacing, vertical: false),
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago248 );
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago249 if (stretches && axis == _column) {
250 return Expanded(child: _margins(n, row));
251 }
252 return _margins(n, row);
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago253 }
254 return _margins(
255 n,
256 Wrap(
257 spacing: spacing,
258 runSpacing: spacing,
259 crossAxisAlignment: align == 'end'
260 ? WrapCrossAlignment.end
261 : WrapCrossAlignment.center,
262 children: kids,
The data model, in Nim d333b6f nandi 19h ago263 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago264 );
265 }
The data model, in Nim d333b6f nandi 19h ago266
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago267 // Container::Card in the Clojure: padding 12, fills its width.
The data model, in Nim d333b6f nandi 19h ago268 case 'card':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago269 return Container(
270 width: double.infinity,
271 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
272 padding: const EdgeInsets.all(t.spaceXs),
273 decoration: BoxDecoration(
274 color: t.card,
275 borderRadius: BorderRadius.circular(t.radiusS),
The data model, in Nim d333b6f nandi 19h ago276 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago277 child: Column(
278 crossAxisAlignment: CrossAxisAlignment.start,
279 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
280 vertical: true)),
The data model, in Nim d333b6f nandi 19h ago281 );
282
283 case 'title':
284 return Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago285 style: _style(t.textTitle3, t.onBg)
286 .copyWith(fontWeight: FontWeight.bold));
The data model, in Nim d333b6f nandi 19h ago287
288 case 'title-2':
289 return Padding(
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago290 padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs),
The data model, in Nim d333b6f nandi 19h ago291 child: Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago292 style: _style(t.textTitle4, t.onBg)
293 .copyWith(fontWeight: FontWeight.w600)),
The data model, in Nim d333b6f nandi 19h ago294 );
295
296 case 'label':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago297 return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg));
The data model, in Nim d333b6f nandi 19h ago298
299 case 'dim-label':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago300 return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim));
301
302 /// Prose, as opposed to a label: this is what a message is, and it
303 /// wraps. Kept apart from `label` because a wrapping label in a row
304 /// lays out against the row's width rather than the column's.
305 case 'text':
306 return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));
307
308 case 'link':
309 return _wrapTap(
310 n.prop('onClick', ''),
311 Text(
312 n.prop('label', ''),
313 style: _style(t.textBody, t.accent)
314 .copyWith(decoration: TextDecoration.underline,
315 decorationColor: t.accent),
316 ),
317 );
318
319 case 'separator':
320 return const Divider(height: 1, thickness: 1, color: t.divider);
321
322 case 'spacer':
323 {
324 final s = _d(n.props['size'], t.spaceXxs);
325 return SizedBox(width: s, height: s);
326 }
The data model, in Nim d333b6f nandi 19h ago327
328 case 'spinner':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago329 return Row(
330 mainAxisSize: MainAxisSize.min,
331 children: [
332 const SizedBox(
333 width: 16,
334 height: 16,
335 child: CircularProgressIndicator(
336 strokeWidth: 2, color: t.accent)),
337 if (n.prop('label', '').isNotEmpty) ...[
338 const SizedBox(width: t.spaceXxs),
339 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
340 ],
341 ],
342 );
343
344 /// A dot that says whether the thing is live, and the words beside it.
345 case 'status':
346 return Row(
347 mainAxisSize: MainAxisSize.min,
348 children: [
349 Container(
350 width: 8,
351 height: 8,
352 decoration: BoxDecoration(
353 color: n.prop('live', false) ? t.success : t.dim,
354 borderRadius: BorderRadius.circular(t.radiusXs),
355 ),
356 ),
357 const SizedBox(width: 6),
358 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
359 ],
360 );
The data model, in Nim d333b6f nandi 19h ago361
362 case 'button':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago363 {
364 final onClick = n.prop('onClick', '');
365 final kind = n.prop('kind', 'default');
366 final label = Text(n.prop('label', ''));
367 if (kind == 'primary') {
368 return FilledButton(
369 onPressed: () => _send(onClick), child: label);
370 }
371 if (kind == 'destructive') {
372 return FilledButton(
373 style: FilledButton.styleFrom(
374 backgroundColor: t.destructive,
375 foregroundColor: t.onDestructive),
376 onPressed: () => _send(onClick),
377 child: label,
378 );
379 }
380 return OutlinedButton(onPressed: () => _send(onClick), child: label);
381 }
The data model, in Nim d333b6f nandi 19h ago382
383 case 'checkbutton':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago384 {
385 // The label is part of the target. 20 logical pixels is a fine tick
386 // on a desktop pointer and a miss on a thumb, so the whole row taps.
387 final onToggled = n.prop('onToggled', '');
388 return InkWell(
389 onTap: () => _send(onToggled),
390 child: Row(
391 mainAxisSize: MainAxisSize.min,
392 children: [
393 Checkbox(
394 value: n.prop('active', false),
395 onChanged: (_) => _send(onToggled),
396 ),
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago397 // Flexible, because the label is prose and the row is as wide
398 // as the window: "Hide join/part messages" beside a checkbox
399 // overflows a phone otherwise.
400 Flexible(
401 child: Text(n.prop('label', ''),
402 style: _style(t.textBody, t.onBg)),
403 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago404 ],
405 ),
406 );
407 }
408
409 case 'emoji':
410 return _wrapTap(
411 n.prop('onClick', ''),
412 Text(n.prop('glyph', n.prop('emoji', '')),
413 style: TextStyle(fontSize: _d(n.props['size'], 16))),
414 );
415
416 /// A reaction pill: the glyph, and the tally beside it where there is
417 /// one to show. The same shape whether it is a reaction under a message,
418 /// a swatch in the picker, or a chip on the sender's row — which is the
419 /// point: what you press to react and what appears once you have should
420 /// look like one family.
421 ///
422 /// A count of zero is no count. The picker passes 0 for every swatch,
423 /// and a grid of little grey zeroes is noise where a reader is scanning
424 /// for a face. `mine` is the accent, because the only thing a pill has
425 /// to say at a glance is whether pressing it again takes yours off.
426 case 'reaction':
427 {
428 final size = _d(n.props['size'], 14);
429 final count = n.prop('count', 0);
430 final mine = n.prop('mine', false);
431 final pad = (0.25 * size).clamp(2.0, 8.0);
432 return _wrapTap(
433 n.prop('onClick', ''),
434 Container(
435 padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2),
436 decoration: BoxDecoration(
437 color: mine ? t.accent : t.component,
438 borderRadius: BorderRadius.circular(t.radiusS),
439 ),
440 child: Row(
441 mainAxisSize: MainAxisSize.min,
442 children: [
443 Text(n.prop('emoji', ''), style: TextStyle(fontSize: size)),
444 if (count > 0) ...[
445 const SizedBox(width: 4),
446 Text('$count',
447 style: _style(t.textCaption,
448 mine ? t.onAccent : t.dim)),
449 ],
450 ],
451 ),
452 ),
453 );
454 }
455
456 /// A face is a way in to who someone is, so it takes the press that
457 /// opens their profile. A picture that will not load is a face that
458 /// stays its initial and nothing else.
459 case 'avatar':
460 {
461 final size = _d(n.props['size'], 32);
462 final provider = _imageProvider(n.prop('url', ''));
463 final fallback = n.prop('fallback', '');
464 final face = CircleAvatar(
465 radius: size / 2,
466 backgroundColor: t.component,
467 backgroundImage: provider,
468 onBackgroundImageError: provider == null ? null : (_, __) {},
469 child: provider == null
470 ? Text(
471 fallback.isNotEmpty
472 ? fallback.substring(0, 1).toUpperCase()
473 : '?',
474 style: _style(t.textBody, t.onBg))
475 : null,
476 );
477 final onClick = n.prop('onClick', '');
478 if (onClick.isEmpty) return face;
479 return InkWell(
480 onTap: () => _send(onClick),
481 customBorder: const CircleBorder(),
482 child: face,
483 );
484 }
485
486 case 'image':
487 {
488 final provider = _imageProvider(n.prop('src', ''));
489 if (provider == null) return const SizedBox.shrink();
490 final maxW = _d(n.props['maxWidth'], 0);
491 final maxH = _d(n.props['maxHeight'], 0);
492 Widget img = Image(
493 image: provider,
494 fit: BoxFit.contain,
495 // A half-written cache file, or one deleted under us: the decoder
496 // throws during the build, and an exception in a build is a red
497 // screen for the whole conversation rather than a gap where one
498 // picture was.
499 errorBuilder: (_, __, ___) => const SizedBox.shrink(),
500 );
501 if (maxW > 0 || maxH > 0) {
502 img = ConstrainedBox(
503 constraints: BoxConstraints(
504 maxWidth: maxW > 0 ? maxW : double.infinity,
505 maxHeight: maxH > 0 ? maxH : double.infinity,
506 ),
507 child: img,
508 );
509 }
510 return _wrapTap(n.prop('onClick', ''), img);
511 }
The data model, in Nim d333b6f nandi 19h ago512
513 case 'entry':
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago514 {
515 final key = n.prop('key', '');
516 final value = n.prop('text', '');
517 final c = _controllers.putIfAbsent(
518 key, () => TextEditingController(text: value));
519 // Only when it actually differs: assigning unconditionally moves the
520 // caret to the end on every keystroke.
521 if (c.text != value) {
522 c.value = c.value.copyWith(
523 text: value,
524 selection: TextSelection.collapsed(offset: value.length),
525 );
526 }
527 final field = TextField(
528 controller: c,
529 focusNode: _focus.putIfAbsent(key, FocusNode.new),
530 style: _style(t.textBody, t.onBg),
531 decoration: InputDecoration(
532 hintText: n.prop('placeholder', ''),
533 hintStyle: _style(t.textBody, t.dim),
534 isDense: true,
535 filled: true,
536 fillColor: t.component,
537 border: OutlineInputBorder(
538 borderRadius: BorderRadius.circular(t.radiusS),
539 borderSide: BorderSide.none,
540 ),
541 ),
542 onChanged: (v) => _send(n.prop('onChange', ''), v),
543 onSubmitted: (_) => _send(n.prop('onSubmit', '')),
544 );
545 final w = _d(n.props['widthRequest'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi 16h ago546 if (w > 0) return SizedBox(width: w, child: field);
547 // No width asked for: take the rest of the row where there is a row
548 // to take it from, and otherwise a definite width. NOT Expanded
549 // unconditionally — a TextField has no intrinsic width, so in a Wrap
550 // it is both illegal and unmeasurable, and that combination is what
551 // took the whole screen down rather than one field.
552 return axis == _row
553 ? Expanded(child: field)
554 : SizedBox(width: 320, child: field);
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago555 }
556
557 case 'scroll':
558 {
559 Widget body = SingleChildScrollView(
560 // The backlog reads from the bottom; a settings list from the top.
561 reverse: n.prop('stickToBottom', false),
562 child: Column(
563 crossAxisAlignment: CrossAxisAlignment.start,
564 children: _spaced(kids, spacing, vertical: true)),
The data model, in Nim d333b6f nandi 19h ago565 );
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago566 body = Scrollbar(child: body);
567 final h = _d(n.props['height'], 0);
568 if (h > 0) return SizedBox(height: h, child: body);
Expanded only where a Flex can hold it f1e99b4 nandi 16h ago569 // No fixed height: take what the column has left, where there is a
570 // column. `reserve` is the Clojure's way of saying the same thing to
571 // a backend that could not do this, and is ignored here on purpose.
572 return flex ? Expanded(child: body) : SizedBox(height: 400, child: body);
The data model, in Nim d333b6f nandi 19h ago573 }
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago574
575 /// A panel over the screen rather than a screen of its own.
576 case 'dialog':
577 return Card(
578 color: t.cardComponent,
579 child: Padding(
580 padding: const EdgeInsets.all(t.spaceS),
581 child: Column(
582 mainAxisSize: MainAxisSize.min,
583 crossAxisAlignment: CrossAxisAlignment.start,
584 children: [
585 if (n.prop('title', '').isNotEmpty)
586 Padding(
587 padding: const EdgeInsets.only(bottom: t.spaceXxs),
588 child: Text(n.prop('title', ''),
589 style: _style(t.textTitle4, t.onCard)
590 .copyWith(fontWeight: FontWeight.w600)),
591 ),
592 ..._spaced(kids, spacing, vertical: true),
593 ],
594 ),
The data model, in Nim d333b6f nandi 19h ago595 ),
596 );
597
598 default:
599 // An unknown tag paints as itself rather than crashing or vanishing.
600 // 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 18h ago601 // is what makes the boundary pleasant to work across.
The data model, in Nim d333b6f nandi 19h ago602 return Container(
603 padding: const EdgeInsets.all(4),
604 color: Colors.orange.withValues(alpha: 0.3),
605 child: Text('?${n.tag}'),
606 );
607 }
608 }
609
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago610 /// One node of an inline paragraph, as a span.
611 ///
612 /// Only `text` and `link` appear here — they are the only things `runNodes`
613 /// emits — and anything else falls back to its plain text so an unexpected
614 /// tag degrades to something readable rather than vanishing.
615 InlineSpan _span(core.UiNode n) {
616 switch (n.tag) {
617 case 'link':
618 final url = n.prop('url', n.prop('label', ''));
619 final onClick = n.prop('onClick', '');
620 return TextSpan(
621 text: n.prop('label', ''),
622 style: _style(t.textBody, t.accent)
623 .copyWith(decoration: TextDecoration.underline,
624 decorationColor: t.accent),
625 recognizer: onClick.isEmpty
626 ? null
627 : (_linkTaps[url] ??= TapGestureRecognizer()
628 ..onTap = () => _send(onClick)),
629 );
630 case 'text':
631 return TextSpan(
632 text: n.prop('text', ''), style: _style(t.textBody, t.onBg));
633 default:
634 return TextSpan(
635 text: n.prop('label', n.prop('text', '')),
636 style: _style(t.textBody, t.onBg));
637 }
638 }
639
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago640 /// `margin`, `marginTop`, `marginBottom`, `marginRight` — the props the
641 /// screens use to buy air without a wrapper each time.
642 Widget _margins(core.UiNode n, Widget child) {
643 final all = _d(n.props['margin'], 0);
644 final top = _d(n.props['marginTop'], all);
645 final bottom = _d(n.props['marginBottom'], all);
646 final right = _d(n.props['marginRight'], all);
647 final left = _d(n.props['marginLeft'], all);
648 if (top == 0 && bottom == 0 && right == 0 && left == 0) return child;
649 return Padding(
650 padding: EdgeInsets.only(
651 top: top, bottom: bottom, right: right, left: left),
652 child: child,
653 );
The data model, in Nim d333b6f nandi 19h ago654 }
655}