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>35994d4 parent: 43a02c2 modified
dart/frq_core/lib/frq_core.dart +31 -4 | @@ -225,13 +225,20 @@ class UiNode { | ||
| 225 | 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 | 232 | @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 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. | |
| 235 | 242 | UiNode render() { |
| 236 | 243 | final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render'); |
| 237 | 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 | 284 | /// Back to a fresh state, for a caller that wants a known starting point. |
| 258 | 285 | void resetUi() => |
| 259 | 286 | _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 | @override | 232 | @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 no | 237 | +/// The current screen. |
| 233 | -/// [dispatch] between gives the same tree, which is what lets Flutter rebuild | 238 | +/// |
| 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 @@ | ||
| 469 | 469 | dart = pkgs.mkShellNoCC { |
| 470 | 470 | name = "frq-dart"; |
| 471 | 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 | 479 | FRQ_DART = "1"; |
| 473 | 480 | }; |
| 474 | 481 | |
| @@ -483,6 +490,17 @@ | ||
| 483 | 490 | name = "frq-nim"; |
| 484 | 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 | 504 | # The recipe's re-entry test, the way FRQ_FLUTTER_DESKTOP is the |
| 487 | 505 | # desktop one's. |
| 488 | 506 | 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 the | 504 | # 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 @@ | ||
| 7 | 7 | /// The measure of whether the split is honest is how boring this file is. If |
| 8 | 8 | /// a feature ever needs a change here AND in Nim, the boundary is in the |
| 9 | 9 | /// wrong place. |
| 10 | +import 'dart:async'; | |
| 11 | + | |
| 10 | 12 | import 'package:flutter/material.dart'; |
| 11 | 13 | import 'package:frq_core/frq_core.dart' as core; |
| 12 | 14 | |
| @@ -21,6 +23,25 @@ class NimApp extends StatefulWidget { | ||
| 21 | 23 | |
| 22 | 24 | class _NimAppState extends State<NimApp> { |
| 23 | 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 | 46 | // One controller per keyed entry, kept across rebuilds. |
| 26 | 47 | // |
| @@ -35,6 +56,7 @@ class _NimAppState extends State<NimApp> { | ||
| 35 | 56 | |
| 36 | 57 | @override |
| 37 | 58 | void dispose() { |
| 59 | + _poll?.cancel(); | |
| 38 | 60 | for (final c in _controllers.values) { |
| 39 | 61 | c.dispose(); |
| 40 | 62 | } |
| @@ -87,6 +109,19 @@ class _NimAppState extends State<NimApp> { | ||
| 87 | 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 | 125 | case 'card': |
| 91 | 126 | return Card( |
| 92 | 127 | 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. If | 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 | 8 | /// 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 | @override | 57 | @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; | ||
| 13 | 13 | import 'package:cljd_flutter/nim_renderer.dart'; |
| 14 | 14 | |
| 15 | 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 | 19 | setUp(core.resetUi); |
| 17 | 20 | |
| 18 | 21 | /// The TextField currently showing [text]. |
| @@ -119,5 +122,8 @@ void main() { | ||
| 119 | 122 | expect(find.byType(CircularProgressIndicator), findsOneWidget); |
| 120 | 123 | expect(find.text('Connect'), findsNothing); |
| 121 | 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 | } |
| @@ -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: | ||
| 497 | 497 | cd dart/frq_core |
| 498 | 498 | dart pub get >/dev/null |
| 499 | 499 | 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_core | 497 | cd dart/frq_core |
| 498 | dart pub get >/dev/null | 498 | dart pub get >/dev/null |
| 499 | dart run test/bench.dart | 499 | 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 | ||
| 55 | 55 | originals**. This is why the originals stay in `common/` rather than being |
| 56 | 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 | 98 | ## Status |
| 59 | 99 | |
| 60 | 100 | `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 | 111 | type arguments, so it meant fighting generic interop to write more of the |
| 72 | 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`, 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 | |
| 76 | 118 | own piece of work — the Flutter app takes the package as a path dependency |
| 77 | 119 | (which means a `pubspec.lock` regeneration and widening the nix build's source |
| 78 | 120 | 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 being | 55 | 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 | ## Status | 98 | ## Status |
| 59 | 99 | ||
| 60 | `frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the | 100 | `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 the | 111 | 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`, so | 114 | +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 its | 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 | ||
| 76 | own piece of work — the Flutter app takes the package as a path dependency | 118 | 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 source | 119 | (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 the | 120 | 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 = | ||
| 42 | 42 | |
| 43 | 43 | func connectAction(s: State): Node = |
| 44 | 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 | 53 | else: |
| 47 | 54 | hbox(%*{"spacing": 8}, |
| 48 | 55 | 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 @@ | ||
| 12 | 12 | ## crosses the boundary. That is what lets the renderer stay dumb. |
| 13 | 13 | |
| 14 | 14 | import std/[json, strutils] |
| 15 | +import trace, ircparse, irc | |
| 15 | 16 | |
| 16 | 17 | type |
| 17 | 18 | AuthMode* = enum |
| @@ -20,6 +21,10 @@ type | ||
| 20 | 21 | Screen* = enum |
| 21 | 22 | scConnect = "connect", scChats = "chats", scChat = "chat" |
| 22 | 23 | |
| 24 | + Message* = object | |
| 25 | + frm*: string | |
| 26 | + text*: string | |
| 27 | + | |
| 23 | 28 | State* = object |
| 24 | 29 | screen*: Screen |
| 25 | 30 | status*: string |
| @@ -37,6 +42,12 @@ type | ||
| 37 | 42 | formAppPassword*: string |
| 38 | 43 | 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 | 51 | const |
| 41 | 52 | defaultHost* = "irc.freeq.at" |
| 42 | 53 | defaultPort* = "6697" |
| @@ -48,7 +59,8 @@ func initState*(): State = | ||
| 48 | 59 | formHost: defaultHost, |
| 49 | 60 | formPort: defaultPort, |
| 50 | 61 | formTls: true, |
| 51 | - formNick: "frq-guest") | |
| 62 | + formNick: "frq-guest", | |
| 63 | + channel: "#test") | |
| 52 | 64 | |
| 53 | 65 | var app* = initState() |
| 54 | 66 | ## The one mutable thing in the spike. Named `app` and not `state` because |
| @@ -63,10 +75,26 @@ var app* = initState() | ||
| 63 | 75 | # anything is ignored rather than fatal: a stale tree held by the renderer for |
| 64 | 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 | 90 | proc dispatch*(event: JsonNode) = |
| 67 | 91 | let id = event{"id"}.getStr() |
| 68 | 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 | 98 | case id |
| 71 | 99 | of "mode.guest": app.authMode = amGuest |
| 72 | 100 | of "mode.bluesky": app.authMode = amBluesky |
| @@ -88,20 +116,107 @@ proc dispatch*(event: JsonNode) = | ||
| 88 | 116 | app.hasError = false |
| 89 | 117 | |
| 90 | 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 | 119 | if app.formHost.strip().len == 0: |
| 96 | 120 | app.error = "A server is required." |
| 97 | 121 | app.hasError = true |
| 122 | + elif app.formNick.strip().len == 0: | |
| 123 | + app.error = "A nickname is required." | |
| 124 | + app.hasError = true | |
| 98 | 125 | else: |
| 99 | 126 | app.connecting = true |
| 100 | 127 | 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() | |
| 104 | 137 | app.connecting = false |
| 138 | + app.registered = false | |
| 139 | + app.screen = scConnect | |
| 105 | 140 | app.status = "Not connected" |
| 106 | 141 | |
| 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 | type | 17 | type |
| 17 | AuthMode* = enum | 18 | AuthMode* = enum |
| @@ -20,6 +21,10 @@ type | |||
| 20 | Screen* = enum | 21 | 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* = object | 28 | State* = object |
| 24 | screen*: Screen | 29 | screen*: Screen |
| 25 | status*: string | 30 | status*: string |
| @@ -37,6 +42,12 @@ type | |||
| 37 | formAppPassword*: string | 42 | formAppPassword*: string |
| 38 | brokerToken*: string | 43 | 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 | const | 51 | 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` because | 66 | ## 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 for | 75 | # 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 id | 98 | case id |
| 71 | of "mode.guest": app.authMode = amGuest | 99 | of "mode.guest": app.authMode = amGuest |
| 72 | of "mode.bluesky": app.authMode = amBluesky | 100 | of "mode.bluesky": app.authMode = amBluesky |
| @@ -88,20 +116,107 @@ proc dispatch*(event: JsonNode) = | |||
| 88 | app.hasError = false | 116 | 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 = true | 121 | 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 = true | 126 | 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 = false | 137 | 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: 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) | ||
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 = | ||
| 81 | 81 | |
| 82 | 82 | func checkbutton*(text: string, active: bool, onToggled: string): Node = |
| 83 | 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) | |
| @@ -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 @@ | ||
| 21 | 21 | ## line being parsed. |
| 22 | 22 | |
| 23 | 23 | import std/json |
| 24 | -import frq/[ircparse, ui, state] | |
| 24 | +import frq/[ircparse, ui, state, irc] | |
| 25 | 25 | import frq/screens/connect as connectScreen |
| 26 | +import frq/screens/chat as chatScreen | |
| 26 | 27 | |
| 27 | 28 | proc NimMain() {.importc.} |
| 28 | 29 | |
| @@ -102,11 +103,24 @@ proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} = | ||
| 102 | 103 | # pixels, and the only things crossing are a tree going out and an event id |
| 103 | 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 | 115 | 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()) | |
| 110 | 124 | |
| 111 | 125 | proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} = |
| 112 | 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 | 137 | dispatch(parseJson($event)) |
| 124 | 138 | except JsonParsingError: |
| 125 | 139 | 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 | 154 | proc frq_ui_reset*() {.exportc, dynlib.} = |
| 129 | 155 | ## Back to a fresh state. For tests, and for a renderer that wants a known |
| 130 | 156 | ## starting point rather than whatever the last run left. |
| 157 | + irc.stop() | |
| 131 | 158 | app = initState() |
| @@ -21,8 +21,9 @@ | |||
| 21 | ## line being parsed. | 21 | ## line being parsed. |
| 22 | 22 | ||
| 23 | import std/json | 23 | import std/json |
| 24 | -import frq/[ircparse, ui, state] | 24 | +import frq/[ircparse, ui, state, irc] |
| 25 | import frq/screens/connect as connectScreen | 25 | 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 id | 103 | # 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 with | 116 | + ## The current screen as a widget tree, in JSON. |
| 107 | - ## no dispatch between gives the same answer, which is what lets the | 117 | + ## |
| 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 | discard | 139 | 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 known | 155 | ## 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 | ||
| 8 | 8 | |
| 9 | 9 | import std/[json, strutils] |
| 10 | 10 | import std/unittest |
| 11 | -import frq/[ui, state] | |
| 11 | +import frq/[ui, state, irc] | |
| 12 | 12 | 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 | 23 | proc find(node: Node, tag: string): seq[Node] = |
| 15 | 24 | ## Every node with this tag, depth first. |
| 16 | 25 | if node.isNil: return |
| @@ -54,6 +63,7 @@ suite "the connect screen": | ||
| 54 | 63 | suite "dispatch": |
| 55 | 64 | setup: |
| 56 | 65 | app = initState() |
| 66 | + dialled = @[] | |
| 57 | 67 | |
| 58 | 68 | test "switching mode changes which fields are shown": |
| 59 | 69 | dispatch(%*{"id": "mode.bluesky"}) |
| @@ -103,3 +113,41 @@ suite "dispatch": | ||
| 103 | 113 | let before = $cs.connectScreen(app).toJson |
| 104 | 114 | dispatch(%*{"id": "no.such.event"}) |
| 105 | 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 | |
| @@ -8,9 +8,18 @@ import std/sequtils | |||
| 8 | 8 | ||
| 9 | import std/[json, strutils] | 9 | import std/[json, strutils] |
| 10 | import std/unittest | 10 | import std/unittest |
| 11 | -import frq/[ui, state] | 11 | +import frq/[ui, state, irc] |
| 12 | import frq/screens/connect as cs | 12 | 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: return | 25 | 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).toJson | 113 | let before = $cs.connectScreen(app).toJson |
| 104 | dispatch(%*{"id": "no.such.event"}) | 114 | dispatch(%*{"id": "no.such.event"}) |
| 105 | check $cs.connectScreen(app).toJson == before | 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 | ||