nandi/frqpublic Fork 0
1bb3f77
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.

Delete the spike that owned the screens

It reimplemented a 148-line connect screen in 96 lines and a 1,518-line chat
screen in 43 — no reactions, no replies, no images, no emoji picker — and the
only way forward from it was rewriting every screen in Nim and losing all of
that. `frq.net` was the seam that actually wanted Nim under it, and that is
wired up and working, so this is dead weight with a confusingly similar name.

Gone: the Nim UI tree, state machine and screens; the Flutter renderer that
walked them and its widget tests; the UI half of the ABI (`frq_ui_render`,
`frq_ui_dispatch`, `frq_ui_poll`, `frq_ui_reset`, `frq_ui_offline`) and its
Dart binding; the benchmark that measured a tree nothing builds now; the
`nim-spike`, `nim-spike-test`, `nim-bench` and `nim-live` recipes; and
FRQ_AUTOCONNECT, which existed to click a button that no longer exists.

`irc.nim` goes with them. It was the spike's socket — registration, PING and
a state machine in one — and `conn.nim` is what replaced it: a transport that
knows about lines and nothing above them.

What is left is fourteen exported symbols: the parser, the transport, and
`frq_trace` so both languages write to one log.

It is all at 1d62d1a if any of it is ever wanted. The measurements are worth
keeping in mind rather than rerunning: a full screen rebuild across the
boundary was 70-105µs, which was never the reason not to do it.

