nandi/frqpublic Fork 0
35994d45d04d2f4d7320773e58286de34ac4085a
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 · 234 lines · 8.1 KBDart Blame HistoryRaw
Nim owns the screen, Dart owns the pixels 43a02c2 nandi yesterday1/// 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.
A message in #test, from Nim 35994d4 nandi yesterday10import 'dart:async';
11
Nim owns the screen, Dart owns the pixels 43a02c2 nandi yesterday12import 'package:flutter/material.dart';
13import '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.
18class NimApp extends StatefulWidget {
19 const NimApp({super.key});
20 @override
21 State<NimApp> createState() => _NimAppState();
22}
23
24class _NimAppState extends State<NimApp> {
25 late core.UiNode _tree = core.render();
A message in #test, from Nim 35994d4 nandi yesterday26 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 }
Nim owns the screen, Dart owns the pixels 43a02c2 nandi yesterday45
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 void _send(String id, [String value = '']) =>
55 setState(() => _tree = core.dispatch(id, value));
56
57 @override
58 void dispose() {
A message in #test, from Nim 35994d4 nandi yesterday59 _poll?.cancel();
Nim owns the screen, Dart owns the pixels 43a02c2 nandi yesterday60 for (final c in _controllers.values) {
61 c.dispose();
62 }
63 super.dispose();
64 }
65
66 @override
67 Widget build(BuildContext context) => MaterialApp(
68 title: 'frq',
69 theme: ThemeData.dark(useMaterial3: true),
70 home: Scaffold(
71 body: SafeArea(child: SingleChildScrollView(child: _build(_tree))),
72 ),
73 );
74
75 Widget _build(core.UiNode n) {
76 final kids = n.children.map(_build).toList();
77
78 switch (n.tag) {
79 case 'page':
80 return Center(
81 child: ConstrainedBox(
82 constraints:
83 BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()),
84 child: Padding(
85 padding: const EdgeInsets.all(24),
86 child: Column(
87 crossAxisAlignment: CrossAxisAlignment.start, children: kids),
88 ),
89 ),
90 );
91
92 case 'vbox':
93 return Column(
94 crossAxisAlignment: CrossAxisAlignment.start,
95 children: _spaced(kids, n.prop('spacing', 0), vertical: true),
96 );
97
98 case 'hbox':
99 // Wrap and not Row, and this was a bug before it was a decision: the
100 // three mode buttons are wider than the 520-point page, and a Row
101 // answers that with a RenderFlex overflow rather than a second line.
102 // A `:hbox` in the screens means "these go together across", not "these
103 // fit"; the tree has no idea how wide the window is and should not.
104 final gap = n.prop('spacing', 0).toDouble();
105 return Wrap(
106 spacing: gap,
107 runSpacing: gap,
108 crossAxisAlignment: WrapCrossAlignment.center,
109 children: kids,
110 );
111
A message in #test, from Nim 35994d4 nandi yesterday112 case 'scroll':
113 return SizedBox(
114 height: n.prop('height', 300).toDouble(),
115 child: Scrollbar(
116 child: SingleChildScrollView(
117 reverse: true,
118 child: Column(
119 crossAxisAlignment: CrossAxisAlignment.start,
120 children: kids),
121 ),
122 ),
123 );
124
Nim owns the screen, Dart owns the pixels 43a02c2 nandi yesterday125 case 'card':
126 return Card(
127 margin: const EdgeInsets.symmetric(vertical: 8),
128 child: Padding(
129 padding: const EdgeInsets.all(16),
130 child: Column(
131 crossAxisAlignment: CrossAxisAlignment.start, children: kids),
132 ),
133 );
134
135 case 'title':
136 return Text(n.prop('label', ''),
137 style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold));
138
139 case 'title-2':
140 return Padding(
141 padding: const EdgeInsets.only(top: 8, bottom: 4),
142 child: Text(n.prop('label', ''),
143 style:
144 const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
145 );
146
147 case 'label':
148 return Text(n.prop('label', ''));
149
150 case 'dim-label':
151 return Opacity(
152 opacity: 0.7,
153 child: Text(n.prop('label', ''),
154 style: const TextStyle(fontSize: 12)));
155
156 case 'spinner':
157 return const SizedBox(
158 width: 16,
159 height: 16,
160 child: CircularProgressIndicator(strokeWidth: 2));
161
162 case 'button':
163 final onClick = n.prop('onClick', '');
164 final label = Text(n.prop('label', ''));
165 // No padding of its own: spacing belongs to the container, which is
166 // the only thing that knows whether this is in a row or a column.
167 return n.prop('kind', 'default') == 'primary'
168 ? FilledButton(onPressed: () => _send(onClick), child: label)
169 : OutlinedButton(onPressed: () => _send(onClick), child: label);
170
171 case 'checkbutton':
172 return Row(mainAxisSize: MainAxisSize.min, children: [
173 Checkbox(
174 value: n.prop('active', false),
175 onChanged: (_) => _send(n.prop('onToggled', '')),
176 ),
177 Text(n.prop('label', '')),
178 ]);
179
180 case 'entry':
181 final key = n.prop('key', '');
182 final text = n.prop('text', '');
183 final c = _controllers.putIfAbsent(
184 key, () => TextEditingController(text: text));
185 // Only when it actually differs: assigning unconditionally moves the
186 // caret to the end on every keystroke, which is the classic way to
187 // make a controlled text field unusable.
188 if (c.text != text) {
189 c.value = c.value.copyWith(
190 text: text,
191 selection: TextSelection.collapsed(offset: text.length),
192 );
193 }
194 final field = TextField(
195 controller: c,
196 decoration: InputDecoration(
197 hintText: n.prop('placeholder', ''),
198 isDense: true,
199 border: const OutlineInputBorder(),
200 ),
201 onChanged: (v) => _send(n.prop('onChange', ''), v),
202 );
203 final w = n.prop('widthRequest', 0);
204 // A width request is a minimum in the screens' vocabulary, but here it
205 // has to be a maximum too: an unconstrained TextField inside a Wrap
206 // has no width at all to take.
207 return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field;
208
209 default:
210 // An unknown tag paints as itself rather than crashing or vanishing.
211 // Nim can add one and see it before this file has heard of it, which
212 // is the behaviour that makes the boundary pleasant to work across.
213 return Container(
214 padding: const EdgeInsets.all(4),
215 color: Colors.orange.withValues(alpha: 0.3),
216 child: Text('?${n.tag}'),
217 );
218 }
219 }
220
221 List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) {
222 if (gap <= 0 || kids.length < 2) return kids;
223 final out = <Widget>[];
224 for (var i = 0; i < kids.length; i++) {
225 if (i > 0) {
226 out.add(vertical
227 ? SizedBox(height: gap.toDouble())
228 : SizedBox(width: gap.toDouble()));
229 }
230 out.add(kids[i]);
231 }
232 return out;
233 }
234}