nandi/frqpublic Fork 0
e505dedb6ac7169a867e5e9f7732e997d8a003f9
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 · 552 lines · 19.6 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 17h 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 17h 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 17h ago14import 'dart:io';
The data model, in Nim d333b6f nandi 19h ago15
16import 'package:flutter/material.dart';
17import 'package:frq_core/frq_core.dart' as core;
18
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago19import 'nim_theme.dart' as t;
20
The data model, in Nim d333b6f nandi 19h ago21class NimApp extends StatefulWidget {
22 const NimApp({super.key});
23 @override
24 State<NimApp> createState() => _NimAppState();
25}
26
27class _NimAppState extends State<NimApp> {
28 late core.UiNode _tree = core.render();
29 Timer? _poll;
30
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago31 // One controller and one focus node per keyed entry, kept across rebuilds.
32 //
33 // This is why `:key` is on every entry in both the Clojure and the Nim: a
34 // controller identified by position instead of name meant the host field and
35 // the port field shared one and both showed the port. The focus node is the
36 // same bug one layer up — the field is rebuilt from a fresh tree on every
37 // keystroke, so without a node held per key the caret goes nowhere after the
38 // first line.
39 final _controllers = <String, TextEditingController>{};
40 final _focus = <String, FocusNode>{};
41
The data model, in Nim d333b6f nandi 19h ago42 @override
43 void initState() {
44 super.initState();
45 // 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 17h ago46 // callback into Dart. At ~70µs a render a 100ms timer costs nothing.
The data model, in Nim d333b6f nandi 19h ago47 _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago48 final next = core.poll();
49 if (next.toString() != _tree.toString()) {
50 setState(() => _tree = next);
The data model, in Nim d333b6f nandi 19h ago51 }
52 });
53 }
54
55 void _send(String id, [String value = '']) {
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago56 if (id.isEmpty) return;
The data model, in Nim d333b6f nandi 19h ago57 setState(() => _tree = core.dispatch(id, value));
58 if (id == 'send') _focus['draft']?.requestFocus();
59 }
60
61 @override
62 void dispose() {
63 _poll?.cancel();
64 for (final c in _controllers.values) {
65 c.dispose();
66 }
67 for (final f in _focus.values) {
68 f.dispose();
69 }
70 super.dispose();
71 }
72
73 @override
74 Widget build(BuildContext context) => MaterialApp(
75 title: 'frq',
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago76 debugShowCheckedModeBanner: false,
77 theme: ThemeData(
78 useMaterial3: true,
79 brightness: Brightness.dark,
80 scaffoldBackgroundColor: t.bg,
81 colorScheme: const ColorScheme.dark(
82 primary: t.accent,
83 onPrimary: t.onAccent,
84 surface: t.bg,
85 onSurface: t.onBg,
86 error: t.destructive,
87 ),
The data model, in Nim d333b6f nandi 19h ago88 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago89 home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))),
The data model, in Nim d333b6f nandi 19h ago90 );
91
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago92 // ---------------------------------------------------------------- helpers
93
94 TextStyle _style(double size, Color color) =>
95 TextStyle(fontSize: size, color: color, height: 1.35);
96
97 double _d(dynamic v, double fallback) =>
98 v is num ? v.toDouble() : fallback;
99
100 /// Gaps between children, as real widgets rather than a `spacing:` — the
101 /// same layout on every Flutter version this might be built against.
102 List<Widget> _spaced(List<Widget> kids, double gap, {required bool vertical}) {
103 if (gap <= 0 || kids.length < 2) return kids;
104 final out = <Widget>[];
105 for (var i = 0; i < kids.length; i++) {
106 if (i > 0) {
107 out.add(vertical ? SizedBox(height: gap) : SizedBox(width: gap));
108 }
109 out.add(kids[i]);
110 }
111 return out;
112 }
113
114 /// A source that may be a bundled asset, a file on disk, or a URL — the
115 /// three the screens hand over, named apart by an `asset:` prefix so they
116 /// stay one property.
117 ImageProvider? _imageProvider(String src) {
118 if (src.isEmpty) return null;
119 if (src.startsWith('asset:')) return AssetImage(src.substring(6));
120 if (src.startsWith('http://') || src.startsWith('https://')) {
121 return NetworkImage(src);
122 }
123 return FileImage(File(src));
124 }
125
126 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {
127 if (onClick.isEmpty) return child;
128 return InkWell(
129 onTap: () => _send(onClick),
130 borderRadius: radius,
131 child: child,
132 );
133 }
134
135 // ------------------------------------------------------------------ build
136
The data model, in Nim d333b6f nandi 19h ago137 Widget _build(core.UiNode n) {
138 final kids = n.children.map(_build).toList();
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago139 final spacing = _d(n.props['spacing'], 0);
The data model, in Nim d333b6f nandi 19h ago140
141 switch (n.tag) {
142 case 'page':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago143 return SingleChildScrollView(
144 child: Center(
145 child: ConstrainedBox(
146 constraints:
147 BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)),
148 child: Padding(
149 padding: const EdgeInsets.all(t.spaceM),
150 child: Column(
151 crossAxisAlignment: CrossAxisAlignment.start,
152 children: _spaced(kids, spacing, vertical: true)),
153 ),
The data model, in Nim d333b6f nandi 19h ago154 ),
155 ),
156 );
157
158 case 'vbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago159 {
160 Widget col = Column(
161 crossAxisAlignment: CrossAxisAlignment.start,
162 mainAxisSize: MainAxisSize.min,
163 children: _spaced(kids, spacing, vertical: true),
164 );
165 col = _margins(n, col);
166 final w = _d(n.props['widthRequest'], 0);
167 if (w > 0) col = SizedBox(width: w, child: col);
168 // `fillHeight` is what keeps the compose bar at the bottom instead
169 // of wherever the backlog happens to end.
170 return n.prop('fillHeight', false) ? Expanded(child: col) : col;
171 }
The data model, in Nim d333b6f nandi 19h ago172
173 case 'hbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago174 {
175 // Wrap and not Row: `:hbox` in the screens means "these go together
176 // across", not "these fit". The head row of the chat screen asks for
177 // more than 360 points has, and a Row answers that with an overflow
178 // rather than a second line.
179 final wrapping = n.prop('wrap', true);
180 final align = n.prop('align', 'center');
181 if (!wrapping) {
182 return _margins(
183 n,
184 Row(
185 crossAxisAlignment: align == 'end'
186 ? CrossAxisAlignment.end
187 : CrossAxisAlignment.center,
188 children: _spaced(kids, spacing, vertical: false),
189 ),
190 );
191 }
192 return _margins(
193 n,
194 Wrap(
195 spacing: spacing,
196 runSpacing: spacing,
197 crossAxisAlignment: align == 'end'
198 ? WrapCrossAlignment.end
199 : WrapCrossAlignment.center,
200 children: kids,
The data model, in Nim d333b6f nandi 19h ago201 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago202 );
203 }
The data model, in Nim d333b6f nandi 19h ago204
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago205 // Container::Card in the Clojure: padding 12, fills its width.
The data model, in Nim d333b6f nandi 19h ago206 case 'card':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago207 return Container(
208 width: double.infinity,
209 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
210 padding: const EdgeInsets.all(t.spaceXs),
211 decoration: BoxDecoration(
212 color: t.card,
213 borderRadius: BorderRadius.circular(t.radiusS),
The data model, in Nim d333b6f nandi 19h ago214 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago215 child: Column(
216 crossAxisAlignment: CrossAxisAlignment.start,
217 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
218 vertical: true)),
The data model, in Nim d333b6f nandi 19h ago219 );
220
221 case 'title':
222 return Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago223 style: _style(t.textTitle3, t.onBg)
224 .copyWith(fontWeight: FontWeight.bold));
The data model, in Nim d333b6f nandi 19h ago225
226 case 'title-2':
227 return Padding(
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago228 padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs),
The data model, in Nim d333b6f nandi 19h ago229 child: Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago230 style: _style(t.textTitle4, t.onBg)
231 .copyWith(fontWeight: FontWeight.w600)),
The data model, in Nim d333b6f nandi 19h ago232 );
233
234 case 'label':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago235 return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg));
The data model, in Nim d333b6f nandi 19h ago236
237 case 'dim-label':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago238 return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim));
239
240 /// Prose, as opposed to a label: this is what a message is, and it
241 /// wraps. Kept apart from `label` because a wrapping label in a row
242 /// lays out against the row's width rather than the column's.
243 case 'text':
244 return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));
245
246 case 'link':
247 return _wrapTap(
248 n.prop('onClick', ''),
249 Text(
250 n.prop('label', ''),
251 style: _style(t.textBody, t.accent)
252 .copyWith(decoration: TextDecoration.underline,
253 decorationColor: t.accent),
254 ),
255 );
256
257 case 'separator':
258 return const Divider(height: 1, thickness: 1, color: t.divider);
259
260 case 'spacer':
261 {
262 final s = _d(n.props['size'], t.spaceXxs);
263 return SizedBox(width: s, height: s);
264 }
The data model, in Nim d333b6f nandi 19h ago265
266 case 'spinner':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago267 return Row(
268 mainAxisSize: MainAxisSize.min,
269 children: [
270 const SizedBox(
271 width: 16,
272 height: 16,
273 child: CircularProgressIndicator(
274 strokeWidth: 2, color: t.accent)),
275 if (n.prop('label', '').isNotEmpty) ...[
276 const SizedBox(width: t.spaceXxs),
277 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
278 ],
279 ],
280 );
281
282 /// A dot that says whether the thing is live, and the words beside it.
283 case 'status':
284 return Row(
285 mainAxisSize: MainAxisSize.min,
286 children: [
287 Container(
288 width: 8,
289 height: 8,
290 decoration: BoxDecoration(
291 color: n.prop('live', false) ? t.success : t.dim,
292 borderRadius: BorderRadius.circular(t.radiusXs),
293 ),
294 ),
295 const SizedBox(width: 6),
296 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
297 ],
298 );
The data model, in Nim d333b6f nandi 19h ago299
300 case 'button':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago301 {
302 final onClick = n.prop('onClick', '');
303 final kind = n.prop('kind', 'default');
304 final label = Text(n.prop('label', ''));
305 if (kind == 'primary') {
306 return FilledButton(
307 onPressed: () => _send(onClick), child: label);
308 }
309 if (kind == 'destructive') {
310 return FilledButton(
311 style: FilledButton.styleFrom(
312 backgroundColor: t.destructive,
313 foregroundColor: t.onDestructive),
314 onPressed: () => _send(onClick),
315 child: label,
316 );
317 }
318 return OutlinedButton(onPressed: () => _send(onClick), child: label);
319 }
The data model, in Nim d333b6f nandi 19h ago320
321 case 'checkbutton':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago322 {
323 // The label is part of the target. 20 logical pixels is a fine tick
324 // on a desktop pointer and a miss on a thumb, so the whole row taps.
325 final onToggled = n.prop('onToggled', '');
326 return InkWell(
327 onTap: () => _send(onToggled),
328 child: Row(
329 mainAxisSize: MainAxisSize.min,
330 children: [
331 Checkbox(
332 value: n.prop('active', false),
333 onChanged: (_) => _send(onToggled),
334 ),
335 Text(n.prop('label', ''), style: _style(t.textBody, t.onBg)),
336 ],
337 ),
338 );
339 }
340
341 case 'emoji':
342 return _wrapTap(
343 n.prop('onClick', ''),
344 Text(n.prop('glyph', n.prop('emoji', '')),
345 style: TextStyle(fontSize: _d(n.props['size'], 16))),
346 );
347
348 /// A reaction pill: the glyph, and the tally beside it where there is
349 /// one to show. The same shape whether it is a reaction under a message,
350 /// a swatch in the picker, or a chip on the sender's row — which is the
351 /// point: what you press to react and what appears once you have should
352 /// look like one family.
353 ///
354 /// A count of zero is no count. The picker passes 0 for every swatch,
355 /// and a grid of little grey zeroes is noise where a reader is scanning
356 /// for a face. `mine` is the accent, because the only thing a pill has
357 /// to say at a glance is whether pressing it again takes yours off.
358 case 'reaction':
359 {
360 final size = _d(n.props['size'], 14);
361 final count = n.prop('count', 0);
362 final mine = n.prop('mine', false);
363 final pad = (0.25 * size).clamp(2.0, 8.0);
364 return _wrapTap(
365 n.prop('onClick', ''),
366 Container(
367 padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2),
368 decoration: BoxDecoration(
369 color: mine ? t.accent : t.component,
370 borderRadius: BorderRadius.circular(t.radiusS),
371 ),
372 child: Row(
373 mainAxisSize: MainAxisSize.min,
374 children: [
375 Text(n.prop('emoji', ''), style: TextStyle(fontSize: size)),
376 if (count > 0) ...[
377 const SizedBox(width: 4),
378 Text('$count',
379 style: _style(t.textCaption,
380 mine ? t.onAccent : t.dim)),
381 ],
382 ],
383 ),
384 ),
385 );
386 }
387
388 /// A face is a way in to who someone is, so it takes the press that
389 /// opens their profile. A picture that will not load is a face that
390 /// stays its initial and nothing else.
391 case 'avatar':
392 {
393 final size = _d(n.props['size'], 32);
394 final provider = _imageProvider(n.prop('url', ''));
395 final fallback = n.prop('fallback', '');
396 final face = CircleAvatar(
397 radius: size / 2,
398 backgroundColor: t.component,
399 backgroundImage: provider,
400 onBackgroundImageError: provider == null ? null : (_, __) {},
401 child: provider == null
402 ? Text(
403 fallback.isNotEmpty
404 ? fallback.substring(0, 1).toUpperCase()
405 : '?',
406 style: _style(t.textBody, t.onBg))
407 : null,
408 );
409 final onClick = n.prop('onClick', '');
410 if (onClick.isEmpty) return face;
411 return InkWell(
412 onTap: () => _send(onClick),
413 customBorder: const CircleBorder(),
414 child: face,
415 );
416 }
417
418 case 'image':
419 {
420 final provider = _imageProvider(n.prop('src', ''));
421 if (provider == null) return const SizedBox.shrink();
422 final maxW = _d(n.props['maxWidth'], 0);
423 final maxH = _d(n.props['maxHeight'], 0);
424 Widget img = Image(
425 image: provider,
426 fit: BoxFit.contain,
427 // A half-written cache file, or one deleted under us: the decoder
428 // throws during the build, and an exception in a build is a red
429 // screen for the whole conversation rather than a gap where one
430 // picture was.
431 errorBuilder: (_, __, ___) => const SizedBox.shrink(),
432 );
433 if (maxW > 0 || maxH > 0) {
434 img = ConstrainedBox(
435 constraints: BoxConstraints(
436 maxWidth: maxW > 0 ? maxW : double.infinity,
437 maxHeight: maxH > 0 ? maxH : double.infinity,
438 ),
439 child: img,
440 );
441 }
442 return _wrapTap(n.prop('onClick', ''), img);
443 }
The data model, in Nim d333b6f nandi 19h ago444
445 case 'entry':
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago446 {
447 final key = n.prop('key', '');
448 final value = n.prop('text', '');
449 final c = _controllers.putIfAbsent(
450 key, () => TextEditingController(text: value));
451 // Only when it actually differs: assigning unconditionally moves the
452 // caret to the end on every keystroke.
453 if (c.text != value) {
454 c.value = c.value.copyWith(
455 text: value,
456 selection: TextSelection.collapsed(offset: value.length),
457 );
458 }
459 final field = TextField(
460 controller: c,
461 focusNode: _focus.putIfAbsent(key, FocusNode.new),
462 style: _style(t.textBody, t.onBg),
463 decoration: InputDecoration(
464 hintText: n.prop('placeholder', ''),
465 hintStyle: _style(t.textBody, t.dim),
466 isDense: true,
467 filled: true,
468 fillColor: t.component,
469 border: OutlineInputBorder(
470 borderRadius: BorderRadius.circular(t.radiusS),
471 borderSide: BorderSide.none,
472 ),
473 ),
474 onChanged: (v) => _send(n.prop('onChange', ''), v),
475 onSubmitted: (_) => _send(n.prop('onSubmit', '')),
476 );
477 final w = _d(n.props['widthRequest'], 0);
478 // A width request is a minimum in the screens' vocabulary, but here
479 // it has to be a maximum too: an unconstrained TextField inside a
480 // Wrap has no width at all to take.
481 return w > 0 ? SizedBox(width: w, child: field) : Expanded(child: field);
482 }
483
484 case 'scroll':
485 {
486 Widget body = SingleChildScrollView(
487 // The backlog reads from the bottom; a settings list from the top.
488 reverse: n.prop('stickToBottom', false),
489 child: Column(
490 crossAxisAlignment: CrossAxisAlignment.start,
491 children: _spaced(kids, spacing, vertical: true)),
The data model, in Nim d333b6f nandi 19h ago492 );
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago493 body = Scrollbar(child: body);
494 final h = _d(n.props['height'], 0);
495 if (h > 0) return SizedBox(height: h, child: body);
496 // No fixed height: take what the column has left. `reserve` is the
497 // Clojure's way of saying the same thing to a backend that could not
498 // do this, and is ignored here on purpose.
499 return Expanded(child: body);
The data model, in Nim d333b6f nandi 19h ago500 }
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago501
502 /// A panel over the screen rather than a screen of its own.
503 case 'dialog':
504 return Card(
505 color: t.cardComponent,
506 child: Padding(
507 padding: const EdgeInsets.all(t.spaceS),
508 child: Column(
509 mainAxisSize: MainAxisSize.min,
510 crossAxisAlignment: CrossAxisAlignment.start,
511 children: [
512 if (n.prop('title', '').isNotEmpty)
513 Padding(
514 padding: const EdgeInsets.only(bottom: t.spaceXxs),
515 child: Text(n.prop('title', ''),
516 style: _style(t.textTitle4, t.onCard)
517 .copyWith(fontWeight: FontWeight.w600)),
518 ),
519 ..._spaced(kids, spacing, vertical: true),
520 ],
521 ),
The data model, in Nim d333b6f nandi 19h ago522 ),
523 );
524
525 default:
526 // An unknown tag paints as itself rather than crashing or vanishing.
527 // 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 17h ago528 // is what makes the boundary pleasant to work across.
The data model, in Nim d333b6f nandi 19h ago529 return Container(
530 padding: const EdgeInsets.all(4),
531 color: Colors.orange.withValues(alpha: 0.3),
532 child: Text('?${n.tag}'),
533 );
534 }
535 }
536
The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago537 /// `margin`, `marginTop`, `marginBottom`, `marginRight` — the props the
538 /// screens use to buy air without a wrapper each time.
539 Widget _margins(core.UiNode n, Widget child) {
540 final all = _d(n.props['margin'], 0);
541 final top = _d(n.props['marginTop'], all);
542 final bottom = _d(n.props['marginBottom'], all);
543 final right = _d(n.props['marginRight'], all);
544 final left = _d(n.props['marginLeft'], all);
545 if (top == 0 && bottom == 0 && right == 0 && left == 0) return child;
546 return Padding(
547 padding: EdgeInsets.only(
548 top: top, bottom: bottom, right: right, left: left),
549 child: child,
550 );
The data model, in Nim d333b6f nandi 19h ago551 }
552}