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