nandi/frqpublic Fork 0
35994d4
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.

A message in #test, from Nim

The spike sends. `just nim-live` dials irc.freeq.at:6697 over TLS, registers,
joins #test, says a line and reads the room's backlog back — and every byte of
that is Nim: the socket, the TLS, the IRC registration, the parser, the state.
Dart's share is the pixels and the FFI call.

    irc  dialling irc.freeq.at:6697 over TLS
    irc  connected
    out  NICK/USER as frq-spike-9464
    in   :irc.freeq.at 001 … :Welcome to irc.freeq.at (guest)
    out  JOIN #test
    out  PRIVMSG #test :frq nim spike: hello from Nim over dart:ffi

Which also answers the bug this started as. Guest connect span forever because
`connect` set `connecting = true` and nothing ever cleared it — there was no
socket, and no Cancel button either, so the only way out of the spinner was
killing the window. There is a socket now, and a Cancel regardless, because a
host that never answers is the case that spinner is actually for.

The threading is the part to read before changing anything. Nim's ORC is
thread-local for ref types, so sharing the state with a reader thread would
mean a lock per field and a heap two threads both collect. Nothing is shared:
the socket thread speaks only in channels, and `drain()` turns its output into
state on whichever thread Dart called in on. Dart polls every 100ms and Nim
never calls back — a Dart callback from a foreign thread needs marshalling onto
the main isolate, and at 70µs a render a timer does the same job for free.

Tracing, as asked for, on the switch the rest of frq already uses: FRQ_TRACE=1
prints every dispatch with the state before and after, every line in and out,
and every event with no handler. It is off by default and the argument to a
trace call is not built when it is off.

Two things the tests caught that the window would have hidden. The reducer's
unit tests were opening real TLS connections to irc.freeq.at, because Connect
now dials — there is a `connector` seam and a `goOffline()` for that, and the
widget tests use it, since a suite that needs a network is one that fails on a
train. And the poll loop compared trees by a `toString` that showed only tags
and prop NAMES, so two screens were equal when a message had arrived and the
room would never have appeared to fill.

Still a spike, and still beside the app rather than in it: `lib/main_nim.dart`
is a second entry point, the ClojureDart one is untouched, and `connect` does
guest registration only — no CAP, no SASL, so no Bluesky identity.