Verified after deleting: `just nim-app build` from a cleaned cljd-out, and the
app against the real server — 473 lines, SASL, the same five rooms. 27 Nim
tests, 20 Dart tests, check-common clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T20:32:35-07:00 Browse files
1bb3f77 parent: 56551a8
modified dart/frq_core/lib/frq_core.dart +0 -94
@@ -247,97 +247,3 @@ String? connRecv() => _takeString(
247247 /// The next transport event — `open`, `close: …`, `error: …` — or null.
248248 String? connEvent() => _takeString(
249249 _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event')());
250-
251-// ---------------------------------------------------------------- the UI
252-//
253-// The spike's claim: Nim owns the state and the screen, Dart owns the pixels.
254-// A tree goes out, an event id comes back, and nothing else crosses.
255-//
256-// `UiNode` is deliberately a dumb bag — a tag, a props map, children. Giving
257-// it a class per widget would put the tag vocabulary in two places and make
258-// every new tag a change on both sides of the boundary; the whole point is
259-// that Nim can grow a screen without Dart being recompiled.
260-
261-/// One node of the widget tree Nim emitted.
262-class UiNode {
263- final String tag;
264- final Map<String, dynamic> props;
265- final List<UiNode> children;
266-
267- const UiNode(this.tag, this.props, this.children);
268-
269- factory UiNode.fromJson(Map<String, dynamic> j) => UiNode(
270- j['tag'] as String,
271- (j['props'] as Map?)?.cast<String, dynamic>() ?? const {},
272- ((j['children'] as List?) ?? const [])
273- .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>()))
274- .toList(growable: false),
275- );
276-
277- /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on
278- /// purpose: the renderer should skip a prop it does not understand rather
279- /// than fail a whole screen over one.
280- T prop<T>(String name, T fallback) {
281- final v = props[name];
282- return v is T ? v : fallback;
283- }
284-
285- /// Structural, and that matters: the poll loop compares two trees by this
286- /// string to decide whether to rebuild. A summary that showed only the tag
287- /// and the prop NAMES would call two screens equal when a message had
288- /// arrived, and the room would never appear to fill.
289- @override
290- String toString() =>
291- '<$tag $props ${children.map((c) => c.toString()).join()}>';
292-}
293-
294-/// The current screen.
295-///
296-/// Not pure: the Nim side drains the socket's queue first, so two calls with
297-/// no [dispatch] between can differ when a line arrived in the gap. That is
298-/// how the room fills, and it is why the renderer polls.
299-UiNode render() {
300- final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
301- final json = _takeString(f());
302- return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
303-}
304-
305-/// Apply an event and get the tree it produced.
306-///
307-/// One call rather than dispatch-then-render, and not to save a crossing: it
308-/// makes the pair atomic, so there is no window in which Dart could render a
309-/// state nothing asked for.
310-UiNode dispatch(String id, [String value = '']) {
311- final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
312- final a = _toC(jsonEncode({'id': id, 'value': value}));
313- try {
314- final json = _takeString(f(a));
315- return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
316- } finally {
317- _freeArg(a);
318- }
319-}
320-
321-/// The tree, asked for because time passed rather than because anything
322-/// happened.
323-///
324-/// Identical to [render] — the Nim side drains the socket queue on both — but
325-/// named for what the caller means. A renderer polls this; it does not poll
326-/// "render".
327-UiNode poll() {
328- final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
329- final json = _takeString(f());
330- return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
331-}
332-
333-/// Stop `connect` from opening a socket.
334-///
335-/// For tests that build the real screens and tap the real Connect button. A
336-/// widget test that dials irc.freeq.at is one that fails on a train, and this
337-/// suite did exactly that before this existed. One way only.
338-void goOffline() =>
339- _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_offline')();
340-
341-/// Back to a fresh state, for a caller that wants a known starting point.
342-void resetUi() =>
343- _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')();
@@ -247,97 +247,3 @@ String? connRecv() => _takeString(
247 /// The next transport event — `open`, `close: …`, `error: …` — or null.247 /// The next transport event — `open`, `close: …`, `error: …` — or null.
248 String? connEvent() => _takeString(248 String? connEvent() => _takeString(
249 _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event')());249 _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event')());
250-
251-// ---------------------------------------------------------------- the UI
252-//
253-// The spike's claim: Nim owns the state and the screen, Dart owns the pixels.
254-// A tree goes out, an event id comes back, and nothing else crosses.
255-//
256-// `UiNode` is deliberately a dumb bag — a tag, a props map, children. Giving
257-// it a class per widget would put the tag vocabulary in two places and make
258-// every new tag a change on both sides of the boundary; the whole point is
259-// that Nim can grow a screen without Dart being recompiled.
260-
261-/// One node of the widget tree Nim emitted.
262-class UiNode {
263- final String tag;
264- final Map<String, dynamic> props;
265- final List<UiNode> children;
266-
267- const UiNode(this.tag, this.props, this.children);
268-
269- factory UiNode.fromJson(Map<String, dynamic> j) => UiNode(
270- j['tag'] as String,
271- (j['props'] as Map?)?.cast<String, dynamic>() ?? const {},
272- ((j['children'] as List?) ?? const [])
273- .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>()))
274- .toList(growable: false),
275- );
276-
277- /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on
278- /// purpose: the renderer should skip a prop it does not understand rather
279- /// than fail a whole screen over one.
280- T prop<T>(String name, T fallback) {
281- final v = props[name];
282- return v is T ? v : fallback;
283- }
284-
285- /// Structural, and that matters: the poll loop compares two trees by this
286- /// string to decide whether to rebuild. A summary that showed only the tag
287- /// and the prop NAMES would call two screens equal when a message had
288- /// arrived, and the room would never appear to fill.
289- @override
290- String toString() =>
291- '<$tag $props ${children.map((c) => c.toString()).join()}>';
292-}
293-
294-/// The current screen.
295-///
296-/// Not pure: the Nim side drains the socket's queue first, so two calls with
297-/// no [dispatch] between can differ when a line arrived in the gap. That is
298-/// how the room fills, and it is why the renderer polls.
299-UiNode render() {
300- final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
301- final json = _takeString(f());
302- return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
303-}
304-
305-/// Apply an event and get the tree it produced.
306-///
307-/// One call rather than dispatch-then-render, and not to save a crossing: it
308-/// makes the pair atomic, so there is no window in which Dart could render a
309-/// state nothing asked for.
310-UiNode dispatch(String id, [String value = '']) {
311- final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
312- final a = _toC(jsonEncode({'id': id, 'value': value}));
313- try {
314- final json = _takeString(f(a));
315- return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
316- } finally {
317- _freeArg(a);
318- }
319-}
320-
321-/// The tree, asked for because time passed rather than because anything
322-/// happened.
323-///
324-/// Identical to [render] — the Nim side drains the socket queue on both — but
325-/// named for what the caller means. A renderer polls this; it does not poll
326-/// "render".
327-UiNode poll() {
328- final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
329- final json = _takeString(f());
330- return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
331-}
332-
333-/// Stop `connect` from opening a socket.
334-///
335-/// For tests that build the real screens and tap the real Connect button. A
336-/// widget test that dials irc.freeq.at is one that fails on a train, and this
337-/// suite did exactly that before this existed. One way only.
338-void goOffline() =>
339- _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_offline')();
340-
341-/// Back to a fresh state, for a caller that wants a known starting point.
342-void resetUi() =>
343- _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')();
deleted dart/frq_core/test/bench.dart +0 -48
deleted file mode 100644
@@ -1,48 +0,0 @@
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- };
deleted file mode 100644
@@ -1,48 +0,0 @@
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 +0 -86
@@ -118,8 +118,6 @@ void main() {
118118 test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at'));
119119 });
120120
121- uiTests();
122-
123121 test('ten thousand calls do not leak or crash the allocator', () {
124122 // The contract this is really testing is ownership: what the core returns
125123 // is freed with frq_free, what we pass in is freed with libc free, and
@@ -130,87 +128,3 @@ void main() {
130128 }
131129 });
132130 }
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,8 +118,6 @@ 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-
123 test('ten thousand calls do not leak or crash the allocator', () {121 test('ten thousand calls do not leak or crash the allocator', () {
124 // The contract this is really testing is ownership: what the core returns122 // The contract this is really testing is ownership: what the core returns
125 // is freed with frq_free, what we pass in is freed with libc free, and123 // is freed with frq_free, what we pass in is freed with libc free, and
@@ -130,87 +128,3 @@ void main() {
130 }128 }
131 });129 });
132 }130 }
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-}
deleted dart/frq_core/tool/live_send.dart +0 -76
deleted file mode 100644
@@ -1,76 +0,0 @@
1-/// The spike's end to end: connect to a real freeq, join #test, say a line.
2-///
3-/// Not in the test suite on purpose. It needs a network, a DNS server and a
4-/// running freeq, and it sends a message to a public channel — none of which
5-/// belongs in something CI runs on every push. `just nim-live` runs it when
6-/// somebody means to.
7-///
8-/// Everything below goes through the FFI, so what it proves is the whole
9-/// stack: Nim's socket, Nim's TLS, Nim's IRC registration, Nim's state, and
10-/// the Dart boundary over all of it.
11-import 'dart:io';
12-import 'package:frq_core/frq_core.dart' as core;
13-
14-Future<void> main(List<String> args) async {
15- final host = args.isNotEmpty ? args[0] : 'irc.freeq.at';
16- final nick = args.length > 1
17- ? args[1]
18- : 'frq-spike-${DateTime.now().millisecondsSinceEpoch % 10000}';
19- final text = args.length > 2
20- ? args[2]
21- : 'frq nim spike: hello from Nim over dart:ffi';
22-
23- core.resetUi();
24- print('$host as $nick');
25-
26- core.dispatch('nick.change', nick);
27- core.dispatch('host.change', host);
28- core.dispatch('connect');
29-
30- // Poll exactly as the renderer does — same call, same cadence — so this
31- // exercises the path the app uses rather than a special one for testing.
32- var tree = core.poll();
33- final deadline = DateTime.now().add(const Duration(seconds: 25));
34- while (DateTime.now().isBefore(deadline)) {
35- await Future<void>.delayed(const Duration(milliseconds: 100));
36- tree = core.poll();
37- if (_find(tree, 'title').any((t) => t.prop('label', '') == '#test')) break;
38- final err = _find(tree, 'label')
39- .map((l) => l.prop('label', ''))
40- .where((l) => l.startsWith(''));
41- if (err.isNotEmpty) {
42- print('${err.first}');
43- exit(1);
44- }
45- }
46-
47- if (!_find(tree, 'title').any((t) => t.prop('label', '') == '#test')) {
48- print('✗ never registered — still on ${_find(tree, "title").map((t) => t.prop("label", ""))}');
49- print(' run with FRQ_TRACE=1 to see the wire');
50- exit(1);
51- }
52- print('✓ registered and joined #test');
53-
54- core.dispatch('draft.change', text);
55- tree = core.dispatch('send');
56-
57- final said = _find(tree, 'label').map((l) => l.prop('label', ''));
58- if (said.contains(text)) {
59- print('✓ sent: $text');
60- } else {
61- print('✗ the line did not reach the backlog');
62- exit(1);
63- }
64-
65- // Give the server a moment to echo anything back, then leave cleanly so the
66- // reader thread is joined rather than killed with the process.
67- await Future<void>.delayed(const Duration(seconds: 3));
68- for (final m in _find(core.poll(), 'label')) {
69- print(' | ${m.prop("label", "")}');
70- }
71- core.dispatch('disconnect');
72- print('✓ disconnected');
73-}
74-
75-List<core.UiNode> _find(core.UiNode n, String tag) =>
76- [if (n.tag == tag) n, for (final c in n.children) ..._find(c, tag)];
deleted file mode 100644
@@ -1,76 +0,0 @@
1-/// The spike's end to end: connect to a real freeq, join #test, say a line.
2-///
3-/// Not in the test suite on purpose. It needs a network, a DNS server and a
4-/// running freeq, and it sends a message to a public channel — none of which
5-/// belongs in something CI runs on every push. `just nim-live` runs it when
6-/// somebody means to.
7-///
8-/// Everything below goes through the FFI, so what it proves is the whole
9-/// stack: Nim's socket, Nim's TLS, Nim's IRC registration, Nim's state, and
10-/// the Dart boundary over all of it.
11-import 'dart:io';
12-import 'package:frq_core/frq_core.dart' as core;
13-
14-Future<void> main(List<String> args) async {
15- final host = args.isNotEmpty ? args[0] : 'irc.freeq.at';
16- final nick = args.length > 1
17- ? args[1]
18- : 'frq-spike-${DateTime.now().millisecondsSinceEpoch % 10000}';
19- final text = args.length > 2
20- ? args[2]
21- : 'frq nim spike: hello from Nim over dart:ffi';
22-
23- core.resetUi();
24- print('$host as $nick');
25-
26- core.dispatch('nick.change', nick);
27- core.dispatch('host.change', host);
28- core.dispatch('connect');
29-
30- // Poll exactly as the renderer does — same call, same cadence — so this
31- // exercises the path the app uses rather than a special one for testing.
32- var tree = core.poll();
33- final deadline = DateTime.now().add(const Duration(seconds: 25));
34- while (DateTime.now().isBefore(deadline)) {
35- await Future<void>.delayed(const Duration(milliseconds: 100));
36- tree = core.poll();
37- if (_find(tree, 'title').any((t) => t.prop('label', '') == '#test')) break;
38- final err = _find(tree, 'label')
39- .map((l) => l.prop('label', ''))
40- .where((l) => l.startsWith(''));
41- if (err.isNotEmpty) {
42- print('${err.first}');
43- exit(1);
44- }
45- }
46-
47- if (!_find(tree, 'title').any((t) => t.prop('label', '') == '#test')) {
48- print('✗ never registered — still on ${_find(tree, "title").map((t) => t.prop("label", ""))}');
49- print(' run with FRQ_TRACE=1 to see the wire');
50- exit(1);
51- }
52- print('✓ registered and joined #test');
53-
54- core.dispatch('draft.change', text);
55- tree = core.dispatch('send');
56-
57- final said = _find(tree, 'label').map((l) => l.prop('label', ''));
58- if (said.contains(text)) {
59- print('✓ sent: $text');
60- } else {
61- print('✗ the line did not reach the backlog');
62- exit(1);
63- }
64-
65- // Give the server a moment to echo anything back, then leave cleanly so the
66- // reader thread is joined rather than killed with the process.
67- await Future<void>.delayed(const Duration(seconds: 3));
68- for (final m in _find(core.poll(), 'label')) {
69- print(' | ${m.prop("label", "")}');
70- }
71- core.dispatch('disconnect');
72- print('✓ disconnected');
73-}
74-
75-List<core.UiNode> _find(core.UiNode n, String tag) =>
76- [if (n.tag == tag) n, for (final c in n.children) ..._find(c, tag)];
deleted flutter/lib/main_nim.dart +0 -16
deleted file mode 100644
@@ -1,16 +0,0 @@
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());
deleted file mode 100644
@@ -1,16 +0,0 @@
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());
modified flutter/lib/main_nim_app.dart +3 -3
@@ -1,6 +1,6 @@
11 /// The real frq, with the Nim core as its transport.
22 ///
3-/// Not to be confused with `main_nim.dart`, which is the earlier spike where
4-/// Nim owned the screens too. This one keeps every screen, every cell and
5-/// every action exactly as they are and swaps only what is underneath them.
3+/// Every screen, every cell and every action exactly as they are; only the
4+/// transport underneath them is Nim. `frq.main-nim` is `frq.main` with one
5+/// line changed.
66 export "cljd-out/frq/main-nim.dart" show main;
@@ -1,6 +1,6 @@
1 /// The real frq, with the Nim core as its transport.1 /// The real frq, with the Nim core as its transport.
2 ///2 ///
3-/// Not to be confused with `main_nim.dart`, which is the earlier spike where3+/// Every screen, every cell and every action exactly as they are; only the
4-/// Nim owned the screens too. This one keeps every screen, every cell and4+/// transport underneath them is Nim. `frq.main-nim` is `frq.main` with one
5-/// every action exactly as they are and swaps only what is underneath them.5+/// line changed.
6 export "cljd-out/frq/main-nim.dart" show main;6 export "cljd-out/frq/main-nim.dart" show main;
deleted flutter/lib/nim_renderer.dart +0 -251
deleted file mode 100644
@@ -1,251 +0,0 @@
1-/// The renderer: a Nim widget tree, walked into Flutter widgets.
2-///
3-/// This is the Dart half of the spike's claim. It knows the tag vocabulary
4-/// and nothing else — no screens, no state, no idea what "connect" means. Nim
5-/// decides what the screen is; this decides what a `vbox` looks like.
6-///
7-/// The measure of whether the split is honest is how boring this file is. If
8-/// a feature ever needs a change here AND in Nim, the boundary is in the
9-/// wrong place.
10-import 'dart:async';
11-
12-import 'package:flutter/material.dart';
13-import 'package:frq_core/frq_core.dart' as core;
14-
15-/// Rebuilds from Nim on every event. One `setState` per dispatch, and the
16-/// whole tree is rebuilt — which is what Flutter does anyway, and is why the
17-/// Nim side does not need a reconciler of its own.
18-class NimApp extends StatefulWidget {
19- const NimApp({super.key});
20- @override
21- State<NimApp> createState() => _NimAppState();
22-}
23-
24-class _NimAppState extends State<NimApp> {
25- late core.UiNode _tree = core.render();
26- Timer? _poll;
27-
28- @override
29- void initState() {
30- super.initState();
31- // Polling, because the socket lives on a Nim thread and there is no
32- // callback into Dart. A Dart callback invoked from a foreign thread has to
33- // be marshalled onto the main isolate — NativeCallable, ports, a whole
34- // mechanism — and at 70µs a render a 100ms timer does the same job for
35- // nothing. It is also why `render` is allowed to be impure.
36- _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
37- final t = core.poll();
38- // Only when it actually differs: a setState per tick would rebuild the
39- // whole tree ten times a second for a screen nobody is touching.
40- if (t.toString() != _tree.toString()) {
41- setState(() => _tree = t);
42- }
43- });
44- }
45-
46- // One controller per keyed entry, kept across rebuilds.
47- //
48- // This is the whole reason `:key` is on every entry in both the Clojure and
49- // the Nim: a controller identified by position instead of name meant the
50- // host field and the port field shared one and both showed the port. The
51- // comment survives three languages now.
52- final _controllers = <String, TextEditingController>{};
53-
54- // One focus node per keyed entry, for the same reason as the controllers.
55- // Without it, sending with Enter drops focus and the next line is typed
56- // into nothing — the field is rebuilt from a fresh tree every time.
57- final _focus = <String, FocusNode>{};
58-
59- void _send(String id, [String value = '']) {
60- setState(() => _tree = core.dispatch(id, value));
61- // Enter in the compose box clears the draft in Nim and rebuilds the
62- // field; putting focus back is what makes a second line typeable.
63- if (id == 'send') _focus['draft']?.requestFocus();
64- }
65-
66- @override
67- void dispose() {
68- _poll?.cancel();
69- for (final c in _controllers.values) {
70- c.dispose();
71- }
72- for (final f in _focus.values) {
73- f.dispose();
74- }
75- super.dispose();
76- }
77-
78- @override
79- Widget build(BuildContext context) => MaterialApp(
80- title: 'frq',
81- theme: ThemeData.dark(useMaterial3: true),
82- home: Scaffold(
83- body: SafeArea(child: SingleChildScrollView(child: _build(_tree))),
84- ),
85- );
86-
87- Widget _build(core.UiNode n) {
88- final kids = n.children.map(_build).toList();
89-
90- switch (n.tag) {
91- case 'page':
92- return Center(
93- child: ConstrainedBox(
94- constraints:
95- BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()),
96- child: Padding(
97- padding: const EdgeInsets.all(24),
98- child: Column(
99- crossAxisAlignment: CrossAxisAlignment.start, children: kids),
100- ),
101- ),
102- );
103-
104- case 'vbox':
105- return Column(
106- crossAxisAlignment: CrossAxisAlignment.start,
107- children: _spaced(kids, n.prop('spacing', 0), vertical: true),
108- );
109-
110- case 'hbox':
111- // Wrap and not Row, and this was a bug before it was a decision: the
112- // three mode buttons are wider than the 520-point page, and a Row
113- // answers that with a RenderFlex overflow rather than a second line.
114- // A `:hbox` in the screens means "these go together across", not "these
115- // fit"; the tree has no idea how wide the window is and should not.
116- final gap = n.prop('spacing', 0).toDouble();
117- return Wrap(
118- spacing: gap,
119- runSpacing: gap,
120- crossAxisAlignment: WrapCrossAlignment.center,
121- children: kids,
122- );
123-
124- case 'scroll':
125- return SizedBox(
126- height: n.prop('height', 300).toDouble(),
127- child: Scrollbar(
128- child: SingleChildScrollView(
129- reverse: true,
130- child: Column(
131- crossAxisAlignment: CrossAxisAlignment.start,
132- children: kids),
133- ),
134- ),
135- );
136-
137- case 'card':
138- return Card(
139- margin: const EdgeInsets.symmetric(vertical: 8),
140- child: Padding(
141- padding: const EdgeInsets.all(16),
142- child: Column(
143- crossAxisAlignment: CrossAxisAlignment.start, children: kids),
144- ),
145- );
146-
147- case 'title':
148- return Text(n.prop('label', ''),
149- style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold));
150-
151- case 'title-2':
152- return Padding(
153- padding: const EdgeInsets.only(top: 8, bottom: 4),
154- child: Text(n.prop('label', ''),
155- style:
156- const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
157- );
158-
159- case 'label':
160- return Text(n.prop('label', ''));
161-
162- case 'dim-label':
163- return Opacity(
164- opacity: 0.7,
165- child: Text(n.prop('label', ''),
166- style: const TextStyle(fontSize: 12)));
167-
168- case 'spinner':
169- return const SizedBox(
170- width: 16,
171- height: 16,
172- child: CircularProgressIndicator(strokeWidth: 2));
173-
174- case 'button':
175- final onClick = n.prop('onClick', '');
176- final label = Text(n.prop('label', ''));
177- // No padding of its own: spacing belongs to the container, which is
178- // the only thing that knows whether this is in a row or a column.
179- return n.prop('kind', 'default') == 'primary'
180- ? FilledButton(onPressed: () => _send(onClick), child: label)
181- : OutlinedButton(onPressed: () => _send(onClick), child: label);
182-
183- case 'checkbutton':
184- return Row(mainAxisSize: MainAxisSize.min, children: [
185- Checkbox(
186- value: n.prop('active', false),
187- onChanged: (_) => _send(n.prop('onToggled', '')),
188- ),
189- Text(n.prop('label', '')),
190- ]);
191-
192- case 'entry':
193- final key = n.prop('key', '');
194- final text = n.prop('text', '');
195- final c = _controllers.putIfAbsent(
196- key, () => TextEditingController(text: text));
197- // Only when it actually differs: assigning unconditionally moves the
198- // caret to the end on every keystroke, which is the classic way to
199- // make a controlled text field unusable.
200- if (c.text != text) {
201- c.value = c.value.copyWith(
202- text: text,
203- selection: TextSelection.collapsed(offset: text.length),
204- );
205- }
206- final field = TextField(
207- controller: c,
208- focusNode: _focus.putIfAbsent(key, FocusNode.new),
209- decoration: InputDecoration(
210- hintText: n.prop('placeholder', ''),
211- isDense: true,
212- border: const OutlineInputBorder(),
213- ),
214- onChanged: (v) => _send(n.prop('onChange', ''), v),
215- onSubmitted: (_) {
216- final submit = n.prop('onSubmit', '');
217- if (submit.isNotEmpty) _send(submit);
218- },
219- );
220- final w = n.prop('widthRequest', 0);
221- // A width request is a minimum in the screens' vocabulary, but here it
222- // has to be a maximum too: an unconstrained TextField inside a Wrap
223- // has no width at all to take.
224- return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field;
225-
226- default:
227- // An unknown tag paints as itself rather than crashing or vanishing.
228- // Nim can add one and see it before this file has heard of it, which
229- // is the behaviour that makes the boundary pleasant to work across.
230- return Container(
231- padding: const EdgeInsets.all(4),
232- color: Colors.orange.withValues(alpha: 0.3),
233- child: Text('?${n.tag}'),
234- );
235- }
236- }
237-
238- List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) {
239- if (gap <= 0 || kids.length < 2) return kids;
240- final out = <Widget>[];
241- for (var i = 0; i < kids.length; i++) {
242- if (i > 0) {
243- out.add(vertical
244- ? SizedBox(height: gap.toDouble())
245- : SizedBox(width: gap.toDouble()));
246- }
247- out.add(kids[i]);
248- }
249- return out;
250- }
251-}
deleted file mode 100644
@@ -1,251 +0,0 @@
1-/// The renderer: a Nim widget tree, walked into Flutter widgets.
2-///
3-/// This is the Dart half of the spike's claim. It knows the tag vocabulary
4-/// and nothing else — no screens, no state, no idea what "connect" means. Nim
5-/// decides what the screen is; this decides what a `vbox` looks like.
6-///
7-/// The measure of whether the split is honest is how boring this file is. If
8-/// a feature ever needs a change here AND in Nim, the boundary is in the
9-/// wrong place.
10-import 'dart:async';
11-
12-import 'package:flutter/material.dart';
13-import 'package:frq_core/frq_core.dart' as core;
14-
15-/// Rebuilds from Nim on every event. One `setState` per dispatch, and the
16-/// whole tree is rebuilt — which is what Flutter does anyway, and is why the
17-/// Nim side does not need a reconciler of its own.
18-class NimApp extends StatefulWidget {
19- const NimApp({super.key});
20- @override
21- State<NimApp> createState() => _NimAppState();
22-}
23-
24-class _NimAppState extends State<NimApp> {
25- late core.UiNode _tree = core.render();
26- Timer? _poll;
27-
28- @override
29- void initState() {
30- super.initState();
31- // Polling, because the socket lives on a Nim thread and there is no
32- // callback into Dart. A Dart callback invoked from a foreign thread has to
33- // be marshalled onto the main isolate — NativeCallable, ports, a whole
34- // mechanism — and at 70µs a render a 100ms timer does the same job for
35- // nothing. It is also why `render` is allowed to be impure.
36- _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
37- final t = core.poll();
38- // Only when it actually differs: a setState per tick would rebuild the
39- // whole tree ten times a second for a screen nobody is touching.
40- if (t.toString() != _tree.toString()) {
41- setState(() => _tree = t);
42- }
43- });
44- }
45-
46- // One controller per keyed entry, kept across rebuilds.
47- //
48- // This is the whole reason `:key` is on every entry in both the Clojure and
49- // the Nim: a controller identified by position instead of name meant the
50- // host field and the port field shared one and both showed the port. The
51- // comment survives three languages now.
52- final _controllers = <String, TextEditingController>{};
53-
54- // One focus node per keyed entry, for the same reason as the controllers.
55- // Without it, sending with Enter drops focus and the next line is typed
56- // into nothing — the field is rebuilt from a fresh tree every time.
57- final _focus = <String, FocusNode>{};
58-
59- void _send(String id, [String value = '']) {
60- setState(() => _tree = core.dispatch(id, value));
61- // Enter in the compose box clears the draft in Nim and rebuilds the
62- // field; putting focus back is what makes a second line typeable.
63- if (id == 'send') _focus['draft']?.requestFocus();
64- }
65-
66- @override
67- void dispose() {
68- _poll?.cancel();
69- for (final c in _controllers.values) {
70- c.dispose();
71- }
72- for (final f in _focus.values) {
73- f.dispose();
74- }
75- super.dispose();
76- }
77-
78- @override
79- Widget build(BuildContext context) => MaterialApp(
80- title: 'frq',
81- theme: ThemeData.dark(useMaterial3: true),
82- home: Scaffold(
83- body: SafeArea(child: SingleChildScrollView(child: _build(_tree))),
84- ),
85- );
86-
87- Widget _build(core.UiNode n) {
88- final kids = n.children.map(_build).toList();
89-
90- switch (n.tag) {
91- case 'page':
92- return Center(
93- child: ConstrainedBox(
94- constraints:
95- BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()),
96- child: Padding(
97- padding: const EdgeInsets.all(24),
98- child: Column(
99- crossAxisAlignment: CrossAxisAlignment.start, children: kids),
100- ),
101- ),
102- );
103-
104- case 'vbox':
105- return Column(
106- crossAxisAlignment: CrossAxisAlignment.start,
107- children: _spaced(kids, n.prop('spacing', 0), vertical: true),
108- );
109-
110- case 'hbox':
111- // Wrap and not Row, and this was a bug before it was a decision: the
112- // three mode buttons are wider than the 520-point page, and a Row
113- // answers that with a RenderFlex overflow rather than a second line.
114- // A `:hbox` in the screens means "these go together across", not "these
115- // fit"; the tree has no idea how wide the window is and should not.
116- final gap = n.prop('spacing', 0).toDouble();
117- return Wrap(
118- spacing: gap,
119- runSpacing: gap,
120- crossAxisAlignment: WrapCrossAlignment.center,
121- children: kids,
122- );
123-
124- case 'scroll':
125- return SizedBox(
126- height: n.prop('height', 300).toDouble(),
127- child: Scrollbar(
128- child: SingleChildScrollView(
129- reverse: true,
130- child: Column(
131- crossAxisAlignment: CrossAxisAlignment.start,
132- children: kids),
133- ),
134- ),
135- );
136-
137- case 'card':
138- return Card(
139- margin: const EdgeInsets.symmetric(vertical: 8),
140- child: Padding(
141- padding: const EdgeInsets.all(16),
142- child: Column(
143- crossAxisAlignment: CrossAxisAlignment.start, children: kids),
144- ),
145- );
146-
147- case 'title':
148- return Text(n.prop('label', ''),
149- style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold));
150-
151- case 'title-2':
152- return Padding(
153- padding: const EdgeInsets.only(top: 8, bottom: 4),
154- child: Text(n.prop('label', ''),
155- style:
156- const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
157- );
158-
159- case 'label':
160- return Text(n.prop('label', ''));
161-
162- case 'dim-label':
163- return Opacity(
164- opacity: 0.7,
165- child: Text(n.prop('label', ''),
166- style: const TextStyle(fontSize: 12)));
167-
168- case 'spinner':
169- return const SizedBox(
170- width: 16,
171- height: 16,
172- child: CircularProgressIndicator(strokeWidth: 2));
173-
174- case 'button':
175- final onClick = n.prop('onClick', '');
176- final label = Text(n.prop('label', ''));
177- // No padding of its own: spacing belongs to the container, which is
178- // the only thing that knows whether this is in a row or a column.
179- return n.prop('kind', 'default') == 'primary'
180- ? FilledButton(onPressed: () => _send(onClick), child: label)
181- : OutlinedButton(onPressed: () => _send(onClick), child: label);
182-
183- case 'checkbutton':
184- return Row(mainAxisSize: MainAxisSize.min, children: [
185- Checkbox(
186- value: n.prop('active', false),
187- onChanged: (_) => _send(n.prop('onToggled', '')),
188- ),
189- Text(n.prop('label', '')),
190- ]);
191-
192- case 'entry':
193- final key = n.prop('key', '');
194- final text = n.prop('text', '');
195- final c = _controllers.putIfAbsent(
196- key, () => TextEditingController(text: text));
197- // Only when it actually differs: assigning unconditionally moves the
198- // caret to the end on every keystroke, which is the classic way to
199- // make a controlled text field unusable.
200- if (c.text != text) {
201- c.value = c.value.copyWith(
202- text: text,
203- selection: TextSelection.collapsed(offset: text.length),
204- );
205- }
206- final field = TextField(
207- controller: c,
208- focusNode: _focus.putIfAbsent(key, FocusNode.new),
209- decoration: InputDecoration(
210- hintText: n.prop('placeholder', ''),
211- isDense: true,
212- border: const OutlineInputBorder(),
213- ),
214- onChanged: (v) => _send(n.prop('onChange', ''), v),
215- onSubmitted: (_) {
216- final submit = n.prop('onSubmit', '');
217- if (submit.isNotEmpty) _send(submit);
218- },
219- );
220- final w = n.prop('widthRequest', 0);
221- // A width request is a minimum in the screens' vocabulary, but here it
222- // has to be a maximum too: an unconstrained TextField inside a Wrap
223- // has no width at all to take.
224- return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field;
225-
226- default:
227- // An unknown tag paints as itself rather than crashing or vanishing.
228- // Nim can add one and see it before this file has heard of it, which
229- // is the behaviour that makes the boundary pleasant to work across.
230- return Container(
231- padding: const EdgeInsets.all(4),
232- color: Colors.orange.withValues(alpha: 0.3),
233- child: Text('?${n.tag}'),
234- );
235- }
236- }
237-
238- List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) {
239- if (gap <= 0 || kids.length < 2) return kids;
240- final out = <Widget>[];
241- for (var i = 0; i < kids.length; i++) {
242- if (i > 0) {
243- out.add(vertical
244- ? SizedBox(height: gap.toDouble())
245- : SizedBox(width: gap.toDouble()));
246- }
247- out.add(kids[i]);
248- }
249- return out;
250- }
251-}
deleted flutter/test/nim_renderer_test.dart +0 -129
deleted file mode 100644
@@ -1,129 +0,0 @@
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- // No sockets from a widget test. `connect` otherwise opens a real TLS
17- // connection to irc.freeq.at, which these tests did until this line.
18- setUpAll(core.goOffline);
19- setUp(core.resetUi);
20-
21- /// The TextField currently showing [text].
22- ///
23- /// By content and not by position, and that distinction caught a bug in
24- /// these tests: in guest mode the FIRST field is the nickname, not the
25- /// host, so `find.byType(TextField).first` was clearing the wrong one and
26- /// the assertion about the host failed for a reason that had nothing to do
27- /// with the code under test.
28- Finder fieldShowing(WidgetTester tester, String text) => find.byWidgetPredicate(
29- (w) => w is TextField && w.controller?.text == text);
30-
31- testWidgets('the connect screen arrives from Nim as real widgets',
32- (tester) async {
33- await tester.pumpWidget(const NimApp());
34-
35- expect(find.text('frq'), findsWidgets);
36- expect(find.text('Server'), findsOneWidget);
37- expect(find.text('Connect'), findsOneWidget);
38- expect(find.byType(Checkbox), findsOneWidget);
39- // Guest is the default mode, so the nickname field is the one shown.
40- expect(find.widgetWithText(OutlinedButton, 'Bluesky'), findsOneWidget);
41- });
42-
43- testWidgets('nothing renders as an unknown tag', (tester) async {
44- await tester.pumpWidget(const NimApp());
45- // The renderer paints an orange `?tag` box for a tag it does not know.
46- // Finding one means Nim emitted something Dart has never heard of, which
47- // is exactly the drift this test exists to catch.
48- expect(find.textContaining('?'), findsNothing);
49- });
50-
51- testWidgets('tapping a mode button changes which fields exist',
52- (tester) async {
53- await tester.pumpWidget(const NimApp());
54- // Guest is the default, so the nickname field is the one on screen.
55- expect(fieldShowing(tester, 'frq-guest'), findsOneWidget);
56-
57- await tester.tap(find.text('Bluesky'));
58- await tester.pump();
59-
60- // The Bluesky copy comes from Nim, not from this side.
61- expect(find.text('Sign in with Bluesky'), findsOneWidget);
62- // ...and the nickname field is gone, because Nim stopped emitting it.
63- expect(fieldShowing(tester, 'frq-guest'), findsNothing);
64- });
65-
66- testWidgets('the TLS checkbox rewrites the port field', (tester) async {
67- await tester.pumpWidget(const NimApp());
68-
69- TextField portField() => tester.widgetList<TextField>(find.byType(TextField))
70- .firstWhere((f) => f.controller?.text == '6697' ||
71- f.controller?.text == '6667');
72-
73- expect(portField().controller!.text, '6697');
74- await tester.tap(find.byType(Checkbox));
75- await tester.pump();
76- expect(portField().controller!.text, '6667');
77- });
78-
79- testWidgets('typing goes to Nim and comes back', (tester) async {
80- await tester.pumpWidget(const NimApp());
81-
82- await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), 'localhost');
83- await tester.pump();
84-
85- // Round trip: the text is in the widget because Nim put it in the tree,
86- // not because the TextField remembered it. Asking Nim directly is what
87- // makes that distinction.
88- expect(core.render().toString(), isNotEmpty);
89- expect(
90- tester.widgetList<TextField>(find.byType(TextField))
91- .any((f) => f.controller?.text == 'localhost'),
92- isTrue,
93- );
94- });
95-
96- testWidgets('Connect with an empty host shows Nim\'s error, and it dismisses',
97- (tester) async {
98- await tester.pumpWidget(const NimApp());
99-
100- // A space, not an empty string: Nim's rule is `strip().len == 0`, and a
101- // space exercises it where "" would also pass a naive emptiness check.
102- await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), ' ');
103- await tester.pump();
104- await tester.tap(find.text('Connect'));
105- await tester.pump();
106-
107- expect(find.textContaining('A server is required'), findsOneWidget);
108- expect(find.text('Dismiss'), findsOneWidget);
109-
110- await tester.tap(find.text('Dismiss'));
111- await tester.pump();
112- expect(find.text('Dismiss'), findsNothing);
113- });
114-
115- testWidgets('Connect swaps the button for a spinner', (tester) async {
116- await tester.pumpWidget(const NimApp());
117- expect(find.byType(CircularProgressIndicator), findsNothing);
118-
119- await tester.tap(find.text('Connect'));
120- await tester.pump();
121-
122- expect(find.byType(CircularProgressIndicator), findsOneWidget);
123- expect(find.text('Connect'), findsNothing);
124- expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget);
125- // And a way out of it, which the first run of the spike did not have:
126- // a connection that never completes was a spinner with no escape.
127- expect(find.text('Cancel'), findsOneWidget);
128- });
129-}
deleted file mode 100644
@@ -1,129 +0,0 @@
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- // No sockets from a widget test. `connect` otherwise opens a real TLS
17- // connection to irc.freeq.at, which these tests did until this line.
18- setUpAll(core.goOffline);
19- setUp(core.resetUi);
20-
21- /// The TextField currently showing [text].
22- ///
23- /// By content and not by position, and that distinction caught a bug in
24- /// these tests: in guest mode the FIRST field is the nickname, not the
25- /// host, so `find.byType(TextField).first` was clearing the wrong one and
26- /// the assertion about the host failed for a reason that had nothing to do
27- /// with the code under test.
28- Finder fieldShowing(WidgetTester tester, String text) => find.byWidgetPredicate(
29- (w) => w is TextField && w.controller?.text == text);
30-
31- testWidgets('the connect screen arrives from Nim as real widgets',
32- (tester) async {
33- await tester.pumpWidget(const NimApp());
34-
35- expect(find.text('frq'), findsWidgets);
36- expect(find.text('Server'), findsOneWidget);
37- expect(find.text('Connect'), findsOneWidget);
38- expect(find.byType(Checkbox), findsOneWidget);
39- // Guest is the default mode, so the nickname field is the one shown.
40- expect(find.widgetWithText(OutlinedButton, 'Bluesky'), findsOneWidget);
41- });
42-
43- testWidgets('nothing renders as an unknown tag', (tester) async {
44- await tester.pumpWidget(const NimApp());
45- // The renderer paints an orange `?tag` box for a tag it does not know.
46- // Finding one means Nim emitted something Dart has never heard of, which
47- // is exactly the drift this test exists to catch.
48- expect(find.textContaining('?'), findsNothing);
49- });
50-
51- testWidgets('tapping a mode button changes which fields exist',
52- (tester) async {
53- await tester.pumpWidget(const NimApp());
54- // Guest is the default, so the nickname field is the one on screen.
55- expect(fieldShowing(tester, 'frq-guest'), findsOneWidget);
56-
57- await tester.tap(find.text('Bluesky'));
58- await tester.pump();
59-
60- // The Bluesky copy comes from Nim, not from this side.
61- expect(find.text('Sign in with Bluesky'), findsOneWidget);
62- // ...and the nickname field is gone, because Nim stopped emitting it.
63- expect(fieldShowing(tester, 'frq-guest'), findsNothing);
64- });
65-
66- testWidgets('the TLS checkbox rewrites the port field', (tester) async {
67- await tester.pumpWidget(const NimApp());
68-
69- TextField portField() => tester.widgetList<TextField>(find.byType(TextField))
70- .firstWhere((f) => f.controller?.text == '6697' ||
71- f.controller?.text == '6667');
72-
73- expect(portField().controller!.text, '6697');
74- await tester.tap(find.byType(Checkbox));
75- await tester.pump();
76- expect(portField().controller!.text, '6667');
77- });
78-
79- testWidgets('typing goes to Nim and comes back', (tester) async {
80- await tester.pumpWidget(const NimApp());
81-
82- await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), 'localhost');
83- await tester.pump();
84-
85- // Round trip: the text is in the widget because Nim put it in the tree,
86- // not because the TextField remembered it. Asking Nim directly is what
87- // makes that distinction.
88- expect(core.render().toString(), isNotEmpty);
89- expect(
90- tester.widgetList<TextField>(find.byType(TextField))
91- .any((f) => f.controller?.text == 'localhost'),
92- isTrue,
93- );
94- });
95-
96- testWidgets('Connect with an empty host shows Nim\'s error, and it dismisses',
97- (tester) async {
98- await tester.pumpWidget(const NimApp());
99-
100- // A space, not an empty string: Nim's rule is `strip().len == 0`, and a
101- // space exercises it where "" would also pass a naive emptiness check.
102- await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), ' ');
103- await tester.pump();
104- await tester.tap(find.text('Connect'));
105- await tester.pump();
106-
107- expect(find.textContaining('A server is required'), findsOneWidget);
108- expect(find.text('Dismiss'), findsOneWidget);
109-
110- await tester.tap(find.text('Dismiss'));
111- await tester.pump();
112- expect(find.text('Dismiss'), findsNothing);
113- });
114-
115- testWidgets('Connect swaps the button for a spinner', (tester) async {
116- await tester.pumpWidget(const NimApp());
117- expect(find.byType(CircularProgressIndicator), findsNothing);
118-
119- await tester.tap(find.text('Connect'));
120- await tester.pump();
121-
122- expect(find.byType(CircularProgressIndicator), findsOneWidget);
123- expect(find.text('Connect'), findsNothing);
124- expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget);
125- // And a way out of it, which the first run of the spike did not have:
126- // a connection that never completes was a spinner with no escape.
127- expect(find.text('Cancel'), findsOneWidget);
128- });
129-}
modified justfile +0 -103
@@ -428,105 +428,6 @@ dart-test:
428428 dart pub get
429429 dart test -r expanded
430430
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- # The Nim core links OpenSSL for the TLS on :6697, and the process that
458- # dlopens it has to be able to find one. Prepended here rather than set in
459- # the shell, so nixGL's own loader path is left alone.
460- export LD_LIBRARY_PATH="${FRQ_OPENSSL_LIB:-}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
461- runner=()
462- [ -e /run/current-system ] || runner=("$NIXGL")
463- case "{{action}}" in
464- build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim.dart ;;
465- run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim.dart ;;
466- *) echo "usage: just nim-spike [run|build]" >&2; exit 1 ;;
467- esac
468-
469-# The spike's widget tests: Nim's tree, as Flutter widgets, driven by taps.
470-#
471-# Headless — no GL, no window — which is what makes this the proof rather than
472-# a screenshot. A screenshot shows that something painted; this shows the round
473-# trip closes: a tap reaches Nim, its state moves, the new tree comes back and
474-# the widgets change to match.
475-nim-spike-test:
476- #!/usr/bin/env bash
477- set -euo pipefail
478- cd "{{justfile_directory()}}"
479- if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then
480- just nim-lib
481- exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \
482- --command just nim-spike-test
483- fi
484- cd flutter
485- flutter pub get
486- flutter test test/nim_renderer_test.dart
487-
488-# What the Nim boundary costs per frame.
489-#
490-# The spike rebuilds the whole screen in Nim and ships it as JSON on every
491-# event, which is the obvious objection to the design. This is the number that
492-# answers it — or doesn't.
493-nim-bench:
494- #!/usr/bin/env bash
495- set -euo pipefail
496- cd "{{justfile_directory()}}"
497- if [ -z "${FRQ_DART:-}" ]; then
498- just nim-lib
499- exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-bench
500- fi
501- cd dart/frq_core
502- dart pub get >/dev/null
503- dart run test/bench.dart
504-
505-# The spike, end to end, against a real freeq.
506-#
507-# Connects, registers, joins #test and says a line — all of it through the
508-# FFI, so what it proves is Nim's socket, Nim's TLS, Nim's IRC registration
509-# and the Dart boundary over the lot.
510-#
511-# Not in any test suite, and not in CI: it needs a network and it sends a
512-# message to a public channel. Run it when you mean to.
513-#
514-# just nim-live irc.freeq.at, a random nick
515-# just nim-live irc.freeq.at mynick "a line"
516-# FRQ_TRACE=1 just nim-live ...and every line on the wire
517-nim-live *args:
518- #!/usr/bin/env bash
519- set -euo pipefail
520- cd "{{justfile_directory()}}"
521- if [ -z "${FRQ_DART:-}" ]; then
522- just nim-lib
523- exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-live "$@"
524- fi
525- shift || true
526- cd dart/frq_core
527- dart pub get >/dev/null
528- exec dart run tool/live_send.dart "$@"
529-
530431 # The real app, with the Nim core as its transport.
531432 #
532433 # This is the wiring that matters: `frq.main-nim` is `frq.main` with one line
@@ -534,10 +435,6 @@ nim-live *args:
534435 # screen, every cell and every action is the one that was already there. Nim
535436 # owns the socket, the TLS and the line framing, and nothing else.
536437 #
537-# Not to be confused with `nim-spike`, which is the earlier experiment where
538-# Nim owned the screens too. That one reimplemented a 1,518-line chat screen in
539-# forty lines and lost everything in between; this one reimplements nothing.
540-#
541438 # just nim-app build it
542439 # just nim-app run open the window
543440 nim-app action="build":
@@ -428,105 +428,6 @@ dart-test:
428 dart pub get428 dart pub get
429 dart test -r expanded429 dart test -r expanded
430 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- # The Nim core links OpenSSL for the TLS on :6697, and the process that
458- # dlopens it has to be able to find one. Prepended here rather than set in
459- # the shell, so nixGL's own loader path is left alone.
460- export LD_LIBRARY_PATH="${FRQ_OPENSSL_LIB:-}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
461- runner=()
462- [ -e /run/current-system ] || runner=("$NIXGL")
463- case "{{action}}" in
464- build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim.dart ;;
465- run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim.dart ;;
466- *) echo "usage: just nim-spike [run|build]" >&2; exit 1 ;;
467- esac
468-
469-# The spike's widget tests: Nim's tree, as Flutter widgets, driven by taps.
470-#
471-# Headless — no GL, no window — which is what makes this the proof rather than
472-# a screenshot. A screenshot shows that something painted; this shows the round
473-# trip closes: a tap reaches Nim, its state moves, the new tree comes back and
474-# the widgets change to match.
475-nim-spike-test:
476- #!/usr/bin/env bash
477- set -euo pipefail
478- cd "{{justfile_directory()}}"
479- if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then
480- just nim-lib
481- exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \
482- --command just nim-spike-test
483- fi
484- cd flutter
485- flutter pub get
486- flutter test test/nim_renderer_test.dart
487-
488-# What the Nim boundary costs per frame.
489-#
490-# The spike rebuilds the whole screen in Nim and ships it as JSON on every
491-# event, which is the obvious objection to the design. This is the number that
492-# answers it — or doesn't.
493-nim-bench:
494- #!/usr/bin/env bash
495- set -euo pipefail
496- cd "{{justfile_directory()}}"
497- if [ -z "${FRQ_DART:-}" ]; then
498- just nim-lib
499- exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-bench
500- fi
501- cd dart/frq_core
502- dart pub get >/dev/null
503- dart run test/bench.dart
504-
505-# The spike, end to end, against a real freeq.
506-#
507-# Connects, registers, joins #test and says a line — all of it through the
508-# FFI, so what it proves is Nim's socket, Nim's TLS, Nim's IRC registration
509-# and the Dart boundary over the lot.
510-#
511-# Not in any test suite, and not in CI: it needs a network and it sends a
512-# message to a public channel. Run it when you mean to.
513-#
514-# just nim-live irc.freeq.at, a random nick
515-# just nim-live irc.freeq.at mynick "a line"
516-# FRQ_TRACE=1 just nim-live ...and every line on the wire
517-nim-live *args:
518- #!/usr/bin/env bash
519- set -euo pipefail
520- cd "{{justfile_directory()}}"
521- if [ -z "${FRQ_DART:-}" ]; then
522- just nim-lib
523- exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-live "$@"
524- fi
525- shift || true
526- cd dart/frq_core
527- dart pub get >/dev/null
528- exec dart run tool/live_send.dart "$@"
529-
530 # The real app, with the Nim core as its transport.431 # The real app, with the Nim core as its transport.
531 #432 #
532 # This is the wiring that matters: `frq.main-nim` is `frq.main` with one line433 # This is the wiring that matters: `frq.main-nim` is `frq.main` with one line
@@ -534,10 +435,6 @@ nim-live *args:
534 # screen, every cell and every action is the one that was already there. Nim435 # screen, every cell and every action is the one that was already there. Nim
535 # owns the socket, the TLS and the line framing, and nothing else.436 # owns the socket, the TLS and the line framing, and nothing else.
536 #437 #
537-# Not to be confused with `nim-spike`, which is the earlier experiment where
538-# Nim owned the screens too. That one reimplemented a 1,518-line chat screen in
539-# forty lines and lost everything in between; this one reimplements nothing.
540-#
541 # just nim-app build it438 # just nim-app build it
542 # just nim-app run open the window439 # just nim-app run open the window
543 nim-app action="build":440 nim-app action="build":
modified nim/README.md +37 -43
@@ -55,58 +55,51 @@ yet, and until it is, **the web build must keep using the ClojureDart
5555 originals**. This is why the originals stay in `common/` rather than being
5656 deleted as each module lands: they are the web's implementation, not dead code.
5757
58-## The spike
58+## What is wired up
5959
60-`just nim-spike` opens a window with no ClojureDart on the path, connects to
61-irc.freeq.at over TLS, joins `#test` and sends a message. Nim owns the state,
62-the screens, the socket and the IRC protocol; Dart owns the pixels.
60+`just nim-app run` is the real client with the Nim core as its transport. Every
61+screen, cell and action is the one that was already there; `frq.main-nim` is
62+`frq.main` with one line changed.
6363
6464 ```
65-src/frq/ui.nim the widget tree, in the screens' own tag vocabulary
66-src/frq/state.nim the record, the reducer, and drain()
67-src/frq/irc.nim the socket, on its own thread, behind two channels
68-src/frq/screens/ connect and chat, as pure functions of the state
69-src/frq/trace.nim FRQ_TRACE=1, the same switch the rest of frq uses
65+src/frq/conn.nim the socket, the TLS, the line framing — on its own thread
66+src/frq/ircparse.nim the IRC wire format
67+src/frq/trace.nim FRQ_TRACE=1, the same switch the rest of frq uses
7068 ```
7169
72-Three decisions worth knowing before changing any of it:
70+The seam is `frq.net`, which already existed with two implementations;
71+`flutter/src/frq/net/nim.cljd` is a third beside `frq.net.dart` and
72+`frq.net.web`. Nim owns the socket and nothing above it — the line goes to the
73+existing `frq.irc.parse`, so `on-msg` receives the same map from the same
74+parser and nothing upstairs can tell which transport it is on.
75+
76+Two things to know before changing `conn.nim`:
7377
7478 * **Nothing is shared with the socket thread.** Nim's ORC is thread-local for
75- ref types, so sharing the state would mean a lock per field and a heap two
76- threads both collect. The reader speaks only in channels, and `drain()` turns
77- its output into state on whichever thread Dart called in on.
78-* **Dart polls; Nim never calls back.** A Dart callback from a foreign thread
79- has to be marshalled onto the main isolate — `NativeCallable`, ports, a whole
80- mechanism — and at 70µs a render a 100ms timer does the same job for free.
81-* **A prop holds an event id, not a closure.** That is the one thing hiccup has
82- that a C ABI cannot, and substituting it is what makes this an architecture
83- rather than a rendering trick.
84-
85-Cost, measured: a full screen rebuild is 70–105µs, or 0.4–0.6% of a 60fps
86-frame, for a 1.6KB tree. The caveat is the tree size rather than the number —
87-the chat screen caps the backlog at fifty rows for exactly this reason, and
88-nothing has measured what a real one costs.
79+ ref types, so sharing state would mean a lock per field and a heap two
80+ threads both collect. It speaks only in channels.
81+* **Writes drain before reads.** IRC has the client speak first, and reading
82+ first deadlocked completely: CAP/NICK/USER sat queued while the loop waited
83+ for a server that had nothing to say until we registered.
84+
85+There was an experiment where Nim owned the state and the screens too. It is
86+gone. It meant a 43-line chat screen standing in for 1,518 — no reactions, no
87+replies, no images — and the path forward from it was rewriting every screen
88+in Nim and losing all of that. It is at 1d62d1a if it is ever wanted.
8989
9090 ```bash
91-just nim-spike # the window; click Connect
92-FRQ_TRACE=1 FRQ_AUTOCONNECT=1 just nim-spike # ...connecting on its own
93-just nim-spike-test # 7 widget tests: real taps, real widgets
94-just nim-live # connect to a real freeq and say a line
95-just nim-bench # what the boundary costs
96-FRQ_TRACE=1 just nim-live # ...and every line on the wire
97-```
91+just nim-app run # the real client, Nim transport
92+just nim-test # the Nim suite
93+just dart-test # the Dart side of the boundary
94+just nim-lib # libfrqcore.so into build/nim
9895
99-Two switches, both for the same reason — a GUI on Wayland cannot be clicked
100-from a script, so without them the only way to check the window connects is to
101-sit in front of it. `FRQ_AUTOCONNECT=1` presses Connect on the first render and
102-`FRQ_NICK` overrides the nickname, because two runs with the same one collide
103-on the server and the second is refused.
96+FRQ_TRACE=1 just nim-app run # every line in and out, both languages
97+```
10498
10599 The GUI needs OpenSSL on its loader path, which the `flutter-desktop` shell
106-provides as `FRQ_OPENSSL_LIB` and the `nim-spike` recipe prepends for the app
100+provides as `FRQ_OPENSSL_LIB` and the `nim-app` recipe prepends for the app
107101 alone. Not set as `LD_LIBRARY_PATH` in the shell itself: that shell also runs
108-Flutter through nixGL, which does its own careful things to the loader path,
109-and a blanket setting there breaks GL on some machines and not others.
102+Flutter through nixGL, which does its own careful things to the loader path.
110103
111104 ## Status
112105
@@ -124,10 +117,11 @@ core exists to have less Clojure in the tree, and `lookupFunction` takes two
124117 type arguments, so it meant fighting generic interop to write more of the
125118 thing being removed. In Dart it is a typedef. See `dart/README.md`.
126119
127-The spike above is wired up and runs. What is **not** done is replacing
128-anything: the shipping app is still the ClojureDart one, `common/frq/irc/parse.cljc`
129-is still what it runs, and the spike is a second entry point beside it
130-(`lib/main_nim.dart`) rather than a replacement for `frq.main`. That step is its
120+The transport is wired up and runs. What is **not** done is replacing
121+anything else: `frq.main` is untouched and still installs `frq.net.dart`, so
122+the shipping app is unchanged and `frq.main-nim` is a second entry point
123+beside it. `common/frq/irc/parse.cljc` is still what does the parsing on every
124+target, including this one. That step is its
131125 own piece of work — the Flutter app takes the package as a path dependency
132126 (which means a `pubspec.lock` regeneration and widening the nix build's source
133127 root), the library has to reach each target (`jniLibs` for the APK, beside the
@@ -55,58 +55,51 @@ yet, and until it is, **the web build must keep using the ClojureDart
55 originals**. This is why the originals stay in `common/` rather than being55 originals**. This is why the originals stay in `common/` rather than being
56 deleted as each module lands: they are the web's implementation, not dead code.56 deleted as each module lands: they are the web's implementation, not dead code.
57 57
58-## The spike58+## What is wired up
59 59
60-`just nim-spike` opens a window with no ClojureDart on the path, connects to60+`just nim-app run` is the real client with the Nim core as its transport. Every
61-irc.freeq.at over TLS, joins `#test` and sends a message. Nim owns the state,61+screen, cell and action is the one that was already there; `frq.main-nim` is
62-the screens, the socket and the IRC protocol; Dart owns the pixels.62+`frq.main` with one line changed.
63 63
64 ```64 ```
65-src/frq/ui.nim the widget tree, in the screens' own tag vocabulary65+src/frq/conn.nim the socket, the TLS, the line framing — on its own thread
66-src/frq/state.nim the record, the reducer, and drain()66+src/frq/ircparse.nim the IRC wire format
67-src/frq/irc.nim the socket, on its own thread, behind two channels67+src/frq/trace.nim FRQ_TRACE=1, the same switch the rest of frq uses
68-src/frq/screens/ connect and chat, as pure functions of the state
69-src/frq/trace.nim FRQ_TRACE=1, the same switch the rest of frq uses
70 ```68 ```
71 69
72-Three decisions worth knowing before changing any of it:70+The seam is `frq.net`, which already existed with two implementations;
71+`flutter/src/frq/net/nim.cljd` is a third beside `frq.net.dart` and
72+`frq.net.web`. Nim owns the socket and nothing above it — the line goes to the
73+existing `frq.irc.parse`, so `on-msg` receives the same map from the same
74+parser and nothing upstairs can tell which transport it is on.
75+
76+Two things to know before changing `conn.nim`:
73 77
74 * **Nothing is shared with the socket thread.** Nim's ORC is thread-local for78 * **Nothing is shared with the socket thread.** Nim's ORC is thread-local for
75- ref types, so sharing the state would mean a lock per field and a heap two79+ ref types, so sharing state would mean a lock per field and a heap two
76- threads both collect. The reader speaks only in channels, and `drain()` turns80+ threads both collect. It speaks only in channels.
77- its output into state on whichever thread Dart called in on.81+* **Writes drain before reads.** IRC has the client speak first, and reading
78-* **Dart polls; Nim never calls back.** A Dart callback from a foreign thread82+ first deadlocked completely: CAP/NICK/USER sat queued while the loop waited
79- has to be marshalled onto the main isolate — `NativeCallable`, ports, a whole83+ for a server that had nothing to say until we registered.
80- mechanism — and at 70µs a render a 100ms timer does the same job for free.84+
81-* **A prop holds an event id, not a closure.** That is the one thing hiccup has85+There was an experiment where Nim owned the state and the screens too. It is
82- that a C ABI cannot, and substituting it is what makes this an architecture86+gone. It meant a 43-line chat screen standing in for 1,518 — no reactions, no
83- rather than a rendering trick.87+replies, no images — and the path forward from it was rewriting every screen
84-88+in Nim and losing all of that. It is at 1d62d1a if it is ever wanted.
85-Cost, measured: a full screen rebuild is 70–105µs, or 0.4–0.6% of a 60fps
86-frame, for a 1.6KB tree. The caveat is the tree size rather than the number —
87-the chat screen caps the backlog at fifty rows for exactly this reason, and
88-nothing has measured what a real one costs.
89 89
90 ```bash90 ```bash
91-just nim-spike # the window; click Connect91+just nim-app run # the real client, Nim transport
92-FRQ_TRACE=1 FRQ_AUTOCONNECT=1 just nim-spike # ...connecting on its own92+just nim-test # the Nim suite
93-just nim-spike-test # 7 widget tests: real taps, real widgets93+just dart-test # the Dart side of the boundary
94-just nim-live # connect to a real freeq and say a line94+just nim-lib # libfrqcore.so into build/nim
95-just nim-bench # what the boundary costs
96-FRQ_TRACE=1 just nim-live # ...and every line on the wire
97-```
98 95
99-Two switches, both for the same reason — a GUI on Wayland cannot be clicked96+FRQ_TRACE=1 just nim-app run # every line in and out, both languages
100-from a script, so without them the only way to check the window connects is to97+```
101-sit in front of it. `FRQ_AUTOCONNECT=1` presses Connect on the first render and
102-`FRQ_NICK` overrides the nickname, because two runs with the same one collide
103-on the server and the second is refused.
104 98
105 The GUI needs OpenSSL on its loader path, which the `flutter-desktop` shell99 The GUI needs OpenSSL on its loader path, which the `flutter-desktop` shell
106-provides as `FRQ_OPENSSL_LIB` and the `nim-spike` recipe prepends for the app100+provides as `FRQ_OPENSSL_LIB` and the `nim-app` recipe prepends for the app
107 alone. Not set as `LD_LIBRARY_PATH` in the shell itself: that shell also runs101 alone. Not set as `LD_LIBRARY_PATH` in the shell itself: that shell also runs
108-Flutter through nixGL, which does its own careful things to the loader path,102+Flutter through nixGL, which does its own careful things to the loader path.
109-and a blanket setting there breaks GL on some machines and not others.
110 103
111 ## Status104 ## Status
112 105
@@ -124,10 +117,11 @@ core exists to have less Clojure in the tree, and `lookupFunction` takes two
124 type arguments, so it meant fighting generic interop to write more of the117 type arguments, so it meant fighting generic interop to write more of the
125 thing being removed. In Dart it is a typedef. See `dart/README.md`.118 thing being removed. In Dart it is a typedef. See `dart/README.md`.
126 119
127-The spike above is wired up and runs. What is **not** done is replacing120+The transport is wired up and runs. What is **not** done is replacing
128-anything: the shipping app is still the ClojureDart one, `common/frq/irc/parse.cljc`121+anything else: `frq.main` is untouched and still installs `frq.net.dart`, so
129-is still what it runs, and the spike is a second entry point beside it122+the shipping app is unchanged and `frq.main-nim` is a second entry point
130-(`lib/main_nim.dart`) rather than a replacement for `frq.main`. That step is its123+beside it. `common/frq/irc/parse.cljc` is still what does the parsing on every
124+target, including this one. That step is its
131 own piece of work — the Flutter app takes the package as a path dependency125 own piece of work — the Flutter app takes the package as a path dependency
132 (which means a `pubspec.lock` regeneration and widening the nix build's source126 (which means a `pubspec.lock` regeneration and widening the nix build's source
133 root), the library has to reach each target (`jniLibs` for the APK, beside the127 root), the library has to reach each target (`jniLibs` for the APK, beside the
deleted nim/src/frq/irc.nim +0 -158
deleted file mode 100644
@@ -1,158 +0,0 @@
1-## The IRC connection: a socket on its own thread, and two queues.
2-##
3-## The threading model is the whole design, and it is chosen to avoid a
4-## problem rather than to be clever. Nim's ORC is thread-local for ref types,
5-## so sharing the `State` record between a reader thread and the UI thread
6-## would mean a lock around every field and a heap two threads both collect.
7-## Instead **nothing is shared**: the socket thread owns the socket and speaks
8-## only in channels, and the state stays where it always was, on whichever
9-## thread called in from Dart.
10-##
11-## reader thread ──lines──▶ inbound ──▶ drain() on the UI thread
12-## UI thread ──lines──▶ outbound ──▶ writer, on the socket thread
13-##
14-## `drain` is called from `frq_ui_render`, so the tree Dart gets is always
15-## built after every line that had arrived when it asked. Dart polls; there is
16-## no callback into Dart and deliberately so — a Dart callback invoked from a
17-## foreign thread has to be marshalled onto the main isolate, which is a whole
18-## mechanism (`NativeCallable`, ports) for something a 100ms timer does for
19-## free at this size.
20-
21-import std/[net, strutils]
22-import trace
23-
24-type
25- ConnConfig* = object
26- host*: string
27- port*: int
28- tls*: bool
29- nick*: string
30-
31- Status* = enum
32- stIdle, stConnecting, stRegistered, stFailed, stClosed
33-
34-var
35- inbound: Channel[string] ## raw lines from the server
36- outbound: Channel[string] ## raw lines to the server
37- statusChan: Channel[string] ## "connecting"/"registered"/"failed: …"/"closed"
38- thread: Thread[ConnConfig]
39- running: bool
40-
41-inbound.open()
42-outbound.open()
43-statusChan.open()
44-
45-proc send*(line: string) =
46- ## Queue a line for the server. Safe from the UI thread.
47- trace("irc.out", line)
48- outbound.send(line)
49-
50-proc tryRecvLine*(): (bool, string) = inbound.tryRecv()
51-proc tryRecvStatus*(): (bool, string) = statusChan.tryRecv()
52-
53-proc readerBody(cfg: ConnConfig) {.thread.} =
54- ## The socket, end to end. Every failure answers with a status rather than
55- ## an exception: this thread has nobody to throw to.
56- {.gcsafe.}:
57- var sock: Socket
58- try:
59- statusChan.send("connecting")
60- trace("irc", "dialling " & cfg.host & ":" & $cfg.port &
61- (if cfg.tls: " over TLS" else: " plain"))
62- sock = newSocket(buffered = true)
63- if cfg.tls:
64- # CVerifyPeer, not CVerifyNone: this carries a nick and, later, a
65- # token. Nim loads libssl by soname at run time, so a bundle that
66- # cannot find one fails here rather than at build.
67- let ctx = newContext(verifyMode = CVerifyPeer)
68- ctx.wrapSocket(sock)
69- sock.connect(cfg.host, Port(cfg.port))
70- trace("irc", "connected")
71-
72- # Registration. No CAP and no SASL in the spike — a guest connect is
73- # NICK and USER, which is the whole of what freeq needs to let one in.
74- sock.send("NICK " & cfg.nick & "\c\L")
75- sock.send("USER " & cfg.nick & " 0 * :" & cfg.nick & "\c\L")
76- trace("irc.out", "NICK/USER as " & cfg.nick)
77-
78- # Non-blocking-ish loop: recvLine with a timeout so the outbound queue
79- # gets a look in between lines. A dedicated writer thread would avoid
80- # the timeout, at the price of a second thread to shut down cleanly.
81- while running:
82- var line: string
83- var timedOut = false
84- try:
85- line = sock.recvLine(timeout = 200)
86- except TimeoutError:
87- timedOut = true
88- except OSError as e:
89- statusChan.send("failed: " & e.msg)
90- break
91-
92- if line == "" and not timedOut:
93- # recvLine answering with an empty string and no timeout is the
94- # server having gone away. A timeout answers the same way, which is
95- # why the two are told apart by the flag rather than by the string.
96- statusChan.send("closed")
97- break
98-
99- if line.len > 0:
100- trace("irc.in", line)
101- # PING is answered here rather than in the reducer: it is the
102- # transport's own housekeeping and the screen has no opinion on it.
103- if line.startsWith("PING"):
104- let token = if ' ' in line: line[line.find(' ') + 1 .. ^1] else: ""
105- sock.send("PONG " & token & "\c\L")
106- trace("irc.out", "PONG " & token)
107- else:
108- inbound.send(line)
109-
110- while true:
111- let (ok, pending) = outbound.tryRecv()
112- if not ok: break
113- sock.send(pending & "\c\L")
114-
115- except CatchableError as e:
116- trace("irc", "!! " & e.msg)
117- statusChan.send("failed: " & e.msg)
118- finally:
119- if not sock.isNil:
120- try: sock.close() except CatchableError: discard
121- trace("irc", "reader thread done")
122-
123-proc startReal(cfg: ConnConfig) {.nimcall, gcsafe.} =
124- if running: return
125- running = true
126- {.cast(gcsafe).}:
127- createThread(thread, readerBody, cfg)
128-
129-var connector*: proc(cfg: ConnConfig) {.nimcall, gcsafe.} = startReal
130- ## How a connection gets opened, as a variable so a test can replace it.
131- ##
132- ## Without this the reducer's tests open real sockets to irc.freeq.at —
133- ## which they did, and which is why this exists: a unit test for "Connect
134- ## sets connecting" should not need a network, a DNS server or a running
135- ## freeq. `tests/tui.nim` swaps in a stub that records the config instead.
136-
137-proc goOffline*() =
138- ## Replace the dialler with one that records and does nothing.
139- ##
140- ## For the widget tests, which build the real screens and tap the real
141- ## Connect button — and which, without this, opened a TLS connection to
142- ## irc.freeq.at from a unit-test runner. A test suite that needs a network
143- ## is a test suite that fails on a train.
144- connector = proc(cfg: ConnConfig) {.nimcall, gcsafe.} =
145- trace("irc", "offline: would have dialled " & cfg.host & ":" & $cfg.port)
146-
147-proc start*(cfg: ConnConfig) =
148- ## Open a connection. A second call while one is running is ignored.
149- trace("irc", "start " & cfg.host & ":" & $cfg.port)
150- connector(cfg)
151-
152-proc stop*() =
153- if not running: return
154- running = false
155- # Up to the recvLine timeout plus a moment; joining rather than detaching so
156- # the socket is shut before anything tries to open another.
157- joinThread(thread)
158- trace("irc", "stopped")
deleted file mode 100644
@@ -1,158 +0,0 @@
1-## The IRC connection: a socket on its own thread, and two queues.
2-##
3-## The threading model is the whole design, and it is chosen to avoid a
4-## problem rather than to be clever. Nim's ORC is thread-local for ref types,
5-## so sharing the `State` record between a reader thread and the UI thread
6-## would mean a lock around every field and a heap two threads both collect.
7-## Instead **nothing is shared**: the socket thread owns the socket and speaks
8-## only in channels, and the state stays where it always was, on whichever
9-## thread called in from Dart.
10-##
11-## reader thread ──lines──▶ inbound ──▶ drain() on the UI thread
12-## UI thread ──lines──▶ outbound ──▶ writer, on the socket thread
13-##
14-## `drain` is called from `frq_ui_render`, so the tree Dart gets is always
15-## built after every line that had arrived when it asked. Dart polls; there is
16-## no callback into Dart and deliberately so — a Dart callback invoked from a
17-## foreign thread has to be marshalled onto the main isolate, which is a whole
18-## mechanism (`NativeCallable`, ports) for something a 100ms timer does for
19-## free at this size.
20-
21-import std/[net, strutils]
22-import trace
23-
24-type
25- ConnConfig* = object
26- host*: string
27- port*: int
28- tls*: bool
29- nick*: string
30-
31- Status* = enum
32- stIdle, stConnecting, stRegistered, stFailed, stClosed
33-
34-var
35- inbound: Channel[string] ## raw lines from the server
36- outbound: Channel[string] ## raw lines to the server
37- statusChan: Channel[string] ## "connecting"/"registered"/"failed: …"/"closed"
38- thread: Thread[ConnConfig]
39- running: bool
40-
41-inbound.open()
42-outbound.open()
43-statusChan.open()
44-
45-proc send*(line: string) =
46- ## Queue a line for the server. Safe from the UI thread.
47- trace("irc.out", line)
48- outbound.send(line)
49-
50-proc tryRecvLine*(): (bool, string) = inbound.tryRecv()
51-proc tryRecvStatus*(): (bool, string) = statusChan.tryRecv()
52-
53-proc readerBody(cfg: ConnConfig) {.thread.} =
54- ## The socket, end to end. Every failure answers with a status rather than
55- ## an exception: this thread has nobody to throw to.
56- {.gcsafe.}:
57- var sock: Socket
58- try:
59- statusChan.send("connecting")
60- trace("irc", "dialling " & cfg.host & ":" & $cfg.port &
61- (if cfg.tls: " over TLS" else: " plain"))
62- sock = newSocket(buffered = true)
63- if cfg.tls:
64- # CVerifyPeer, not CVerifyNone: this carries a nick and, later, a
65- # token. Nim loads libssl by soname at run time, so a bundle that
66- # cannot find one fails here rather than at build.
67- let ctx = newContext(verifyMode = CVerifyPeer)
68- ctx.wrapSocket(sock)
69- sock.connect(cfg.host, Port(cfg.port))
70- trace("irc", "connected")
71-
72- # Registration. No CAP and no SASL in the spike — a guest connect is
73- # NICK and USER, which is the whole of what freeq needs to let one in.
74- sock.send("NICK " & cfg.nick & "\c\L")
75- sock.send("USER " & cfg.nick & " 0 * :" & cfg.nick & "\c\L")
76- trace("irc.out", "NICK/USER as " & cfg.nick)
77-
78- # Non-blocking-ish loop: recvLine with a timeout so the outbound queue
79- # gets a look in between lines. A dedicated writer thread would avoid
80- # the timeout, at the price of a second thread to shut down cleanly.
81- while running:
82- var line: string
83- var timedOut = false
84- try:
85- line = sock.recvLine(timeout = 200)
86- except TimeoutError:
87- timedOut = true
88- except OSError as e:
89- statusChan.send("failed: " & e.msg)
90- break
91-
92- if line == "" and not timedOut:
93- # recvLine answering with an empty string and no timeout is the
94- # server having gone away. A timeout answers the same way, which is
95- # why the two are told apart by the flag rather than by the string.
96- statusChan.send("closed")
97- break
98-
99- if line.len > 0:
100- trace("irc.in", line)
101- # PING is answered here rather than in the reducer: it is the
102- # transport's own housekeeping and the screen has no opinion on it.
103- if line.startsWith("PING"):
104- let token = if ' ' in line: line[line.find(' ') + 1 .. ^1] else: ""
105- sock.send("PONG " & token & "\c\L")
106- trace("irc.out", "PONG " & token)
107- else:
108- inbound.send(line)
109-
110- while true:
111- let (ok, pending) = outbound.tryRecv()
112- if not ok: break
113- sock.send(pending & "\c\L")
114-
115- except CatchableError as e:
116- trace("irc", "!! " & e.msg)
117- statusChan.send("failed: " & e.msg)
118- finally:
119- if not sock.isNil:
120- try: sock.close() except CatchableError: discard
121- trace("irc", "reader thread done")
122-
123-proc startReal(cfg: ConnConfig) {.nimcall, gcsafe.} =
124- if running: return
125- running = true
126- {.cast(gcsafe).}:
127- createThread(thread, readerBody, cfg)
128-
129-var connector*: proc(cfg: ConnConfig) {.nimcall, gcsafe.} = startReal
130- ## How a connection gets opened, as a variable so a test can replace it.
131- ##
132- ## Without this the reducer's tests open real sockets to irc.freeq.at —
133- ## which they did, and which is why this exists: a unit test for "Connect
134- ## sets connecting" should not need a network, a DNS server or a running
135- ## freeq. `tests/tui.nim` swaps in a stub that records the config instead.
136-
137-proc goOffline*() =
138- ## Replace the dialler with one that records and does nothing.
139- ##
140- ## For the widget tests, which build the real screens and tap the real
141- ## Connect button — and which, without this, opened a TLS connection to
142- ## irc.freeq.at from a unit-test runner. A test suite that needs a network
143- ## is a test suite that fails on a train.
144- connector = proc(cfg: ConnConfig) {.nimcall, gcsafe.} =
145- trace("irc", "offline: would have dialled " & cfg.host & ":" & $cfg.port)
146-
147-proc start*(cfg: ConnConfig) =
148- ## Open a connection. A second call while one is running is ignored.
149- trace("irc", "start " & cfg.host & ":" & $cfg.port)
150- connector(cfg)
151-
152-proc stop*() =
153- if not running: return
154- running = false
155- # Up to the recvLine timeout plus a moment; joining rather than detaching so
156- # the socket is shut before anything tries to open another.
157- joinThread(thread)
158- trace("irc", "stopped")
deleted nim/src/frq/screens/chat.nim +0 -43
deleted file mode 100644
@@ -1,43 +0,0 @@
1-## The room, once there is one.
2-##
3-## The spike's destination: a backlog, a box, and a Send. Small on purpose —
4-## the point is that a line typed here reaches #test and a line from #test
5-## arrives here, not that it looks like the finished client.
6-
7-import std/json
8-import ../ui, ../state
9-
10-func messageRow(m: Message): Node =
11- if m.frm == "*":
12- # Comings and goings, dimmer than what people said.
13- dimLabel(m.text)
14- elif m.frm == "notice":
15- dimLabel("" & m.text)
16- else:
17- hbox(%*{"spacing": 6},
18- label(m.frm & ":"),
19- label(m.text))
20-
21-func chatScreen*(s: State): Node =
22- var rows: seq[Node]
23- # The last fifty, newest at the bottom. A cap rather than a scrollback
24- # policy: the tree crosses the boundary whole on every render, and an
25- # unbounded backlog is the one thing that would make that cost matter.
26- let start = max(0, s.messages.len - 50)
27- for i in start ..< s.messages.len:
28- rows.add messageRow(s.messages[i])
29- if rows.len == 0:
30- rows.add dimLabel("Nothing yet. Say something.")
31-
32- page(%*{"maxWidth": 640},
33- hbox(%*{"spacing": 8},
34- title(s.channel),
35- dimLabel(s.status),
36- button("Disconnect", "disconnect")),
37- card(
38- scroll(%*{"height": 380},
39- n("vbox", %*{"spacing": 4}, rows))),
40- hbox(%*{"spacing": 8},
41- entry("draft", s.draft, "Message " & s.channel, "draft.change",
42- width = 460, onSubmit = "send"),
43- button("Send", "send", "primary")))
deleted file mode 100644
@@ -1,43 +0,0 @@
1-## The room, once there is one.
2-##
3-## The spike's destination: a backlog, a box, and a Send. Small on purpose —
4-## the point is that a line typed here reaches #test and a line from #test
5-## arrives here, not that it looks like the finished client.
6-
7-import std/json
8-import ../ui, ../state
9-
10-func messageRow(m: Message): Node =
11- if m.frm == "*":
12- # Comings and goings, dimmer than what people said.
13- dimLabel(m.text)
14- elif m.frm == "notice":
15- dimLabel("" & m.text)
16- else:
17- hbox(%*{"spacing": 6},
18- label(m.frm & ":"),
19- label(m.text))
20-
21-func chatScreen*(s: State): Node =
22- var rows: seq[Node]
23- # The last fifty, newest at the bottom. A cap rather than a scrollback
24- # policy: the tree crosses the boundary whole on every render, and an
25- # unbounded backlog is the one thing that would make that cost matter.
26- let start = max(0, s.messages.len - 50)
27- for i in start ..< s.messages.len:
28- rows.add messageRow(s.messages[i])
29- if rows.len == 0:
30- rows.add dimLabel("Nothing yet. Say something.")
31-
32- page(%*{"maxWidth": 640},
33- hbox(%*{"spacing": 8},
34- title(s.channel),
35- dimLabel(s.status),
36- button("Disconnect", "disconnect")),
37- card(
38- scroll(%*{"height": 380},
39- n("vbox", %*{"spacing": 4}, rows))),
40- hbox(%*{"spacing": 8},
41- entry("draft", s.draft, "Message " & s.channel, "draft.change",
42- width = 460, onSubmit = "send"),
43- button("Send", "send", "primary")))
deleted nim/src/frq/screens/connect.nim +0 -96
deleted file mode 100644
@@ -1,96 +0,0 @@
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- # Cancel, and not just a spinner. Without it a connection that never
46- # completes — a host that does not answer, a TLS handshake that hangs — is
47- # a spinner with no way out but killing the window, which is exactly what
48- # the first run of this spike did.
49- hbox(%*{"spacing": 8},
50- spinner(),
51- dimLabel(s.status),
52- button("Cancel", "cancel"))
53- else:
54- hbox(%*{"spacing": 8},
55- button("Connect", "connect", "primary"),
56- dimLabel(s.status))
57-
58-func authFields(s: State): Node =
59- case s.authMode
60- of amGuest:
61- vbox(%*{"spacing": 6},
62- label("Nickname"),
63- entry("nick", s.formNick, "frq-guest", "nick.change", width = 220))
64- of amBluesky:
65- vbox(%*{"spacing": 6},
66- title2("Sign in with Bluesky"),
67- dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " &
68- "hands back a token; no password passes through frq."),
69- label("Handle"),
70- entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
71- width = 320),
72- vbox(%*{"key": "remembered", "spacing": 4},
73- if s.brokerToken.len > 0:
74- dimLabel("Session remembered — Connect will not need the browser.")
75- else: nil))
76- of amAppPassword:
77- vbox(%*{"spacing": 6},
78- title2("Sign in with an app password"),
79- dimLabel("Goes to your own PDS and nowhere else. Never written to disk."),
80- label("Handle"),
81- entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
82- width = 320),
83- label("App password"),
84- entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx",
85- "app-password.change", width = 320))
86-
87-func connectScreen*(s: State): Node =
88- page(%*{"maxWidth": 520},
89- title("frq"),
90- dimLabel("freeq client — guest, or your Bluesky identity."),
91- errorNote(s),
92- card(
93- modeTabs(s),
94- authFields(s),
95- serverFields(s),
96- connectAction(s)))
deleted file mode 100644
@@ -1,96 +0,0 @@
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- # Cancel, and not just a spinner. Without it a connection that never
46- # completes — a host that does not answer, a TLS handshake that hangs — is
47- # a spinner with no way out but killing the window, which is exactly what
48- # the first run of this spike did.
49- hbox(%*{"spacing": 8},
50- spinner(),
51- dimLabel(s.status),
52- button("Cancel", "cancel"))
53- else:
54- hbox(%*{"spacing": 8},
55- button("Connect", "connect", "primary"),
56- dimLabel(s.status))
57-
58-func authFields(s: State): Node =
59- case s.authMode
60- of amGuest:
61- vbox(%*{"spacing": 6},
62- label("Nickname"),
63- entry("nick", s.formNick, "frq-guest", "nick.change", width = 220))
64- of amBluesky:
65- vbox(%*{"spacing": 6},
66- title2("Sign in with Bluesky"),
67- dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " &
68- "hands back a token; no password passes through frq."),
69- label("Handle"),
70- entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
71- width = 320),
72- vbox(%*{"key": "remembered", "spacing": 4},
73- if s.brokerToken.len > 0:
74- dimLabel("Session remembered — Connect will not need the browser.")
75- else: nil))
76- of amAppPassword:
77- vbox(%*{"spacing": 6},
78- title2("Sign in with an app password"),
79- dimLabel("Goes to your own PDS and nowhere else. Never written to disk."),
80- label("Handle"),
81- entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
82- width = 320),
83- label("App password"),
84- entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx",
85- "app-password.change", width = 320))
86-
87-func connectScreen*(s: State): Node =
88- page(%*{"maxWidth": 520},
89- title("frq"),
90- dimLabel("freeq client — guest, or your Bluesky identity."),
91- errorNote(s),
92- card(
93- modeTabs(s),
94- authFields(s),
95- serverFields(s),
96- connectAction(s)))
deleted nim/src/frq/state.nim +0 -246
deleted file mode 100644
@@ -1,246 +0,0 @@
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, os, strutils]
15-import trace, ircparse, irc
16-
17-type
18- AuthMode* = enum
19- amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"
20-
21- Screen* = enum
22- scConnect = "connect", scChats = "chats", scChat = "chat"
23-
24- Message* = object
25- frm*: string
26- text*: string
27-
28- State* = object
29- screen*: Screen
30- status*: string
31- error*: string
32- hasError*: bool
33- connecting*: bool
34-
35- # The connect form.
36- authMode*: AuthMode
37- formHost*: string
38- formPort*: string
39- formTls*: bool
40- formNick*: string
41- formHandle*: string
42- formAppPassword*: string
43- brokerToken*: string
44-
45- # The one channel the spike knows about, and its backlog.
46- channel*: string
47- messages*: seq[Message]
48- draft*: string
49- registered*: bool
50-
51-const
52- defaultHost* = "irc.freeq.at"
53- defaultPort* = "6697"
54-
55-func initState*(): State =
56- State(screen: scConnect,
57- status: "Not connected",
58- authMode: amGuest,
59- formHost: defaultHost,
60- formPort: defaultPort,
61- formTls: true,
62- formNick: "frq-guest",
63- channel: "#test")
64-
65-var app* = initState()
66- ## The one mutable thing in the spike. Named `app` and not `state` because
67- ## `state` is ambiguous against unittest's own in a test module, which is
68- ## the sort of collision worth losing five characters to avoid.
69-
70-# ------------------------------------------------------------------ events
71-#
72-# One entry point, and a string id rather than an enum, because the ids are
73-# written into the tree that crosses the boundary and an enum on this side
74-# would be a number Dart had to agree with. A name that does not match
75-# anything is ignored rather than fatal: a stale tree held by the renderer for
76-# one frame after a state change is a normal race, not an error.
77-
78-proc summary(s: State): string =
79- ## What is worth seeing in a trace line, which is not every field: the
80- ## password is deliberately absent, and the token is reported as present or
81- ## not rather than printed. A trace that cannot be pasted into a bug report
82- ## is a trace people turn off.
83- "screen=" & $s.screen & " mode=" & $s.authMode &
84- " host=" & s.formHost & ":" & s.formPort &
85- (if s.formTls: "+tls" else: "") &
86- " connecting=" & $s.connecting &
87- (if s.hasError: " error=" & s.error.escape else: "") &
88- (if s.brokerToken.len > 0: " token=yes" else: "")
89-
90-proc dispatch*(event: JsonNode) =
91- let id = event{"id"}.getStr()
92- let value = event{"value"}.getStr()
93-
94- traced "dispatch": "" & id &
95- (if value.len > 0: " value=" & value.escape else: "") &
96- " before: " & app.summary
97-
98- case id
99- of "mode.guest": app.authMode = amGuest
100- of "mode.bluesky": app.authMode = amBluesky
101- of "mode.app-password": app.authMode = amAppPassword
102-
103- of "host.change": app.formHost = value
104- of "port.change": app.formPort = value
105- of "nick.change": app.formNick = value
106- of "handle.change": app.formHandle = value
107- of "app-password.change": app.formAppPassword = value
108-
109- of "tls.toggle":
110- app.formTls = not app.formTls
111- # The port follows the tick, exactly as the Clojure's :on-toggled does.
112- app.formPort = if app.formTls: "6697" else: "6667"
113-
114- of "error.dismiss":
115- app.error = ""
116- app.hasError = false
117-
118- of "connect":
119- if app.formHost.strip().len == 0:
120- app.error = "A server is required."
121- app.hasError = true
122- elif app.formNick.strip().len == 0:
123- app.error = "A nickname is required."
124- app.hasError = true
125- else:
126- app.connecting = true
127- app.status = "Connecting to " & app.formHost & ":" & app.formPort &
128- (if app.formTls: " over TLS" else: "") & ""
129- irc.start(ConnConfig(host: app.formHost.strip(),
130- port: try: parseInt(app.formPort.strip())
131- except ValueError: (if app.formTls: 6697 else: 6667),
132- tls: app.formTls,
133- nick: app.formNick.strip()))
134-
135- of "cancel", "disconnect":
136- irc.stop()
137- app.connecting = false
138- app.registered = false
139- app.screen = scConnect
140- app.status = "Not connected"
141-
142- of "draft.change": app.draft = value
143-
144- of "send":
145- # The point of the spike: a line the user typed, out to #test.
146- let text = app.draft.strip()
147- if text.len > 0 and app.registered:
148- irc.send("PRIVMSG " & app.channel & " :" & text)
149- # Echoed locally, because IRC does not send your own PRIVMSG back to
150- # you. Every client does this and every client that forgets looks like
151- # it dropped the message.
152- app.messages.add Message(frm: app.formNick, text: text)
153- app.draft = ""
154-
155- else:
156- trace("dispatch", "!! no handler for " & id.escape & " — ignored")
157-
158- traced "dispatch": " after: " & app.summary
159-
160-# ------------------------------------------------------------------- drain
161-#
162-# Called on the UI thread before a render, so the tree Dart receives is built
163-# after every line that had arrived when it asked. This is where the socket
164-# thread's output becomes state; nothing else touches it.
165-
166-proc drain*() =
167- while true:
168- let (ok, s) = tryRecvStatus()
169- if not ok: break
170- trace("status", s)
171- if s == "connecting":
172- app.status = "Connecting…"
173- elif s.startsWith("failed:"):
174- app.connecting = false
175- app.registered = false
176- app.error = s[7 .. ^1].strip()
177- app.hasError = true
178- app.status = "Not connected"
179- elif s == "closed":
180- app.connecting = false
181- app.registered = false
182- app.status = "Disconnected"
183-
184- while true:
185- let (ok, line) = tryRecvLine()
186- if not ok: break
187- let p = parseLine(line)
188- case p.command
189- of "001":
190- # Welcome: registration is done, so join the channel and show the room.
191- app.registered = true
192- app.connecting = false
193- app.status = "Connected as " & app.formNick
194- app.screen = scChat
195- irc.send("JOIN " & app.channel)
196- trace("irc", "registered; joining " & app.channel)
197-
198- of "PRIVMSG":
199- if p.params.len >= 2:
200- app.messages.add Message(frm: nickOf(p.prefix), text: p.params[^1])
201-
202- of "JOIN":
203- if p.params.len >= 1:
204- app.messages.add Message(frm: "*", text: nickOf(p.prefix) & " joined " & p.params[0])
205-
206- of "PART", "QUIT":
207- app.messages.add Message(frm: "*", text: nickOf(p.prefix) & " left")
208-
209- of "NOTICE":
210- if p.params.len >= 2:
211- app.messages.add Message(frm: "notice", text: p.params[^1])
212-
213- of "432", "433", "436":
214- # Nickname refused. Worth naming rather than showing a numeric: this is
215- # the most likely way a guest connect fails and the least obvious.
216- app.error = "That nickname is taken or invalid."
217- app.hasError = true
218- app.connecting = false
219-
220- else:
221- # Everything else is the MOTD and friends — traced, not shown.
222- trace("irc.skip", p.command & " " & $p.params)
223-
224-
225-# -------------------------------------------------------------- autoconnect
226-#
227-# `FRQ_AUTOCONNECT=1` presses Connect as soon as the first screen is asked
228-# for. In the same spirit as FRQ_TRACE and for the same reason: a GUI on
229-# Wayland cannot be clicked from a script, so without this the only way to
230-# check that the window connects is to sit in front of it. It also makes
231-# `just nim-spike` a one-command demo.
232-#
233-# `FRQ_NICK` overrides the nickname, because two runs with the same one
234-# collide on the server and the second is refused.
235-
236-var autoconnectDone = false
237-
238-proc maybeAutoconnect*() =
239- if autoconnectDone: return
240- autoconnectDone = true
241- let want = getEnv("FRQ_AUTOCONNECT")
242- if want.len == 0 or want == "0": return
243- let nick = getEnv("FRQ_NICK")
244- if nick.len > 0: app.formNick = nick
245- trace("auto", "FRQ_AUTOCONNECT set — connecting as " & app.formNick)
246- dispatch(%*{"id": "connect"})
deleted file mode 100644
@@ -1,246 +0,0 @@
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, os, strutils]
15-import trace, ircparse, irc
16-
17-type
18- AuthMode* = enum
19- amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"
20-
21- Screen* = enum
22- scConnect = "connect", scChats = "chats", scChat = "chat"
23-
24- Message* = object
25- frm*: string
26- text*: string
27-
28- State* = object
29- screen*: Screen
30- status*: string
31- error*: string
32- hasError*: bool
33- connecting*: bool
34-
35- # The connect form.
36- authMode*: AuthMode
37- formHost*: string
38- formPort*: string
39- formTls*: bool
40- formNick*: string
41- formHandle*: string
42- formAppPassword*: string
43- brokerToken*: string
44-
45- # The one channel the spike knows about, and its backlog.
46- channel*: string
47- messages*: seq[Message]
48- draft*: string
49- registered*: bool
50-
51-const
52- defaultHost* = "irc.freeq.at"
53- defaultPort* = "6697"
54-
55-func initState*(): State =
56- State(screen: scConnect,
57- status: "Not connected",
58- authMode: amGuest,
59- formHost: defaultHost,
60- formPort: defaultPort,
61- formTls: true,
62- formNick: "frq-guest",
63- channel: "#test")
64-
65-var app* = initState()
66- ## The one mutable thing in the spike. Named `app` and not `state` because
67- ## `state` is ambiguous against unittest's own in a test module, which is
68- ## the sort of collision worth losing five characters to avoid.
69-
70-# ------------------------------------------------------------------ events
71-#
72-# One entry point, and a string id rather than an enum, because the ids are
73-# written into the tree that crosses the boundary and an enum on this side
74-# would be a number Dart had to agree with. A name that does not match
75-# anything is ignored rather than fatal: a stale tree held by the renderer for
76-# one frame after a state change is a normal race, not an error.
77-
78-proc summary(s: State): string =
79- ## What is worth seeing in a trace line, which is not every field: the
80- ## password is deliberately absent, and the token is reported as present or
81- ## not rather than printed. A trace that cannot be pasted into a bug report
82- ## is a trace people turn off.
83- "screen=" & $s.screen & " mode=" & $s.authMode &
84- " host=" & s.formHost & ":" & s.formPort &
85- (if s.formTls: "+tls" else: "") &
86- " connecting=" & $s.connecting &
87- (if s.hasError: " error=" & s.error.escape else: "") &
88- (if s.brokerToken.len > 0: " token=yes" else: "")
89-
90-proc dispatch*(event: JsonNode) =
91- let id = event{"id"}.getStr()
92- let value = event{"value"}.getStr()
93-
94- traced "dispatch": "" & id &
95- (if value.len > 0: " value=" & value.escape else: "") &
96- " before: " & app.summary
97-
98- case id
99- of "mode.guest": app.authMode = amGuest
100- of "mode.bluesky": app.authMode = amBluesky
101- of "mode.app-password": app.authMode = amAppPassword
102-
103- of "host.change": app.formHost = value
104- of "port.change": app.formPort = value
105- of "nick.change": app.formNick = value
106- of "handle.change": app.formHandle = value
107- of "app-password.change": app.formAppPassword = value
108-
109- of "tls.toggle":
110- app.formTls = not app.formTls
111- # The port follows the tick, exactly as the Clojure's :on-toggled does.
112- app.formPort = if app.formTls: "6697" else: "6667"
113-
114- of "error.dismiss":
115- app.error = ""
116- app.hasError = false
117-
118- of "connect":
119- if app.formHost.strip().len == 0:
120- app.error = "A server is required."
121- app.hasError = true
122- elif app.formNick.strip().len == 0:
123- app.error = "A nickname is required."
124- app.hasError = true
125- else:
126- app.connecting = true
127- app.status = "Connecting to " & app.formHost & ":" & app.formPort &
128- (if app.formTls: " over TLS" else: "") & ""
129- irc.start(ConnConfig(host: app.formHost.strip(),
130- port: try: parseInt(app.formPort.strip())
131- except ValueError: (if app.formTls: 6697 else: 6667),
132- tls: app.formTls,
133- nick: app.formNick.strip()))
134-
135- of "cancel", "disconnect":
136- irc.stop()
137- app.connecting = false
138- app.registered = false
139- app.screen = scConnect
140- app.status = "Not connected"
141-
142- of "draft.change": app.draft = value
143-
144- of "send":
145- # The point of the spike: a line the user typed, out to #test.
146- let text = app.draft.strip()
147- if text.len > 0 and app.registered:
148- irc.send("PRIVMSG " & app.channel & " :" & text)
149- # Echoed locally, because IRC does not send your own PRIVMSG back to
150- # you. Every client does this and every client that forgets looks like
151- # it dropped the message.
152- app.messages.add Message(frm: app.formNick, text: text)
153- app.draft = ""
154-
155- else:
156- trace("dispatch", "!! no handler for " & id.escape & " — ignored")
157-
158- traced "dispatch": " after: " & app.summary
159-
160-# ------------------------------------------------------------------- drain
161-#
162-# Called on the UI thread before a render, so the tree Dart receives is built
163-# after every line that had arrived when it asked. This is where the socket
164-# thread's output becomes state; nothing else touches it.
165-
166-proc drain*() =
167- while true:
168- let (ok, s) = tryRecvStatus()
169- if not ok: break
170- trace("status", s)
171- if s == "connecting":
172- app.status = "Connecting…"
173- elif s.startsWith("failed:"):
174- app.connecting = false
175- app.registered = false
176- app.error = s[7 .. ^1].strip()
177- app.hasError = true
178- app.status = "Not connected"
179- elif s == "closed":
180- app.connecting = false
181- app.registered = false
182- app.status = "Disconnected"
183-
184- while true:
185- let (ok, line) = tryRecvLine()
186- if not ok: break
187- let p = parseLine(line)
188- case p.command
189- of "001":
190- # Welcome: registration is done, so join the channel and show the room.
191- app.registered = true
192- app.connecting = false
193- app.status = "Connected as " & app.formNick
194- app.screen = scChat
195- irc.send("JOIN " & app.channel)
196- trace("irc", "registered; joining " & app.channel)
197-
198- of "PRIVMSG":
199- if p.params.len >= 2:
200- app.messages.add Message(frm: nickOf(p.prefix), text: p.params[^1])
201-
202- of "JOIN":
203- if p.params.len >= 1:
204- app.messages.add Message(frm: "*", text: nickOf(p.prefix) & " joined " & p.params[0])
205-
206- of "PART", "QUIT":
207- app.messages.add Message(frm: "*", text: nickOf(p.prefix) & " left")
208-
209- of "NOTICE":
210- if p.params.len >= 2:
211- app.messages.add Message(frm: "notice", text: p.params[^1])
212-
213- of "432", "433", "436":
214- # Nickname refused. Worth naming rather than showing a numeric: this is
215- # the most likely way a guest connect fails and the least obvious.
216- app.error = "That nickname is taken or invalid."
217- app.hasError = true
218- app.connecting = false
219-
220- else:
221- # Everything else is the MOTD and friends — traced, not shown.
222- trace("irc.skip", p.command & " " & $p.params)
223-
224-
225-# -------------------------------------------------------------- autoconnect
226-#
227-# `FRQ_AUTOCONNECT=1` presses Connect as soon as the first screen is asked
228-# for. In the same spirit as FRQ_TRACE and for the same reason: a GUI on
229-# Wayland cannot be clicked from a script, so without this the only way to
230-# check that the window connects is to sit in front of it. It also makes
231-# `just nim-spike` a one-command demo.
232-#
233-# `FRQ_NICK` overrides the nickname, because two runs with the same one
234-# collide on the server and the second is refused.
235-
236-var autoconnectDone = false
237-
238-proc maybeAutoconnect*() =
239- if autoconnectDone: return
240- autoconnectDone = true
241- let want = getEnv("FRQ_AUTOCONNECT")
242- if want.len == 0 or want == "0": return
243- let nick = getEnv("FRQ_NICK")
244- if nick.len > 0: app.formNick = nick
245- trace("auto", "FRQ_AUTOCONNECT set — connecting as " & app.formNick)
246- dispatch(%*{"id": "connect"})
modified nim/src/frq/trace.nim +2 -2
@@ -5,8 +5,8 @@
55 ## thing to remember rather than a thing to use.
66 ##
77 ## To stderr and not stdout: stdout is a bundle's own, and a Flutter app on
8-## Linux prints to the terminal it was launched from. `just nim-spike` shows
9-## these inline.
8+## Linux prints to the terminal it was launched from. `just nim-app run`
9+## shows these inline.
1010 ##
1111 ## Cheap when off. The check is a `let` read once at load rather than a getEnv
1212 ## per call, and every `trace` call site guards on it before doing any of the
@@ -5,8 +5,8 @@
5 ## thing to remember rather than a thing to use.5 ## thing to remember rather than a thing to use.
6 ##6 ##
7 ## To stderr and not stdout: stdout is a bundle's own, and a Flutter app on7 ## To stderr and not stdout: stdout is a bundle's own, and a Flutter app on
8-## Linux prints to the terminal it was launched from. `just nim-spike` shows8+## Linux prints to the terminal it was launched from. `just nim-app run`
9-## these inline.9+## shows these inline.
10 ##10 ##
11 ## Cheap when off. The check is a `let` read once at load rather than a getEnv11 ## Cheap when off. The check is a `let` read once at load rather than a getEnv
12 ## per call, and every `trace` call site guards on it before doing any of the12 ## per call, and every `trace` call site guards on it before doing any of the
deleted nim/src/frq/ui.nim +0 -92
deleted file mode 100644
@@ -1,92 +0,0 @@
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,
73- onSubmit = ""): Node =
74- ## Every entry carries a key, and for the reason the Clojure's comment
75- ## gives: a renderer that keeps a text controller per field needs a stable
76- ## name for it, and without one the host and the port shared a controller
77- ## and both showed the port.
78- var p = %*{"key": key, "text": text, "placeholder": placeholder,
79- "onChange": onChange}
80- if width > 0: p["widthRequest"] = %width
81- # Enter, where the field has something to do with it. A compose box that
82- # only sends on a button click is one nobody can type into at speed.
83- if onSubmit.len > 0: p["onSubmit"] = %onSubmit
84- n("entry", p)
85-
86-func checkbutton*(text: string, active: bool, onToggled: string): Node =
87- n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})
88-
89-func scroll*(props: JsonNode, children: varargs[Node]): Node =
90- ## A list that is taller than the room it has. The renderer decides how that
91- ## is done; the tree only says that it is expected.
92- n("scroll", props, @children)
deleted file mode 100644
@@ -1,92 +0,0 @@
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,
73- onSubmit = ""): Node =
74- ## Every entry carries a key, and for the reason the Clojure's comment
75- ## gives: a renderer that keeps a text controller per field needs a stable
76- ## name for it, and without one the host and the port shared a controller
77- ## and both showed the port.
78- var p = %*{"key": key, "text": text, "placeholder": placeholder,
79- "onChange": onChange}
80- if width > 0: p["widthRequest"] = %width
81- # Enter, where the field has something to do with it. A compose box that
82- # only sends on a button click is one nobody can type into at speed.
83- if onSubmit.len > 0: p["onSubmit"] = %onSubmit
84- n("entry", p)
85-
86-func checkbutton*(text: string, active: bool, onToggled: string): Node =
87- n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})
88-
89-func scroll*(props: JsonNode, children: varargs[Node]): Node =
90- ## A list that is taller than the room it has. The renderer decides how that
91- ## is done; the tree only says that it is expected.
92- n("scroll", props, @children)
modified nim/src/frq_core.nim +7 -66
@@ -19,13 +19,16 @@
1919 ## version skew that segfaults rather than one that fails; JSON costs a parse
2020 ## per call, which is nothing against the network round trip that produced the
2121 ## line being parsed.
22+##
23+## There was a second half to this ABI once — `frq_ui_render`, `frq_ui_dispatch`
24+## and a state machine and screens behind them — from an experiment where Nim
25+## owned the UI as well. It is gone: it meant reimplementing screens that
26+## already exist and are far better, and the seam that actually wanted Nim
27+## under it was `frq.net`.
2228
2329 import std/json
24-import frq/[ircparse, ui, state, irc]
30+import frq/[ircparse, trace]
2531 import frq/conn as tr
26-import frq/trace
27-import frq/screens/connect as connectScreen
28-import frq/screens/chat as chatScreen
2932
3033 proc NimMain() {.importc.}
3134
@@ -99,68 +102,6 @@ proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
99102 if prefix == nil: return nil
100103 dup(nickOf($prefix))
101104
102-# ------------------------------------------------------------------- the UI
103-#
104-# The spike's real claim: Nim owns the state and the screen, Dart owns the
105-# pixels, and the only things crossing are a tree going out and an event id
106-# coming back. See `frq/ui.nim`.
107-
108-proc currentTree(): string =
109- maybeAutoconnect()
110- ## Whichever screen the state says. `drain` first, so the tree Dart gets is
111- ## built after every line that had arrived when it asked — that is the whole
112- ## of the polling model, and it is why there is no callback into Dart.
113- drain()
114- case app.screen
115- of scChat: $chatScreen.chatScreen(app).toJson
116- else: $connectScreen.connectScreen(app).toJson
117-
118-proc frq_ui_render*(): cstring {.exportc, dynlib.} =
119- ## The current screen as a widget tree, in JSON.
120- ##
121- ## No longer pure, and the change is worth naming: it drains the socket's
122- ## queue first, so two calls with no dispatch between can differ when a line
123- ## arrived in the gap. That is the point — it is how the room fills — but it
124- ## means the renderer must be free to call this whenever it likes, which is
125- ## what the Dart side's poll timer does.
126- dup(currentTree())
127-
128-proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
129- ## Apply an event and answer with the tree it produced.
130- ##
131- ## One call rather than dispatch-then-render, and not to save a crossing:
132- ## it makes the pair atomic. Two calls leave a window in which Dart could
133- ## render a state nothing asked for, which is the sort of thing that shows
134- ## up once a week and never in a test.
135- ##
136- ## A malformed event is ignored rather than fatal — it arrives from a tree
137- ## the renderer may have been holding for a frame, which is a normal race.
138- if event != nil:
139- try:
140- dispatch(parseJson($event))
141- except JsonParsingError:
142- discard
143- dup(currentTree())
144-
145-proc frq_ui_poll*(): cstring {.exportc, dynlib.} =
146- ## The tree, for a renderer that is asking because time passed rather than
147- ## because anything happened. Identical to `frq_ui_render` — named
148- ## separately so the Dart side reads as what it means.
149- dup(currentTree())
150-
151-proc frq_ui_offline*() {.exportc, dynlib.} =
152- ## Stop `connect` from opening a socket, for a test that wants the screens
153- ## without the network. There is no way back — a process that has asked for
154- ## this is a test process.
155- goOffline()
156-
157-proc frq_ui_reset*() {.exportc, dynlib.} =
158- ## Back to a fresh state. For tests, and for a renderer that wants a known
159- ## starting point rather than whatever the last run left.
160- irc.stop()
161- app = initState()
162-
163-
164105 # --------------------------------------------------------------- transport
165106 #
166107 # `frq.net`'s three operations, for `frq.net.nim` to install. This is the
@@ -19,13 +19,16 @@
19 ## version skew that segfaults rather than one that fails; JSON costs a parse19 ## version skew that segfaults rather than one that fails; JSON costs a parse
20 ## per call, which is nothing against the network round trip that produced the20 ## per call, which is nothing against the network round trip that produced the
21 ## line being parsed.21 ## line being parsed.
22+##
23+## There was a second half to this ABI once — `frq_ui_render`, `frq_ui_dispatch`
24+## and a state machine and screens behind them — from an experiment where Nim
25+## owned the UI as well. It is gone: it meant reimplementing screens that
26+## already exist and are far better, and the seam that actually wanted Nim
27+## under it was `frq.net`.
22 28
23 import std/json29 import std/json
24-import frq/[ircparse, ui, state, irc]30+import frq/[ircparse, trace]
25 import frq/conn as tr31 import frq/conn as tr
26-import frq/trace
27-import frq/screens/connect as connectScreen
28-import frq/screens/chat as chatScreen
29 32
30 proc NimMain() {.importc.}33 proc NimMain() {.importc.}
31 34
@@ -99,68 +102,6 @@ proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
99 if prefix == nil: return nil102 if prefix == nil: return nil
100 dup(nickOf($prefix))103 dup(nickOf($prefix))
101 104
102-# ------------------------------------------------------------------- the UI
103-#
104-# The spike's real claim: Nim owns the state and the screen, Dart owns the
105-# pixels, and the only things crossing are a tree going out and an event id
106-# coming back. See `frq/ui.nim`.
107-
108-proc currentTree(): string =
109- maybeAutoconnect()
110- ## Whichever screen the state says. `drain` first, so the tree Dart gets is
111- ## built after every line that had arrived when it asked — that is the whole
112- ## of the polling model, and it is why there is no callback into Dart.
113- drain()
114- case app.screen
115- of scChat: $chatScreen.chatScreen(app).toJson
116- else: $connectScreen.connectScreen(app).toJson
117-
118-proc frq_ui_render*(): cstring {.exportc, dynlib.} =
119- ## The current screen as a widget tree, in JSON.
120- ##
121- ## No longer pure, and the change is worth naming: it drains the socket's
122- ## queue first, so two calls with no dispatch between can differ when a line
123- ## arrived in the gap. That is the point — it is how the room fills — but it
124- ## means the renderer must be free to call this whenever it likes, which is
125- ## what the Dart side's poll timer does.
126- dup(currentTree())
127-
128-proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
129- ## Apply an event and answer with the tree it produced.
130- ##
131- ## One call rather than dispatch-then-render, and not to save a crossing:
132- ## it makes the pair atomic. Two calls leave a window in which Dart could
133- ## render a state nothing asked for, which is the sort of thing that shows
134- ## up once a week and never in a test.
135- ##
136- ## A malformed event is ignored rather than fatal — it arrives from a tree
137- ## the renderer may have been holding for a frame, which is a normal race.
138- if event != nil:
139- try:
140- dispatch(parseJson($event))
141- except JsonParsingError:
142- discard
143- dup(currentTree())
144-
145-proc frq_ui_poll*(): cstring {.exportc, dynlib.} =
146- ## The tree, for a renderer that is asking because time passed rather than
147- ## because anything happened. Identical to `frq_ui_render` — named
148- ## separately so the Dart side reads as what it means.
149- dup(currentTree())
150-
151-proc frq_ui_offline*() {.exportc, dynlib.} =
152- ## Stop `connect` from opening a socket, for a test that wants the screens
153- ## without the network. There is no way back — a process that has asked for
154- ## this is a test process.
155- goOffline()
156-
157-proc frq_ui_reset*() {.exportc, dynlib.} =
158- ## Back to a fresh state. For tests, and for a renderer that wants a known
159- ## starting point rather than whatever the last run left.
160- irc.stop()
161- app = initState()
162-
163-
164 # --------------------------------------------------------------- transport105 # --------------------------------------------------------------- transport
165 #106 #
166 # `frq.net`'s three operations, for `frq.net.nim` to install. This is the107 # `frq.net`'s three operations, for `frq.net.nim` to install. This is the
deleted nim/tests/tui.nim +0 -153
deleted file mode 100644
@@ -1,153 +0,0 @@
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, irc]
12-import frq/screens/connect as cs
13-
14-# The reducer calls `irc.start` on a Connect, and a unit test has no business
15-# opening a socket to irc.freeq.at — it did, before this stub, and the suite
16-# failed on a machine with no network for reasons that had nothing to do with
17-# the code. The stub records what it was asked for so the tests can assert on
18-# it, which is more than the real one would have told them.
19-var dialled: seq[ConnConfig]
20-irc.connector = proc(cfg: ConnConfig) {.nimcall, gcsafe.} =
21- {.cast(gcsafe).}: dialled.add cfg
22-
23-proc find(node: Node, tag: string): seq[Node] =
24- ## Every node with this tag, depth first.
25- if node.isNil: return
26- if node.tag == tag: result.add node
27- for c in node.children:
28- result.add c.find(tag)
29-
30-proc texts(node: Node, tag: string): seq[string] =
31- for n in node.find(tag):
32- result.add n.props{"label"}.getStr()
33-
34-suite "the connect screen":
35- setup:
36- app = initState()
37-
38- test "renders a page with the title and the server fields":
39- let t = cs.connectScreen(app)
40- check t.tag == "page"
41- check "frq" in t.texts("title")
42- check "Server" in t.texts("label")
43- check t.find("checkbutton").len == 1
44-
45- test "is pure — twice with no dispatch is the same tree":
46- check $cs.connectScreen(app).toJson == $cs.connectScreen(app).toJson
47-
48- test "guest is the default mode and shows a nickname field":
49- let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr())
50- check "nick" in keys
51- check "handle" notin keys
52-
53- test "the selected mode is the primary button, and only it":
54- let t = cs.connectScreen(app)
55- var primary: seq[string]
56- for b in t.find("button"):
57- if b.props{"kind"}.getStr() == "primary":
58- primary.add b.props{"label"}.getStr()
59- # Guest is selected; Connect is primary because it is the action.
60- check "Guest" in primary
61- check "Bluesky" notin primary
62-
63-suite "dispatch":
64- setup:
65- app = initState()
66- dialled = @[]
67-
68- test "switching mode changes which fields are shown":
69- dispatch(%*{"id": "mode.bluesky"})
70- let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr())
71- check "handle" in keys
72- check "nick" notin keys
73-
74- test "typing into the host field lands in the tree":
75- dispatch(%*{"id": "host.change", "value": "localhost"})
76- let host = cs.connectScreen(app).find("entry").filterIt(
77- it.props{"key"}.getStr() == "host")[0]
78- check host.props{"text"}.getStr() == "localhost"
79-
80- test "the TLS tick carries the port with it":
81- check app.formPort == "6697"
82- dispatch(%*{"id": "tls.toggle"})
83- check not app.formTls
84- check app.formPort == "6667"
85- dispatch(%*{"id": "tls.toggle"})
86- check app.formPort == "6697"
87-
88- test "connecting swaps the button for a spinner":
89- check cs.connectScreen(app).find("spinner").len == 0
90- dispatch(%*{"id": "connect"})
91- let t = cs.connectScreen(app)
92- check t.find("spinner").len == 1
93- check "Connect" notin t.texts("button")
94-
95- test "an empty host is refused, and the error is dismissable":
96- dispatch(%*{"id": "host.change", "value": " "})
97- dispatch(%*{"id": "connect"})
98- check app.hasError
99- check "Dismiss" in cs.connectScreen(app).texts("button")
100- dispatch(%*{"id": "error.dismiss"})
101- check not app.hasError
102- check "Dismiss" notin cs.connectScreen(app).texts("button")
103-
104- test "the error note keeps its place in the tree either way":
105- # The bug the stable wrapper exists for: a renderer matching children by
106- # position would patch the header into a card when the error appeared.
107- let before = cs.connectScreen(app).children.mapIt(it.tag)
108- dispatch(%*{"id": "host.change", "value": ""})
109- dispatch(%*{"id": "connect"})
110- check cs.connectScreen(app).children.mapIt(it.tag) == before
111-
112- test "an unknown event is ignored rather than fatal":
113- let before = $cs.connectScreen(app).toJson
114- dispatch(%*{"id": "no.such.event"})
115- check $cs.connectScreen(app).toJson == before
116-
117-suite "connecting":
118- setup:
119- app = initState()
120- dialled = @[]
121-
122- test "Connect dials the host and port on the form":
123- dispatch(%*{"id": "host.change", "value": "irc.example.org"})
124- dispatch(%*{"id": "connect"})
125- check dialled.len == 1
126- check dialled[0].host == "irc.example.org"
127- check dialled[0].port == 6697
128- check dialled[0].tls
129- check dialled[0].nick == "frq-guest"
130-
131- test "unticking TLS dials the plain port":
132- dispatch(%*{"id": "tls.toggle"})
133- dispatch(%*{"id": "connect"})
134- check dialled[0].port == 6667
135- check not dialled[0].tls
136-
137- test "a blank nickname is refused before anything is dialled":
138- dispatch(%*{"id": "nick.change", "value": " "})
139- dispatch(%*{"id": "connect"})
140- check dialled.len == 0
141- check app.hasError
142-
143- test "a nonsense port falls back to the one the tick implies":
144- dispatch(%*{"id": "port.change", "value": "not-a-port"})
145- dispatch(%*{"id": "connect"})
146- check dialled[0].port == 6697
147-
148- test "sending before registration does not queue a line":
149- dispatch(%*{"id": "draft.change", "value": "hello"})
150- dispatch(%*{"id": "send"})
151- # Still in the box: nothing was sent, and the text was not eaten.
152- check app.draft == "hello"
153- check app.messages.len == 0
deleted file mode 100644
@@ -1,153 +0,0 @@
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, irc]
12-import frq/screens/connect as cs
13-
14-# The reducer calls `irc.start` on a Connect, and a unit test has no business
15-# opening a socket to irc.freeq.at — it did, before this stub, and the suite
16-# failed on a machine with no network for reasons that had nothing to do with
17-# the code. The stub records what it was asked for so the tests can assert on
18-# it, which is more than the real one would have told them.
19-var dialled: seq[ConnConfig]
20-irc.connector = proc(cfg: ConnConfig) {.nimcall, gcsafe.} =
21- {.cast(gcsafe).}: dialled.add cfg
22-
23-proc find(node: Node, tag: string): seq[Node] =
24- ## Every node with this tag, depth first.
25- if node.isNil: return
26- if node.tag == tag: result.add node
27- for c in node.children:
28- result.add c.find(tag)
29-
30-proc texts(node: Node, tag: string): seq[string] =
31- for n in node.find(tag):
32- result.add n.props{"label"}.getStr()
33-
34-suite "the connect screen":
35- setup:
36- app = initState()
37-
38- test "renders a page with the title and the server fields":
39- let t = cs.connectScreen(app)
40- check t.tag == "page"
41- check "frq" in t.texts("title")
42- check "Server" in t.texts("label")
43- check t.find("checkbutton").len == 1
44-
45- test "is pure — twice with no dispatch is the same tree":
46- check $cs.connectScreen(app).toJson == $cs.connectScreen(app).toJson
47-
48- test "guest is the default mode and shows a nickname field":
49- let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr())
50- check "nick" in keys
51- check "handle" notin keys
52-
53- test "the selected mode is the primary button, and only it":
54- let t = cs.connectScreen(app)
55- var primary: seq[string]
56- for b in t.find("button"):
57- if b.props{"kind"}.getStr() == "primary":
58- primary.add b.props{"label"}.getStr()
59- # Guest is selected; Connect is primary because it is the action.
60- check "Guest" in primary
61- check "Bluesky" notin primary
62-
63-suite "dispatch":
64- setup:
65- app = initState()
66- dialled = @[]
67-
68- test "switching mode changes which fields are shown":
69- dispatch(%*{"id": "mode.bluesky"})
70- let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr())
71- check "handle" in keys
72- check "nick" notin keys
73-
74- test "typing into the host field lands in the tree":
75- dispatch(%*{"id": "host.change", "value": "localhost"})
76- let host = cs.connectScreen(app).find("entry").filterIt(
77- it.props{"key"}.getStr() == "host")[0]
78- check host.props{"text"}.getStr() == "localhost"
79-
80- test "the TLS tick carries the port with it":
81- check app.formPort == "6697"
82- dispatch(%*{"id": "tls.toggle"})
83- check not app.formTls
84- check app.formPort == "6667"
85- dispatch(%*{"id": "tls.toggle"})
86- check app.formPort == "6697"
87-
88- test "connecting swaps the button for a spinner":
89- check cs.connectScreen(app).find("spinner").len == 0
90- dispatch(%*{"id": "connect"})
91- let t = cs.connectScreen(app)
92- check t.find("spinner").len == 1
93- check "Connect" notin t.texts("button")
94-
95- test "an empty host is refused, and the error is dismissable":
96- dispatch(%*{"id": "host.change", "value": " "})
97- dispatch(%*{"id": "connect"})
98- check app.hasError
99- check "Dismiss" in cs.connectScreen(app).texts("button")
100- dispatch(%*{"id": "error.dismiss"})
101- check not app.hasError
102- check "Dismiss" notin cs.connectScreen(app).texts("button")
103-
104- test "the error note keeps its place in the tree either way":
105- # The bug the stable wrapper exists for: a renderer matching children by
106- # position would patch the header into a card when the error appeared.
107- let before = cs.connectScreen(app).children.mapIt(it.tag)
108- dispatch(%*{"id": "host.change", "value": ""})
109- dispatch(%*{"id": "connect"})
110- check cs.connectScreen(app).children.mapIt(it.tag) == before
111-
112- test "an unknown event is ignored rather than fatal":
113- let before = $cs.connectScreen(app).toJson
114- dispatch(%*{"id": "no.such.event"})
115- check $cs.connectScreen(app).toJson == before
116-
117-suite "connecting":
118- setup:
119- app = initState()
120- dialled = @[]
121-
122- test "Connect dials the host and port on the form":
123- dispatch(%*{"id": "host.change", "value": "irc.example.org"})
124- dispatch(%*{"id": "connect"})
125- check dialled.len == 1
126- check dialled[0].host == "irc.example.org"
127- check dialled[0].port == 6697
128- check dialled[0].tls
129- check dialled[0].nick == "frq-guest"
130-
131- test "unticking TLS dials the plain port":
132- dispatch(%*{"id": "tls.toggle"})
133- dispatch(%*{"id": "connect"})
134- check dialled[0].port == 6667
135- check not dialled[0].tls
136-
137- test "a blank nickname is refused before anything is dialled":
138- dispatch(%*{"id": "nick.change", "value": " "})
139- dispatch(%*{"id": "connect"})
140- check dialled.len == 0
141- check app.hasError
142-
143- test "a nonsense port falls back to the one the tick implies":
144- dispatch(%*{"id": "port.change", "value": "not-a-port"})
145- dispatch(%*{"id": "connect"})
146- check dialled[0].port == 6697
147-
148- test "sending before registration does not queue a line":
149- dispatch(%*{"id": "draft.change", "value": "hello"})
150- dispatch(%*{"id": "send"})
151- # Still in the box: nothing was sent, and the text was not eaten.
152- check app.draft == "hello"
153- check app.messages.len == 0