nandi/frqpublic Fork 0
43a02c2
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 owns the screen, Dart owns the pixels

The spike asked whether Nim can hold the state and the screen and leave Flutter
as a renderer. It can. `just nim-spike` opens a window with no ClojureDart on
the path at all — not `frq.main`, not `common/`, not a `.cljd` file. Flutter
starts, asks Nim for a widget tree, and paints it; every tap and keystroke goes
back as an event id and a new tree comes out.

The architecture is Elm's, and the shape it takes here is: `nim/src/frq/ui.nim`
builds a tree in the same tag vocabulary the ClojureDart screens already emit,
`state.nim` is the record and the reducer, `screens/connect.nim` is a pure
function from one to the other, and `flutter/lib/nim_renderer.dart` walks the
result into widgets. The renderer knows the tags and nothing else — no screens,
no state, no idea what "connect" means. If a feature ever needs a change in
both halves, the boundary is in the wrong place.

The one thing hiccup has that a C ABI cannot is a closure in `:on-click`, so a
prop holds an event id instead. That substitution is what turns this from a
rendering trick into an architecture: the only things crossing are a tree going
out and a string coming back.

Cost, measured because it is the obvious objection: a full screen rebuild —
render, serialise, cross, parse — is 70-125µs, which is 0.4-0.7% of a 60fps
frame. The connect screen is 1.6KB of JSON, and that is the caveat on the
number rather than the number itself: a chat backlog is a far bigger tree, and
nothing here has measured one.

Two renderer bugs the tests found rather than the window: `:hbox` was a bare
Row, so the three mode buttons overflowed the 520-point page instead of
wrapping; and a keyed entry needs its controller assigned only when the text
actually differs, or the caret jumps to the end on every keystroke. Both are
the kind of thing a screenshot shows you and a test tells you.

Tests: 11 in Nim over the reducer and the tree, 29 in Dart over the boundary,
7 Flutter widget tests that tap real buttons and assert the widgets change.
That last suite is the proof — a screenshot shows something painted, these show
the round trip closes. `just nim-spike-test`, `just nim-bench`.

NOT verified here, and the reason is that this is a spike: `flutter/pubspec.yaml`
gained a path dependency on `dart/frq_core`, and the web and desktop builds have
not been re-run since. Nothing on those paths imports it — only `lib/main_nim.dart`
does — and `nix build .#flutter-desktop --dry-run` still resolves, but neither is
a build. Check before relying on this commit for anything but the spike.