Verified: 43 Nim tests, 29 Dart, 7 Flutter widget tests, check-common clean,
and `just nim-live` against the real server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T20:07:32-07:00 Browse files
35994d4 parent: 43a02c2
modified dart/frq_core/lib/frq_core.dart +31 -4
@@ -225,13 +225,20 @@ class UiNode {
225225 return v is T ? v : fallback;
226226 }
227227
228+ /// Structural, and that matters: the poll loop compares two trees by this
229+ /// string to decide whether to rebuild. A summary that showed only the tag
230+ /// and the prop NAMES would call two screens equal when a message had
231+ /// arrived, and the room would never appear to fill.
228232 @override
229- String toString() => '<$tag ${props.keys.join(",")} (${children.length})>';
233+ String toString() =>
234+ '<$tag $props ${children.map((c) => c.toString()).join()}>';
230235 }
231236
232-/// The current screen. Pure on the Nim side: calling it twice with no
233-/// [dispatch] between gives the same tree, which is what lets Flutter rebuild
234-/// whenever it likes rather than when Nim says so.
237+/// The current screen.
238+///
239+/// Not pure: the Nim side drains the socket's queue first, so two calls with
240+/// no [dispatch] between can differ when a line arrived in the gap. That is
241+/// how the room fills, and it is why the renderer polls.
235242 UiNode render() {
236243 final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
237244 final json = _takeString(f());
@@ -254,6 +261,26 @@ UiNode dispatch(String id, [String value = '']) {
254261 }
255262 }
256263
264+/// The tree, asked for because time passed rather than because anything
265+/// happened.
266+///
267+/// Identical to [render] — the Nim side drains the socket queue on both — but
268+/// named for what the caller means. A renderer polls this; it does not poll
269+/// "render".
270+UiNode poll() {
271+ final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
272+ final json = _takeString(f());
273+ return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
274+}
275+
276+/// Stop `connect` from opening a socket.
277+///
278+/// For tests that build the real screens and tap the real Connect button. A
279+/// widget test that dials irc.freeq.at is one that fails on a train, and this
280+/// suite did exactly that before this existed. One way only.
281+void goOffline() =>
282+ _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_offline')();
283+
257284 /// Back to a fresh state, for a caller that wants a known starting point.
258285 void resetUi() =>
259286 _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')();
@@ -225,13 +225,20 @@ class UiNode {
225 return v is T ? v : fallback;225 return v is T ? v : fallback;
226 }226 }
227 227
228+ /// Structural, and that matters: the poll loop compares two trees by this
229+ /// string to decide whether to rebuild. A summary that showed only the tag
230+ /// and the prop NAMES would call two screens equal when a message had
231+ /// arrived, and the room would never appear to fill.
228 @override232 @override
229- String toString() => '<$tag ${props.keys.join(",")} (${children.length})>';233+ String toString() =>
234+ '<$tag $props ${children.map((c) => c.toString()).join()}>';
230 }235 }
231 236
232-/// The current screen. Pure on the Nim side: calling it twice with no237+/// The current screen.
233-/// [dispatch] between gives the same tree, which is what lets Flutter rebuild238+///
234-/// whenever it likes rather than when Nim says so.239+/// Not pure: the Nim side drains the socket's queue first, so two calls with
240+/// no [dispatch] between can differ when a line arrived in the gap. That is
241+/// how the room fills, and it is why the renderer polls.
235 UiNode render() {242 UiNode render() {
236 final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');243 final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
237 final json = _takeString(f());244 final json = _takeString(f());
@@ -254,6 +261,26 @@ UiNode dispatch(String id, [String value = '']) {
254 }261 }
255 }262 }
256 263
264+/// The tree, asked for because time passed rather than because anything
265+/// happened.
266+///
267+/// Identical to [render] — the Nim side drains the socket queue on both — but
268+/// named for what the caller means. A renderer polls this; it does not poll
269+/// "render".
270+UiNode poll() {
271+ final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
272+ final json = _takeString(f());
273+ return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>);
274+}
275+
276+/// Stop `connect` from opening a socket.
277+///
278+/// For tests that build the real screens and tap the real Connect button. A
279+/// widget test that dials irc.freeq.at is one that fails on a train, and this
280+/// suite did exactly that before this existed. One way only.
281+void goOffline() =>
282+ _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_offline')();
283+
257 /// Back to a fresh state, for a caller that wants a known starting point.284 /// Back to a fresh state, for a caller that wants a known starting point.
258 void resetUi() =>285 void resetUi() =>
259 _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')();286 _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')();
added dart/frq_core/tool/live_send.dart +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
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)];
new file mode 100644
@@ -0,0 +1,76 @@
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)];
modified flake.nix +18 -0
@@ -469,6 +469,13 @@
469469 dart = pkgs.mkShellNoCC {
470470 name = "frq-dart";
471471 packages = [ pkgs.dart pkgs.just ];
472+
473+ # libfrqcore.so is linked against OpenSSL, and the process that
474+ # dlopens it has to be able to find one. Named here rather than
475+ # left to the host: a machine whose libssl is a different soname
476+ # fails at `frq_init` with a message about the wrong library.
477+ LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.openssl ];
478+
472479 FRQ_DART = "1";
473480 };
474481
@@ -483,6 +490,17 @@
483490 name = "frq-nim";
484491 packages = [ pkgs.nim pkgs.just ];
485492
493+ # OpenSSL, because `-d:ssl` in nim/nim.cfg makes std/net link
494+ # -lssl and -lcrypto: the IRC connection is TLS on :6697, which is
495+ # the only port freeq actually listens on.
496+ buildInputs = [ pkgs.openssl ];
497+
498+ # And on the loader path as well as the linker's. Nim resolves the
499+ # OpenSSL entry points through dynlib at run time, so without this
500+ # `newContext` finds nothing behind the symbol and dies with a
501+ # SIGSEGV that says nothing about SSL at all.
502+ LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.openssl ];
503+
486504 # The recipe's re-entry test, the way FRQ_FLUTTER_DESKTOP is the
487505 # desktop one's.
488506 FRQ_NIM = "1";
@@ -469,6 +469,13 @@
469 dart = pkgs.mkShellNoCC {469 dart = pkgs.mkShellNoCC {
470 name = "frq-dart";470 name = "frq-dart";
471 packages = [ pkgs.dart pkgs.just ];471 packages = [ pkgs.dart pkgs.just ];
472+
473+ # libfrqcore.so is linked against OpenSSL, and the process that
474+ # dlopens it has to be able to find one. Named here rather than
475+ # left to the host: a machine whose libssl is a different soname
476+ # fails at `frq_init` with a message about the wrong library.
477+ LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.openssl ];
478+
472 FRQ_DART = "1";479 FRQ_DART = "1";
473 };480 };
474 481
@@ -483,6 +490,17 @@
483 name = "frq-nim";490 name = "frq-nim";
484 packages = [ pkgs.nim pkgs.just ];491 packages = [ pkgs.nim pkgs.just ];
485 492
493+ # OpenSSL, because `-d:ssl` in nim/nim.cfg makes std/net link
494+ # -lssl and -lcrypto: the IRC connection is TLS on :6697, which is
495+ # the only port freeq actually listens on.
496+ buildInputs = [ pkgs.openssl ];
497+
498+ # And on the loader path as well as the linker's. Nim resolves the
499+ # OpenSSL entry points through dynlib at run time, so without this
500+ # `newContext` finds nothing behind the symbol and dies with a
501+ # SIGSEGV that says nothing about SSL at all.
502+ LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.openssl ];
503+
486 # The recipe's re-entry test, the way FRQ_FLUTTER_DESKTOP is the504 # The recipe's re-entry test, the way FRQ_FLUTTER_DESKTOP is the
487 # desktop one's.505 # desktop one's.
488 FRQ_NIM = "1";506 FRQ_NIM = "1";
modified flutter/lib/nim_renderer.dart +35 -0
@@ -7,6 +7,8 @@
77 /// The measure of whether the split is honest is how boring this file is. If
88 /// a feature ever needs a change here AND in Nim, the boundary is in the
99 /// wrong place.
10+import 'dart:async';
11+
1012 import 'package:flutter/material.dart';
1113 import 'package:frq_core/frq_core.dart' as core;
1214
@@ -21,6 +23,25 @@ class NimApp extends StatefulWidget {
2123
2224 class _NimAppState extends State<NimApp> {
2325 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+ }
2445
2546 // One controller per keyed entry, kept across rebuilds.
2647 //
@@ -35,6 +56,7 @@ class _NimAppState extends State<NimApp> {
3556
3657 @override
3758 void dispose() {
59+ _poll?.cancel();
3860 for (final c in _controllers.values) {
3961 c.dispose();
4062 }
@@ -87,6 +109,19 @@ class _NimAppState extends State<NimApp> {
87109 children: kids,
88110 );
89111
112+ case 'scroll':
113+ return SizedBox(
114+ height: n.prop('height', 300).toDouble(),
115+ child: Scrollbar(
116+ child: SingleChildScrollView(
117+ reverse: true,
118+ child: Column(
119+ crossAxisAlignment: CrossAxisAlignment.start,
120+ children: kids),
121+ ),
122+ ),
123+ );
124+
90125 case 'card':
91126 return Card(
92127 margin: const EdgeInsets.symmetric(vertical: 8),
@@ -7,6 +7,8 @@
7 /// The measure of whether the split is honest is how boring this file is. If7 /// 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 the8 /// a feature ever needs a change here AND in Nim, the boundary is in the
9 /// wrong place.9 /// wrong place.
10+import 'dart:async';
11+
10 import 'package:flutter/material.dart';12 import 'package:flutter/material.dart';
11 import 'package:frq_core/frq_core.dart' as core;13 import 'package:frq_core/frq_core.dart' as core;
12 14
@@ -21,6 +23,25 @@ class NimApp extends StatefulWidget {
21 23
22 class _NimAppState extends State<NimApp> {24 class _NimAppState extends State<NimApp> {
23 late core.UiNode _tree = core.render();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+ }
24 45
25 // One controller per keyed entry, kept across rebuilds.46 // One controller per keyed entry, kept across rebuilds.
26 //47 //
@@ -35,6 +56,7 @@ class _NimAppState extends State<NimApp> {
35 56
36 @override57 @override
37 void dispose() {58 void dispose() {
59+ _poll?.cancel();
38 for (final c in _controllers.values) {60 for (final c in _controllers.values) {
39 c.dispose();61 c.dispose();
40 }62 }
@@ -87,6 +109,19 @@ class _NimAppState extends State<NimApp> {
87 children: kids,109 children: kids,
88 );110 );
89 111
112+ case 'scroll':
113+ return SizedBox(
114+ height: n.prop('height', 300).toDouble(),
115+ child: Scrollbar(
116+ child: SingleChildScrollView(
117+ reverse: true,
118+ child: Column(
119+ crossAxisAlignment: CrossAxisAlignment.start,
120+ children: kids),
121+ ),
122+ ),
123+ );
124+
90 case 'card':125 case 'card':
91 return Card(126 return Card(
92 margin: const EdgeInsets.symmetric(vertical: 8),127 margin: const EdgeInsets.symmetric(vertical: 8),
modified flutter/test/nim_renderer_test.dart +6 -0
@@ -13,6 +13,9 @@ import 'package:frq_core/frq_core.dart' as core;
1313 import 'package:cljd_flutter/nim_renderer.dart';
1414
1515 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);
1619 setUp(core.resetUi);
1720
1821 /// The TextField currently showing [text].
@@ -119,5 +122,8 @@ void main() {
119122 expect(find.byType(CircularProgressIndicator), findsOneWidget);
120123 expect(find.text('Connect'), findsNothing);
121124 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);
122128 });
123129 }
@@ -13,6 +13,9 @@ import 'package:frq_core/frq_core.dart' as core;
13 import 'package:cljd_flutter/nim_renderer.dart';13 import 'package:cljd_flutter/nim_renderer.dart';
14 14
15 void main() {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);
16 setUp(core.resetUi);19 setUp(core.resetUi);
17 20
18 /// The TextField currently showing [text].21 /// The TextField currently showing [text].
@@ -119,5 +122,8 @@ void main() {
119 expect(find.byType(CircularProgressIndicator), findsOneWidget);122 expect(find.byType(CircularProgressIndicator), findsOneWidget);
120 expect(find.text('Connect'), findsNothing);123 expect(find.text('Connect'), findsNothing);
121 expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget);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);
122 });128 });
123 }129 }
modified justfile +25 -0
@@ -497,3 +497,28 @@ nim-bench:
497497 cd dart/frq_core
498498 dart pub get >/dev/null
499499 dart run test/bench.dart
500+
501+# The spike, end to end, against a real freeq.
502+#
503+# Connects, registers, joins #test and says a line — all of it through the
504+# FFI, so what it proves is Nim's socket, Nim's TLS, Nim's IRC registration
505+# and the Dart boundary over the lot.
506+#
507+# Not in any test suite, and not in CI: it needs a network and it sends a
508+# message to a public channel. Run it when you mean to.
509+#
510+# just nim-live irc.freeq.at, a random nick
511+# just nim-live irc.freeq.at mynick "a line"
512+# FRQ_TRACE=1 just nim-live ...and every line on the wire
513+nim-live *args:
514+ #!/usr/bin/env bash
515+ set -euo pipefail
516+ cd "{{justfile_directory()}}"
517+ if [ -z "${FRQ_DART:-}" ]; then
518+ just nim-lib
519+ exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-live "$@"
520+ fi
521+ shift || true
522+ cd dart/frq_core
523+ dart pub get >/dev/null
524+ exec dart run tool/live_send.dart "$@"
@@ -497,3 +497,28 @@ nim-bench:
497 cd dart/frq_core497 cd dart/frq_core
498 dart pub get >/dev/null498 dart pub get >/dev/null
499 dart run test/bench.dart499 dart run test/bench.dart
500+
501+# The spike, end to end, against a real freeq.
502+#
503+# Connects, registers, joins #test and says a line — all of it through the
504+# FFI, so what it proves is Nim's socket, Nim's TLS, Nim's IRC registration
505+# and the Dart boundary over the lot.
506+#
507+# Not in any test suite, and not in CI: it needs a network and it sends a
508+# message to a public channel. Run it when you mean to.
509+#
510+# just nim-live irc.freeq.at, a random nick
511+# just nim-live irc.freeq.at mynick "a line"
512+# FRQ_TRACE=1 just nim-live ...and every line on the wire
513+nim-live *args:
514+ #!/usr/bin/env bash
515+ set -euo pipefail
516+ cd "{{justfile_directory()}}"
517+ if [ -z "${FRQ_DART:-}" ]; then
518+ just nim-lib
519+ exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-live "$@"
520+ fi
521+ shift || true
522+ cd dart/frq_core
523+ dart pub get >/dev/null
524+ exec dart run tool/live_send.dart "$@"
modified nim/README.md +44 -2
@@ -55,6 +55,46 @@ 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
59+
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.
63+
64+```
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
70+```
71+
72+Three decisions worth knowing before changing any of it:
73+
74+* **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.
89+
90+```bash
91+just nim-spike # the window
92+just nim-spike-test # 7 widget tests: real taps, real widgets
93+just nim-live # connect to a real freeq and say a line
94+just nim-bench # what the boundary costs
95+FRQ_TRACE=1 just nim-live # ...and every line on the wire
96+```
97+
5898 ## Status
5999
60100 `frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the
@@ -71,8 +111,10 @@ core exists to have less Clojure in the tree, and `lookupFunction` takes two
71111 type arguments, so it meant fighting generic interop to write more of the
72112 thing being removed. In Dart it is a typedef. See `dart/README.md`.
73113
74-What is **not** done is the wiring: nothing imports `frq_core`, so
75-`common/frq/irc/parse.cljc` is still what every target runs. That step is its
114+The spike above is wired up and runs. What is **not** done is replacing
115+anything: the shipping app is still the ClojureDart one, `common/frq/irc/parse.cljc`
116+is still what it runs, and the spike is a second entry point beside it
117+(`lib/main_nim.dart`) rather than a replacement for `frq.main`. That step is its
76118 own piece of work — the Flutter app takes the package as a path dependency
77119 (which means a `pubspec.lock` regeneration and widening the nix build's source
78120 root), the library has to reach each target (`jniLibs` for the APK, beside the
@@ -55,6 +55,46 @@ 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 spike
59+
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.
63+
64+```
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
70+```
71+
72+Three decisions worth knowing before changing any of it:
73+
74+* **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.
89+
90+```bash
91+just nim-spike # the window
92+just nim-spike-test # 7 widget tests: real taps, real widgets
93+just nim-live # connect to a real freeq and say a line
94+just nim-bench # what the boundary costs
95+FRQ_TRACE=1 just nim-live # ...and every line on the wire
96+```
97+
58 ## Status98 ## Status
59 99
60 `frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the100 `frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the
@@ -71,8 +111,10 @@ core exists to have less Clojure in the tree, and `lookupFunction` takes two
71 type arguments, so it meant fighting generic interop to write more of the111 type arguments, so it meant fighting generic interop to write more of the
72 thing being removed. In Dart it is a typedef. See `dart/README.md`.112 thing being removed. In Dart it is a typedef. See `dart/README.md`.
73 113
74-What is **not** done is the wiring: nothing imports `frq_core`, so114+The spike above is wired up and runs. What is **not** done is replacing
75-`common/frq/irc/parse.cljc` is still what every target runs. That step is its115+anything: the shipping app is still the ClojureDart one, `common/frq/irc/parse.cljc`
116+is still what it runs, and the spike is a second entry point beside it
117+(`lib/main_nim.dart`) rather than a replacement for `frq.main`. That step is its
76 own piece of work — the Flutter app takes the package as a path dependency118 own piece of work — the Flutter app takes the package as a path dependency
77 (which means a `pubspec.lock` regeneration and widening the nix build's source119 (which means a `pubspec.lock` regeneration and widening the nix build's source
78 root), the library has to reach each target (`jniLibs` for the APK, beside the120 root), the library has to reach each target (`jniLibs` for the APK, beside the
added nim/nim.cfg +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+# Applies to every compile under nim/, so no recipe has to remember it.
2+
3+# std/net's TLS is behind this define — without it `newContext` does not
4+# exist, which is a confusing way to be told that the socket cannot do 6697.
5+# Nim loads libssl by soname at run time rather than linking it, so this costs
6+# nothing at build and fails at connect on a machine with no OpenSSL.
7+-d:ssl
8+
9+# The reader runs on its own thread; see src/frq/irc.nim for why nothing is
10+# shared with it but channels. On by default in Nim 2, stated anyway because
11+# the design depends on it.
12+--threads:on
new file mode 100644
@@ -0,0 +1,12 @@
1+# Applies to every compile under nim/, so no recipe has to remember it.
2+
3+# std/net's TLS is behind this define — without it `newContext` does not
4+# exist, which is a confusing way to be told that the socket cannot do 6697.
5+# Nim loads libssl by soname at run time rather than linking it, so this costs
6+# nothing at build and fails at connect on a machine with no OpenSSL.
7+-d:ssl
8+
9+# The reader runs on its own thread; see src/frq/irc.nim for why nothing is
10+# shared with it but channels. On by default in Nim 2, stated anyway because
11+# the design depends on it.
12+--threads:on
added nim/src/frq/irc.nim +158 -0
new file mode 100644
@@ -0,0 +1,158 @@
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")
new file mode 100644
@@ -0,0 +1,158 @@
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")
added nim/src/frq/screens/chat.nim +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
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),
43+ button("Send", "send", "primary")))
new file mode 100644
@@ -0,0 +1,43 @@
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),
43+ button("Send", "send", "primary")))
modified nim/src/frq/screens/connect.nim +8 -1
@@ -42,7 +42,14 @@ func serverFields(s: State): Node =
4242
4343 func connectAction(s: State): Node =
4444 if s.connecting:
45- hbox(%*{"spacing": 8}, spinner(), dimLabel(s.status))
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"))
4653 else:
4754 hbox(%*{"spacing": 8},
4855 button("Connect", "connect", "primary"),
@@ -42,7 +42,14 @@ func serverFields(s: State): Node =
42 42
43 func connectAction(s: State): Node =43 func connectAction(s: State): Node =
44 if s.connecting:44 if s.connecting:
45- hbox(%*{"spacing": 8}, spinner(), dimLabel(s.status))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"))
46 else:53 else:
47 hbox(%*{"spacing": 8},54 hbox(%*{"spacing": 8},
48 button("Connect", "connect", "primary"),55 button("Connect", "connect", "primary"),
modified nim/src/frq/state.nim +124 -9
@@ -12,6 +12,7 @@
1212 ## crosses the boundary. That is what lets the renderer stay dumb.
1313
1414 import std/[json, strutils]
15+import trace, ircparse, irc
1516
1617 type
1718 AuthMode* = enum
@@ -20,6 +21,10 @@ type
2021 Screen* = enum
2122 scConnect = "connect", scChats = "chats", scChat = "chat"
2223
24+ Message* = object
25+ frm*: string
26+ text*: string
27+
2328 State* = object
2429 screen*: Screen
2530 status*: string
@@ -37,6 +42,12 @@ type
3742 formAppPassword*: string
3843 brokerToken*: string
3944
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+
4051 const
4152 defaultHost* = "irc.freeq.at"
4253 defaultPort* = "6697"
@@ -48,7 +59,8 @@ func initState*(): State =
4859 formHost: defaultHost,
4960 formPort: defaultPort,
5061 formTls: true,
51- formNick: "frq-guest")
62+ formNick: "frq-guest",
63+ channel: "#test")
5264
5365 var app* = initState()
5466 ## The one mutable thing in the spike. Named `app` and not `state` because
@@ -63,10 +75,26 @@ var app* = initState()
6375 # anything is ignored rather than fatal: a stale tree held by the renderer for
6476 # one frame after a state change is a normal race, not an error.
6577
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+
6690 proc dispatch*(event: JsonNode) =
6791 let id = event{"id"}.getStr()
6892 let value = event{"value"}.getStr()
6993
94+ traced "dispatch": "" & id &
95+ (if value.len > 0: " value=" & value.escape else: "") &
96+ " before: " & app.summary
97+
7098 case id
7199 of "mode.guest": app.authMode = amGuest
72100 of "mode.bluesky": app.authMode = amBluesky
@@ -88,20 +116,107 @@ proc dispatch*(event: JsonNode) =
88116 app.hasError = false
89117
90118 of "connect":
91- # The spike stops at the edge of I/O: there is no socket here yet, so
92- # this reports what it would do. Wiring the real connection in is the
93- # `nim/README.md` step about Nim owning the transport, and it does not
94- # change anything about the tree or the renderer.
95119 if app.formHost.strip().len == 0:
96120 app.error = "A server is required."
97121 app.hasError = true
122+ elif app.formNick.strip().len == 0:
123+ app.error = "A nickname is required."
124+ app.hasError = true
98125 else:
99126 app.connecting = true
100127 app.status = "Connecting to " & app.formHost & ":" & app.formPort &
101- (if app.formTls: " over TLS" else: "") & ""
102-
103- of "cancel":
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()
104137 app.connecting = false
138+ app.registered = false
139+ app.screen = scConnect
105140 app.status = "Not connected"
106141
107- else: discard
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)
@@ -12,6 +12,7 @@
12 ## crosses the boundary. That is what lets the renderer stay dumb.12 ## crosses the boundary. That is what lets the renderer stay dumb.
13 13
14 import std/[json, strutils]14 import std/[json, strutils]
15+import trace, ircparse, irc
15 16
16 type17 type
17 AuthMode* = enum18 AuthMode* = enum
@@ -20,6 +21,10 @@ type
20 Screen* = enum21 Screen* = enum
21 scConnect = "connect", scChats = "chats", scChat = "chat"22 scConnect = "connect", scChats = "chats", scChat = "chat"
22 23
24+ Message* = object
25+ frm*: string
26+ text*: string
27+
23 State* = object28 State* = object
24 screen*: Screen29 screen*: Screen
25 status*: string30 status*: string
@@ -37,6 +42,12 @@ type
37 formAppPassword*: string42 formAppPassword*: string
38 brokerToken*: string43 brokerToken*: string
39 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+
40 const51 const
41 defaultHost* = "irc.freeq.at"52 defaultHost* = "irc.freeq.at"
42 defaultPort* = "6697"53 defaultPort* = "6697"
@@ -48,7 +59,8 @@ func initState*(): State =
48 formHost: defaultHost,59 formHost: defaultHost,
49 formPort: defaultPort,60 formPort: defaultPort,
50 formTls: true,61 formTls: true,
51- formNick: "frq-guest")62+ formNick: "frq-guest",
63+ channel: "#test")
52 64
53 var app* = initState()65 var app* = initState()
54 ## The one mutable thing in the spike. Named `app` and not `state` because66 ## The one mutable thing in the spike. Named `app` and not `state` because
@@ -63,10 +75,26 @@ var app* = initState()
63 # anything is ignored rather than fatal: a stale tree held by the renderer for75 # anything is ignored rather than fatal: a stale tree held by the renderer for
64 # one frame after a state change is a normal race, not an error.76 # one frame after a state change is a normal race, not an error.
65 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+
66 proc dispatch*(event: JsonNode) =90 proc dispatch*(event: JsonNode) =
67 let id = event{"id"}.getStr()91 let id = event{"id"}.getStr()
68 let value = event{"value"}.getStr()92 let value = event{"value"}.getStr()
69 93
94+ traced "dispatch": "" & id &
95+ (if value.len > 0: " value=" & value.escape else: "") &
96+ " before: " & app.summary
97+
70 case id98 case id
71 of "mode.guest": app.authMode = amGuest99 of "mode.guest": app.authMode = amGuest
72 of "mode.bluesky": app.authMode = amBluesky100 of "mode.bluesky": app.authMode = amBluesky
@@ -88,20 +116,107 @@ proc dispatch*(event: JsonNode) =
88 app.hasError = false116 app.hasError = false
89 117
90 of "connect":118 of "connect":
91- # The spike stops at the edge of I/O: there is no socket here yet, so
92- # this reports what it would do. Wiring the real connection in is the
93- # `nim/README.md` step about Nim owning the transport, and it does not
94- # change anything about the tree or the renderer.
95 if app.formHost.strip().len == 0:119 if app.formHost.strip().len == 0:
96 app.error = "A server is required."120 app.error = "A server is required."
97 app.hasError = true121 app.hasError = true
122+ elif app.formNick.strip().len == 0:
123+ app.error = "A nickname is required."
124+ app.hasError = true
98 else:125 else:
99 app.connecting = true126 app.connecting = true
100 app.status = "Connecting to " & app.formHost & ":" & app.formPort &127 app.status = "Connecting to " & app.formHost & ":" & app.formPort &
101- (if app.formTls: " over TLS" else: "") & ""128+ (if app.formTls: " over TLS" else: "") & ""
102-129+ irc.start(ConnConfig(host: app.formHost.strip(),
103- of "cancel":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()
104 app.connecting = false137 app.connecting = false
138+ app.registered = false
139+ app.screen = scConnect
105 app.status = "Not connected"140 app.status = "Not connected"
106 141
107- else: discard142+ 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)
added nim/src/frq/trace.nim +33 -0
new file mode 100644
@@ -0,0 +1,33 @@
1+## Tracing, on when `FRQ_TRACE` is set.
2+##
3+## The same switch the rest of frq uses — README.md documents `FRQ_TRACE=1`
4+## for the IRC lines — because a second convention for a second language is a
5+## thing to remember rather than a thing to use.
6+##
7+## 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.
10+##
11+## 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 the
13+## string building — which matters because the argument to a trace call is
14+## usually the expensive part.
15+
16+import std/[os, strutils, times]
17+
18+let enabled* = getEnv("FRQ_TRACE").len > 0 and getEnv("FRQ_TRACE") != "0"
19+
20+proc trace*(topic: string, msg: string) =
21+ ## One line: a timestamp, a topic, and the message.
22+ if not enabled: return
23+ let t = now().format("HH:mm:ss'.'fff")
24+ stderr.writeLine("[frq " & t & "] " & topic.alignLeft(9) & " " & msg)
25+ # Flushed every line rather than at exit: a trace lost when the process dies
26+ # is worth nothing, and the process dying is the case most worth tracing.
27+ stderr.flushFile()
28+
29+template traced*(topic: string, body: untyped) =
30+ ## For a message that costs something to build. The body is not evaluated
31+ ## at all when tracing is off.
32+ if enabled:
33+ trace(topic, body)
new file mode 100644
@@ -0,0 +1,33 @@
1+## Tracing, on when `FRQ_TRACE` is set.
2+##
3+## The same switch the rest of frq uses — README.md documents `FRQ_TRACE=1`
4+## for the IRC lines — because a second convention for a second language is a
5+## thing to remember rather than a thing to use.
6+##
7+## 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.
10+##
11+## 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 the
13+## string building — which matters because the argument to a trace call is
14+## usually the expensive part.
15+
16+import std/[os, strutils, times]
17+
18+let enabled* = getEnv("FRQ_TRACE").len > 0 and getEnv("FRQ_TRACE") != "0"
19+
20+proc trace*(topic: string, msg: string) =
21+ ## One line: a timestamp, a topic, and the message.
22+ if not enabled: return
23+ let t = now().format("HH:mm:ss'.'fff")
24+ stderr.writeLine("[frq " & t & "] " & topic.alignLeft(9) & " " & msg)
25+ # Flushed every line rather than at exit: a trace lost when the process dies
26+ # is worth nothing, and the process dying is the case most worth tracing.
27+ stderr.flushFile()
28+
29+template traced*(topic: string, body: untyped) =
30+ ## For a message that costs something to build. The body is not evaluated
31+ ## at all when tracing is off.
32+ if enabled:
33+ trace(topic, body)
modified nim/src/frq/ui.nim +5 -0
@@ -81,3 +81,8 @@ func entry*(key, text, placeholder, onChange: string, width = 0): Node =
8181
8282 func checkbutton*(text: string, active: bool, onToggled: string): Node =
8383 n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})
84+
85+func scroll*(props: JsonNode, children: varargs[Node]): Node =
86+ ## A list that is taller than the room it has. The renderer decides how that
87+ ## is done; the tree only says that it is expected.
88+ n("scroll", props, @children)
@@ -81,3 +81,8 @@ func entry*(key, text, placeholder, onChange: string, width = 0): Node =
81 81
82 func checkbutton*(text: string, active: bool, onToggled: string): Node =82 func checkbutton*(text: string, active: bool, onToggled: string): Node =
83 n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})83 n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})
84+
85+func scroll*(props: JsonNode, children: varargs[Node]): Node =
86+ ## A list that is taller than the room it has. The renderer decides how that
87+ ## is done; the tree only says that it is expected.
88+ n("scroll", props, @children)
modified nim/src/frq_core.nim +33 -6
@@ -21,8 +21,9 @@
2121 ## line being parsed.
2222
2323 import std/json
24-import frq/[ircparse, ui, state]
24+import frq/[ircparse, ui, state, irc]
2525 import frq/screens/connect as connectScreen
26+import frq/screens/chat as chatScreen
2627
2728 proc NimMain() {.importc.}
2829
@@ -102,11 +103,24 @@ proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
102103 # pixels, and the only things crossing are a tree going out and an event id
103104 # coming back. See `frq/ui.nim`.
104105
106+proc currentTree(): string =
107+ ## Whichever screen the state says. `drain` first, so the tree Dart gets is
108+ ## built after every line that had arrived when it asked — that is the whole
109+ ## of the polling model, and it is why there is no callback into Dart.
110+ drain()
111+ case app.screen
112+ of scChat: $chatScreen.chatScreen(app).toJson
113+ else: $connectScreen.connectScreen(app).toJson
114+
105115 proc frq_ui_render*(): cstring {.exportc, dynlib.} =
106- ## The current screen as a widget tree, in JSON. Pure: calling it twice with
107- ## no dispatch between gives the same answer, which is what lets the
108- ## renderer rebuild whenever Flutter asks rather than when Nim says so.
109- dup($connectScreen.connectScreen(app).toJson)
116+ ## The current screen as a widget tree, in JSON.
117+ ##
118+ ## No longer pure, and the change is worth naming: it drains the socket's
119+ ## queue first, so two calls with no dispatch between can differ when a line
120+ ## arrived in the gap. That is the point — it is how the room fills — but it
121+ ## means the renderer must be free to call this whenever it likes, which is
122+ ## what the Dart side's poll timer does.
123+ dup(currentTree())
110124
111125 proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
112126 ## Apply an event and answer with the tree it produced.
@@ -123,9 +137,22 @@ proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
123137 dispatch(parseJson($event))
124138 except JsonParsingError:
125139 discard
126- dup($connectScreen.connectScreen(app).toJson)
140+ dup(currentTree())
141+
142+proc frq_ui_poll*(): cstring {.exportc, dynlib.} =
143+ ## The tree, for a renderer that is asking because time passed rather than
144+ ## because anything happened. Identical to `frq_ui_render` — named
145+ ## separately so the Dart side reads as what it means.
146+ dup(currentTree())
147+
148+proc frq_ui_offline*() {.exportc, dynlib.} =
149+ ## Stop `connect` from opening a socket, for a test that wants the screens
150+ ## without the network. There is no way back — a process that has asked for
151+ ## this is a test process.
152+ goOffline()
127153
128154 proc frq_ui_reset*() {.exportc, dynlib.} =
129155 ## Back to a fresh state. For tests, and for a renderer that wants a known
130156 ## starting point rather than whatever the last run left.
157+ irc.stop()
131158 app = initState()
@@ -21,8 +21,9 @@
21 ## line being parsed.21 ## line being parsed.
22 22
23 import std/json23 import std/json
24-import frq/[ircparse, ui, state]24+import frq/[ircparse, ui, state, irc]
25 import frq/screens/connect as connectScreen25 import frq/screens/connect as connectScreen
26+import frq/screens/chat as chatScreen
26 27
27 proc NimMain() {.importc.}28 proc NimMain() {.importc.}
28 29
@@ -102,11 +103,24 @@ proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
102 # pixels, and the only things crossing are a tree going out and an event id103 # pixels, and the only things crossing are a tree going out and an event id
103 # coming back. See `frq/ui.nim`.104 # coming back. See `frq/ui.nim`.
104 105
106+proc currentTree(): string =
107+ ## Whichever screen the state says. `drain` first, so the tree Dart gets is
108+ ## built after every line that had arrived when it asked — that is the whole
109+ ## of the polling model, and it is why there is no callback into Dart.
110+ drain()
111+ case app.screen
112+ of scChat: $chatScreen.chatScreen(app).toJson
113+ else: $connectScreen.connectScreen(app).toJson
114+
105 proc frq_ui_render*(): cstring {.exportc, dynlib.} =115 proc frq_ui_render*(): cstring {.exportc, dynlib.} =
106- ## The current screen as a widget tree, in JSON. Pure: calling it twice with116+ ## The current screen as a widget tree, in JSON.
107- ## no dispatch between gives the same answer, which is what lets the117+ ##
108- ## renderer rebuild whenever Flutter asks rather than when Nim says so.118+ ## No longer pure, and the change is worth naming: it drains the socket's
109- dup($connectScreen.connectScreen(app).toJson)119+ ## queue first, so two calls with no dispatch between can differ when a line
120+ ## arrived in the gap. That is the point — it is how the room fills — but it
121+ ## means the renderer must be free to call this whenever it likes, which is
122+ ## what the Dart side's poll timer does.
123+ dup(currentTree())
110 124
111 proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =125 proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
112 ## Apply an event and answer with the tree it produced.126 ## Apply an event and answer with the tree it produced.
@@ -123,9 +137,22 @@ proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
123 dispatch(parseJson($event))137 dispatch(parseJson($event))
124 except JsonParsingError:138 except JsonParsingError:
125 discard139 discard
126- dup($connectScreen.connectScreen(app).toJson)140+ dup(currentTree())
141+
142+proc frq_ui_poll*(): cstring {.exportc, dynlib.} =
143+ ## The tree, for a renderer that is asking because time passed rather than
144+ ## because anything happened. Identical to `frq_ui_render` — named
145+ ## separately so the Dart side reads as what it means.
146+ dup(currentTree())
147+
148+proc frq_ui_offline*() {.exportc, dynlib.} =
149+ ## Stop `connect` from opening a socket, for a test that wants the screens
150+ ## without the network. There is no way back — a process that has asked for
151+ ## this is a test process.
152+ goOffline()
127 153
128 proc frq_ui_reset*() {.exportc, dynlib.} =154 proc frq_ui_reset*() {.exportc, dynlib.} =
129 ## Back to a fresh state. For tests, and for a renderer that wants a known155 ## Back to a fresh state. For tests, and for a renderer that wants a known
130 ## starting point rather than whatever the last run left.156 ## starting point rather than whatever the last run left.
157+ irc.stop()
131 app = initState()158 app = initState()
modified nim/tests/tui.nim +49 -1
@@ -8,9 +8,18 @@ import std/sequtils
88
99 import std/[json, strutils]
1010 import std/unittest
11-import frq/[ui, state]
11+import frq/[ui, state, irc]
1212 import frq/screens/connect as cs
1313
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+
1423 proc find(node: Node, tag: string): seq[Node] =
1524 ## Every node with this tag, depth first.
1625 if node.isNil: return
@@ -54,6 +63,7 @@ suite "the connect screen":
5463 suite "dispatch":
5564 setup:
5665 app = initState()
66+ dialled = @[]
5767
5868 test "switching mode changes which fields are shown":
5969 dispatch(%*{"id": "mode.bluesky"})
@@ -103,3 +113,41 @@ suite "dispatch":
103113 let before = $cs.connectScreen(app).toJson
104114 dispatch(%*{"id": "no.such.event"})
105115 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
@@ -8,9 +8,18 @@ import std/sequtils
8 8
9 import std/[json, strutils]9 import std/[json, strutils]
10 import std/unittest10 import std/unittest
11-import frq/[ui, state]11+import frq/[ui, state, irc]
12 import frq/screens/connect as cs12 import frq/screens/connect as cs
13 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+
14 proc find(node: Node, tag: string): seq[Node] =23 proc find(node: Node, tag: string): seq[Node] =
15 ## Every node with this tag, depth first.24 ## Every node with this tag, depth first.
16 if node.isNil: return25 if node.isNil: return
@@ -54,6 +63,7 @@ suite "the connect screen":
54 suite "dispatch":63 suite "dispatch":
55 setup:64 setup:
56 app = initState()65 app = initState()
66+ dialled = @[]
57 67
58 test "switching mode changes which fields are shown":68 test "switching mode changes which fields are shown":
59 dispatch(%*{"id": "mode.bluesky"})69 dispatch(%*{"id": "mode.bluesky"})
@@ -103,3 +113,41 @@ suite "dispatch":
103 let before = $cs.connectScreen(app).toJson113 let before = $cs.connectScreen(app).toJson
104 dispatch(%*{"id": "no.such.event"})114 dispatch(%*{"id": "no.such.event"})
105 check $cs.connectScreen(app).toJson == before115 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