nandi/frqpublic Fork 0
43a02c2ddd7ebb7cdcd7d46f8c9a20b5b1b55b5e
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 · 199 lines · 6.9 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.
10import 'package:flutter/material.dart';
11import 'package:frq_core/frq_core.dart' as core;
12
13/// Rebuilds from Nim on every event. One `setState` per dispatch, and the
14/// whole tree is rebuilt — which is what Flutter does anyway, and is why the
15/// Nim side does not need a reconciler of its own.
16class NimApp extends StatefulWidget {
17 const NimApp({super.key});
18 @override
19 State<NimApp> createState() => _NimAppState();
20}
21
22class _NimAppState extends State<NimApp> {
23 late core.UiNode _tree = core.render();
24
25 // One controller per keyed entry, kept across rebuilds.
26 //
27 // This is the whole reason `:key` is on every entry in both the Clojure and
28 // the Nim: a controller identified by position instead of name meant the
29 // host field and the port field shared one and both showed the port. The
30 // comment survives three languages now.
31 final _controllers = <String, TextEditingController>{};
32
33 void _send(String id, [String value = '']) =>
34 setState(() => _tree = core.dispatch(id, value));
35
36 @override
37 void dispose() {
38 for (final c in _controllers.values) {
39 c.dispose();
40 }
41 super.dispose();
42 }
43
44 @override
45 Widget build(BuildContext context) => MaterialApp(
46 title: 'frq',
47 theme: ThemeData.dark(useMaterial3: true),
48 home: Scaffold(
49 body: SafeArea(child: SingleChildScrollView(child: _build(_tree))),
50 ),
51 );
52
53 Widget _build(core.UiNode n) {
54 final kids = n.children.map(_build).toList();
55
56 switch (n.tag) {
57 case 'page':
58 return Center(
59 child: ConstrainedBox(
60 constraints:
61 BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()),
62 child: Padding(
63 padding: const EdgeInsets.all(24),
64 child: Column(
65 crossAxisAlignment: CrossAxisAlignment.start, children: kids),
66 ),
67 ),
68 );
69
70 case 'vbox':
71 return Column(
72 crossAxisAlignment: CrossAxisAlignment.start,
73 children: _spaced(kids, n.prop('spacing', 0), vertical: true),
74 );
75
76 case 'hbox':
77 // Wrap and not Row, and this was a bug before it was a decision: the
78 // three mode buttons are wider than the 520-point page, and a Row
79 // answers that with a RenderFlex overflow rather than a second line.
80 // A `:hbox` in the screens means "these go together across", not "these
81 // fit"; the tree has no idea how wide the window is and should not.
82 final gap = n.prop('spacing', 0).toDouble();
83 return Wrap(
84 spacing: gap,
85 runSpacing: gap,
86 crossAxisAlignment: WrapCrossAlignment.center,
87 children: kids,
88 );
89
90 case 'card':
91 return Card(
92 margin: const EdgeInsets.symmetric(vertical: 8),
93 child: Padding(
94 padding: const EdgeInsets.all(16),
95 child: Column(
96 crossAxisAlignment: CrossAxisAlignment.start, children: kids),
97 ),
98 );
99
100 case 'title':
101 return Text(n.prop('label', ''),
102 style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold));
103
104 case 'title-2':
105 return Padding(
106 padding: const EdgeInsets.only(top: 8, bottom: 4),
107 child: Text(n.prop('label', ''),
108 style:
109 const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
110 );
111
112 case 'label':
113 return Text(n.prop('label', ''));
114
115 case 'dim-label':
116 return Opacity(
117 opacity: 0.7,
118 child: Text(n.prop('label', ''),
119 style: const TextStyle(fontSize: 12)));
120
121 case 'spinner':
122 return const SizedBox(
123 width: 16,
124 height: 16,
125 child: CircularProgressIndicator(strokeWidth: 2));
126
127 case 'button':
128 final onClick = n.prop('onClick', '');
129 final label = Text(n.prop('label', ''));
130 // No padding of its own: spacing belongs to the container, which is
131 // the only thing that knows whether this is in a row or a column.
132 return n.prop('kind', 'default') == 'primary'
133 ? FilledButton(onPressed: () => _send(onClick), child: label)
134 : OutlinedButton(onPressed: () => _send(onClick), child: label);
135
136 case 'checkbutton':
137 return Row(mainAxisSize: MainAxisSize.min, children: [
138 Checkbox(
139 value: n.prop('active', false),
140 onChanged: (_) => _send(n.prop('onToggled', '')),
141 ),
142 Text(n.prop('label', '')),
143 ]);
144
145 case 'entry':
146 final key = n.prop('key', '');
147 final text = n.prop('text', '');
148 final c = _controllers.putIfAbsent(
149 key, () => TextEditingController(text: text));
150 // Only when it actually differs: assigning unconditionally moves the
151 // caret to the end on every keystroke, which is the classic way to
152 // make a controlled text field unusable.
153 if (c.text != text) {
154 c.value = c.value.copyWith(
155 text: text,
156 selection: TextSelection.collapsed(offset: text.length),
157 );
158 }
159 final field = TextField(
160 controller: c,
161 decoration: InputDecoration(
162 hintText: n.prop('placeholder', ''),
163 isDense: true,
164 border: const OutlineInputBorder(),
165 ),
166 onChanged: (v) => _send(n.prop('onChange', ''), v),
167 );
168 final w = n.prop('widthRequest', 0);
169 // A width request is a minimum in the screens' vocabulary, but here it
170 // has to be a maximum too: an unconstrained TextField inside a Wrap
171 // has no width at all to take.
172 return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field;
173
174 default:
175 // An unknown tag paints as itself rather than crashing or vanishing.
176 // Nim can add one and see it before this file has heard of it, which
177 // is the behaviour that makes the boundary pleasant to work across.
178 return Container(
179 padding: const EdgeInsets.all(4),
180 color: Colors.orange.withValues(alpha: 0.3),
181 child: Text('?${n.tag}'),
182 );
183 }
184 }
185
186 List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) {
187 if (gap <= 0 || kids.length < 2) return kids;
188 final out = <Widget>[];
189 for (var i = 0; i < kids.length; i++) {
190 if (i > 0) {
191 out.add(vertical
192 ? SizedBox(height: gap.toDouble())
193 : SizedBox(width: gap.toDouble()));
194 }
195 out.add(kids[i]);
196 }
197 return out;
198 }
199}