nandi/frqpublic Fork 0
f1e99b45fe553489e43293e795175dc08ca4bb88
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 · 587 lines · 21.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
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 yesterday19import 'nim_theme.dart' as t;
20
The data model, in Nim d333b6f nandi yesterday21class 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 yesterday31 // 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 yesterday42 @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 yesterday46 // callback into Dart. At ~70µs a render a 100ms timer costs nothing.
The data model, in Nim d333b6f nandi yesterday47 _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday48 final next = core.poll();
49 if (next.toString() != _tree.toString()) {
50 setState(() => _tree = next);
The data model, in Nim d333b6f nandi yesterday51 }
52 });
53 }
54
55 void _send(String id, [String value = '']) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday56 if (id.isEmpty) return;
The data model, in Nim d333b6f nandi yesterday57 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 yesterday76 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 yesterday88 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday89 home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))),
The data model, in Nim d333b6f nandi yesterday90 );
91
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday92 // ---------------------------------------------------------------- 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
Expanded only where a Flex can hold it f1e99b4 nandi yesterday137 /// The axis of the widget a node is being built *into*, because `Expanded`
138 /// is only legal inside a Flex and there is no way to ask Flutter after the
139 /// fact.
140 ///
141 /// Getting this wrong is what "Cannot hit test a render box that has never
142 /// been laid out" means, in a pile: an `Expanded` inside a `Wrap` fails the
143 /// layout, and every box under it is then asked to hit-test without ever
144 /// having been laid out. The chats screen did exactly that — two unsized
145 /// entries in an `hbox`, which is a Wrap.
146 static const _noAxis = '';
147 static const _row = 'row';
148 static const _column = 'column';
149
150 Widget _build(core.UiNode n, [String axis = _noAxis]) {
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday151 final spacing = _d(n.props['spacing'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday152 final flex = axis == _row || axis == _column;
153
154 // What this node's own children are being built into.
155 final childAxis = switch (n.tag) {
156 'page' || 'vbox' || 'card' || 'scroll' || 'dialog' => _column,
157 'hbox' => n.prop('wrap', true) ? _noAxis : _row,
158 _ => _noAxis,
159 };
160 final kids = n.children.map((c) => _build(c, childAxis)).toList();
The data model, in Nim d333b6f nandi yesterday161
162 switch (n.tag) {
163 case 'page':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday164 return SingleChildScrollView(
165 child: Center(
166 child: ConstrainedBox(
167 constraints:
168 BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)),
169 child: Padding(
170 padding: const EdgeInsets.all(t.spaceM),
171 child: Column(
172 crossAxisAlignment: CrossAxisAlignment.start,
173 children: _spaced(kids, spacing, vertical: true)),
174 ),
The data model, in Nim d333b6f nandi yesterday175 ),
176 ),
177 );
178
179 case 'vbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday180 {
181 Widget col = Column(
182 crossAxisAlignment: CrossAxisAlignment.start,
183 mainAxisSize: MainAxisSize.min,
184 children: _spaced(kids, spacing, vertical: true),
185 );
186 col = _margins(n, col);
187 final w = _d(n.props['widthRequest'], 0);
188 if (w > 0) col = SizedBox(width: w, child: col);
189 // `fillHeight` is what keeps the compose bar at the bottom instead
Expanded only where a Flex can hold it f1e99b4 nandi yesterday190 // of wherever the backlog happens to end — but only a Flex can be
191 // told to expand into.
192 return (n.prop('fillHeight', false) && flex)
193 ? Expanded(child: col)
194 : col;
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday195 }
The data model, in Nim d333b6f nandi yesterday196
197 case 'hbox':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday198 {
199 // Wrap and not Row: `:hbox` in the screens means "these go together
200 // across", not "these fit". The head row of the chat screen asks for
201 // more than 360 points has, and a Row answers that with an overflow
202 // rather than a second line.
203 final wrapping = n.prop('wrap', true);
204 final align = n.prop('align', 'center');
205 if (!wrapping) {
Expanded only where a Flex can hold it f1e99b4 nandi yesterday206 // A child asking to fill the height gets it from the row's cross
207 // axis, not from an Expanded — Expanded in a Row is about width.
208 final stretches =
209 n.children.any((c) => c.prop('fillHeight', false));
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday210 return _margins(
211 n,
212 Row(
Expanded only where a Flex can hold it f1e99b4 nandi yesterday213 crossAxisAlignment: stretches
214 ? CrossAxisAlignment.stretch
215 : (align == 'end'
216 ? CrossAxisAlignment.end
217 : CrossAxisAlignment.center),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday218 children: _spaced(kids, spacing, vertical: false),
219 ),
220 );
221 }
222 return _margins(
223 n,
224 Wrap(
225 spacing: spacing,
226 runSpacing: spacing,
227 crossAxisAlignment: align == 'end'
228 ? WrapCrossAlignment.end
229 : WrapCrossAlignment.center,
230 children: kids,
The data model, in Nim d333b6f nandi yesterday231 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday232 );
233 }
The data model, in Nim d333b6f nandi yesterday234
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday235 // Container::Card in the Clojure: padding 12, fills its width.
The data model, in Nim d333b6f nandi yesterday236 case 'card':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday237 return Container(
238 width: double.infinity,
239 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
240 padding: const EdgeInsets.all(t.spaceXs),
241 decoration: BoxDecoration(
242 color: t.card,
243 borderRadius: BorderRadius.circular(t.radiusS),
The data model, in Nim d333b6f nandi yesterday244 ),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday245 child: Column(
246 crossAxisAlignment: CrossAxisAlignment.start,
247 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
248 vertical: true)),
The data model, in Nim d333b6f nandi yesterday249 );
250
251 case 'title':
252 return Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday253 style: _style(t.textTitle3, t.onBg)
254 .copyWith(fontWeight: FontWeight.bold));
The data model, in Nim d333b6f nandi yesterday255
256 case 'title-2':
257 return Padding(
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday258 padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs),
The data model, in Nim d333b6f nandi yesterday259 child: Text(n.prop('label', ''),
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday260 style: _style(t.textTitle4, t.onBg)
261 .copyWith(fontWeight: FontWeight.w600)),
The data model, in Nim d333b6f nandi yesterday262 );
263
264 case 'label':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday265 return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg));
The data model, in Nim d333b6f nandi yesterday266
267 case 'dim-label':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday268 return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim));
269
270 /// Prose, as opposed to a label: this is what a message is, and it
271 /// wraps. Kept apart from `label` because a wrapping label in a row
272 /// lays out against the row's width rather than the column's.
273 case 'text':
274 return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));
275
276 case 'link':
277 return _wrapTap(
278 n.prop('onClick', ''),
279 Text(
280 n.prop('label', ''),
281 style: _style(t.textBody, t.accent)
282 .copyWith(decoration: TextDecoration.underline,
283 decorationColor: t.accent),
284 ),
285 );
286
287 case 'separator':
288 return const Divider(height: 1, thickness: 1, color: t.divider);
289
290 case 'spacer':
291 {
292 final s = _d(n.props['size'], t.spaceXxs);
293 return SizedBox(width: s, height: s);
294 }
The data model, in Nim d333b6f nandi yesterday295
296 case 'spinner':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday297 return Row(
298 mainAxisSize: MainAxisSize.min,
299 children: [
300 const SizedBox(
301 width: 16,
302 height: 16,
303 child: CircularProgressIndicator(
304 strokeWidth: 2, color: t.accent)),
305 if (n.prop('label', '').isNotEmpty) ...[
306 const SizedBox(width: t.spaceXxs),
307 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
308 ],
309 ],
310 );
311
312 /// A dot that says whether the thing is live, and the words beside it.
313 case 'status':
314 return Row(
315 mainAxisSize: MainAxisSize.min,
316 children: [
317 Container(
318 width: 8,
319 height: 8,
320 decoration: BoxDecoration(
321 color: n.prop('live', false) ? t.success : t.dim,
322 borderRadius: BorderRadius.circular(t.radiusXs),
323 ),
324 ),
325 const SizedBox(width: 6),
326 Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)),
327 ],
328 );
The data model, in Nim d333b6f nandi yesterday329
330 case 'button':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday331 {
332 final onClick = n.prop('onClick', '');
333 final kind = n.prop('kind', 'default');
334 final label = Text(n.prop('label', ''));
335 if (kind == 'primary') {
336 return FilledButton(
337 onPressed: () => _send(onClick), child: label);
338 }
339 if (kind == 'destructive') {
340 return FilledButton(
341 style: FilledButton.styleFrom(
342 backgroundColor: t.destructive,
343 foregroundColor: t.onDestructive),
344 onPressed: () => _send(onClick),
345 child: label,
346 );
347 }
348 return OutlinedButton(onPressed: () => _send(onClick), child: label);
349 }
The data model, in Nim d333b6f nandi yesterday350
351 case 'checkbutton':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday352 {
353 // The label is part of the target. 20 logical pixels is a fine tick
354 // on a desktop pointer and a miss on a thumb, so the whole row taps.
355 final onToggled = n.prop('onToggled', '');
356 return InkWell(
357 onTap: () => _send(onToggled),
358 child: Row(
359 mainAxisSize: MainAxisSize.min,
360 children: [
361 Checkbox(
362 value: n.prop('active', false),
363 onChanged: (_) => _send(onToggled),
364 ),
365 Text(n.prop('label', ''), style: _style(t.textBody, t.onBg)),
366 ],
367 ),
368 );
369 }
370
371 case 'emoji':
372 return _wrapTap(
373 n.prop('onClick', ''),
374 Text(n.prop('glyph', n.prop('emoji', '')),
375 style: TextStyle(fontSize: _d(n.props['size'], 16))),
376 );
377
378 /// A reaction pill: the glyph, and the tally beside it where there is
379 /// one to show. The same shape whether it is a reaction under a message,
380 /// a swatch in the picker, or a chip on the sender's row — which is the
381 /// point: what you press to react and what appears once you have should
382 /// look like one family.
383 ///
384 /// A count of zero is no count. The picker passes 0 for every swatch,
385 /// and a grid of little grey zeroes is noise where a reader is scanning
386 /// for a face. `mine` is the accent, because the only thing a pill has
387 /// to say at a glance is whether pressing it again takes yours off.
388 case 'reaction':
389 {
390 final size = _d(n.props['size'], 14);
391 final count = n.prop('count', 0);
392 final mine = n.prop('mine', false);
393 final pad = (0.25 * size).clamp(2.0, 8.0);
394 return _wrapTap(
395 n.prop('onClick', ''),
396 Container(
397 padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2),
398 decoration: BoxDecoration(
399 color: mine ? t.accent : t.component,
400 borderRadius: BorderRadius.circular(t.radiusS),
401 ),
402 child: Row(
403 mainAxisSize: MainAxisSize.min,
404 children: [
405 Text(n.prop('emoji', ''), style: TextStyle(fontSize: size)),
406 if (count > 0) ...[
407 const SizedBox(width: 4),
408 Text('$count',
409 style: _style(t.textCaption,
410 mine ? t.onAccent : t.dim)),
411 ],
412 ],
413 ),
414 ),
415 );
416 }
417
418 /// A face is a way in to who someone is, so it takes the press that
419 /// opens their profile. A picture that will not load is a face that
420 /// stays its initial and nothing else.
421 case 'avatar':
422 {
423 final size = _d(n.props['size'], 32);
424 final provider = _imageProvider(n.prop('url', ''));
425 final fallback = n.prop('fallback', '');
426 final face = CircleAvatar(
427 radius: size / 2,
428 backgroundColor: t.component,
429 backgroundImage: provider,
430 onBackgroundImageError: provider == null ? null : (_, __) {},
431 child: provider == null
432 ? Text(
433 fallback.isNotEmpty
434 ? fallback.substring(0, 1).toUpperCase()
435 : '?',
436 style: _style(t.textBody, t.onBg))
437 : null,
438 );
439 final onClick = n.prop('onClick', '');
440 if (onClick.isEmpty) return face;
441 return InkWell(
442 onTap: () => _send(onClick),
443 customBorder: const CircleBorder(),
444 child: face,
445 );
446 }
447
448 case 'image':
449 {
450 final provider = _imageProvider(n.prop('src', ''));
451 if (provider == null) return const SizedBox.shrink();
452 final maxW = _d(n.props['maxWidth'], 0);
453 final maxH = _d(n.props['maxHeight'], 0);
454 Widget img = Image(
455 image: provider,
456 fit: BoxFit.contain,
457 // A half-written cache file, or one deleted under us: the decoder
458 // throws during the build, and an exception in a build is a red
459 // screen for the whole conversation rather than a gap where one
460 // picture was.
461 errorBuilder: (_, __, ___) => const SizedBox.shrink(),
462 );
463 if (maxW > 0 || maxH > 0) {
464 img = ConstrainedBox(
465 constraints: BoxConstraints(
466 maxWidth: maxW > 0 ? maxW : double.infinity,
467 maxHeight: maxH > 0 ? maxH : double.infinity,
468 ),
469 child: img,
470 );
471 }
472 return _wrapTap(n.prop('onClick', ''), img);
473 }
The data model, in Nim d333b6f nandi yesterday474
475 case 'entry':
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday476 {
477 final key = n.prop('key', '');
478 final value = n.prop('text', '');
479 final c = _controllers.putIfAbsent(
480 key, () => TextEditingController(text: value));
481 // Only when it actually differs: assigning unconditionally moves the
482 // caret to the end on every keystroke.
483 if (c.text != value) {
484 c.value = c.value.copyWith(
485 text: value,
486 selection: TextSelection.collapsed(offset: value.length),
487 );
488 }
489 final field = TextField(
490 controller: c,
491 focusNode: _focus.putIfAbsent(key, FocusNode.new),
492 style: _style(t.textBody, t.onBg),
493 decoration: InputDecoration(
494 hintText: n.prop('placeholder', ''),
495 hintStyle: _style(t.textBody, t.dim),
496 isDense: true,
497 filled: true,
498 fillColor: t.component,
499 border: OutlineInputBorder(
500 borderRadius: BorderRadius.circular(t.radiusS),
501 borderSide: BorderSide.none,
502 ),
503 ),
504 onChanged: (v) => _send(n.prop('onChange', ''), v),
505 onSubmitted: (_) => _send(n.prop('onSubmit', '')),
506 );
507 final w = _d(n.props['widthRequest'], 0);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday508 if (w > 0) return SizedBox(width: w, child: field);
509 // No width asked for: take the rest of the row where there is a row
510 // to take it from, and otherwise a definite width. NOT Expanded
511 // unconditionally — a TextField has no intrinsic width, so in a Wrap
512 // it is both illegal and unmeasurable, and that combination is what
513 // took the whole screen down rather than one field.
514 return axis == _row
515 ? Expanded(child: field)
516 : SizedBox(width: 320, child: field);
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday517 }
518
519 case 'scroll':
520 {
521 Widget body = SingleChildScrollView(
522 // The backlog reads from the bottom; a settings list from the top.
523 reverse: n.prop('stickToBottom', false),
524 child: Column(
525 crossAxisAlignment: CrossAxisAlignment.start,
526 children: _spaced(kids, spacing, vertical: true)),
The data model, in Nim d333b6f nandi yesterday527 );
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday528 body = Scrollbar(child: body);
529 final h = _d(n.props['height'], 0);
530 if (h > 0) return SizedBox(height: h, child: body);
Expanded only where a Flex can hold it f1e99b4 nandi yesterday531 // No fixed height: take what the column has left, where there is a
532 // column. `reserve` is the Clojure's way of saying the same thing to
533 // a backend that could not do this, and is ignored here on purpose.
534 return flex ? Expanded(child: body) : SizedBox(height: 400, child: body);
The data model, in Nim d333b6f nandi yesterday535 }
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday536
537 /// A panel over the screen rather than a screen of its own.
538 case 'dialog':
539 return Card(
540 color: t.cardComponent,
541 child: Padding(
542 padding: const EdgeInsets.all(t.spaceS),
543 child: Column(
544 mainAxisSize: MainAxisSize.min,
545 crossAxisAlignment: CrossAxisAlignment.start,
546 children: [
547 if (n.prop('title', '').isNotEmpty)
548 Padding(
549 padding: const EdgeInsets.only(bottom: t.spaceXxs),
550 child: Text(n.prop('title', ''),
551 style: _style(t.textTitle4, t.onCard)
552 .copyWith(fontWeight: FontWeight.w600)),
553 ),
554 ..._spaced(kids, spacing, vertical: true),
555 ],
556 ),
The data model, in Nim d333b6f nandi yesterday557 ),
558 );
559
560 default:
561 // An unknown tag paints as itself rather than crashing or vanishing.
562 // 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 yesterday563 // is what makes the boundary pleasant to work across.
The data model, in Nim d333b6f nandi yesterday564 return Container(
565 padding: const EdgeInsets.all(4),
566 color: Colors.orange.withValues(alpha: 0.3),
567 child: Text('?${n.tag}'),
568 );
569 }
570 }
571
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday572 /// `margin`, `marginTop`, `marginBottom`, `marginRight` — the props the
573 /// screens use to buy air without a wrapper each time.
574 Widget _margins(core.UiNode n, Widget child) {
575 final all = _d(n.props['margin'], 0);
576 final top = _d(n.props['marginTop'], all);
577 final bottom = _d(n.props['marginBottom'], all);
578 final right = _d(n.props['marginRight'], all);
579 final left = _d(n.props['marginLeft'], all);
580 if (top == 0 && bottom == 0 && right == 0 && left == 0) return child;
581 return Padding(
582 padding: EdgeInsets.only(
583 top: top, bottom: bottom, right: right, left: left),
584 child: child,
585 );
The data model, in Nim d333b6f nandi yesterday586 }
587}