nandi/frqpublic Fork 0
d333b6f7b4be024f494b613751b18edbeed856bc
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 · 251 lines · 8.8 KBDart Blame HistoryRaw
The data model, in Nim d333b6f nandi 17h ago1/// 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.
10import 'dart:async';
11
12import '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();
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}