| The data model, in Nim d333b6f nandi 17h ago | 1 | /// The renderer: a Nim widget tree, walked into Flutter widgets. |
| 2 | /// |
| 3 | /// This is the Dart half of the spike's claim. It knows the tag vocabulary |
| 4 | /// and nothing else — no screens, no state, no idea what "connect" means. Nim |
| 5 | /// decides what the screen is; this decides what a `vbox` looks like. |
| 6 | /// |
| 7 | /// The measure of whether the split is honest is how boring this file is. If |
| 8 | /// a feature ever needs a change here AND in Nim, the boundary is in the |
| 9 | /// wrong place. |
| 10 | import 'dart:async'; |
| 11 | |
| 12 | import 'package:flutter/material.dart'; |
| 13 | import 'package:frq_core/frq_core.dart' as core; |
| 14 | |
| 15 | /// Rebuilds from Nim on every event. One `setState` per dispatch, and the |
| 16 | /// whole tree is rebuilt — which is what Flutter does anyway, and is why the |
| 17 | /// Nim side does not need a reconciler of its own. |
| 18 | class NimApp extends StatefulWidget { |
| 19 | const NimApp({super.key}); |
| 20 | @override |
| 21 | State<NimApp> createState() => _NimAppState(); |
| 22 | } |
| 23 | |
| 24 | class _NimAppState extends State<NimApp> { |
| 25 | late core.UiNode _tree = core.render(); |
| 26 | Timer? _poll; |
| 27 | |
| 28 | @override |
| 29 | void initState() { |
| 30 | super.initState(); |
| 31 | // Polling, because the socket lives on a Nim thread and there is no |
| 32 | // callback into Dart. A Dart callback invoked from a foreign thread has to |
| 33 | // be marshalled onto the main isolate — NativeCallable, ports, a whole |
| 34 | // mechanism — and at 70µs a render a 100ms timer does the same job for |
| 35 | // nothing. It is also why `render` is allowed to be impure. |
| 36 | _poll = Timer.periodic(const Duration(milliseconds: 100), (_) { |
| 37 | final t = core.poll(); |
| 38 | // Only when it actually differs: a setState per tick would rebuild the |
| 39 | // whole tree ten times a second for a screen nobody is touching. |
| 40 | if (t.toString() != _tree.toString()) { |
| 41 | setState(() => _tree = t); |
| 42 | } |
| 43 | }); |
| 44 | } |
| 45 | |
| 46 | // One controller per keyed entry, kept across rebuilds. |
| 47 | // |
| 48 | // This is the whole reason `:key` is on every entry in both the Clojure and |
| 49 | // the Nim: a controller identified by position instead of name meant the |
| 50 | // host field and the port field shared one and both showed the port. The |
| 51 | // comment survives three languages now. |
| 52 | final _controllers = <String, TextEditingController>{}; |
| 53 | |
| 54 | // One focus node per keyed entry, for the same reason as the controllers. |
| 55 | // Without it, sending with Enter drops focus and the next line is typed |
| 56 | // into nothing — the field is rebuilt from a fresh tree every time. |
| 57 | final _focus = <String, FocusNode>{}; |
| 58 | |
| 59 | void _send(String id, [String value = '']) { |
| 60 | setState(() => _tree = core.dispatch(id, value)); |
| 61 | // Enter in the compose box clears the draft in Nim and rebuilds the |
| 62 | // field; putting focus back is what makes a second line typeable. |
| 63 | if (id == 'send') _focus['draft']?.requestFocus(); |
| 64 | } |
| 65 | |
| 66 | @override |
| 67 | void dispose() { |
| 68 | _poll?.cancel(); |
| 69 | for (final c in _controllers.values) { |
| 70 | c.dispose(); |
| 71 | } |
| 72 | for (final f in _focus.values) { |
| 73 | f.dispose(); |
| 74 | } |
| 75 | super.dispose(); |
| 76 | } |
| 77 | |
| 78 | @override |
| 79 | Widget build(BuildContext context) => MaterialApp( |
| 80 | title: 'frq', |
| 81 | theme: ThemeData.dark(useMaterial3: true), |
| 82 | home: Scaffold( |
| 83 | body: SafeArea(child: SingleChildScrollView(child: _build(_tree))), |
| 84 | ), |
| 85 | ); |
| 86 | |
| 87 | Widget _build(core.UiNode n) { |
| 88 | final kids = n.children.map(_build).toList(); |
| 89 | |
| 90 | switch (n.tag) { |
| 91 | case 'page': |
| 92 | return Center( |
| 93 | child: ConstrainedBox( |
| 94 | constraints: |
| 95 | BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()), |
| 96 | child: Padding( |
| 97 | padding: const EdgeInsets.all(24), |
| 98 | child: Column( |
| 99 | crossAxisAlignment: CrossAxisAlignment.start, children: kids), |
| 100 | ), |
| 101 | ), |
| 102 | ); |
| 103 | |
| 104 | case 'vbox': |
| 105 | return Column( |
| 106 | crossAxisAlignment: CrossAxisAlignment.start, |
| 107 | children: _spaced(kids, n.prop('spacing', 0), vertical: true), |
| 108 | ); |
| 109 | |
| 110 | case 'hbox': |
| 111 | // Wrap and not Row, and this was a bug before it was a decision: the |
| 112 | // three mode buttons are wider than the 520-point page, and a Row |
| 113 | // answers that with a RenderFlex overflow rather than a second line. |
| 114 | // A `:hbox` in the screens means "these go together across", not "these |
| 115 | // fit"; the tree has no idea how wide the window is and should not. |
| 116 | final gap = n.prop('spacing', 0).toDouble(); |
| 117 | return Wrap( |
| 118 | spacing: gap, |
| 119 | runSpacing: gap, |
| 120 | crossAxisAlignment: WrapCrossAlignment.center, |
| 121 | children: kids, |
| 122 | ); |
| 123 | |
| 124 | case 'scroll': |
| 125 | return SizedBox( |
| 126 | height: n.prop('height', 300).toDouble(), |
| 127 | child: Scrollbar( |
| 128 | child: SingleChildScrollView( |
| 129 | reverse: true, |
| 130 | child: Column( |
| 131 | crossAxisAlignment: CrossAxisAlignment.start, |
| 132 | children: kids), |
| 133 | ), |
| 134 | ), |
| 135 | ); |
| 136 | |
| 137 | case 'card': |
| 138 | return Card( |
| 139 | margin: const EdgeInsets.symmetric(vertical: 8), |
| 140 | child: Padding( |
| 141 | padding: const EdgeInsets.all(16), |
| 142 | child: Column( |
| 143 | crossAxisAlignment: CrossAxisAlignment.start, children: kids), |
| 144 | ), |
| 145 | ); |
| 146 | |
| 147 | case 'title': |
| 148 | return Text(n.prop('label', ''), |
| 149 | style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)); |
| 150 | |
| 151 | case 'title-2': |
| 152 | return Padding( |
| 153 | padding: const EdgeInsets.only(top: 8, bottom: 4), |
| 154 | child: Text(n.prop('label', ''), |
| 155 | style: |
| 156 | const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), |
| 157 | ); |
| 158 | |
| 159 | case 'label': |
| 160 | return Text(n.prop('label', '')); |
| 161 | |
| 162 | case 'dim-label': |
| 163 | return Opacity( |
| 164 | opacity: 0.7, |
| 165 | child: Text(n.prop('label', ''), |
| 166 | style: const TextStyle(fontSize: 12))); |
| 167 | |
| 168 | case 'spinner': |
| 169 | return const SizedBox( |
| 170 | width: 16, |
| 171 | height: 16, |
| 172 | child: CircularProgressIndicator(strokeWidth: 2)); |
| 173 | |
| 174 | case 'button': |
| 175 | final onClick = n.prop('onClick', ''); |
| 176 | final label = Text(n.prop('label', '')); |
| 177 | // No padding of its own: spacing belongs to the container, which is |
| 178 | // the only thing that knows whether this is in a row or a column. |
| 179 | return n.prop('kind', 'default') == 'primary' |
| 180 | ? FilledButton(onPressed: () => _send(onClick), child: label) |
| 181 | : OutlinedButton(onPressed: () => _send(onClick), child: label); |
| 182 | |
| 183 | case 'checkbutton': |
| 184 | return Row(mainAxisSize: MainAxisSize.min, children: [ |
| 185 | Checkbox( |
| 186 | value: n.prop('active', false), |
| 187 | onChanged: (_) => _send(n.prop('onToggled', '')), |
| 188 | ), |
| 189 | Text(n.prop('label', '')), |
| 190 | ]); |
| 191 | |
| 192 | case 'entry': |
| 193 | final key = n.prop('key', ''); |
| 194 | final text = n.prop('text', ''); |
| 195 | final c = _controllers.putIfAbsent( |
| 196 | key, () => TextEditingController(text: text)); |
| 197 | // Only when it actually differs: assigning unconditionally moves the |
| 198 | // caret to the end on every keystroke, which is the classic way to |
| 199 | // make a controlled text field unusable. |
| 200 | if (c.text != text) { |
| 201 | c.value = c.value.copyWith( |
| 202 | text: text, |
| 203 | selection: TextSelection.collapsed(offset: text.length), |
| 204 | ); |
| 205 | } |
| 206 | final field = TextField( |
| 207 | controller: c, |
| 208 | focusNode: _focus.putIfAbsent(key, FocusNode.new), |
| 209 | decoration: InputDecoration( |
| 210 | hintText: n.prop('placeholder', ''), |
| 211 | isDense: true, |
| 212 | border: const OutlineInputBorder(), |
| 213 | ), |
| 214 | onChanged: (v) => _send(n.prop('onChange', ''), v), |
| 215 | onSubmitted: (_) { |
| 216 | final submit = n.prop('onSubmit', ''); |
| 217 | if (submit.isNotEmpty) _send(submit); |
| 218 | }, |
| 219 | ); |
| 220 | final w = n.prop('widthRequest', 0); |
| 221 | // A width request is a minimum in the screens' vocabulary, but here it |
| 222 | // has to be a maximum too: an unconstrained TextField inside a Wrap |
| 223 | // has no width at all to take. |
| 224 | return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field; |
| 225 | |
| 226 | default: |
| 227 | // An unknown tag paints as itself rather than crashing or vanishing. |
| 228 | // Nim can add one and see it before this file has heard of it, which |
| 229 | // is the behaviour that makes the boundary pleasant to work across. |
| 230 | return Container( |
| 231 | padding: const EdgeInsets.all(4), |
| 232 | color: Colors.orange.withValues(alpha: 0.3), |
| 233 | child: Text('?${n.tag}'), |
| 234 | ); |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) { |
| 239 | if (gap <= 0 || kids.length < 2) return kids; |
| 240 | final out = <Widget>[]; |
| 241 | for (var i = 0; i < kids.length; i++) { |
| 242 | if (i > 0) { |
| 243 | out.add(vertical |
| 244 | ? SizedBox(height: gap.toDouble()) |
| 245 | : SizedBox(width: gap.toDouble())); |
| 246 | } |
| 247 | out.add(kids[i]); |
| 248 | } |
| 249 | return out; |
| 250 | } |
| 251 | } |