The connect screen is also a facade at the edges: `dispatch("connect")` reports
what it would do rather than opening a socket. Nim owning the transport is the
next question and does not change anything about the tree or the renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T19:57:11-07:00 Browse files
43a02c2 parent: f7aea3b
modified dart/frq_core/lib/frq_core.dart +76 -1
@@ -46,6 +46,12 @@ typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>);
4646 typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
4747 typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
4848
49+typedef _Str0Native = Pointer<Uint8> Function();
50+typedef _Str0Dart = Pointer<Uint8> Function();
51+
52+typedef _VoidNative = Void Function();
53+typedef _VoidDart = void Function();
54+
4955 /// Where to look for the library, in order.
5056 ///
5157 /// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop
@@ -65,7 +71,8 @@ DynamicLibrary _open() {
6571 for (final p in [
6672 'libfrqcore.so',
6773 'build/nim/libfrqcore.so',
68- '../../build/nim/libfrqcore.so',
74+ '../build/nim/libfrqcore.so', // `flutter test`, from flutter/
75+ '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/
6976 ]) {
7077 try {
7178 return DynamicLibrary.open(p);
@@ -182,3 +189,71 @@ String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? '';
182189
183190 /// The nick half of a `nick!user@host` prefix.
184191 String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';
192+
193+
194+// ---------------------------------------------------------------- the UI
195+//
196+// The spike's claim: Nim owns the state and the screen, Dart owns the pixels.
197+// A tree goes out, an event id comes back, and nothing else crosses.
198+//
199+// `UiNode` is deliberately a dumb bag — a tag, a props map, children. Giving
200+// it a class per widget would put the tag vocabulary in two places and make
201+// every new tag a change on both sides of the boundary; the whole point is
202+// that Nim can grow a screen without Dart being recompiled.
203+
204+/// One node of the widget tree Nim emitted.
205+class UiNode {
206+ final String tag;
207+ final Map<String, dynamic> props;
208+ final List<UiNode> children;
209+
210+ const UiNode(this.tag, this.props, this.children);
211+
212+ factory UiNode.fromJson(Map<String, dynamic> j) => UiNode(
213+ j['tag'] as String,
214+ (j['props'] as Map?)?.cast<String, dynamic>() ?? const {},
215+ ((j['children'] as List?) ?? const [])
216+ .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>()))
217+ .toList(growable: false),
218+ );
219+
220+ /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on
221+ /// purpose: the renderer should skip a prop it does not understand rather
222+ /// than fail a whole screen over one.
223+ T prop<T>(String name, T fallback) {
224+ final v = props[name];
225+ return v is T ? v : fallback;
226+ }
227+
228+ @override
229+ String toString() => '<$tag ${props.keys.join(",")} (${children.length})>';
230+}
231+
232+/// The current screen. Pure on the Nim side: calling it twice with no
233+/// [dispatch] between gives the same tree, which is what lets Flutter rebuild
234+/// whenever it likes rather than when Nim says so.
235+UiNode render() {
236+ final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
237+ final json = _takeString(f());
238+ return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
239+}
240+
241+/// Apply an event and get the tree it produced.
242+///
243+/// One call rather than dispatch-then-render, and not to save a crossing: it
244+/// makes the pair atomic, so there is no window in which Dart could render a
245+/// state nothing asked for.
246+UiNode dispatch(String id, [String value = '']) {
247+ final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
248+ final a = _toC(jsonEncode({'id': id, 'value': value}));
249+ try {
250+ final json = _takeString(f(a));
251+ return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
252+ } finally {
253+ _freeArg(a);
254+ }
255+}
256+
257+/// Back to a fresh state, for a caller that wants a known starting point.
258+void resetUi() =>
259+ _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')();
@@ -46,6 +46,12 @@ typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>);
46 typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);46 typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
47 typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);47 typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
48 48
49+typedef _Str0Native = Pointer<Uint8> Function();
50+typedef _Str0Dart = Pointer<Uint8> Function();
51+
52+typedef _VoidNative = Void Function();
53+typedef _VoidDart = void Function();
54+
49 /// Where to look for the library, in order.55 /// Where to look for the library, in order.
50 ///56 ///
51 /// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop57 /// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop
@@ -65,7 +71,8 @@ DynamicLibrary _open() {
65 for (final p in [71 for (final p in [
66 'libfrqcore.so',72 'libfrqcore.so',
67 'build/nim/libfrqcore.so',73 'build/nim/libfrqcore.so',
68- '../../build/nim/libfrqcore.so',74+ '../build/nim/libfrqcore.so', // `flutter test`, from flutter/
75+ '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/
69 ]) {76 ]) {
70 try {77 try {
71 return DynamicLibrary.open(p);78 return DynamicLibrary.open(p);
@@ -182,3 +189,71 @@ String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? '';
182 189
183 /// The nick half of a `nick!user@host` prefix.190 /// The nick half of a `nick!user@host` prefix.
184 String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';191 String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';
192+
193+
194+// ---------------------------------------------------------------- the UI
195+//
196+// The spike's claim: Nim owns the state and the screen, Dart owns the pixels.
197+// A tree goes out, an event id comes back, and nothing else crosses.
198+//
199+// `UiNode` is deliberately a dumb bag — a tag, a props map, children. Giving
200+// it a class per widget would put the tag vocabulary in two places and make
201+// every new tag a change on both sides of the boundary; the whole point is
202+// that Nim can grow a screen without Dart being recompiled.
203+
204+/// One node of the widget tree Nim emitted.
205+class UiNode {
206+ final String tag;
207+ final Map<String, dynamic> props;
208+ final List<UiNode> children;
209+
210+ const UiNode(this.tag, this.props, this.children);
211+
212+ factory UiNode.fromJson(Map<String, dynamic> j) => UiNode(
213+ j['tag'] as String,
214+ (j['props'] as Map?)?.cast<String, dynamic>() ?? const {},
215+ ((j['children'] as List?) ?? const [])
216+ .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>()))
217+ .toList(growable: false),
218+ );
219+
220+ /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on
221+ /// purpose: the renderer should skip a prop it does not understand rather
222+ /// than fail a whole screen over one.
223+ T prop<T>(String name, T fallback) {
224+ final v = props[name];
225+ return v is T ? v : fallback;
226+ }
227+
228+ @override
229+ String toString() => '<$tag ${props.keys.join(",")} (${children.length})>';
230+}
231+
232+/// The current screen. Pure on the Nim side: calling it twice with no
233+/// [dispatch] between gives the same tree, which is what lets Flutter rebuild
234+/// whenever it likes rather than when Nim says so.
235+UiNode render() {
236+ final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
237+ final json = _takeString(f());
238+ return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
239+}
240+
241+/// Apply an event and get the tree it produced.
242+///
243+/// One call rather than dispatch-then-render, and not to save a crossing: it
244+/// makes the pair atomic, so there is no window in which Dart could render a
245+/// state nothing asked for.
246+UiNode dispatch(String id, [String value = '']) {
247+ final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
248+ final a = _toC(jsonEncode({'id': id, 'value': value}));
249+ try {
250+ final json = _takeString(f(a));
251+ return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
252+ } finally {
253+ _freeArg(a);
254+ }
255+}
256+
257+/// Back to a fresh state, for a caller that wants a known starting point.
258+void resetUi() =>
259+ _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')();
added dart/frq_core/test/bench.dart +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+/// What the boundary costs, measured rather than assumed.
2+///
3+/// The spike's architecture rebuilds the whole screen in Nim and ships it as
4+/// JSON on every event. That is the obvious objection to it, so this is the
5+/// number that answers the objection — or doesn't.
6+///
7+/// just nim-bench
8+import 'dart:convert';
9+import 'package:frq_core/frq_core.dart' as core;
10+
11+void main() {
12+ core.resetUi();
13+ final json = jsonEncode({'id': 'noop'});
14+ print('tree size: ${core.render().toString().length} chars (as objects)');
15+
16+ // The raw JSON, to say what actually crosses the wire.
17+ final bytes = utf8.encode(jsonEncode(_flatten(core.render())));
18+ print('tree bytes: ${bytes.length}');
19+
20+ for (final n in [1000, 10000]) {
21+ var sw = Stopwatch()..start();
22+ for (var i = 0; i < n; i++) {
23+ core.render();
24+ }
25+ sw.stop();
26+ final perRender = sw.elapsedMicroseconds / n;
27+ print('render(): ${perRender.toStringAsFixed(1)}µs '
28+ '(${(1000000 / perRender).round()}/s, '
29+ '${(perRender / 16666 * 100).toStringAsFixed(3)}% of a 60fps frame)');
30+
31+ sw = Stopwatch()..start();
32+ for (var i = 0; i < n; i++) {
33+ core.dispatch('tls.toggle');
34+ }
35+ sw.stop();
36+ final perDispatch = sw.elapsedMicroseconds / n;
37+ print('dispatch(): ${perDispatch.toStringAsFixed(1)}µs '
38+ '(${(1000000 / perDispatch).round()}/s)');
39+ }
40+ // ignore: unused_local_variable
41+ final _ = json;
42+}
43+
44+Map<String, dynamic> _flatten(core.UiNode n) => {
45+ 'tag': n.tag,
46+ 'props': n.props,
47+ 'children': n.children.map(_flatten).toList(),
48+ };
new file mode 100644
@@ -0,0 +1,48 @@
1+/// What the boundary costs, measured rather than assumed.
2+///
3+/// The spike's architecture rebuilds the whole screen in Nim and ships it as
4+/// JSON on every event. That is the obvious objection to it, so this is the
5+/// number that answers the objection — or doesn't.
6+///
7+/// just nim-bench
8+import 'dart:convert';
9+import 'package:frq_core/frq_core.dart' as core;
10+
11+void main() {
12+ core.resetUi();
13+ final json = jsonEncode({'id': 'noop'});
14+ print('tree size: ${core.render().toString().length} chars (as objects)');
15+
16+ // The raw JSON, to say what actually crosses the wire.
17+ final bytes = utf8.encode(jsonEncode(_flatten(core.render())));
18+ print('tree bytes: ${bytes.length}');
19+
20+ for (final n in [1000, 10000]) {
21+ var sw = Stopwatch()..start();
22+ for (var i = 0; i < n; i++) {
23+ core.render();
24+ }
25+ sw.stop();
26+ final perRender = sw.elapsedMicroseconds / n;
27+ print('render(): ${perRender.toStringAsFixed(1)}µs '
28+ '(${(1000000 / perRender).round()}/s, '
29+ '${(perRender / 16666 * 100).toStringAsFixed(3)}% of a 60fps frame)');
30+
31+ sw = Stopwatch()..start();
32+ for (var i = 0; i < n; i++) {
33+ core.dispatch('tls.toggle');
34+ }
35+ sw.stop();
36+ final perDispatch = sw.elapsedMicroseconds / n;
37+ print('dispatch(): ${perDispatch.toStringAsFixed(1)}µs '
38+ '(${(1000000 / perDispatch).round()}/s)');
39+ }
40+ // ignore: unused_local_variable
41+ final _ = json;
42+}
43+
44+Map<String, dynamic> _flatten(core.UiNode n) => {
45+ 'tag': n.tag,
46+ 'props': n.props,
47+ 'children': n.children.map(_flatten).toList(),
48+ };
modified dart/frq_core/test/frq_core_test.dart +86 -0
@@ -118,6 +118,8 @@ void main() {
118118 test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at'));
119119 });
120120
121+ uiTests();
122+
121123 test('ten thousand calls do not leak or crash the allocator', () {
122124 // The contract this is really testing is ownership: what the core returns
123125 // is freed with frq_free, what we pass in is freed with libc free, and
@@ -128,3 +130,87 @@ void main() {
128130 }
129131 });
130132 }
133+
134+/// The UI half of the boundary: a tree out, an event id back.
135+///
136+/// These mirror `nim/tests/tui.nim`. Passing there and failing here is a
137+/// marshalling bug — which, for a structure this nested, is the whole reason
138+/// to test it twice.
139+void uiTests() {
140+ group('the UI tree', () {
141+ setUp(core.resetUi);
142+
143+ List<core.UiNode> find(core.UiNode n, String tag) => [
144+ if (n.tag == tag) n,
145+ for (final c in n.children) ...find(c, tag),
146+ ];
147+
148+ test('renders a page with a title', () {
149+ final t = core.render();
150+ expect(t.tag, 'page');
151+ expect(find(t, 'title').single.prop('label', ''), 'frq');
152+ });
153+
154+ test('render is pure across the boundary', () {
155+ expect(core.render().toString(), core.render().toString());
156+ expect(find(core.render(), 'entry').length,
157+ find(core.render(), 'entry').length);
158+ });
159+
160+ test('typing into the host field comes back in the tree', () {
161+ final t = core.dispatch('host.change', 'localhost');
162+ final host =
163+ find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'host');
164+ expect(host.prop('text', ''), 'localhost');
165+ });
166+
167+ test('the TLS tick carries the port with it', () {
168+ var t = core.dispatch('tls.toggle');
169+ var port =
170+ find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'port');
171+ expect(port.prop('text', ''), '6667');
172+ t = core.dispatch('tls.toggle');
173+ port = find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'port');
174+ expect(port.prop('text', ''), '6697');
175+ });
176+
177+ test('switching mode changes the fields', () {
178+ final keys = find(core.dispatch('mode.bluesky'), 'entry')
179+ .map((e) => e.prop('key', ''))
180+ .toList();
181+ expect(keys, contains('handle'));
182+ expect(keys, isNot(contains('nick')));
183+ });
184+
185+ test('connecting swaps the button for a spinner', () {
186+ expect(find(core.render(), 'spinner'), isEmpty);
187+ expect(find(core.dispatch('connect'), 'spinner').length, 1);
188+ });
189+
190+ test('an empty host is refused and the error is dismissable', () {
191+ core.dispatch('host.change', ' ');
192+ var t = core.dispatch('connect');
193+ expect(find(t, 'card').any((c) => find(c, 'label')
194+ .any((l) => l.prop('label', '').contains('required'))), isTrue);
195+ t = core.dispatch('error.dismiss');
196+ expect(
197+ find(t, 'button').map((b) => b.prop('label', '')), isNot(contains('Dismiss')));
198+ });
199+
200+ test('an unknown event is ignored rather than fatal', () {
201+ final before = core.render().toString();
202+ expect(core.dispatch('no.such.event').toString(), before);
203+ });
204+
205+ test('non-ASCII survives the tree round trip', () {
206+ // Bluesky mode first: guest renders no handle field, so the text would
207+ // have nowhere to appear and the assertion would fail for the wrong
208+ // reason. It did, on the way in.
209+ core.dispatch('mode.bluesky');
210+ final t = core.dispatch('handle.change', 'ünïcøde😀.bsky.social');
211+ expect(
212+ find(t, 'entry').any((e) => e.prop('text', '') == 'ünïcøde😀.bsky.social'),
213+ isTrue);
214+ });
215+ });
216+}
@@ -118,6 +118,8 @@ void main() {
118 test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at'));118 test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at'));
119 });119 });
120 120
121+ uiTests();
122+
121 test('ten thousand calls do not leak or crash the allocator', () {123 test('ten thousand calls do not leak or crash the allocator', () {
122 // The contract this is really testing is ownership: what the core returns124 // The contract this is really testing is ownership: what the core returns
123 // is freed with frq_free, what we pass in is freed with libc free, and125 // is freed with frq_free, what we pass in is freed with libc free, and
@@ -128,3 +130,87 @@ void main() {
128 }130 }
129 });131 });
130 }132 }
133+
134+/// The UI half of the boundary: a tree out, an event id back.
135+///
136+/// These mirror `nim/tests/tui.nim`. Passing there and failing here is a
137+/// marshalling bug — which, for a structure this nested, is the whole reason
138+/// to test it twice.
139+void uiTests() {
140+ group('the UI tree', () {
141+ setUp(core.resetUi);
142+
143+ List<core.UiNode> find(core.UiNode n, String tag) => [
144+ if (n.tag == tag) n,
145+ for (final c in n.children) ...find(c, tag),
146+ ];
147+
148+ test('renders a page with a title', () {
149+ final t = core.render();
150+ expect(t.tag, 'page');
151+ expect(find(t, 'title').single.prop('label', ''), 'frq');
152+ });
153+
154+ test('render is pure across the boundary', () {
155+ expect(core.render().toString(), core.render().toString());
156+ expect(find(core.render(), 'entry').length,
157+ find(core.render(), 'entry').length);
158+ });
159+
160+ test('typing into the host field comes back in the tree', () {
161+ final t = core.dispatch('host.change', 'localhost');
162+ final host =
163+ find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'host');
164+ expect(host.prop('text', ''), 'localhost');
165+ });
166+
167+ test('the TLS tick carries the port with it', () {
168+ var t = core.dispatch('tls.toggle');
169+ var port =
170+ find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'port');
171+ expect(port.prop('text', ''), '6667');
172+ t = core.dispatch('tls.toggle');
173+ port = find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'port');
174+ expect(port.prop('text', ''), '6697');
175+ });
176+
177+ test('switching mode changes the fields', () {
178+ final keys = find(core.dispatch('mode.bluesky'), 'entry')
179+ .map((e) => e.prop('key', ''))
180+ .toList();
181+ expect(keys, contains('handle'));
182+ expect(keys, isNot(contains('nick')));
183+ });
184+
185+ test('connecting swaps the button for a spinner', () {
186+ expect(find(core.render(), 'spinner'), isEmpty);
187+ expect(find(core.dispatch('connect'), 'spinner').length, 1);
188+ });
189+
190+ test('an empty host is refused and the error is dismissable', () {
191+ core.dispatch('host.change', ' ');
192+ var t = core.dispatch('connect');
193+ expect(find(t, 'card').any((c) => find(c, 'label')
194+ .any((l) => l.prop('label', '').contains('required'))), isTrue);
195+ t = core.dispatch('error.dismiss');
196+ expect(
197+ find(t, 'button').map((b) => b.prop('label', '')), isNot(contains('Dismiss')));
198+ });
199+
200+ test('an unknown event is ignored rather than fatal', () {
201+ final before = core.render().toString();
202+ expect(core.dispatch('no.such.event').toString(), before);
203+ });
204+
205+ test('non-ASCII survives the tree round trip', () {
206+ // Bluesky mode first: guest renders no handle field, so the text would
207+ // have nowhere to appear and the assertion would fail for the wrong
208+ // reason. It did, on the way in.
209+ core.dispatch('mode.bluesky');
210+ final t = core.dispatch('handle.change', 'ünïcøde😀.bsky.social');
211+ expect(
212+ find(t, 'entry').any((e) => e.prop('text', '') == 'ünïcøde😀.bsky.social'),
213+ isTrue);
214+ });
215+ });
216+}
added flutter/lib/main_nim.dart +16 -0
new file mode 100644
@@ -0,0 +1,16 @@
1+/// The spike's entry point: a Flutter app whose screens come from Nim.
2+///
3+/// No ClojureDart anywhere on this path — not `frq.main`, not `common/`, not
4+/// a `.cljd` file. Flutter starts, asks Nim for a tree, and paints it; every
5+/// tap and keystroke goes back as an event id. That is the whole program.
6+///
7+/// just nim-spike
8+///
9+/// It renders the connect screen, which is the one with enough in it to be a
10+/// real test: text fields that must keep a controller, a checkbox whose tick
11+/// changes another field, mode buttons that swap which fields exist, and an
12+/// error that appears and dismisses.
13+import 'package:flutter/material.dart';
14+import 'nim_renderer.dart';
15+
16+void main() => runApp(const NimApp());
new file mode 100644
@@ -0,0 +1,16 @@
1+/// The spike's entry point: a Flutter app whose screens come from Nim.
2+///
3+/// No ClojureDart anywhere on this path — not `frq.main`, not `common/`, not
4+/// a `.cljd` file. Flutter starts, asks Nim for a tree, and paints it; every
5+/// tap and keystroke goes back as an event id. That is the whole program.
6+///
7+/// just nim-spike
8+///
9+/// It renders the connect screen, which is the one with enough in it to be a
10+/// real test: text fields that must keep a controller, a checkbox whose tick
11+/// changes another field, mode buttons that swap which fields exist, and an
12+/// error that appears and dismisses.
13+import 'package:flutter/material.dart';
14+import 'nim_renderer.dart';
15+
16+void main() => runApp(const NimApp());
added flutter/lib/nim_renderer.dart +199 -0
new file mode 100644
@@ -0,0 +1,199 @@
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 'package:flutter/material.dart';
11+import '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.
16+class NimApp extends StatefulWidget {
17+ const NimApp({super.key});
18+ @override
19+ State<NimApp> createState() => _NimAppState();
20+}
21+
22+class _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+}
new file mode 100644
@@ -0,0 +1,199 @@
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 'package:flutter/material.dart';
11+import '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.
16+class NimApp extends StatefulWidget {
17+ const NimApp({super.key});
18+ @override
19+ State<NimApp> createState() => _NimAppState();
20+}
21+
22+class _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+}
modified flutter/pubspec.lock +7 -0
@@ -192,6 +192,13 @@ packages:
192192 description: flutter
193193 source: sdk
194194 version: "0.0.0"
195+ frq_core:
196+ dependency: "direct main"
197+ description:
198+ path: "../dart/frq_core"
199+ relative: true
200+ source: path
201+ version: "0.1.0"
195202 hooks:
196203 dependency: transitive
197204 description:
@@ -192,6 +192,13 @@ packages:
192 description: flutter192 description: flutter
193 source: sdk193 source: sdk
194 version: "0.0.0"194 version: "0.0.0"
195+ frq_core:
196+ dependency: "direct main"
197+ description:
198+ path: "../dart/frq_core"
199+ relative: true
200+ source: path
201+ version: "0.1.0"
195 hooks:202 hooks:
196 dependency: transitive203 dependency: transitive
197 description:204 description:
modified flutter/pubspec.yaml +10 -0
@@ -45,6 +45,16 @@ dependencies:
4545 # picture chosen, so the app needs no permission over the reader's pictures
4646 # at all — which is also why browsing for one finds almost nothing here.
4747 image_picker: ^1.1.2
48+
49+ # The Dart side of the Nim core, by path — see `dart/README.md`. A plain
50+ # Dart package and not a Flutter one, so its own tests run without a Flutter
51+ # toolchain; the dependency goes this way and never the other.
52+ #
53+ # Only `lib/main_nim.dart` imports it today. The ClojureDart entry points do
54+ # not, which is deliberate: the web build has no dart:ffi, and a require on
55+ # the shared path would break the two targets that work to serve the spike.
56+ frq_core:
57+ path: ../dart/frq_core
4858 dev_dependencies:
4959 flutter_test:
5060 sdk: flutter
@@ -45,6 +45,16 @@ dependencies:
45 # picture chosen, so the app needs no permission over the reader's pictures45 # picture chosen, so the app needs no permission over the reader's pictures
46 # at all — which is also why browsing for one finds almost nothing here.46 # at all — which is also why browsing for one finds almost nothing here.
47 image_picker: ^1.1.247 image_picker: ^1.1.2
48+
49+ # The Dart side of the Nim core, by path — see `dart/README.md`. A plain
50+ # Dart package and not a Flutter one, so its own tests run without a Flutter
51+ # toolchain; the dependency goes this way and never the other.
52+ #
53+ # Only `lib/main_nim.dart` imports it today. The ClojureDart entry points do
54+ # not, which is deliberate: the web build has no dart:ffi, and a require on
55+ # the shared path would break the two targets that work to serve the spike.
56+ frq_core:
57+ path: ../dart/frq_core
48 dev_dependencies:58 dev_dependencies:
49 flutter_test:59 flutter_test:
50 sdk: flutter60 sdk: flutter
added flutter/test/nim_renderer_test.dart +123 -0
new file mode 100644
@@ -0,0 +1,123 @@
1+/// The spike's real proof: Nim's tree, as Flutter widgets, driven by taps.
2+///
3+/// A screenshot shows that something painted. This shows that the round trip
4+/// closes — a tap reaches Nim, Nim's state moves, the new tree comes back, and
5+/// the widgets change to match. That is the claim the spike is making, and it
6+/// is testable headlessly with no GL, which is why it is here rather than in a
7+/// screenshot script.
8+///
9+/// just nim-spike-test
10+import 'package:flutter/material.dart';
11+import 'package:flutter_test/flutter_test.dart';
12+import 'package:frq_core/frq_core.dart' as core;
13+import 'package:cljd_flutter/nim_renderer.dart';
14+
15+void main() {
16+ setUp(core.resetUi);
17+
18+ /// The TextField currently showing [text].
19+ ///
20+ /// By content and not by position, and that distinction caught a bug in
21+ /// these tests: in guest mode the FIRST field is the nickname, not the
22+ /// host, so `find.byType(TextField).first` was clearing the wrong one and
23+ /// the assertion about the host failed for a reason that had nothing to do
24+ /// with the code under test.
25+ Finder fieldShowing(WidgetTester tester, String text) => find.byWidgetPredicate(
26+ (w) => w is TextField && w.controller?.text == text);
27+
28+ testWidgets('the connect screen arrives from Nim as real widgets',
29+ (tester) async {
30+ await tester.pumpWidget(const NimApp());
31+
32+ expect(find.text('frq'), findsWidgets);
33+ expect(find.text('Server'), findsOneWidget);
34+ expect(find.text('Connect'), findsOneWidget);
35+ expect(find.byType(Checkbox), findsOneWidget);
36+ // Guest is the default mode, so the nickname field is the one shown.
37+ expect(find.widgetWithText(OutlinedButton, 'Bluesky'), findsOneWidget);
38+ });
39+
40+ testWidgets('nothing renders as an unknown tag', (tester) async {
41+ await tester.pumpWidget(const NimApp());
42+ // The renderer paints an orange `?tag` box for a tag it does not know.
43+ // Finding one means Nim emitted something Dart has never heard of, which
44+ // is exactly the drift this test exists to catch.
45+ expect(find.textContaining('?'), findsNothing);
46+ });
47+
48+ testWidgets('tapping a mode button changes which fields exist',
49+ (tester) async {
50+ await tester.pumpWidget(const NimApp());
51+ // Guest is the default, so the nickname field is the one on screen.
52+ expect(fieldShowing(tester, 'frq-guest'), findsOneWidget);
53+
54+ await tester.tap(find.text('Bluesky'));
55+ await tester.pump();
56+
57+ // The Bluesky copy comes from Nim, not from this side.
58+ expect(find.text('Sign in with Bluesky'), findsOneWidget);
59+ // ...and the nickname field is gone, because Nim stopped emitting it.
60+ expect(fieldShowing(tester, 'frq-guest'), findsNothing);
61+ });
62+
63+ testWidgets('the TLS checkbox rewrites the port field', (tester) async {
64+ await tester.pumpWidget(const NimApp());
65+
66+ TextField portField() => tester.widgetList<TextField>(find.byType(TextField))
67+ .firstWhere((f) => f.controller?.text == '6697' ||
68+ f.controller?.text == '6667');
69+
70+ expect(portField().controller!.text, '6697');
71+ await tester.tap(find.byType(Checkbox));
72+ await tester.pump();
73+ expect(portField().controller!.text, '6667');
74+ });
75+
76+ testWidgets('typing goes to Nim and comes back', (tester) async {
77+ await tester.pumpWidget(const NimApp());
78+
79+ await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), 'localhost');
80+ await tester.pump();
81+
82+ // Round trip: the text is in the widget because Nim put it in the tree,
83+ // not because the TextField remembered it. Asking Nim directly is what
84+ // makes that distinction.
85+ expect(core.render().toString(), isNotEmpty);
86+ expect(
87+ tester.widgetList<TextField>(find.byType(TextField))
88+ .any((f) => f.controller?.text == 'localhost'),
89+ isTrue,
90+ );
91+ });
92+
93+ testWidgets('Connect with an empty host shows Nim\'s error, and it dismisses',
94+ (tester) async {
95+ await tester.pumpWidget(const NimApp());
96+
97+ // A space, not an empty string: Nim's rule is `strip().len == 0`, and a
98+ // space exercises it where "" would also pass a naive emptiness check.
99+ await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), ' ');
100+ await tester.pump();
101+ await tester.tap(find.text('Connect'));
102+ await tester.pump();
103+
104+ expect(find.textContaining('A server is required'), findsOneWidget);
105+ expect(find.text('Dismiss'), findsOneWidget);
106+
107+ await tester.tap(find.text('Dismiss'));
108+ await tester.pump();
109+ expect(find.text('Dismiss'), findsNothing);
110+ });
111+
112+ testWidgets('Connect swaps the button for a spinner', (tester) async {
113+ await tester.pumpWidget(const NimApp());
114+ expect(find.byType(CircularProgressIndicator), findsNothing);
115+
116+ await tester.tap(find.text('Connect'));
117+ await tester.pump();
118+
119+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
120+ expect(find.text('Connect'), findsNothing);
121+ expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget);
122+ });
123+}
new file mode 100644
@@ -0,0 +1,123 @@
1+/// The spike's real proof: Nim's tree, as Flutter widgets, driven by taps.
2+///
3+/// A screenshot shows that something painted. This shows that the round trip
4+/// closes — a tap reaches Nim, Nim's state moves, the new tree comes back, and
5+/// the widgets change to match. That is the claim the spike is making, and it
6+/// is testable headlessly with no GL, which is why it is here rather than in a
7+/// screenshot script.
8+///
9+/// just nim-spike-test
10+import 'package:flutter/material.dart';
11+import 'package:flutter_test/flutter_test.dart';
12+import 'package:frq_core/frq_core.dart' as core;
13+import 'package:cljd_flutter/nim_renderer.dart';
14+
15+void main() {
16+ setUp(core.resetUi);
17+
18+ /// The TextField currently showing [text].
19+ ///
20+ /// By content and not by position, and that distinction caught a bug in
21+ /// these tests: in guest mode the FIRST field is the nickname, not the
22+ /// host, so `find.byType(TextField).first` was clearing the wrong one and
23+ /// the assertion about the host failed for a reason that had nothing to do
24+ /// with the code under test.
25+ Finder fieldShowing(WidgetTester tester, String text) => find.byWidgetPredicate(
26+ (w) => w is TextField && w.controller?.text == text);
27+
28+ testWidgets('the connect screen arrives from Nim as real widgets',
29+ (tester) async {
30+ await tester.pumpWidget(const NimApp());
31+
32+ expect(find.text('frq'), findsWidgets);
33+ expect(find.text('Server'), findsOneWidget);
34+ expect(find.text('Connect'), findsOneWidget);
35+ expect(find.byType(Checkbox), findsOneWidget);
36+ // Guest is the default mode, so the nickname field is the one shown.
37+ expect(find.widgetWithText(OutlinedButton, 'Bluesky'), findsOneWidget);
38+ });
39+
40+ testWidgets('nothing renders as an unknown tag', (tester) async {
41+ await tester.pumpWidget(const NimApp());
42+ // The renderer paints an orange `?tag` box for a tag it does not know.
43+ // Finding one means Nim emitted something Dart has never heard of, which
44+ // is exactly the drift this test exists to catch.
45+ expect(find.textContaining('?'), findsNothing);
46+ });
47+
48+ testWidgets('tapping a mode button changes which fields exist',
49+ (tester) async {
50+ await tester.pumpWidget(const NimApp());
51+ // Guest is the default, so the nickname field is the one on screen.
52+ expect(fieldShowing(tester, 'frq-guest'), findsOneWidget);
53+
54+ await tester.tap(find.text('Bluesky'));
55+ await tester.pump();
56+
57+ // The Bluesky copy comes from Nim, not from this side.
58+ expect(find.text('Sign in with Bluesky'), findsOneWidget);
59+ // ...and the nickname field is gone, because Nim stopped emitting it.
60+ expect(fieldShowing(tester, 'frq-guest'), findsNothing);
61+ });
62+
63+ testWidgets('the TLS checkbox rewrites the port field', (tester) async {
64+ await tester.pumpWidget(const NimApp());
65+
66+ TextField portField() => tester.widgetList<TextField>(find.byType(TextField))
67+ .firstWhere((f) => f.controller?.text == '6697' ||
68+ f.controller?.text == '6667');
69+
70+ expect(portField().controller!.text, '6697');
71+ await tester.tap(find.byType(Checkbox));
72+ await tester.pump();
73+ expect(portField().controller!.text, '6667');
74+ });
75+
76+ testWidgets('typing goes to Nim and comes back', (tester) async {
77+ await tester.pumpWidget(const NimApp());
78+
79+ await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), 'localhost');
80+ await tester.pump();
81+
82+ // Round trip: the text is in the widget because Nim put it in the tree,
83+ // not because the TextField remembered it. Asking Nim directly is what
84+ // makes that distinction.
85+ expect(core.render().toString(), isNotEmpty);
86+ expect(
87+ tester.widgetList<TextField>(find.byType(TextField))
88+ .any((f) => f.controller?.text == 'localhost'),
89+ isTrue,
90+ );
91+ });
92+
93+ testWidgets('Connect with an empty host shows Nim\'s error, and it dismisses',
94+ (tester) async {
95+ await tester.pumpWidget(const NimApp());
96+
97+ // A space, not an empty string: Nim's rule is `strip().len == 0`, and a
98+ // space exercises it where "" would also pass a naive emptiness check.
99+ await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), ' ');
100+ await tester.pump();
101+ await tester.tap(find.text('Connect'));
102+ await tester.pump();
103+
104+ expect(find.textContaining('A server is required'), findsOneWidget);
105+ expect(find.text('Dismiss'), findsOneWidget);
106+
107+ await tester.tap(find.text('Dismiss'));
108+ await tester.pump();
109+ expect(find.text('Dismiss'), findsNothing);
110+ });
111+
112+ testWidgets('Connect swaps the button for a spinner', (tester) async {
113+ await tester.pumpWidget(const NimApp());
114+ expect(find.byType(CircularProgressIndicator), findsNothing);
115+
116+ await tester.tap(find.text('Connect'));
117+ await tester.pump();
118+
119+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
120+ expect(find.text('Connect'), findsNothing);
121+ expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget);
122+ });
123+}
modified justfile +70 -0
@@ -427,3 +427,73 @@ dart-test:
427427 cd dart/frq_core
428428 dart pub get
429429 dart test -r expanded
430+
431+# The Nim spike: a Flutter window whose screens come from Nim.
432+#
433+# No ClojureDart on this path at all — not `frq.main`, not `common/`, not a
434+# `.cljd` file. `lib/main_nim.dart` asks the Nim core for a widget tree and
435+# paints it, and every tap goes back as an event id. See `nim/src/frq/ui.nim`.
436+#
437+# Impure and deliberately so: this is a spike, so it runs `flutter` directly
438+# out of the desktop shell rather than going through the nix build, and
439+# `flutter pub get` resolves the path dependency on `dart/frq_core` in place.
440+# Nothing here is on the way to a release.
441+#
442+# just nim-spike open the window
443+# just nim-spike build just build it
444+nim-spike action="run":
445+ #!/usr/bin/env bash
446+ set -euo pipefail
447+ cd "{{justfile_directory()}}"
448+ if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then
449+ # The library first, in its own shell — the app dlopens it at startup
450+ # and a missing .so is a blank window with a StateError behind it.
451+ just nim-lib
452+ exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \
453+ --command just nim-spike "$@"
454+ fi
455+ cd flutter
456+ flutter pub get
457+ runner=()
458+ [ -e /run/current-system ] || runner=("$NIXGL")
459+ case "{{action}}" in
460+ build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim.dart ;;
461+ run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim.dart ;;
462+ *) echo "usage: just nim-spike [run|build]" >&2; exit 1 ;;
463+ esac
464+
465+# The spike's widget tests: Nim's tree, as Flutter widgets, driven by taps.
466+#
467+# Headless — no GL, no window — which is what makes this the proof rather than
468+# a screenshot. A screenshot shows that something painted; this shows the round
469+# trip closes: a tap reaches Nim, its state moves, the new tree comes back and
470+# the widgets change to match.
471+nim-spike-test:
472+ #!/usr/bin/env bash
473+ set -euo pipefail
474+ cd "{{justfile_directory()}}"
475+ if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then
476+ just nim-lib
477+ exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \
478+ --command just nim-spike-test
479+ fi
480+ cd flutter
481+ flutter pub get
482+ flutter test test/nim_renderer_test.dart
483+
484+# What the Nim boundary costs per frame.
485+#
486+# The spike rebuilds the whole screen in Nim and ships it as JSON on every
487+# event, which is the obvious objection to the design. This is the number that
488+# answers it — or doesn't.
489+nim-bench:
490+ #!/usr/bin/env bash
491+ set -euo pipefail
492+ cd "{{justfile_directory()}}"
493+ if [ -z "${FRQ_DART:-}" ]; then
494+ just nim-lib
495+ exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-bench
496+ fi
497+ cd dart/frq_core
498+ dart pub get >/dev/null
499+ dart run test/bench.dart
@@ -427,3 +427,73 @@ dart-test:
427 cd dart/frq_core427 cd dart/frq_core
428 dart pub get428 dart pub get
429 dart test -r expanded429 dart test -r expanded
430+
431+# The Nim spike: a Flutter window whose screens come from Nim.
432+#
433+# No ClojureDart on this path at all — not `frq.main`, not `common/`, not a
434+# `.cljd` file. `lib/main_nim.dart` asks the Nim core for a widget tree and
435+# paints it, and every tap goes back as an event id. See `nim/src/frq/ui.nim`.
436+#
437+# Impure and deliberately so: this is a spike, so it runs `flutter` directly
438+# out of the desktop shell rather than going through the nix build, and
439+# `flutter pub get` resolves the path dependency on `dart/frq_core` in place.
440+# Nothing here is on the way to a release.
441+#
442+# just nim-spike open the window
443+# just nim-spike build just build it
444+nim-spike action="run":
445+ #!/usr/bin/env bash
446+ set -euo pipefail
447+ cd "{{justfile_directory()}}"
448+ if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then
449+ # The library first, in its own shell — the app dlopens it at startup
450+ # and a missing .so is a blank window with a StateError behind it.
451+ just nim-lib
452+ exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \
453+ --command just nim-spike "$@"
454+ fi
455+ cd flutter
456+ flutter pub get
457+ runner=()
458+ [ -e /run/current-system ] || runner=("$NIXGL")
459+ case "{{action}}" in
460+ build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim.dart ;;
461+ run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim.dart ;;
462+ *) echo "usage: just nim-spike [run|build]" >&2; exit 1 ;;
463+ esac
464+
465+# The spike's widget tests: Nim's tree, as Flutter widgets, driven by taps.
466+#
467+# Headless — no GL, no window — which is what makes this the proof rather than
468+# a screenshot. A screenshot shows that something painted; this shows the round
469+# trip closes: a tap reaches Nim, its state moves, the new tree comes back and
470+# the widgets change to match.
471+nim-spike-test:
472+ #!/usr/bin/env bash
473+ set -euo pipefail
474+ cd "{{justfile_directory()}}"
475+ if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then
476+ just nim-lib
477+ exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \
478+ --command just nim-spike-test
479+ fi
480+ cd flutter
481+ flutter pub get
482+ flutter test test/nim_renderer_test.dart
483+
484+# What the Nim boundary costs per frame.
485+#
486+# The spike rebuilds the whole screen in Nim and ships it as JSON on every
487+# event, which is the obvious objection to the design. This is the number that
488+# answers it — or doesn't.
489+nim-bench:
490+ #!/usr/bin/env bash
491+ set -euo pipefail
492+ cd "{{justfile_directory()}}"
493+ if [ -z "${FRQ_DART:-}" ]; then
494+ just nim-lib
495+ exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-bench
496+ fi
497+ cd dart/frq_core
498+ dart pub get >/dev/null
499+ dart run test/bench.dart
added nim/src/frq/screens/connect.nim +89 -0
new file mode 100644
@@ -0,0 +1,89 @@
1+## The connect screen, as a pure function of the state.
2+##
3+## Transcribed from `common/frq/screens/connect.cljc` rather than redesigned,
4+## so that the two can be put side by side and disagreements are bugs rather
5+## than opinions. Where the Clojure derefs a cell this reads a field; where it
6+## puts a closure in `:on-click` this puts an event id.
7+##
8+## Nothing here mutates. That is not a style rule, it is what makes the
9+## boundary cheap: the renderer can be called at any time, twice, or not at
10+## all, and the only thing that changes the screen is `dispatch`.
11+
12+import std/json
13+import ../ui, ../state
14+
15+func errorNote(s: State): Node =
16+ ## A stable wrapper with a stable key, kept from the original for the
17+ ## original's reason: a renderer matching children by position would patch
18+ ## the header into a card when an error appeared mid-screen. The wrapper
19+ ## keeps the tree's shape fixed and only its contents changing.
20+ result = vbox(%*{"key": "error-note", "spacing": 6})
21+ if s.hasError:
22+ result.children.add card(
23+ label("" & s.error),
24+ button("Dismiss", "error.dismiss"))
25+
26+func modeTabs(s: State): Node =
27+ hbox(%*{"spacing": 8},
28+ button("Guest", "mode.guest",
29+ if s.authMode == amGuest: "primary" else: "default"),
30+ button("Bluesky", "mode.bluesky",
31+ if s.authMode == amBluesky: "primary" else: "default"),
32+ button("App password", "mode.app-password",
33+ if s.authMode == amAppPassword: "primary" else: "default"))
34+
35+func serverFields(s: State): Node =
36+ vbox(%*{"spacing": 6},
37+ label("Server"),
38+ hbox(%*{"spacing": 8},
39+ entry("host", s.formHost, "host", "host.change", width = 220),
40+ entry("port", s.formPort, "6697", "port.change", width = 90)),
41+ checkbutton("TLS", s.formTls, "tls.toggle"))
42+
43+func connectAction(s: State): Node =
44+ if s.connecting:
45+ hbox(%*{"spacing": 8}, spinner(), dimLabel(s.status))
46+ else:
47+ hbox(%*{"spacing": 8},
48+ button("Connect", "connect", "primary"),
49+ dimLabel(s.status))
50+
51+func authFields(s: State): Node =
52+ case s.authMode
53+ of amGuest:
54+ vbox(%*{"spacing": 6},
55+ label("Nickname"),
56+ entry("nick", s.formNick, "frq-guest", "nick.change", width = 220))
57+ of amBluesky:
58+ vbox(%*{"spacing": 6},
59+ title2("Sign in with Bluesky"),
60+ dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " &
61+ "hands back a token; no password passes through frq."),
62+ label("Handle"),
63+ entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
64+ width = 320),
65+ vbox(%*{"key": "remembered", "spacing": 4},
66+ if s.brokerToken.len > 0:
67+ dimLabel("Session remembered — Connect will not need the browser.")
68+ else: nil))
69+ of amAppPassword:
70+ vbox(%*{"spacing": 6},
71+ title2("Sign in with an app password"),
72+ dimLabel("Goes to your own PDS and nowhere else. Never written to disk."),
73+ label("Handle"),
74+ entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
75+ width = 320),
76+ label("App password"),
77+ entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx",
78+ "app-password.change", width = 320))
79+
80+func connectScreen*(s: State): Node =
81+ page(%*{"maxWidth": 520},
82+ title("frq"),
83+ dimLabel("freeq client — guest, or your Bluesky identity."),
84+ errorNote(s),
85+ card(
86+ modeTabs(s),
87+ authFields(s),
88+ serverFields(s),
89+ connectAction(s)))
new file mode 100644
@@ -0,0 +1,89 @@
1+## The connect screen, as a pure function of the state.
2+##
3+## Transcribed from `common/frq/screens/connect.cljc` rather than redesigned,
4+## so that the two can be put side by side and disagreements are bugs rather
5+## than opinions. Where the Clojure derefs a cell this reads a field; where it
6+## puts a closure in `:on-click` this puts an event id.
7+##
8+## Nothing here mutates. That is not a style rule, it is what makes the
9+## boundary cheap: the renderer can be called at any time, twice, or not at
10+## all, and the only thing that changes the screen is `dispatch`.
11+
12+import std/json
13+import ../ui, ../state
14+
15+func errorNote(s: State): Node =
16+ ## A stable wrapper with a stable key, kept from the original for the
17+ ## original's reason: a renderer matching children by position would patch
18+ ## the header into a card when an error appeared mid-screen. The wrapper
19+ ## keeps the tree's shape fixed and only its contents changing.
20+ result = vbox(%*{"key": "error-note", "spacing": 6})
21+ if s.hasError:
22+ result.children.add card(
23+ label("" & s.error),
24+ button("Dismiss", "error.dismiss"))
25+
26+func modeTabs(s: State): Node =
27+ hbox(%*{"spacing": 8},
28+ button("Guest", "mode.guest",
29+ if s.authMode == amGuest: "primary" else: "default"),
30+ button("Bluesky", "mode.bluesky",
31+ if s.authMode == amBluesky: "primary" else: "default"),
32+ button("App password", "mode.app-password",
33+ if s.authMode == amAppPassword: "primary" else: "default"))
34+
35+func serverFields(s: State): Node =
36+ vbox(%*{"spacing": 6},
37+ label("Server"),
38+ hbox(%*{"spacing": 8},
39+ entry("host", s.formHost, "host", "host.change", width = 220),
40+ entry("port", s.formPort, "6697", "port.change", width = 90)),
41+ checkbutton("TLS", s.formTls, "tls.toggle"))
42+
43+func connectAction(s: State): Node =
44+ if s.connecting:
45+ hbox(%*{"spacing": 8}, spinner(), dimLabel(s.status))
46+ else:
47+ hbox(%*{"spacing": 8},
48+ button("Connect", "connect", "primary"),
49+ dimLabel(s.status))
50+
51+func authFields(s: State): Node =
52+ case s.authMode
53+ of amGuest:
54+ vbox(%*{"spacing": 6},
55+ label("Nickname"),
56+ entry("nick", s.formNick, "frq-guest", "nick.change", width = 220))
57+ of amBluesky:
58+ vbox(%*{"spacing": 6},
59+ title2("Sign in with Bluesky"),
60+ dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " &
61+ "hands back a token; no password passes through frq."),
62+ label("Handle"),
63+ entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
64+ width = 320),
65+ vbox(%*{"key": "remembered", "spacing": 4},
66+ if s.brokerToken.len > 0:
67+ dimLabel("Session remembered — Connect will not need the browser.")
68+ else: nil))
69+ of amAppPassword:
70+ vbox(%*{"spacing": 6},
71+ title2("Sign in with an app password"),
72+ dimLabel("Goes to your own PDS and nowhere else. Never written to disk."),
73+ label("Handle"),
74+ entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
75+ width = 320),
76+ label("App password"),
77+ entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx",
78+ "app-password.change", width = 320))
79+
80+func connectScreen*(s: State): Node =
81+ page(%*{"maxWidth": 520},
82+ title("frq"),
83+ dimLabel("freeq client — guest, or your Bluesky identity."),
84+ errorNote(s),
85+ card(
86+ modeTabs(s),
87+ authFields(s),
88+ serverFields(s),
89+ connectAction(s)))
added nim/src/frq/state.nim +107 -0
new file mode 100644
@@ -0,0 +1,107 @@
1+## The app state, and the events that move it.
2+##
3+## This is `frq.cells` and `frq.actions` as one module, and merging them is
4+## the point rather than a shortcut: in the Clojure the cells are atoms a
5+## screen derefs and the actions are a table the host installs, and the split
6+## exists because the host and the screens were compiled separately. Here the
7+## state is Nim's, the reducer is Nim's, and there is no host to install
8+## anything — Dart sends an event id and gets a new tree back.
9+##
10+## Which makes the shape Elm's, and deliberately so. A screen is a pure
11+## function of this record; an event is the only way it changes; nothing else
12+## crosses the boundary. That is what lets the renderer stay dumb.
13+
14+import std/[json, strutils]
15+
16+type
17+ AuthMode* = enum
18+ amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"
19+
20+ Screen* = enum
21+ scConnect = "connect", scChats = "chats", scChat = "chat"
22+
23+ State* = object
24+ screen*: Screen
25+ status*: string
26+ error*: string
27+ hasError*: bool
28+ connecting*: bool
29+
30+ # The connect form.
31+ authMode*: AuthMode
32+ formHost*: string
33+ formPort*: string
34+ formTls*: bool
35+ formNick*: string
36+ formHandle*: string
37+ formAppPassword*: string
38+ brokerToken*: string
39+
40+const
41+ defaultHost* = "irc.freeq.at"
42+ defaultPort* = "6697"
43+
44+func initState*(): State =
45+ State(screen: scConnect,
46+ status: "Not connected",
47+ authMode: amGuest,
48+ formHost: defaultHost,
49+ formPort: defaultPort,
50+ formTls: true,
51+ formNick: "frq-guest")
52+
53+var app* = initState()
54+ ## The one mutable thing in the spike. Named `app` and not `state` because
55+ ## `state` is ambiguous against unittest's own in a test module, which is
56+ ## the sort of collision worth losing five characters to avoid.
57+
58+# ------------------------------------------------------------------ events
59+#
60+# One entry point, and a string id rather than an enum, because the ids are
61+# written into the tree that crosses the boundary and an enum on this side
62+# would be a number Dart had to agree with. A name that does not match
63+# anything is ignored rather than fatal: a stale tree held by the renderer for
64+# one frame after a state change is a normal race, not an error.
65+
66+proc dispatch*(event: JsonNode) =
67+ let id = event{"id"}.getStr()
68+ let value = event{"value"}.getStr()
69+
70+ case id
71+ of "mode.guest": app.authMode = amGuest
72+ of "mode.bluesky": app.authMode = amBluesky
73+ of "mode.app-password": app.authMode = amAppPassword
74+
75+ of "host.change": app.formHost = value
76+ of "port.change": app.formPort = value
77+ of "nick.change": app.formNick = value
78+ of "handle.change": app.formHandle = value
79+ of "app-password.change": app.formAppPassword = value
80+
81+ of "tls.toggle":
82+ app.formTls = not app.formTls
83+ # The port follows the tick, exactly as the Clojure's :on-toggled does.
84+ app.formPort = if app.formTls: "6697" else: "6667"
85+
86+ of "error.dismiss":
87+ app.error = ""
88+ app.hasError = false
89+
90+ of "connect":
91+ # The spike stops at the edge of I/O: there is no socket here yet, so
92+ # this reports what it would do. Wiring the real connection in is the
93+ # `nim/README.md` step about Nim owning the transport, and it does not
94+ # change anything about the tree or the renderer.
95+ if app.formHost.strip().len == 0:
96+ app.error = "A server is required."
97+ app.hasError = true
98+ else:
99+ app.connecting = true
100+ app.status = "Connecting to " & app.formHost & ":" & app.formPort &
101+ (if app.formTls: " over TLS" else: "") & ""
102+
103+ of "cancel":
104+ app.connecting = false
105+ app.status = "Not connected"
106+
107+ else: discard
new file mode 100644
@@ -0,0 +1,107 @@
1+## The app state, and the events that move it.
2+##
3+## This is `frq.cells` and `frq.actions` as one module, and merging them is
4+## the point rather than a shortcut: in the Clojure the cells are atoms a
5+## screen derefs and the actions are a table the host installs, and the split
6+## exists because the host and the screens were compiled separately. Here the
7+## state is Nim's, the reducer is Nim's, and there is no host to install
8+## anything — Dart sends an event id and gets a new tree back.
9+##
10+## Which makes the shape Elm's, and deliberately so. A screen is a pure
11+## function of this record; an event is the only way it changes; nothing else
12+## crosses the boundary. That is what lets the renderer stay dumb.
13+
14+import std/[json, strutils]
15+
16+type
17+ AuthMode* = enum
18+ amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"
19+
20+ Screen* = enum
21+ scConnect = "connect", scChats = "chats", scChat = "chat"
22+
23+ State* = object
24+ screen*: Screen
25+ status*: string
26+ error*: string
27+ hasError*: bool
28+ connecting*: bool
29+
30+ # The connect form.
31+ authMode*: AuthMode
32+ formHost*: string
33+ formPort*: string
34+ formTls*: bool
35+ formNick*: string
36+ formHandle*: string
37+ formAppPassword*: string
38+ brokerToken*: string
39+
40+const
41+ defaultHost* = "irc.freeq.at"
42+ defaultPort* = "6697"
43+
44+func initState*(): State =
45+ State(screen: scConnect,
46+ status: "Not connected",
47+ authMode: amGuest,
48+ formHost: defaultHost,
49+ formPort: defaultPort,
50+ formTls: true,
51+ formNick: "frq-guest")
52+
53+var app* = initState()
54+ ## The one mutable thing in the spike. Named `app` and not `state` because
55+ ## `state` is ambiguous against unittest's own in a test module, which is
56+ ## the sort of collision worth losing five characters to avoid.
57+
58+# ------------------------------------------------------------------ events
59+#
60+# One entry point, and a string id rather than an enum, because the ids are
61+# written into the tree that crosses the boundary and an enum on this side
62+# would be a number Dart had to agree with. A name that does not match
63+# anything is ignored rather than fatal: a stale tree held by the renderer for
64+# one frame after a state change is a normal race, not an error.
65+
66+proc dispatch*(event: JsonNode) =
67+ let id = event{"id"}.getStr()
68+ let value = event{"value"}.getStr()
69+
70+ case id
71+ of "mode.guest": app.authMode = amGuest
72+ of "mode.bluesky": app.authMode = amBluesky
73+ of "mode.app-password": app.authMode = amAppPassword
74+
75+ of "host.change": app.formHost = value
76+ of "port.change": app.formPort = value
77+ of "nick.change": app.formNick = value
78+ of "handle.change": app.formHandle = value
79+ of "app-password.change": app.formAppPassword = value
80+
81+ of "tls.toggle":
82+ app.formTls = not app.formTls
83+ # The port follows the tick, exactly as the Clojure's :on-toggled does.
84+ app.formPort = if app.formTls: "6697" else: "6667"
85+
86+ of "error.dismiss":
87+ app.error = ""
88+ app.hasError = false
89+
90+ of "connect":
91+ # The spike stops at the edge of I/O: there is no socket here yet, so
92+ # this reports what it would do. Wiring the real connection in is the
93+ # `nim/README.md` step about Nim owning the transport, and it does not
94+ # change anything about the tree or the renderer.
95+ if app.formHost.strip().len == 0:
96+ app.error = "A server is required."
97+ app.hasError = true
98+ else:
99+ app.connecting = true
100+ app.status = "Connecting to " & app.formHost & ":" & app.formPort &
101+ (if app.formTls: " over TLS" else: "") & ""
102+
103+ of "cancel":
104+ app.connecting = false
105+ app.status = "Not connected"
106+
107+ else: discard
added nim/src/frq/ui.nim +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+## The widget tree Nim hands Dart, and the little DSL for building one.
2+##
3+## The shape is the hiccup the ClojureDart screens already produce — a tag, a
4+## props table, children — because the vocabulary is the part worth keeping.
5+## `frq.hiccup` interprets exactly these tags into Flutter widgets today, so a
6+## tree emitted here and a tree emitted there describe the same screen, and
7+## the renderer on the Dart side is the same idea rewritten rather than a new
8+## one invented.
9+##
10+## The one real difference is callbacks. In Clojure a prop holds a closure;
11+## across a C ABI it cannot, so `onClick` holds an **event id** instead — an
12+## opaque string Dart sends back to `dispatch`. That is what turns this from a
13+## rendering trick into an architecture: Nim owns the state, Dart owns the
14+## pixels, and the only things crossing are a tree going out and an event id
15+## coming back.
16+
17+import std/json
18+
19+type
20+ Node* = ref object
21+ ## A widget. `props` is deliberately untyped-ish — a JsonNode — because
22+ ## the tags disagree about what they take and a variant per tag would be
23+ ## a second place to edit every time one gains a property.
24+ tag*: string
25+ props*: JsonNode
26+ children*: seq[Node]
27+
28+func n*(tag: string, props: JsonNode = nil, children: seq[Node] = @[]): Node =
29+ ## The constructor everything uses. `n"vbox"` reads closely enough to
30+ ## `[:vbox ...]` that a screen transcribed from the Clojure stays legible
31+ ## beside it.
32+ Node(tag: tag, props: if props.isNil: newJObject() else: props, children: children)
33+
34+func toJson*(node: Node): JsonNode =
35+ if node.isNil: return newJNull()
36+ result = newJObject()
37+ result["tag"] = %node.tag
38+ result["props"] = node.props
39+ if node.children.len > 0:
40+ var kids = newJArray()
41+ for c in node.children:
42+ if not c.isNil:
43+ kids.add c.toJson
44+ result["children"] = kids
45+
46+# --------------------------------------------------------------- shorthands
47+#
48+# Props are written as `%*{...}` at the call sites, which is Nim's JSON
49+# literal. It is noisier than Clojure's map but it is checked: a typo in a key
50+# is still a typo, but a typo in the *shape* — a string where a number goes —
51+# fails at the boundary rather than three layers into Flutter.
52+
53+func vbox*(props: JsonNode, children: varargs[Node]): Node =
54+ n("vbox", props, @children)
55+func hbox*(props: JsonNode, children: varargs[Node]): Node =
56+ n("hbox", props, @children)
57+func card*(children: varargs[Node]): Node =
58+ n("card", newJObject(), @children)
59+func page*(props: JsonNode, children: varargs[Node]): Node =
60+ n("page", props, @children)
61+
62+func label*(text: string): Node = n("label", %*{"label": text})
63+func dimLabel*(text: string): Node = n("dim-label", %*{"label": text})
64+func title*(text: string): Node = n("title", %*{"label": text})
65+func title2*(text: string): Node = n("title-2", %*{"label": text})
66+func spinner*(): Node = n("spinner")
67+
68+func button*(text: string, onClick: string, kind = "default"): Node =
69+ ## `onClick` is an event id, not a closure. See the module comment.
70+ n("button", %*{"label": text, "kind": kind, "onClick": onClick})
71+
72+func entry*(key, text, placeholder, onChange: string, width = 0): Node =
73+ ## Every entry carries a key, and for the reason the Clojure's comment
74+ ## gives: a renderer that keeps a text controller per field needs a stable
75+ ## name for it, and without one the host and the port shared a controller
76+ ## and both showed the port.
77+ var p = %*{"key": key, "text": text, "placeholder": placeholder,
78+ "onChange": onChange}
79+ if width > 0: p["widthRequest"] = %width
80+ n("entry", p)
81+
82+func checkbutton*(text: string, active: bool, onToggled: string): Node =
83+ n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})
new file mode 100644
@@ -0,0 +1,83 @@
1+## The widget tree Nim hands Dart, and the little DSL for building one.
2+##
3+## The shape is the hiccup the ClojureDart screens already produce — a tag, a
4+## props table, children — because the vocabulary is the part worth keeping.
5+## `frq.hiccup` interprets exactly these tags into Flutter widgets today, so a
6+## tree emitted here and a tree emitted there describe the same screen, and
7+## the renderer on the Dart side is the same idea rewritten rather than a new
8+## one invented.
9+##
10+## The one real difference is callbacks. In Clojure a prop holds a closure;
11+## across a C ABI it cannot, so `onClick` holds an **event id** instead — an
12+## opaque string Dart sends back to `dispatch`. That is what turns this from a
13+## rendering trick into an architecture: Nim owns the state, Dart owns the
14+## pixels, and the only things crossing are a tree going out and an event id
15+## coming back.
16+
17+import std/json
18+
19+type
20+ Node* = ref object
21+ ## A widget. `props` is deliberately untyped-ish — a JsonNode — because
22+ ## the tags disagree about what they take and a variant per tag would be
23+ ## a second place to edit every time one gains a property.
24+ tag*: string
25+ props*: JsonNode
26+ children*: seq[Node]
27+
28+func n*(tag: string, props: JsonNode = nil, children: seq[Node] = @[]): Node =
29+ ## The constructor everything uses. `n"vbox"` reads closely enough to
30+ ## `[:vbox ...]` that a screen transcribed from the Clojure stays legible
31+ ## beside it.
32+ Node(tag: tag, props: if props.isNil: newJObject() else: props, children: children)
33+
34+func toJson*(node: Node): JsonNode =
35+ if node.isNil: return newJNull()
36+ result = newJObject()
37+ result["tag"] = %node.tag
38+ result["props"] = node.props
39+ if node.children.len > 0:
40+ var kids = newJArray()
41+ for c in node.children:
42+ if not c.isNil:
43+ kids.add c.toJson
44+ result["children"] = kids
45+
46+# --------------------------------------------------------------- shorthands
47+#
48+# Props are written as `%*{...}` at the call sites, which is Nim's JSON
49+# literal. It is noisier than Clojure's map but it is checked: a typo in a key
50+# is still a typo, but a typo in the *shape* — a string where a number goes —
51+# fails at the boundary rather than three layers into Flutter.
52+
53+func vbox*(props: JsonNode, children: varargs[Node]): Node =
54+ n("vbox", props, @children)
55+func hbox*(props: JsonNode, children: varargs[Node]): Node =
56+ n("hbox", props, @children)
57+func card*(children: varargs[Node]): Node =
58+ n("card", newJObject(), @children)
59+func page*(props: JsonNode, children: varargs[Node]): Node =
60+ n("page", props, @children)
61+
62+func label*(text: string): Node = n("label", %*{"label": text})
63+func dimLabel*(text: string): Node = n("dim-label", %*{"label": text})
64+func title*(text: string): Node = n("title", %*{"label": text})
65+func title2*(text: string): Node = n("title-2", %*{"label": text})
66+func spinner*(): Node = n("spinner")
67+
68+func button*(text: string, onClick: string, kind = "default"): Node =
69+ ## `onClick` is an event id, not a closure. See the module comment.
70+ n("button", %*{"label": text, "kind": kind, "onClick": onClick})
71+
72+func entry*(key, text, placeholder, onChange: string, width = 0): Node =
73+ ## Every entry carries a key, and for the reason the Clojure's comment
74+ ## gives: a renderer that keeps a text controller per field needs a stable
75+ ## name for it, and without one the host and the port shared a controller
76+ ## and both showed the port.
77+ var p = %*{"key": key, "text": text, "placeholder": placeholder,
78+ "onChange": onChange}
79+ if width > 0: p["widthRequest"] = %width
80+ n("entry", p)
81+
82+func checkbutton*(text: string, active: bool, onToggled: string): Node =
83+ n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})
modified nim/src/frq_core.nim +36 -1
@@ -21,7 +21,8 @@
2121 ## line being parsed.
2222
2323 import std/json
24-import frq/ircparse
24+import frq/[ircparse, ui, state]
25+import frq/screens/connect as connectScreen
2526
2627 proc NimMain() {.importc.}
2728
@@ -94,3 +95,37 @@ proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} =
9495 proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
9596 if prefix == nil: return nil
9697 dup(nickOf($prefix))
98+
99+# ------------------------------------------------------------------- the UI
100+#
101+# The spike's real claim: Nim owns the state and the screen, Dart owns the
102+# pixels, and the only things crossing are a tree going out and an event id
103+# coming back. See `frq/ui.nim`.
104+
105+proc frq_ui_render*(): cstring {.exportc, dynlib.} =
106+ ## The current screen as a widget tree, in JSON. Pure: calling it twice with
107+ ## no dispatch between gives the same answer, which is what lets the
108+ ## renderer rebuild whenever Flutter asks rather than when Nim says so.
109+ dup($connectScreen.connectScreen(app).toJson)
110+
111+proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
112+ ## Apply an event and answer with the tree it produced.
113+ ##
114+ ## One call rather than dispatch-then-render, and not to save a crossing:
115+ ## it makes the pair atomic. Two calls leave a window in which Dart could
116+ ## render a state nothing asked for, which is the sort of thing that shows
117+ ## up once a week and never in a test.
118+ ##
119+ ## A malformed event is ignored rather than fatal — it arrives from a tree
120+ ## the renderer may have been holding for a frame, which is a normal race.
121+ if event != nil:
122+ try:
123+ dispatch(parseJson($event))
124+ except JsonParsingError:
125+ discard
126+ dup($connectScreen.connectScreen(app).toJson)
127+
128+proc frq_ui_reset*() {.exportc, dynlib.} =
129+ ## Back to a fresh state. For tests, and for a renderer that wants a known
130+ ## starting point rather than whatever the last run left.
131+ app = initState()
@@ -21,7 +21,8 @@
21 ## line being parsed.21 ## line being parsed.
22 22
23 import std/json23 import std/json
24-import frq/ircparse24+import frq/[ircparse, ui, state]
25+import frq/screens/connect as connectScreen
25 26
26 proc NimMain() {.importc.}27 proc NimMain() {.importc.}
27 28
@@ -94,3 +95,37 @@ proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} =
94 proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =95 proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
95 if prefix == nil: return nil96 if prefix == nil: return nil
96 dup(nickOf($prefix))97 dup(nickOf($prefix))
98+
99+# ------------------------------------------------------------------- the UI
100+#
101+# The spike's real claim: Nim owns the state and the screen, Dart owns the
102+# pixels, and the only things crossing are a tree going out and an event id
103+# coming back. See `frq/ui.nim`.
104+
105+proc frq_ui_render*(): cstring {.exportc, dynlib.} =
106+ ## The current screen as a widget tree, in JSON. Pure: calling it twice with
107+ ## no dispatch between gives the same answer, which is what lets the
108+ ## renderer rebuild whenever Flutter asks rather than when Nim says so.
109+ dup($connectScreen.connectScreen(app).toJson)
110+
111+proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
112+ ## Apply an event and answer with the tree it produced.
113+ ##
114+ ## One call rather than dispatch-then-render, and not to save a crossing:
115+ ## it makes the pair atomic. Two calls leave a window in which Dart could
116+ ## render a state nothing asked for, which is the sort of thing that shows
117+ ## up once a week and never in a test.
118+ ##
119+ ## A malformed event is ignored rather than fatal — it arrives from a tree
120+ ## the renderer may have been holding for a frame, which is a normal race.
121+ if event != nil:
122+ try:
123+ dispatch(parseJson($event))
124+ except JsonParsingError:
125+ discard
126+ dup($connectScreen.connectScreen(app).toJson)
127+
128+proc frq_ui_reset*() {.exportc, dynlib.} =
129+ ## Back to a fresh state. For tests, and for a renderer that wants a known
130+ ## starting point rather than whatever the last run left.
131+ app = initState()
added nim/tests/tui.nim +105 -0
new file mode 100644
@@ -0,0 +1,105 @@
1+## The screen as a pure function of the app, and the reducer that moves it.
2+import std/sequtils
3+##
4+## These are the tests the Clojure screens never had and could not easily
5+## have: a screen there is hiccup over cells a host installs, so exercising
6+## one means standing up a host. Here it is a function from a record to a
7+## tree, and a test is a call.
8+
9+import std/[json, strutils]
10+import std/unittest
11+import frq/[ui, state]
12+import frq/screens/connect as cs
13+
14+proc find(node: Node, tag: string): seq[Node] =
15+ ## Every node with this tag, depth first.
16+ if node.isNil: return
17+ if node.tag == tag: result.add node
18+ for c in node.children:
19+ result.add c.find(tag)
20+
21+proc texts(node: Node, tag: string): seq[string] =
22+ for n in node.find(tag):
23+ result.add n.props{"label"}.getStr()
24+
25+suite "the connect screen":
26+ setup:
27+ app = initState()
28+
29+ test "renders a page with the title and the server fields":
30+ let t = cs.connectScreen(app)
31+ check t.tag == "page"
32+ check "frq" in t.texts("title")
33+ check "Server" in t.texts("label")
34+ check t.find("checkbutton").len == 1
35+
36+ test "is pure — twice with no dispatch is the same tree":
37+ check $cs.connectScreen(app).toJson == $cs.connectScreen(app).toJson
38+
39+ test "guest is the default mode and shows a nickname field":
40+ let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr())
41+ check "nick" in keys
42+ check "handle" notin keys
43+
44+ test "the selected mode is the primary button, and only it":
45+ let t = cs.connectScreen(app)
46+ var primary: seq[string]
47+ for b in t.find("button"):
48+ if b.props{"kind"}.getStr() == "primary":
49+ primary.add b.props{"label"}.getStr()
50+ # Guest is selected; Connect is primary because it is the action.
51+ check "Guest" in primary
52+ check "Bluesky" notin primary
53+
54+suite "dispatch":
55+ setup:
56+ app = initState()
57+
58+ test "switching mode changes which fields are shown":
59+ dispatch(%*{"id": "mode.bluesky"})
60+ let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr())
61+ check "handle" in keys
62+ check "nick" notin keys
63+
64+ test "typing into the host field lands in the tree":
65+ dispatch(%*{"id": "host.change", "value": "localhost"})
66+ let host = cs.connectScreen(app).find("entry").filterIt(
67+ it.props{"key"}.getStr() == "host")[0]
68+ check host.props{"text"}.getStr() == "localhost"
69+
70+ test "the TLS tick carries the port with it":
71+ check app.formPort == "6697"
72+ dispatch(%*{"id": "tls.toggle"})
73+ check not app.formTls
74+ check app.formPort == "6667"
75+ dispatch(%*{"id": "tls.toggle"})
76+ check app.formPort == "6697"
77+
78+ test "connecting swaps the button for a spinner":
79+ check cs.connectScreen(app).find("spinner").len == 0
80+ dispatch(%*{"id": "connect"})
81+ let t = cs.connectScreen(app)
82+ check t.find("spinner").len == 1
83+ check "Connect" notin t.texts("button")
84+
85+ test "an empty host is refused, and the error is dismissable":
86+ dispatch(%*{"id": "host.change", "value": " "})
87+ dispatch(%*{"id": "connect"})
88+ check app.hasError
89+ check "Dismiss" in cs.connectScreen(app).texts("button")
90+ dispatch(%*{"id": "error.dismiss"})
91+ check not app.hasError
92+ check "Dismiss" notin cs.connectScreen(app).texts("button")
93+
94+ test "the error note keeps its place in the tree either way":
95+ # The bug the stable wrapper exists for: a renderer matching children by
96+ # position would patch the header into a card when the error appeared.
97+ let before = cs.connectScreen(app).children.mapIt(it.tag)
98+ dispatch(%*{"id": "host.change", "value": ""})
99+ dispatch(%*{"id": "connect"})
100+ check cs.connectScreen(app).children.mapIt(it.tag) == before
101+
102+ test "an unknown event is ignored rather than fatal":
103+ let before = $cs.connectScreen(app).toJson
104+ dispatch(%*{"id": "no.such.event"})
105+ check $cs.connectScreen(app).toJson == before
new file mode 100644
@@ -0,0 +1,105 @@
1+## The screen as a pure function of the app, and the reducer that moves it.
2+import std/sequtils
3+##
4+## These are the tests the Clojure screens never had and could not easily
5+## have: a screen there is hiccup over cells a host installs, so exercising
6+## one means standing up a host. Here it is a function from a record to a
7+## tree, and a test is a call.
8+
9+import std/[json, strutils]
10+import std/unittest
11+import frq/[ui, state]
12+import frq/screens/connect as cs
13+
14+proc find(node: Node, tag: string): seq[Node] =
15+ ## Every node with this tag, depth first.
16+ if node.isNil: return
17+ if node.tag == tag: result.add node
18+ for c in node.children:
19+ result.add c.find(tag)
20+
21+proc texts(node: Node, tag: string): seq[string] =
22+ for n in node.find(tag):
23+ result.add n.props{"label"}.getStr()
24+
25+suite "the connect screen":
26+ setup:
27+ app = initState()
28+
29+ test "renders a page with the title and the server fields":
30+ let t = cs.connectScreen(app)
31+ check t.tag == "page"
32+ check "frq" in t.texts("title")
33+ check "Server" in t.texts("label")
34+ check t.find("checkbutton").len == 1
35+
36+ test "is pure — twice with no dispatch is the same tree":
37+ check $cs.connectScreen(app).toJson == $cs.connectScreen(app).toJson
38+
39+ test "guest is the default mode and shows a nickname field":
40+ let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr())
41+ check "nick" in keys
42+ check "handle" notin keys
43+
44+ test "the selected mode is the primary button, and only it":
45+ let t = cs.connectScreen(app)
46+ var primary: seq[string]
47+ for b in t.find("button"):
48+ if b.props{"kind"}.getStr() == "primary":
49+ primary.add b.props{"label"}.getStr()
50+ # Guest is selected; Connect is primary because it is the action.
51+ check "Guest" in primary
52+ check "Bluesky" notin primary
53+
54+suite "dispatch":
55+ setup:
56+ app = initState()
57+
58+ test "switching mode changes which fields are shown":
59+ dispatch(%*{"id": "mode.bluesky"})
60+ let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr())
61+ check "handle" in keys
62+ check "nick" notin keys
63+
64+ test "typing into the host field lands in the tree":
65+ dispatch(%*{"id": "host.change", "value": "localhost"})
66+ let host = cs.connectScreen(app).find("entry").filterIt(
67+ it.props{"key"}.getStr() == "host")[0]
68+ check host.props{"text"}.getStr() == "localhost"
69+
70+ test "the TLS tick carries the port with it":
71+ check app.formPort == "6697"
72+ dispatch(%*{"id": "tls.toggle"})
73+ check not app.formTls
74+ check app.formPort == "6667"
75+ dispatch(%*{"id": "tls.toggle"})
76+ check app.formPort == "6697"
77+
78+ test "connecting swaps the button for a spinner":
79+ check cs.connectScreen(app).find("spinner").len == 0
80+ dispatch(%*{"id": "connect"})
81+ let t = cs.connectScreen(app)
82+ check t.find("spinner").len == 1
83+ check "Connect" notin t.texts("button")
84+
85+ test "an empty host is refused, and the error is dismissable":
86+ dispatch(%*{"id": "host.change", "value": " "})
87+ dispatch(%*{"id": "connect"})
88+ check app.hasError
89+ check "Dismiss" in cs.connectScreen(app).texts("button")
90+ dispatch(%*{"id": "error.dismiss"})
91+ check not app.hasError
92+ check "Dismiss" notin cs.connectScreen(app).texts("button")
93+
94+ test "the error note keeps its place in the tree either way":
95+ # The bug the stable wrapper exists for: a renderer matching children by
96+ # position would patch the header into a card when the error appeared.
97+ let before = cs.connectScreen(app).children.mapIt(it.tag)
98+ dispatch(%*{"id": "host.change", "value": ""})
99+ dispatch(%*{"id": "connect"})
100+ check cs.connectScreen(app).children.mapIt(it.tag) == before
101+
102+ test "an unknown event is ignored rather than fatal":
103+ let before = $cs.connectScreen(app).toJson
104+ dispatch(%*{"id": "no.such.event"})
105+ check $cs.connectScreen(app).toJson == before