The renderer, and a transport bug that took four tries
`just nim-ui run` opens a window whose screens are Nim's. The renderer covers
all 22 tags the real screens use — avatar, reaction, link, image, separator,
status, emoji, dialog, spacer, text and the rest — with `frq.hiccup`'s
treatments rather than guesses at them, so a tree from Nim paints the way the
same tree painted under ClojureDart. `nim_theme.dart` carries the tokens.
`reducer.nim` is `frq.actions` and the reducers scattered through `frq.main`,
in one place: 40-odd events, the IRC registration, and `drain`, which turns
the socket's output into rooms and messages. Events are `name:argument`
strings because that is what fits in a prop; the alternative is a second
serialisation to version for arguments that are always one string.
Proof, against the real server, through the FFI:
✓ chats screen: Logged in as frq-ui-4249
✓ rooms: [#test]
✓ chat screen: #test
✓ 79 text runs, 11 links, 86 avatars, 2 separators
✓ tags in use: avatar, button, dim-label, entry, hbox, image, label,
link, scroll, separator, spacer, text, title, vbox
Every tag in that last line is one the renderer handles, which is the check
that matters: an unhandled one paints as an orange `?tag` and there are none.
The transport is where the time went, and it was four bugs wearing one
symptom — the window connected and then sat there.
`frq_init` called `NimMain`, and on Linux `--app:lib` already runs the module
initialisers from a library constructor. Everything ran twice, which for
conn.nim meant `open()` on channels that were already open: the reader drained
a different queue from the one the writer filled.
`import conn` and `import frq/conn` name the same file by two paths, and Nim
compiles it twice — two sets of globals, two states. Every intra-package
import says `frq/…` now.
`recvLine(timeout)` does not time out on a TLS socket, and neither does
`recv(timeout)`: select fires when a TLS *record* arrives, which need not hold
a complete line, and SSL_read then blocks for one. A single thread cannot both
wait for the server and notice what the client wants to say, so there are two
— one reader, one writer — which is what every IRC client does and what
OpenSSL supports.
And `--mm:refc` gives each thread its own GC heap, so the Socket the two
threads share is a ref from another heap and dereferencing it segfaults. ORC's
heap is shared. That is why the memory model is not a preference.
Each of those was invisible to the others until the one in front of it was
fixed, which is the argument for the tracing: `frq_trace` puts both languages
in one log and that is the only reason this was findable.
176 Nim tests, 20 Dart, check-common clean, and the window runs with no
exceptions and no unknown tags.
Not yet ported: the emoji picker, the overview strip, the lightbox, the
profile card, and SASL — so the two signed-in modes on the connect screen
reach the server and land as guests, which the screen does not yet say.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>58e6c97 parent: 87a28cc modified
dart/frq_core/lib/frq_core.dart +79 -0 | @@ -247,3 +247,82 @@ 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 | + | |
| 252 | +// ---------------------------------------------------------------- the UI | |
| 253 | +// | |
| 254 | +// Nim owns the state and the screens; Dart owns the pixels. A tree goes out, | |
| 255 | +// an event id comes back, and nothing else crosses. | |
| 256 | +// | |
| 257 | +// `UiNode` is deliberately a dumb bag — a tag, a props map, children. A class | |
| 258 | +// per widget would put the tag vocabulary in two places and make every new tag | |
| 259 | +// a change on both sides; the point is that Nim can grow a screen without this | |
| 260 | +// file being touched. | |
| 261 | + | |
| 262 | +/// One node of the widget tree Nim emitted. | |
| 263 | +class UiNode { | |
| 264 | + final String tag; | |
| 265 | + final Map<String, dynamic> props; | |
| 266 | + final List<UiNode> children; | |
| 267 | + | |
| 268 | + const UiNode(this.tag, this.props, this.children); | |
| 269 | + | |
| 270 | + factory UiNode.fromJson(Map<String, dynamic> j) => UiNode( | |
| 271 | + j['tag'] as String, | |
| 272 | + (j['props'] as Map?)?.cast<String, dynamic>() ?? const {}, | |
| 273 | + ((j['children'] as List?) ?? const []) | |
| 274 | + .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>())) | |
| 275 | + .toList(growable: false), | |
| 276 | + ); | |
| 277 | + | |
| 278 | + /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on | |
| 279 | + /// purpose: a renderer should skip a prop it does not understand rather than | |
| 280 | + /// fail a whole screen over one. | |
| 281 | + T prop<T>(String name, T fallback) { | |
| 282 | + final v = props[name]; | |
| 283 | + return v is T ? v : fallback; | |
| 284 | + } | |
| 285 | + | |
| 286 | + /// Structural, and that matters: the poll loop compares two trees by this | |
| 287 | + /// string to decide whether to rebuild. A summary showing only tags and prop | |
| 288 | + /// NAMES would call two screens equal when a message had arrived, and the | |
| 289 | + /// room would never appear to fill. | |
| 290 | + @override | |
| 291 | + String toString() => | |
| 292 | + '<$tag $props ${children.map((c) => c.toString()).join()}>'; | |
| 293 | +} | |
| 294 | + | |
| 295 | +UiNode _treeFrom(String? json) => | |
| 296 | + UiNode.fromJson(jsonDecode(json ?? '{"tag":"vbox"}') as Map<String, dynamic>); | |
| 297 | + | |
| 298 | +/// The current screen. | |
| 299 | +/// | |
| 300 | +/// Not pure: the Nim side drains the socket's queue first, so two calls with | |
| 301 | +/// no [dispatch] between can differ when a line arrived in the gap. That is how | |
| 302 | +/// the room fills, and why the renderer polls. | |
| 303 | +UiNode render() => _treeFrom( | |
| 304 | + _takeString(_lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render')())); | |
| 305 | + | |
| 306 | +/// The tree, asked for because time passed rather than because anything | |
| 307 | +/// happened. Same work as [render]; named for what the caller means. | |
| 308 | +UiNode poll() => _treeFrom( | |
| 309 | + _takeString(_lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll')())); | |
| 310 | + | |
| 311 | +/// Apply an event and get the tree it produced. | |
| 312 | +/// | |
| 313 | +/// One call rather than dispatch-then-render, and not to save a crossing: it | |
| 314 | +/// makes the pair atomic, so there is no window in which Dart could render a | |
| 315 | +/// state nothing asked for. | |
| 316 | +UiNode dispatch(String id, [String value = '']) { | |
| 317 | + final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch'); | |
| 318 | + final a = _toC(jsonEncode({'id': id, 'value': value})); | |
| 319 | + try { | |
| 320 | + return _treeFrom(_takeString(f(a))); | |
| 321 | + } finally { | |
| 322 | + _freeArg(a); | |
| 323 | + } | |
| 324 | +} | |
| 325 | + | |
| 326 | +/// Back to a fresh state, for a caller that wants a known starting point. | |
| 327 | +void resetUi() => | |
| 328 | + _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')(); | |
| @@ -247,3 +247,82 @@ 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 | + | ||
| 252 | +// ---------------------------------------------------------------- the UI | ||
| 253 | +// | ||
| 254 | +// Nim owns the state and the screens; Dart owns the pixels. A tree goes out, | ||
| 255 | +// an event id comes back, and nothing else crosses. | ||
| 256 | +// | ||
| 257 | +// `UiNode` is deliberately a dumb bag — a tag, a props map, children. A class | ||
| 258 | +// per widget would put the tag vocabulary in two places and make every new tag | ||
| 259 | +// a change on both sides; the point is that Nim can grow a screen without this | ||
| 260 | +// file being touched. | ||
| 261 | + | ||
| 262 | +/// One node of the widget tree Nim emitted. | ||
| 263 | +class UiNode { | ||
| 264 | + final String tag; | ||
| 265 | + final Map<String, dynamic> props; | ||
| 266 | + final List<UiNode> children; | ||
| 267 | + | ||
| 268 | + const UiNode(this.tag, this.props, this.children); | ||
| 269 | + | ||
| 270 | + factory UiNode.fromJson(Map<String, dynamic> j) => UiNode( | ||
| 271 | + j['tag'] as String, | ||
| 272 | + (j['props'] as Map?)?.cast<String, dynamic>() ?? const {}, | ||
| 273 | + ((j['children'] as List?) ?? const []) | ||
| 274 | + .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>())) | ||
| 275 | + .toList(growable: false), | ||
| 276 | + ); | ||
| 277 | + | ||
| 278 | + /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on | ||
| 279 | + /// purpose: a renderer should skip a prop it does not understand rather than | ||
| 280 | + /// fail a whole screen over one. | ||
| 281 | + T prop<T>(String name, T fallback) { | ||
| 282 | + final v = props[name]; | ||
| 283 | + return v is T ? v : fallback; | ||
| 284 | + } | ||
| 285 | + | ||
| 286 | + /// Structural, and that matters: the poll loop compares two trees by this | ||
| 287 | + /// string to decide whether to rebuild. A summary showing only tags and prop | ||
| 288 | + /// NAMES would call two screens equal when a message had arrived, and the | ||
| 289 | + /// room would never appear to fill. | ||
| 290 | + @override | ||
| 291 | + String toString() => | ||
| 292 | + '<$tag $props ${children.map((c) => c.toString()).join()}>'; | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +UiNode _treeFrom(String? json) => | ||
| 296 | + UiNode.fromJson(jsonDecode(json ?? '{"tag":"vbox"}') as Map<String, dynamic>); | ||
| 297 | + | ||
| 298 | +/// The current screen. | ||
| 299 | +/// | ||
| 300 | +/// Not pure: the Nim side drains the socket's queue first, so two calls with | ||
| 301 | +/// no [dispatch] between can differ when a line arrived in the gap. That is how | ||
| 302 | +/// the room fills, and why the renderer polls. | ||
| 303 | +UiNode render() => _treeFrom( | ||
| 304 | + _takeString(_lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render')())); | ||
| 305 | + | ||
| 306 | +/// The tree, asked for because time passed rather than because anything | ||
| 307 | +/// happened. Same work as [render]; named for what the caller means. | ||
| 308 | +UiNode poll() => _treeFrom( | ||
| 309 | + _takeString(_lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll')())); | ||
| 310 | + | ||
| 311 | +/// Apply an event and get the tree it produced. | ||
| 312 | +/// | ||
| 313 | +/// One call rather than dispatch-then-render, and not to save a crossing: it | ||
| 314 | +/// makes the pair atomic, so there is no window in which Dart could render a | ||
| 315 | +/// state nothing asked for. | ||
| 316 | +UiNode dispatch(String id, [String value = '']) { | ||
| 317 | + final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch'); | ||
| 318 | + final a = _toC(jsonEncode({'id': id, 'value': value})); | ||
| 319 | + try { | ||
| 320 | + return _treeFrom(_takeString(f(a))); | ||
| 321 | + } finally { | ||
| 322 | + _freeArg(a); | ||
| 323 | + } | ||
| 324 | +} | ||
| 325 | + | ||
| 326 | +/// Back to a fresh state, for a caller that wants a known starting point. | ||
| 327 | +void resetUi() => | ||
| 328 | + _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')(); | ||
added
dart/frq_core/tool/live_ui.dart +97 -0 | new file mode 100644 | ||
| @@ -0,0 +1,97 @@ | ||
| 1 | +/// The whole stack, end to end: Nim's socket, Nim's state, Nim's screens. | |
| 2 | +/// | |
| 3 | +/// Connects to a real freeq, waits for the room list to fill, opens a room and | |
| 4 | +/// prints what the tree actually contains. Everything goes through the FFI, so | |
| 5 | +/// what it proves is the same path the window uses. | |
| 6 | +/// | |
| 7 | +/// Not in any test suite: it needs a network and a running freeq. | |
| 8 | +/// | |
| 9 | +/// just nim-live | |
| 10 | +import 'dart:io'; | |
| 11 | +import 'package:frq_core/frq_core.dart' as core; | |
| 12 | + | |
| 13 | +List<core.UiNode> find(core.UiNode n, String tag) => | |
| 14 | + [if (n.tag == tag) n, for (final c in n.children) ...find(c, tag)]; | |
| 15 | + | |
| 16 | +List<String> labels(core.UiNode n, String tag) => | |
| 17 | + find(n, tag).map((e) => e.prop('label', '')).toList(); | |
| 18 | + | |
| 19 | +Future<void> main(List<String> args) async { | |
| 20 | + final host = args.isNotEmpty ? args[0] : 'irc.freeq.at'; | |
| 21 | + final nick = args.length > 1 | |
| 22 | + ? args[1] | |
| 23 | + : 'frq-ui-${DateTime.now().millisecondsSinceEpoch % 10000}'; | |
| 24 | + | |
| 25 | + core.resetUi(); | |
| 26 | + print('→ $host as $nick'); | |
| 27 | + | |
| 28 | + core.dispatch('nick.change', nick); | |
| 29 | + core.dispatch('host.change', host); | |
| 30 | + var tree = core.dispatch('connect'); | |
| 31 | + | |
| 32 | + final deadline = DateTime.now().add(const Duration(seconds: 25)); | |
| 33 | + while (DateTime.now().isBefore(deadline)) { | |
| 34 | + await Future<void>.delayed(const Duration(milliseconds: 100)); | |
| 35 | + tree = core.poll(); | |
| 36 | + if (labels(tree, 'title').any((l) => l.startsWith('Logged in as'))) break; | |
| 37 | + final err = labels(tree, 'label').where((l) => l.startsWith('⚠')); | |
| 38 | + if (err.isNotEmpty) { | |
| 39 | + print('✗ ${err.first}'); | |
| 40 | + exit(1); | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + if (!labels(tree, 'title').any((l) => l.startsWith('Logged in as'))) { | |
| 45 | + print('✗ never reached the chats screen — still ${labels(tree, "title")}'); | |
| 46 | + exit(1); | |
| 47 | + } | |
| 48 | + print('✓ chats screen: ${labels(tree, "title").first}'); | |
| 49 | + | |
| 50 | + // The room list, from the real server. Waited for rather than read on the | |
| 51 | + // instant we land: registration finishes before the JOIN echo that creates | |
| 52 | + // the buffer, so reading immediately is reading too early. | |
| 53 | + final roomsBy = DateTime.now().add(const Duration(seconds: 10)); | |
| 54 | + var rooms = <String>[]; | |
| 55 | + while (DateTime.now().isBefore(roomsBy)) { | |
| 56 | + await Future<void>.delayed(const Duration(milliseconds: 200)); | |
| 57 | + tree = core.poll(); | |
| 58 | + rooms = labels(tree, 'title-2'); | |
| 59 | + if (rooms.isNotEmpty) break; | |
| 60 | + } | |
| 61 | + print('✓ rooms: $rooms'); | |
| 62 | + if (rooms.isEmpty) { | |
| 63 | + print('✗ no rooms in the list'); | |
| 64 | + exit(1); | |
| 65 | + } | |
| 66 | + | |
| 67 | + // Open one and look at the conversation. | |
| 68 | + tree = core.dispatch('room.open:${rooms.first}'); | |
| 69 | + await Future<void>.delayed(const Duration(seconds: 2)); | |
| 70 | + tree = core.poll(); | |
| 71 | + | |
| 72 | + print('✓ chat screen: ${labels(tree, "title").first}'); | |
| 73 | + final said = find(tree, 'text').map((e) => e.prop('text', '')).toList(); | |
| 74 | + print('✓ ${said.length} text runs, ${find(tree, "link").length} links, ' | |
| 75 | + '${find(tree, "avatar").length} avatars, ' | |
| 76 | + '${find(tree, "reaction").length} reaction chips, ' | |
| 77 | + '${find(tree, "separator").length} separators'); | |
| 78 | + for (final line in said.take(6)) { | |
| 79 | + print(' | $line'); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // Every tag the tree contains, so an unrendered one shows up here rather | |
| 83 | + // than as an orange box in the window. | |
| 84 | + final tags = <String>{}; | |
| 85 | + void walk(core.UiNode n) { | |
| 86 | + tags.add(n.tag); | |
| 87 | + for (final c in n.children) { | |
| 88 | + walk(c); | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + walk(tree); | |
| 93 | + print('✓ tags in use: ${(tags.toList()..sort()).join(", ")}'); | |
| 94 | + | |
| 95 | + core.dispatch('disconnect'); | |
| 96 | + print('✓ disconnected'); | |
| 97 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,97 @@ | |||
| 1 | +/// The whole stack, end to end: Nim's socket, Nim's state, Nim's screens. | ||
| 2 | +/// | ||
| 3 | +/// Connects to a real freeq, waits for the room list to fill, opens a room and | ||
| 4 | +/// prints what the tree actually contains. Everything goes through the FFI, so | ||
| 5 | +/// what it proves is the same path the window uses. | ||
| 6 | +/// | ||
| 7 | +/// Not in any test suite: it needs a network and a running freeq. | ||
| 8 | +/// | ||
| 9 | +/// just nim-live | ||
| 10 | +import 'dart:io'; | ||
| 11 | +import 'package:frq_core/frq_core.dart' as core; | ||
| 12 | + | ||
| 13 | +List<core.UiNode> find(core.UiNode n, String tag) => | ||
| 14 | + [if (n.tag == tag) n, for (final c in n.children) ...find(c, tag)]; | ||
| 15 | + | ||
| 16 | +List<String> labels(core.UiNode n, String tag) => | ||
| 17 | + find(n, tag).map((e) => e.prop('label', '')).toList(); | ||
| 18 | + | ||
| 19 | +Future<void> main(List<String> args) async { | ||
| 20 | + final host = args.isNotEmpty ? args[0] : 'irc.freeq.at'; | ||
| 21 | + final nick = args.length > 1 | ||
| 22 | + ? args[1] | ||
| 23 | + : 'frq-ui-${DateTime.now().millisecondsSinceEpoch % 10000}'; | ||
| 24 | + | ||
| 25 | + core.resetUi(); | ||
| 26 | + print('→ $host as $nick'); | ||
| 27 | + | ||
| 28 | + core.dispatch('nick.change', nick); | ||
| 29 | + core.dispatch('host.change', host); | ||
| 30 | + var tree = core.dispatch('connect'); | ||
| 31 | + | ||
| 32 | + final deadline = DateTime.now().add(const Duration(seconds: 25)); | ||
| 33 | + while (DateTime.now().isBefore(deadline)) { | ||
| 34 | + await Future<void>.delayed(const Duration(milliseconds: 100)); | ||
| 35 | + tree = core.poll(); | ||
| 36 | + if (labels(tree, 'title').any((l) => l.startsWith('Logged in as'))) break; | ||
| 37 | + final err = labels(tree, 'label').where((l) => l.startsWith('⚠')); | ||
| 38 | + if (err.isNotEmpty) { | ||
| 39 | + print('✗ ${err.first}'); | ||
| 40 | + exit(1); | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + if (!labels(tree, 'title').any((l) => l.startsWith('Logged in as'))) { | ||
| 45 | + print('✗ never reached the chats screen — still ${labels(tree, "title")}'); | ||
| 46 | + exit(1); | ||
| 47 | + } | ||
| 48 | + print('✓ chats screen: ${labels(tree, "title").first}'); | ||
| 49 | + | ||
| 50 | + // The room list, from the real server. Waited for rather than read on the | ||
| 51 | + // instant we land: registration finishes before the JOIN echo that creates | ||
| 52 | + // the buffer, so reading immediately is reading too early. | ||
| 53 | + final roomsBy = DateTime.now().add(const Duration(seconds: 10)); | ||
| 54 | + var rooms = <String>[]; | ||
| 55 | + while (DateTime.now().isBefore(roomsBy)) { | ||
| 56 | + await Future<void>.delayed(const Duration(milliseconds: 200)); | ||
| 57 | + tree = core.poll(); | ||
| 58 | + rooms = labels(tree, 'title-2'); | ||
| 59 | + if (rooms.isNotEmpty) break; | ||
| 60 | + } | ||
| 61 | + print('✓ rooms: $rooms'); | ||
| 62 | + if (rooms.isEmpty) { | ||
| 63 | + print('✗ no rooms in the list'); | ||
| 64 | + exit(1); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + // Open one and look at the conversation. | ||
| 68 | + tree = core.dispatch('room.open:${rooms.first}'); | ||
| 69 | + await Future<void>.delayed(const Duration(seconds: 2)); | ||
| 70 | + tree = core.poll(); | ||
| 71 | + | ||
| 72 | + print('✓ chat screen: ${labels(tree, "title").first}'); | ||
| 73 | + final said = find(tree, 'text').map((e) => e.prop('text', '')).toList(); | ||
| 74 | + print('✓ ${said.length} text runs, ${find(tree, "link").length} links, ' | ||
| 75 | + '${find(tree, "avatar").length} avatars, ' | ||
| 76 | + '${find(tree, "reaction").length} reaction chips, ' | ||
| 77 | + '${find(tree, "separator").length} separators'); | ||
| 78 | + for (final line in said.take(6)) { | ||
| 79 | + print(' | $line'); | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + // Every tag the tree contains, so an unrendered one shows up here rather | ||
| 83 | + // than as an orange box in the window. | ||
| 84 | + final tags = <String>{}; | ||
| 85 | + void walk(core.UiNode n) { | ||
| 86 | + tags.add(n.tag); | ||
| 87 | + for (final c in n.children) { | ||
| 88 | + walk(c); | ||
| 89 | + } | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + walk(tree); | ||
| 93 | + print('✓ tags in use: ${(tags.toList()..sort()).join(", ")}'); | ||
| 94 | + | ||
| 95 | + core.dispatch('disconnect'); | ||
| 96 | + print('✓ disconnected'); | ||
| 97 | +} | ||
added
flutter/lib/main_nim.dart +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +/// frq, with Nim owning the state and the screens. | |
| 2 | +/// | |
| 3 | +/// No ClojureDart on this path at all — not `frq.main`, not `common/`, not a | |
| 4 | +/// `.cljd` file. Flutter starts, asks Nim for a widget tree and paints it; | |
| 5 | +/// every tap and keystroke goes back as an event id. | |
| 6 | +/// | |
| 7 | +/// just nim-ui run | |
| 8 | +/// | |
| 9 | +/// The screens are ports of `common/frq/screens/`, not sketches of them: same | |
| 10 | +/// copy, same fields, same stable wrappers. What is not yet here is the emoji | |
| 11 | +/// picker, the overview strip, the lightbox and the profile card — see | |
| 12 | +/// `nim/README.md` for what is done and what is not. | |
| 13 | +import 'package:flutter/material.dart'; | |
| 14 | +import 'nim_renderer.dart'; | |
| 15 | + | |
| 16 | +void main() => runApp(const NimApp()); | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +/// frq, with Nim owning the state and the screens. | ||
| 2 | +/// | ||
| 3 | +/// No ClojureDart on this path at all — not `frq.main`, not `common/`, not a | ||
| 4 | +/// `.cljd` file. Flutter starts, asks Nim for a widget tree and paints it; | ||
| 5 | +/// every tap and keystroke goes back as an event id. | ||
| 6 | +/// | ||
| 7 | +/// just nim-ui run | ||
| 8 | +/// | ||
| 9 | +/// The screens are ports of `common/frq/screens/`, not sketches of them: same | ||
| 10 | +/// copy, same fields, same stable wrappers. What is not yet here is the emoji | ||
| 11 | +/// picker, the overview strip, the lightbox and the profile card — see | ||
| 12 | +/// `nim/README.md` for what is done and what is not. | ||
| 13 | +import 'package:flutter/material.dart'; | ||
| 14 | +import 'nim_renderer.dart'; | ||
| 15 | + | ||
| 16 | +void main() => runApp(const NimApp()); | ||
modified
flutter/lib/nim_renderer.dart +447 -146 | @@ -1,20 +1,23 @@ | ||
| 1 | 1 | /// The renderer: a Nim widget tree, walked into Flutter widgets. |
| 2 | 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. | |
| 3 | +/// This knows the tag vocabulary and nothing else — no screens, no state, no | |
| 4 | +/// idea what "connect" means. Nim decides what the screen is; this decides | |
| 5 | +/// what a `vbox` looks like. | |
| 6 | 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. | |
| 7 | +/// The measure of whether the split is honest is how boring this file is. If a | |
| 8 | +/// feature ever needs a change here AND in Nim, the boundary is in the wrong | |
| 9 | +/// place. The treatments are `flutter/src/frq/hiccup.cljd`'s, so a tree from | |
| 10 | +/// Nim paints the way the same tree painted under ClojureDart. | |
| 11 | +library; | |
| 12 | + | |
| 10 | 13 | import 'dart:async'; |
| 14 | +import 'dart:io'; | |
| 11 | 15 | |
| 12 | 16 | import 'package:flutter/material.dart'; |
| 13 | 17 | import 'package:frq_core/frq_core.dart' as core; |
| 14 | 18 | |
| 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. | |
| 19 | +import 'nim_theme.dart' as t; | |
| 20 | + | |
| 18 | 21 | class NimApp extends StatefulWidget { |
| 19 | 22 | const NimApp({super.key}); |
| 20 | 23 | @override |
| @@ -25,41 +28,33 @@ class _NimAppState extends State<NimApp> { | ||
| 25 | 28 | late core.UiNode _tree = core.render(); |
| 26 | 29 | Timer? _poll; |
| 27 | 30 | |
| 31 | + // One controller and one focus node per keyed entry, kept across rebuilds. | |
| 32 | + // | |
| 33 | + // This is why `:key` is on every entry in both the Clojure and the Nim: a | |
| 34 | + // controller identified by position instead of name meant the host field and | |
| 35 | + // the port field shared one and both showed the port. The focus node is the | |
| 36 | + // same bug one layer up — the field is rebuilt from a fresh tree on every | |
| 37 | + // keystroke, so without a node held per key the caret goes nowhere after the | |
| 38 | + // first line. | |
| 39 | + final _controllers = <String, TextEditingController>{}; | |
| 40 | + final _focus = <String, FocusNode>{}; | |
| 41 | + | |
| 28 | 42 | @override |
| 29 | 43 | void initState() { |
| 30 | 44 | super.initState(); |
| 31 | 45 | // 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. | |
| 46 | + // callback into Dart. At ~70µs a render a 100ms timer costs nothing. | |
| 36 | 47 | _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); | |
| 48 | + final next = core.poll(); | |
| 49 | + if (next.toString() != _tree.toString()) { | |
| 50 | + setState(() => _tree = next); | |
| 42 | 51 | } |
| 43 | 52 | }); |
| 44 | 53 | } |
| 45 | 54 | |
| 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 | 55 | void _send(String id, [String value = '']) { |
| 56 | + if (id.isEmpty) return; | |
| 60 | 57 | 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 | 58 | if (id == 'send') _focus['draft']?.requestFocus(); |
| 64 | 59 | } |
| 65 | 60 | |
| @@ -78,155 +73,459 @@ class _NimAppState extends State<NimApp> { | ||
| 78 | 73 | @override |
| 79 | 74 | Widget build(BuildContext context) => MaterialApp( |
| 80 | 75 | title: 'frq', |
| 81 | - theme: ThemeData.dark(useMaterial3: true), | |
| 82 | - home: Scaffold( | |
| 83 | - body: SafeArea(child: SingleChildScrollView(child: _build(_tree))), | |
| 76 | + debugShowCheckedModeBanner: false, | |
| 77 | + theme: ThemeData( | |
| 78 | + useMaterial3: true, | |
| 79 | + brightness: Brightness.dark, | |
| 80 | + scaffoldBackgroundColor: t.bg, | |
| 81 | + colorScheme: const ColorScheme.dark( | |
| 82 | + primary: t.accent, | |
| 83 | + onPrimary: t.onAccent, | |
| 84 | + surface: t.bg, | |
| 85 | + onSurface: t.onBg, | |
| 86 | + error: t.destructive, | |
| 87 | + ), | |
| 84 | 88 | ), |
| 89 | + home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))), | |
| 85 | 90 | ); |
| 86 | 91 | |
| 92 | + // ---------------------------------------------------------------- helpers | |
| 93 | + | |
| 94 | + TextStyle _style(double size, Color color) => | |
| 95 | + TextStyle(fontSize: size, color: color, height: 1.35); | |
| 96 | + | |
| 97 | + double _d(dynamic v, double fallback) => | |
| 98 | + v is num ? v.toDouble() : fallback; | |
| 99 | + | |
| 100 | + /// Gaps between children, as real widgets rather than a `spacing:` — the | |
| 101 | + /// same layout on every Flutter version this might be built against. | |
| 102 | + List<Widget> _spaced(List<Widget> kids, double gap, {required bool vertical}) { | |
| 103 | + if (gap <= 0 || kids.length < 2) return kids; | |
| 104 | + final out = <Widget>[]; | |
| 105 | + for (var i = 0; i < kids.length; i++) { | |
| 106 | + if (i > 0) { | |
| 107 | + out.add(vertical ? SizedBox(height: gap) : SizedBox(width: gap)); | |
| 108 | + } | |
| 109 | + out.add(kids[i]); | |
| 110 | + } | |
| 111 | + return out; | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// A source that may be a bundled asset, a file on disk, or a URL — the | |
| 115 | + /// three the screens hand over, named apart by an `asset:` prefix so they | |
| 116 | + /// stay one property. | |
| 117 | + ImageProvider? _imageProvider(String src) { | |
| 118 | + if (src.isEmpty) return null; | |
| 119 | + if (src.startsWith('asset:')) return AssetImage(src.substring(6)); | |
| 120 | + if (src.startsWith('http://') || src.startsWith('https://')) { | |
| 121 | + return NetworkImage(src); | |
| 122 | + } | |
| 123 | + return FileImage(File(src)); | |
| 124 | + } | |
| 125 | + | |
| 126 | + Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) { | |
| 127 | + if (onClick.isEmpty) return child; | |
| 128 | + return InkWell( | |
| 129 | + onTap: () => _send(onClick), | |
| 130 | + borderRadius: radius, | |
| 131 | + child: child, | |
| 132 | + ); | |
| 133 | + } | |
| 134 | + | |
| 135 | + // ------------------------------------------------------------------ build | |
| 136 | + | |
| 87 | 137 | Widget _build(core.UiNode n) { |
| 88 | 138 | final kids = n.children.map(_build).toList(); |
| 139 | + final spacing = _d(n.props['spacing'], 0); | |
| 89 | 140 | |
| 90 | 141 | switch (n.tag) { |
| 91 | 142 | 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), | |
| 143 | + return SingleChildScrollView( | |
| 144 | + child: Center( | |
| 145 | + child: ConstrainedBox( | |
| 146 | + constraints: | |
| 147 | + BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)), | |
| 148 | + child: Padding( | |
| 149 | + padding: const EdgeInsets.all(t.spaceM), | |
| 150 | + child: Column( | |
| 151 | + crossAxisAlignment: CrossAxisAlignment.start, | |
| 152 | + children: _spaced(kids, spacing, vertical: true)), | |
| 153 | + ), | |
| 100 | 154 | ), |
| 101 | 155 | ), |
| 102 | 156 | ); |
| 103 | 157 | |
| 104 | 158 | case 'vbox': |
| 105 | - return Column( | |
| 106 | - crossAxisAlignment: CrossAxisAlignment.start, | |
| 107 | - children: _spaced(kids, n.prop('spacing', 0), vertical: true), | |
| 108 | - ); | |
| 159 | + { | |
| 160 | + Widget col = Column( | |
| 161 | + crossAxisAlignment: CrossAxisAlignment.start, | |
| 162 | + mainAxisSize: MainAxisSize.min, | |
| 163 | + children: _spaced(kids, spacing, vertical: true), | |
| 164 | + ); | |
| 165 | + col = _margins(n, col); | |
| 166 | + final w = _d(n.props['widthRequest'], 0); | |
| 167 | + if (w > 0) col = SizedBox(width: w, child: col); | |
| 168 | + // `fillHeight` is what keeps the compose bar at the bottom instead | |
| 169 | + // of wherever the backlog happens to end. | |
| 170 | + return n.prop('fillHeight', false) ? Expanded(child: col) : col; | |
| 171 | + } | |
| 109 | 172 | |
| 110 | 173 | 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), | |
| 174 | + { | |
| 175 | + // Wrap and not Row: `:hbox` in the screens means "these go together | |
| 176 | + // across", not "these fit". The head row of the chat screen asks for | |
| 177 | + // more than 360 points has, and a Row answers that with an overflow | |
| 178 | + // rather than a second line. | |
| 179 | + final wrapping = n.prop('wrap', true); | |
| 180 | + final align = n.prop('align', 'center'); | |
| 181 | + if (!wrapping) { | |
| 182 | + return _margins( | |
| 183 | + n, | |
| 184 | + Row( | |
| 185 | + crossAxisAlignment: align == 'end' | |
| 186 | + ? CrossAxisAlignment.end | |
| 187 | + : CrossAxisAlignment.center, | |
| 188 | + children: _spaced(kids, spacing, vertical: false), | |
| 189 | + ), | |
| 190 | + ); | |
| 191 | + } | |
| 192 | + return _margins( | |
| 193 | + n, | |
| 194 | + Wrap( | |
| 195 | + spacing: spacing, | |
| 196 | + runSpacing: spacing, | |
| 197 | + crossAxisAlignment: align == 'end' | |
| 198 | + ? WrapCrossAlignment.end | |
| 199 | + : WrapCrossAlignment.center, | |
| 200 | + children: kids, | |
| 133 | 201 | ), |
| 134 | - ), | |
| 135 | - ); | |
| 202 | + ); | |
| 203 | + } | |
| 136 | 204 | |
| 205 | + // Container::Card in the Clojure: padding 12, fills its width. | |
| 137 | 206 | 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), | |
| 207 | + return Container( | |
| 208 | + width: double.infinity, | |
| 209 | + margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs), | |
| 210 | + padding: const EdgeInsets.all(t.spaceXs), | |
| 211 | + decoration: BoxDecoration( | |
| 212 | + color: t.card, | |
| 213 | + borderRadius: BorderRadius.circular(t.radiusS), | |
| 144 | 214 | ), |
| 215 | + child: Column( | |
| 216 | + crossAxisAlignment: CrossAxisAlignment.start, | |
| 217 | + children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs, | |
| 218 | + vertical: true)), | |
| 145 | 219 | ); |
| 146 | 220 | |
| 147 | 221 | case 'title': |
| 148 | 222 | return Text(n.prop('label', ''), |
| 149 | - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)); | |
| 223 | + style: _style(t.textTitle3, t.onBg) | |
| 224 | + .copyWith(fontWeight: FontWeight.bold)); | |
| 150 | 225 | |
| 151 | 226 | case 'title-2': |
| 152 | 227 | return Padding( |
| 153 | - padding: const EdgeInsets.only(top: 8, bottom: 4), | |
| 228 | + padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs), | |
| 154 | 229 | child: Text(n.prop('label', ''), |
| 155 | - style: | |
| 156 | - const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), | |
| 230 | + style: _style(t.textTitle4, t.onBg) | |
| 231 | + .copyWith(fontWeight: FontWeight.w600)), | |
| 157 | 232 | ); |
| 158 | 233 | |
| 159 | 234 | case 'label': |
| 160 | - return Text(n.prop('label', '')); | |
| 235 | + return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg)); | |
| 161 | 236 | |
| 162 | 237 | case 'dim-label': |
| 163 | - return Opacity( | |
| 164 | - opacity: 0.7, | |
| 165 | - child: Text(n.prop('label', ''), | |
| 166 | - style: const TextStyle(fontSize: 12))); | |
| 238 | + return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)); | |
| 239 | + | |
| 240 | + /// Prose, as opposed to a label: this is what a message is, and it | |
| 241 | + /// wraps. Kept apart from `label` because a wrapping label in a row | |
| 242 | + /// lays out against the row's width rather than the column's. | |
| 243 | + case 'text': | |
| 244 | + return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg)); | |
| 245 | + | |
| 246 | + case 'link': | |
| 247 | + return _wrapTap( | |
| 248 | + n.prop('onClick', ''), | |
| 249 | + Text( | |
| 250 | + n.prop('label', ''), | |
| 251 | + style: _style(t.textBody, t.accent) | |
| 252 | + .copyWith(decoration: TextDecoration.underline, | |
| 253 | + decorationColor: t.accent), | |
| 254 | + ), | |
| 255 | + ); | |
| 256 | + | |
| 257 | + case 'separator': | |
| 258 | + return const Divider(height: 1, thickness: 1, color: t.divider); | |
| 259 | + | |
| 260 | + case 'spacer': | |
| 261 | + { | |
| 262 | + final s = _d(n.props['size'], t.spaceXxs); | |
| 263 | + return SizedBox(width: s, height: s); | |
| 264 | + } | |
| 167 | 265 | |
| 168 | 266 | case 'spinner': |
| 169 | - return const SizedBox( | |
| 170 | - width: 16, | |
| 171 | - height: 16, | |
| 172 | - child: CircularProgressIndicator(strokeWidth: 2)); | |
| 267 | + return Row( | |
| 268 | + mainAxisSize: MainAxisSize.min, | |
| 269 | + children: [ | |
| 270 | + const SizedBox( | |
| 271 | + width: 16, | |
| 272 | + height: 16, | |
| 273 | + child: CircularProgressIndicator( | |
| 274 | + strokeWidth: 2, color: t.accent)), | |
| 275 | + if (n.prop('label', '').isNotEmpty) ...[ | |
| 276 | + const SizedBox(width: t.spaceXxs), | |
| 277 | + Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)), | |
| 278 | + ], | |
| 279 | + ], | |
| 280 | + ); | |
| 281 | + | |
| 282 | + /// A dot that says whether the thing is live, and the words beside it. | |
| 283 | + case 'status': | |
| 284 | + return Row( | |
| 285 | + mainAxisSize: MainAxisSize.min, | |
| 286 | + children: [ | |
| 287 | + Container( | |
| 288 | + width: 8, | |
| 289 | + height: 8, | |
| 290 | + decoration: BoxDecoration( | |
| 291 | + color: n.prop('live', false) ? t.success : t.dim, | |
| 292 | + borderRadius: BorderRadius.circular(t.radiusXs), | |
| 293 | + ), | |
| 294 | + ), | |
| 295 | + const SizedBox(width: 6), | |
| 296 | + Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)), | |
| 297 | + ], | |
| 298 | + ); | |
| 173 | 299 | |
| 174 | 300 | 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); | |
| 301 | + { | |
| 302 | + final onClick = n.prop('onClick', ''); | |
| 303 | + final kind = n.prop('kind', 'default'); | |
| 304 | + final label = Text(n.prop('label', '')); | |
| 305 | + if (kind == 'primary') { | |
| 306 | + return FilledButton( | |
| 307 | + onPressed: () => _send(onClick), child: label); | |
| 308 | + } | |
| 309 | + if (kind == 'destructive') { | |
| 310 | + return FilledButton( | |
| 311 | + style: FilledButton.styleFrom( | |
| 312 | + backgroundColor: t.destructive, | |
| 313 | + foregroundColor: t.onDestructive), | |
| 314 | + onPressed: () => _send(onClick), | |
| 315 | + child: label, | |
| 316 | + ); | |
| 317 | + } | |
| 318 | + return OutlinedButton(onPressed: () => _send(onClick), child: label); | |
| 319 | + } | |
| 182 | 320 | |
| 183 | 321 | 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 | - ]); | |
| 322 | + { | |
| 323 | + // The label is part of the target. 20 logical pixels is a fine tick | |
| 324 | + // on a desktop pointer and a miss on a thumb, so the whole row taps. | |
| 325 | + final onToggled = n.prop('onToggled', ''); | |
| 326 | + return InkWell( | |
| 327 | + onTap: () => _send(onToggled), | |
| 328 | + child: Row( | |
| 329 | + mainAxisSize: MainAxisSize.min, | |
| 330 | + children: [ | |
| 331 | + Checkbox( | |
| 332 | + value: n.prop('active', false), | |
| 333 | + onChanged: (_) => _send(onToggled), | |
| 334 | + ), | |
| 335 | + Text(n.prop('label', ''), style: _style(t.textBody, t.onBg)), | |
| 336 | + ], | |
| 337 | + ), | |
| 338 | + ); | |
| 339 | + } | |
| 340 | + | |
| 341 | + case 'emoji': | |
| 342 | + return _wrapTap( | |
| 343 | + n.prop('onClick', ''), | |
| 344 | + Text(n.prop('glyph', n.prop('emoji', '')), | |
| 345 | + style: TextStyle(fontSize: _d(n.props['size'], 16))), | |
| 346 | + ); | |
| 347 | + | |
| 348 | + /// A reaction pill: the glyph, and the tally beside it where there is | |
| 349 | + /// one to show. The same shape whether it is a reaction under a message, | |
| 350 | + /// a swatch in the picker, or a chip on the sender's row — which is the | |
| 351 | + /// point: what you press to react and what appears once you have should | |
| 352 | + /// look like one family. | |
| 353 | + /// | |
| 354 | + /// A count of zero is no count. The picker passes 0 for every swatch, | |
| 355 | + /// and a grid of little grey zeroes is noise where a reader is scanning | |
| 356 | + /// for a face. `mine` is the accent, because the only thing a pill has | |
| 357 | + /// to say at a glance is whether pressing it again takes yours off. | |
| 358 | + case 'reaction': | |
| 359 | + { | |
| 360 | + final size = _d(n.props['size'], 14); | |
| 361 | + final count = n.prop('count', 0); | |
| 362 | + final mine = n.prop('mine', false); | |
| 363 | + final pad = (0.25 * size).clamp(2.0, 8.0); | |
| 364 | + return _wrapTap( | |
| 365 | + n.prop('onClick', ''), | |
| 366 | + Container( | |
| 367 | + padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2), | |
| 368 | + decoration: BoxDecoration( | |
| 369 | + color: mine ? t.accent : t.component, | |
| 370 | + borderRadius: BorderRadius.circular(t.radiusS), | |
| 371 | + ), | |
| 372 | + child: Row( | |
| 373 | + mainAxisSize: MainAxisSize.min, | |
| 374 | + children: [ | |
| 375 | + Text(n.prop('emoji', ''), style: TextStyle(fontSize: size)), | |
| 376 | + if (count > 0) ...[ | |
| 377 | + const SizedBox(width: 4), | |
| 378 | + Text('$count', | |
| 379 | + style: _style(t.textCaption, | |
| 380 | + mine ? t.onAccent : t.dim)), | |
| 381 | + ], | |
| 382 | + ], | |
| 383 | + ), | |
| 384 | + ), | |
| 385 | + ); | |
| 386 | + } | |
| 387 | + | |
| 388 | + /// A face is a way in to who someone is, so it takes the press that | |
| 389 | + /// opens their profile. A picture that will not load is a face that | |
| 390 | + /// stays its initial and nothing else. | |
| 391 | + case 'avatar': | |
| 392 | + { | |
| 393 | + final size = _d(n.props['size'], 32); | |
| 394 | + final provider = _imageProvider(n.prop('url', '')); | |
| 395 | + final fallback = n.prop('fallback', ''); | |
| 396 | + final face = CircleAvatar( | |
| 397 | + radius: size / 2, | |
| 398 | + backgroundColor: t.component, | |
| 399 | + backgroundImage: provider, | |
| 400 | + onBackgroundImageError: provider == null ? null : (_, __) {}, | |
| 401 | + child: provider == null | |
| 402 | + ? Text( | |
| 403 | + fallback.isNotEmpty | |
| 404 | + ? fallback.substring(0, 1).toUpperCase() | |
| 405 | + : '?', | |
| 406 | + style: _style(t.textBody, t.onBg)) | |
| 407 | + : null, | |
| 408 | + ); | |
| 409 | + final onClick = n.prop('onClick', ''); | |
| 410 | + if (onClick.isEmpty) return face; | |
| 411 | + return InkWell( | |
| 412 | + onTap: () => _send(onClick), | |
| 413 | + customBorder: const CircleBorder(), | |
| 414 | + child: face, | |
| 415 | + ); | |
| 416 | + } | |
| 417 | + | |
| 418 | + case 'image': | |
| 419 | + { | |
| 420 | + final provider = _imageProvider(n.prop('src', '')); | |
| 421 | + if (provider == null) return const SizedBox.shrink(); | |
| 422 | + final maxW = _d(n.props['maxWidth'], 0); | |
| 423 | + final maxH = _d(n.props['maxHeight'], 0); | |
| 424 | + Widget img = Image( | |
| 425 | + image: provider, | |
| 426 | + fit: BoxFit.contain, | |
| 427 | + // A half-written cache file, or one deleted under us: the decoder | |
| 428 | + // throws during the build, and an exception in a build is a red | |
| 429 | + // screen for the whole conversation rather than a gap where one | |
| 430 | + // picture was. | |
| 431 | + errorBuilder: (_, __, ___) => const SizedBox.shrink(), | |
| 432 | + ); | |
| 433 | + if (maxW > 0 || maxH > 0) { | |
| 434 | + img = ConstrainedBox( | |
| 435 | + constraints: BoxConstraints( | |
| 436 | + maxWidth: maxW > 0 ? maxW : double.infinity, | |
| 437 | + maxHeight: maxH > 0 ? maxH : double.infinity, | |
| 438 | + ), | |
| 439 | + child: img, | |
| 440 | + ); | |
| 441 | + } | |
| 442 | + return _wrapTap(n.prop('onClick', ''), img); | |
| 443 | + } | |
| 191 | 444 | |
| 192 | 445 | 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), | |
| 446 | + { | |
| 447 | + final key = n.prop('key', ''); | |
| 448 | + final value = n.prop('text', ''); | |
| 449 | + final c = _controllers.putIfAbsent( | |
| 450 | + key, () => TextEditingController(text: value)); | |
| 451 | + // Only when it actually differs: assigning unconditionally moves the | |
| 452 | + // caret to the end on every keystroke. | |
| 453 | + if (c.text != value) { | |
| 454 | + c.value = c.value.copyWith( | |
| 455 | + text: value, | |
| 456 | + selection: TextSelection.collapsed(offset: value.length), | |
| 457 | + ); | |
| 458 | + } | |
| 459 | + final field = TextField( | |
| 460 | + controller: c, | |
| 461 | + focusNode: _focus.putIfAbsent(key, FocusNode.new), | |
| 462 | + style: _style(t.textBody, t.onBg), | |
| 463 | + decoration: InputDecoration( | |
| 464 | + hintText: n.prop('placeholder', ''), | |
| 465 | + hintStyle: _style(t.textBody, t.dim), | |
| 466 | + isDense: true, | |
| 467 | + filled: true, | |
| 468 | + fillColor: t.component, | |
| 469 | + border: OutlineInputBorder( | |
| 470 | + borderRadius: BorderRadius.circular(t.radiusS), | |
| 471 | + borderSide: BorderSide.none, | |
| 472 | + ), | |
| 473 | + ), | |
| 474 | + onChanged: (v) => _send(n.prop('onChange', ''), v), | |
| 475 | + onSubmitted: (_) => _send(n.prop('onSubmit', '')), | |
| 476 | + ); | |
| 477 | + final w = _d(n.props['widthRequest'], 0); | |
| 478 | + // A width request is a minimum in the screens' vocabulary, but here | |
| 479 | + // it has to be a maximum too: an unconstrained TextField inside a | |
| 480 | + // Wrap has no width at all to take. | |
| 481 | + return w > 0 ? SizedBox(width: w, child: field) : Expanded(child: field); | |
| 482 | + } | |
| 483 | + | |
| 484 | + case 'scroll': | |
| 485 | + { | |
| 486 | + Widget body = SingleChildScrollView( | |
| 487 | + // The backlog reads from the bottom; a settings list from the top. | |
| 488 | + reverse: n.prop('stickToBottom', false), | |
| 489 | + child: Column( | |
| 490 | + crossAxisAlignment: CrossAxisAlignment.start, | |
| 491 | + children: _spaced(kids, spacing, vertical: true)), | |
| 204 | 492 | ); |
| 493 | + body = Scrollbar(child: body); | |
| 494 | + final h = _d(n.props['height'], 0); | |
| 495 | + if (h > 0) return SizedBox(height: h, child: body); | |
| 496 | + // No fixed height: take what the column has left. `reserve` is the | |
| 497 | + // Clojure's way of saying the same thing to a backend that could not | |
| 498 | + // do this, and is ignored here on purpose. | |
| 499 | + return Expanded(child: body); | |
| 205 | 500 | } |
| 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(), | |
| 501 | + | |
| 502 | + /// A panel over the screen rather than a screen of its own. | |
| 503 | + case 'dialog': | |
| 504 | + return Card( | |
| 505 | + color: t.cardComponent, | |
| 506 | + child: Padding( | |
| 507 | + padding: const EdgeInsets.all(t.spaceS), | |
| 508 | + child: Column( | |
| 509 | + mainAxisSize: MainAxisSize.min, | |
| 510 | + crossAxisAlignment: CrossAxisAlignment.start, | |
| 511 | + children: [ | |
| 512 | + if (n.prop('title', '').isNotEmpty) | |
| 513 | + Padding( | |
| 514 | + padding: const EdgeInsets.only(bottom: t.spaceXxs), | |
| 515 | + child: Text(n.prop('title', ''), | |
| 516 | + style: _style(t.textTitle4, t.onCard) | |
| 517 | + .copyWith(fontWeight: FontWeight.w600)), | |
| 518 | + ), | |
| 519 | + ..._spaced(kids, spacing, vertical: true), | |
| 520 | + ], | |
| 521 | + ), | |
| 213 | 522 | ), |
| 214 | - onChanged: (v) => _send(n.prop('onChange', ''), v), | |
| 215 | - onSubmitted: (_) { | |
| 216 | - final submit = n.prop('onSubmit', ''); | |
| 217 | - if (submit.isNotEmpty) _send(submit); | |
| 218 | - }, | |
| 219 | 523 | ); |
| 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 | 524 | |
| 226 | 525 | default: |
| 227 | 526 | // An unknown tag paints as itself rather than crashing or vanishing. |
| 228 | 527 | // 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. | |
| 528 | + // is what makes the boundary pleasant to work across. | |
| 230 | 529 | return Container( |
| 231 | 530 | padding: const EdgeInsets.all(4), |
| 232 | 531 | color: Colors.orange.withValues(alpha: 0.3), |
| @@ -235,17 +534,19 @@ class _NimAppState extends State<NimApp> { | ||
| 235 | 534 | } |
| 236 | 535 | } |
| 237 | 536 | |
| 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; | |
| 537 | + /// `margin`, `marginTop`, `marginBottom`, `marginRight` — the props the | |
| 538 | + /// screens use to buy air without a wrapper each time. | |
| 539 | + Widget _margins(core.UiNode n, Widget child) { | |
| 540 | + final all = _d(n.props['margin'], 0); | |
| 541 | + final top = _d(n.props['marginTop'], all); | |
| 542 | + final bottom = _d(n.props['marginBottom'], all); | |
| 543 | + final right = _d(n.props['marginRight'], all); | |
| 544 | + final left = _d(n.props['marginLeft'], all); | |
| 545 | + if (top == 0 && bottom == 0 && right == 0 && left == 0) return child; | |
| 546 | + return Padding( | |
| 547 | + padding: EdgeInsets.only( | |
| 548 | + top: top, bottom: bottom, right: right, left: left), | |
| 549 | + child: child, | |
| 550 | + ); | |
| 250 | 551 | } |
| 251 | 552 | } |
| @@ -1,20 +1,23 @@ | |||
| 1 | /// The renderer: a Nim widget tree, walked into Flutter widgets. | 1 | /// The renderer: a Nim widget tree, walked into Flutter widgets. |
| 2 | /// | 2 | /// |
| 3 | -/// This is the Dart half of the spike's claim. It knows the tag vocabulary | 3 | +/// This knows the tag vocabulary and nothing else — no screens, no state, no |
| 4 | -/// and nothing else — no screens, no state, no idea what "connect" means. Nim | 4 | +/// idea what "connect" means. Nim decides what the screen is; this decides |
| 5 | -/// decides what the screen is; this decides what a `vbox` looks like. | 5 | +/// what a `vbox` looks like. |
| 6 | /// | 6 | /// |
| 7 | -/// The measure of whether the split is honest is how boring this file is. If | 7 | +/// The measure of whether the split is honest is how boring this file is. If a |
| 8 | -/// a feature ever needs a change here AND in Nim, the boundary is in the | 8 | +/// feature ever needs a change here AND in Nim, the boundary is in the wrong |
| 9 | -/// wrong place. | 9 | +/// place. The treatments are `flutter/src/frq/hiccup.cljd`'s, so a tree from |
| 10 | +/// Nim paints the way the same tree painted under ClojureDart. | ||
| 11 | +library; | ||
| 12 | + | ||
| 10 | import 'dart:async'; | 13 | import 'dart:async'; |
| 14 | +import 'dart:io'; | ||
| 11 | 15 | ||
| 12 | import 'package:flutter/material.dart'; | 16 | import 'package:flutter/material.dart'; |
| 13 | import 'package:frq_core/frq_core.dart' as core; | 17 | import 'package:frq_core/frq_core.dart' as core; |
| 14 | 18 | ||
| 15 | -/// Rebuilds from Nim on every event. One `setState` per dispatch, and the | 19 | +import 'nim_theme.dart' as t; |
| 16 | -/// whole tree is rebuilt — which is what Flutter does anyway, and is why the | 20 | + |
| 17 | -/// Nim side does not need a reconciler of its own. | ||
| 18 | class NimApp extends StatefulWidget { | 21 | class NimApp extends StatefulWidget { |
| 19 | const NimApp({super.key}); | 22 | const NimApp({super.key}); |
| 20 | @override | 23 | @override |
| @@ -25,41 +28,33 @@ class _NimAppState extends State<NimApp> { | |||
| 25 | late core.UiNode _tree = core.render(); | 28 | late core.UiNode _tree = core.render(); |
| 26 | Timer? _poll; | 29 | Timer? _poll; |
| 27 | 30 | ||
| 31 | + // One controller and one focus node per keyed entry, kept across rebuilds. | ||
| 32 | + // | ||
| 33 | + // This is why `:key` is on every entry in both the Clojure and the Nim: a | ||
| 34 | + // controller identified by position instead of name meant the host field and | ||
| 35 | + // the port field shared one and both showed the port. The focus node is the | ||
| 36 | + // same bug one layer up — the field is rebuilt from a fresh tree on every | ||
| 37 | + // keystroke, so without a node held per key the caret goes nowhere after the | ||
| 38 | + // first line. | ||
| 39 | + final _controllers = <String, TextEditingController>{}; | ||
| 40 | + final _focus = <String, FocusNode>{}; | ||
| 41 | + | ||
| 28 | @override | 42 | @override |
| 29 | void initState() { | 43 | void initState() { |
| 30 | super.initState(); | 44 | super.initState(); |
| 31 | // Polling, because the socket lives on a Nim thread and there is no | 45 | // 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 | 46 | + // callback into Dart. At ~70µs a render a 100ms timer costs nothing. |
| 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), (_) { | 47 | _poll = Timer.periodic(const Duration(milliseconds: 100), (_) { |
| 37 | - final t = core.poll(); | 48 | + final next = core.poll(); |
| 38 | - // Only when it actually differs: a setState per tick would rebuild the | 49 | + if (next.toString() != _tree.toString()) { |
| 39 | - // whole tree ten times a second for a screen nobody is touching. | 50 | + setState(() => _tree = next); |
| 40 | - if (t.toString() != _tree.toString()) { | ||
| 41 | - setState(() => _tree = t); | ||
| 42 | } | 51 | } |
| 43 | }); | 52 | }); |
| 44 | } | 53 | } |
| 45 | 54 | ||
| 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 = '']) { | 55 | void _send(String id, [String value = '']) { |
| 56 | + if (id.isEmpty) return; | ||
| 60 | setState(() => _tree = core.dispatch(id, value)); | 57 | 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(); | 58 | if (id == 'send') _focus['draft']?.requestFocus(); |
| 64 | } | 59 | } |
| 65 | 60 | ||
| @@ -78,155 +73,459 @@ class _NimAppState extends State<NimApp> { | |||
| 78 | @override | 73 | @override |
| 79 | Widget build(BuildContext context) => MaterialApp( | 74 | Widget build(BuildContext context) => MaterialApp( |
| 80 | title: 'frq', | 75 | title: 'frq', |
| 81 | - theme: ThemeData.dark(useMaterial3: true), | 76 | + debugShowCheckedModeBanner: false, |
| 82 | - home: Scaffold( | 77 | + theme: ThemeData( |
| 83 | - body: SafeArea(child: SingleChildScrollView(child: _build(_tree))), | 78 | + useMaterial3: true, |
| 79 | + brightness: Brightness.dark, | ||
| 80 | + scaffoldBackgroundColor: t.bg, | ||
| 81 | + colorScheme: const ColorScheme.dark( | ||
| 82 | + primary: t.accent, | ||
| 83 | + onPrimary: t.onAccent, | ||
| 84 | + surface: t.bg, | ||
| 85 | + onSurface: t.onBg, | ||
| 86 | + error: t.destructive, | ||
| 87 | + ), | ||
| 84 | ), | 88 | ), |
| 89 | + home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))), | ||
| 85 | ); | 90 | ); |
| 86 | 91 | ||
| 92 | + // ---------------------------------------------------------------- helpers | ||
| 93 | + | ||
| 94 | + TextStyle _style(double size, Color color) => | ||
| 95 | + TextStyle(fontSize: size, color: color, height: 1.35); | ||
| 96 | + | ||
| 97 | + double _d(dynamic v, double fallback) => | ||
| 98 | + v is num ? v.toDouble() : fallback; | ||
| 99 | + | ||
| 100 | + /// Gaps between children, as real widgets rather than a `spacing:` — the | ||
| 101 | + /// same layout on every Flutter version this might be built against. | ||
| 102 | + List<Widget> _spaced(List<Widget> kids, double gap, {required bool vertical}) { | ||
| 103 | + if (gap <= 0 || kids.length < 2) return kids; | ||
| 104 | + final out = <Widget>[]; | ||
| 105 | + for (var i = 0; i < kids.length; i++) { | ||
| 106 | + if (i > 0) { | ||
| 107 | + out.add(vertical ? SizedBox(height: gap) : SizedBox(width: gap)); | ||
| 108 | + } | ||
| 109 | + out.add(kids[i]); | ||
| 110 | + } | ||
| 111 | + return out; | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + /// A source that may be a bundled asset, a file on disk, or a URL — the | ||
| 115 | + /// three the screens hand over, named apart by an `asset:` prefix so they | ||
| 116 | + /// stay one property. | ||
| 117 | + ImageProvider? _imageProvider(String src) { | ||
| 118 | + if (src.isEmpty) return null; | ||
| 119 | + if (src.startsWith('asset:')) return AssetImage(src.substring(6)); | ||
| 120 | + if (src.startsWith('http://') || src.startsWith('https://')) { | ||
| 121 | + return NetworkImage(src); | ||
| 122 | + } | ||
| 123 | + return FileImage(File(src)); | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) { | ||
| 127 | + if (onClick.isEmpty) return child; | ||
| 128 | + return InkWell( | ||
| 129 | + onTap: () => _send(onClick), | ||
| 130 | + borderRadius: radius, | ||
| 131 | + child: child, | ||
| 132 | + ); | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + // ------------------------------------------------------------------ build | ||
| 136 | + | ||
| 87 | Widget _build(core.UiNode n) { | 137 | Widget _build(core.UiNode n) { |
| 88 | final kids = n.children.map(_build).toList(); | 138 | final kids = n.children.map(_build).toList(); |
| 139 | + final spacing = _d(n.props['spacing'], 0); | ||
| 89 | 140 | ||
| 90 | switch (n.tag) { | 141 | switch (n.tag) { |
| 91 | case 'page': | 142 | case 'page': |
| 92 | - return Center( | 143 | + return SingleChildScrollView( |
| 93 | - child: ConstrainedBox( | 144 | + child: Center( |
| 94 | - constraints: | 145 | + child: ConstrainedBox( |
| 95 | - BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()), | 146 | + constraints: |
| 96 | - child: Padding( | 147 | + BoxConstraints(maxWidth: _d(n.props['maxWidth'], 520)), |
| 97 | - padding: const EdgeInsets.all(24), | 148 | + child: Padding( |
| 98 | - child: Column( | 149 | + padding: const EdgeInsets.all(t.spaceM), |
| 99 | - crossAxisAlignment: CrossAxisAlignment.start, children: kids), | 150 | + child: Column( |
| 151 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 152 | + children: _spaced(kids, spacing, vertical: true)), | ||
| 153 | + ), | ||
| 100 | ), | 154 | ), |
| 101 | ), | 155 | ), |
| 102 | ); | 156 | ); |
| 103 | 157 | ||
| 104 | case 'vbox': | 158 | case 'vbox': |
| 105 | - return Column( | 159 | + { |
| 106 | - crossAxisAlignment: CrossAxisAlignment.start, | 160 | + Widget col = Column( |
| 107 | - children: _spaced(kids, n.prop('spacing', 0), vertical: true), | 161 | + crossAxisAlignment: CrossAxisAlignment.start, |
| 108 | - ); | 162 | + mainAxisSize: MainAxisSize.min, |
| 163 | + children: _spaced(kids, spacing, vertical: true), | ||
| 164 | + ); | ||
| 165 | + col = _margins(n, col); | ||
| 166 | + final w = _d(n.props['widthRequest'], 0); | ||
| 167 | + if (w > 0) col = SizedBox(width: w, child: col); | ||
| 168 | + // `fillHeight` is what keeps the compose bar at the bottom instead | ||
| 169 | + // of wherever the backlog happens to end. | ||
| 170 | + return n.prop('fillHeight', false) ? Expanded(child: col) : col; | ||
| 171 | + } | ||
| 109 | 172 | ||
| 110 | case 'hbox': | 173 | case 'hbox': |
| 111 | - // Wrap and not Row, and this was a bug before it was a decision: the | 174 | + { |
| 112 | - // three mode buttons are wider than the 520-point page, and a Row | 175 | + // Wrap and not Row: `:hbox` in the screens means "these go together |
| 113 | - // answers that with a RenderFlex overflow rather than a second line. | 176 | + // across", not "these fit". The head row of the chat screen asks for |
| 114 | - // A `:hbox` in the screens means "these go together across", not "these | 177 | + // more than 360 points has, and a Row answers that with an overflow |
| 115 | - // fit"; the tree has no idea how wide the window is and should not. | 178 | + // rather than a second line. |
| 116 | - final gap = n.prop('spacing', 0).toDouble(); | 179 | + final wrapping = n.prop('wrap', true); |
| 117 | - return Wrap( | 180 | + final align = n.prop('align', 'center'); |
| 118 | - spacing: gap, | 181 | + if (!wrapping) { |
| 119 | - runSpacing: gap, | 182 | + return _margins( |
| 120 | - crossAxisAlignment: WrapCrossAlignment.center, | 183 | + n, |
| 121 | - children: kids, | 184 | + Row( |
| 122 | - ); | 185 | + crossAxisAlignment: align == 'end' |
| 123 | - | 186 | + ? CrossAxisAlignment.end |
| 124 | - case 'scroll': | 187 | + : CrossAxisAlignment.center, |
| 125 | - return SizedBox( | 188 | + children: _spaced(kids, spacing, vertical: false), |
| 126 | - height: n.prop('height', 300).toDouble(), | 189 | + ), |
| 127 | - child: Scrollbar( | 190 | + ); |
| 128 | - child: SingleChildScrollView( | 191 | + } |
| 129 | - reverse: true, | 192 | + return _margins( |
| 130 | - child: Column( | 193 | + n, |
| 131 | - crossAxisAlignment: CrossAxisAlignment.start, | 194 | + Wrap( |
| 132 | - children: kids), | 195 | + spacing: spacing, |
| 196 | + runSpacing: spacing, | ||
| 197 | + crossAxisAlignment: align == 'end' | ||
| 198 | + ? WrapCrossAlignment.end | ||
| 199 | + : WrapCrossAlignment.center, | ||
| 200 | + children: kids, | ||
| 133 | ), | 201 | ), |
| 134 | - ), | 202 | + ); |
| 135 | - ); | 203 | + } |
| 136 | 204 | ||
| 205 | + // Container::Card in the Clojure: padding 12, fills its width. | ||
| 137 | case 'card': | 206 | case 'card': |
| 138 | - return Card( | 207 | + return Container( |
| 139 | - margin: const EdgeInsets.symmetric(vertical: 8), | 208 | + width: double.infinity, |
| 140 | - child: Padding( | 209 | + margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs), |
| 141 | - padding: const EdgeInsets.all(16), | 210 | + padding: const EdgeInsets.all(t.spaceXs), |
| 142 | - child: Column( | 211 | + decoration: BoxDecoration( |
| 143 | - crossAxisAlignment: CrossAxisAlignment.start, children: kids), | 212 | + color: t.card, |
| 213 | + borderRadius: BorderRadius.circular(t.radiusS), | ||
| 144 | ), | 214 | ), |
| 215 | + child: Column( | ||
| 216 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 217 | + children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs, | ||
| 218 | + vertical: true)), | ||
| 145 | ); | 219 | ); |
| 146 | 220 | ||
| 147 | case 'title': | 221 | case 'title': |
| 148 | return Text(n.prop('label', ''), | 222 | return Text(n.prop('label', ''), |
| 149 | - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)); | 223 | + style: _style(t.textTitle3, t.onBg) |
| 224 | + .copyWith(fontWeight: FontWeight.bold)); | ||
| 150 | 225 | ||
| 151 | case 'title-2': | 226 | case 'title-2': |
| 152 | return Padding( | 227 | return Padding( |
| 153 | - padding: const EdgeInsets.only(top: 8, bottom: 4), | 228 | + padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs), |
| 154 | child: Text(n.prop('label', ''), | 229 | child: Text(n.prop('label', ''), |
| 155 | - style: | 230 | + style: _style(t.textTitle4, t.onBg) |
| 156 | - const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), | 231 | + .copyWith(fontWeight: FontWeight.w600)), |
| 157 | ); | 232 | ); |
| 158 | 233 | ||
| 159 | case 'label': | 234 | case 'label': |
| 160 | - return Text(n.prop('label', '')); | 235 | + return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg)); |
| 161 | 236 | ||
| 162 | case 'dim-label': | 237 | case 'dim-label': |
| 163 | - return Opacity( | 238 | + return Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)); |
| 164 | - opacity: 0.7, | 239 | + |
| 165 | - child: Text(n.prop('label', ''), | 240 | + /// Prose, as opposed to a label: this is what a message is, and it |
| 166 | - style: const TextStyle(fontSize: 12))); | 241 | + /// wraps. Kept apart from `label` because a wrapping label in a row |
| 242 | + /// lays out against the row's width rather than the column's. | ||
| 243 | + case 'text': | ||
| 244 | + return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg)); | ||
| 245 | + | ||
| 246 | + case 'link': | ||
| 247 | + return _wrapTap( | ||
| 248 | + n.prop('onClick', ''), | ||
| 249 | + Text( | ||
| 250 | + n.prop('label', ''), | ||
| 251 | + style: _style(t.textBody, t.accent) | ||
| 252 | + .copyWith(decoration: TextDecoration.underline, | ||
| 253 | + decorationColor: t.accent), | ||
| 254 | + ), | ||
| 255 | + ); | ||
| 256 | + | ||
| 257 | + case 'separator': | ||
| 258 | + return const Divider(height: 1, thickness: 1, color: t.divider); | ||
| 259 | + | ||
| 260 | + case 'spacer': | ||
| 261 | + { | ||
| 262 | + final s = _d(n.props['size'], t.spaceXxs); | ||
| 263 | + return SizedBox(width: s, height: s); | ||
| 264 | + } | ||
| 167 | 265 | ||
| 168 | case 'spinner': | 266 | case 'spinner': |
| 169 | - return const SizedBox( | 267 | + return Row( |
| 170 | - width: 16, | 268 | + mainAxisSize: MainAxisSize.min, |
| 171 | - height: 16, | 269 | + children: [ |
| 172 | - child: CircularProgressIndicator(strokeWidth: 2)); | 270 | + const SizedBox( |
| 271 | + width: 16, | ||
| 272 | + height: 16, | ||
| 273 | + child: CircularProgressIndicator( | ||
| 274 | + strokeWidth: 2, color: t.accent)), | ||
| 275 | + if (n.prop('label', '').isNotEmpty) ...[ | ||
| 276 | + const SizedBox(width: t.spaceXxs), | ||
| 277 | + Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)), | ||
| 278 | + ], | ||
| 279 | + ], | ||
| 280 | + ); | ||
| 281 | + | ||
| 282 | + /// A dot that says whether the thing is live, and the words beside it. | ||
| 283 | + case 'status': | ||
| 284 | + return Row( | ||
| 285 | + mainAxisSize: MainAxisSize.min, | ||
| 286 | + children: [ | ||
| 287 | + Container( | ||
| 288 | + width: 8, | ||
| 289 | + height: 8, | ||
| 290 | + decoration: BoxDecoration( | ||
| 291 | + color: n.prop('live', false) ? t.success : t.dim, | ||
| 292 | + borderRadius: BorderRadius.circular(t.radiusXs), | ||
| 293 | + ), | ||
| 294 | + ), | ||
| 295 | + const SizedBox(width: 6), | ||
| 296 | + Text(n.prop('label', ''), style: _style(t.textCaption, t.dim)), | ||
| 297 | + ], | ||
| 298 | + ); | ||
| 173 | 299 | ||
| 174 | case 'button': | 300 | case 'button': |
| 175 | - final onClick = n.prop('onClick', ''); | 301 | + { |
| 176 | - final label = Text(n.prop('label', '')); | 302 | + final onClick = n.prop('onClick', ''); |
| 177 | - // No padding of its own: spacing belongs to the container, which is | 303 | + final kind = n.prop('kind', 'default'); |
| 178 | - // the only thing that knows whether this is in a row or a column. | 304 | + final label = Text(n.prop('label', '')); |
| 179 | - return n.prop('kind', 'default') == 'primary' | 305 | + if (kind == 'primary') { |
| 180 | - ? FilledButton(onPressed: () => _send(onClick), child: label) | 306 | + return FilledButton( |
| 181 | - : OutlinedButton(onPressed: () => _send(onClick), child: label); | 307 | + onPressed: () => _send(onClick), child: label); |
| 308 | + } | ||
| 309 | + if (kind == 'destructive') { | ||
| 310 | + return FilledButton( | ||
| 311 | + style: FilledButton.styleFrom( | ||
| 312 | + backgroundColor: t.destructive, | ||
| 313 | + foregroundColor: t.onDestructive), | ||
| 314 | + onPressed: () => _send(onClick), | ||
| 315 | + child: label, | ||
| 316 | + ); | ||
| 317 | + } | ||
| 318 | + return OutlinedButton(onPressed: () => _send(onClick), child: label); | ||
| 319 | + } | ||
| 182 | 320 | ||
| 183 | case 'checkbutton': | 321 | case 'checkbutton': |
| 184 | - return Row(mainAxisSize: MainAxisSize.min, children: [ | 322 | + { |
| 185 | - Checkbox( | 323 | + // The label is part of the target. 20 logical pixels is a fine tick |
| 186 | - value: n.prop('active', false), | 324 | + // on a desktop pointer and a miss on a thumb, so the whole row taps. |
| 187 | - onChanged: (_) => _send(n.prop('onToggled', '')), | 325 | + final onToggled = n.prop('onToggled', ''); |
| 188 | - ), | 326 | + return InkWell( |
| 189 | - Text(n.prop('label', '')), | 327 | + onTap: () => _send(onToggled), |
| 190 | - ]); | 328 | + child: Row( |
| 329 | + mainAxisSize: MainAxisSize.min, | ||
| 330 | + children: [ | ||
| 331 | + Checkbox( | ||
| 332 | + value: n.prop('active', false), | ||
| 333 | + onChanged: (_) => _send(onToggled), | ||
| 334 | + ), | ||
| 335 | + Text(n.prop('label', ''), style: _style(t.textBody, t.onBg)), | ||
| 336 | + ], | ||
| 337 | + ), | ||
| 338 | + ); | ||
| 339 | + } | ||
| 340 | + | ||
| 341 | + case 'emoji': | ||
| 342 | + return _wrapTap( | ||
| 343 | + n.prop('onClick', ''), | ||
| 344 | + Text(n.prop('glyph', n.prop('emoji', '')), | ||
| 345 | + style: TextStyle(fontSize: _d(n.props['size'], 16))), | ||
| 346 | + ); | ||
| 347 | + | ||
| 348 | + /// A reaction pill: the glyph, and the tally beside it where there is | ||
| 349 | + /// one to show. The same shape whether it is a reaction under a message, | ||
| 350 | + /// a swatch in the picker, or a chip on the sender's row — which is the | ||
| 351 | + /// point: what you press to react and what appears once you have should | ||
| 352 | + /// look like one family. | ||
| 353 | + /// | ||
| 354 | + /// A count of zero is no count. The picker passes 0 for every swatch, | ||
| 355 | + /// and a grid of little grey zeroes is noise where a reader is scanning | ||
| 356 | + /// for a face. `mine` is the accent, because the only thing a pill has | ||
| 357 | + /// to say at a glance is whether pressing it again takes yours off. | ||
| 358 | + case 'reaction': | ||
| 359 | + { | ||
| 360 | + final size = _d(n.props['size'], 14); | ||
| 361 | + final count = n.prop('count', 0); | ||
| 362 | + final mine = n.prop('mine', false); | ||
| 363 | + final pad = (0.25 * size).clamp(2.0, 8.0); | ||
| 364 | + return _wrapTap( | ||
| 365 | + n.prop('onClick', ''), | ||
| 366 | + Container( | ||
| 367 | + padding: EdgeInsets.symmetric(horizontal: pad, vertical: pad / 2), | ||
| 368 | + decoration: BoxDecoration( | ||
| 369 | + color: mine ? t.accent : t.component, | ||
| 370 | + borderRadius: BorderRadius.circular(t.radiusS), | ||
| 371 | + ), | ||
| 372 | + child: Row( | ||
| 373 | + mainAxisSize: MainAxisSize.min, | ||
| 374 | + children: [ | ||
| 375 | + Text(n.prop('emoji', ''), style: TextStyle(fontSize: size)), | ||
| 376 | + if (count > 0) ...[ | ||
| 377 | + const SizedBox(width: 4), | ||
| 378 | + Text('$count', | ||
| 379 | + style: _style(t.textCaption, | ||
| 380 | + mine ? t.onAccent : t.dim)), | ||
| 381 | + ], | ||
| 382 | + ], | ||
| 383 | + ), | ||
| 384 | + ), | ||
| 385 | + ); | ||
| 386 | + } | ||
| 387 | + | ||
| 388 | + /// A face is a way in to who someone is, so it takes the press that | ||
| 389 | + /// opens their profile. A picture that will not load is a face that | ||
| 390 | + /// stays its initial and nothing else. | ||
| 391 | + case 'avatar': | ||
| 392 | + { | ||
| 393 | + final size = _d(n.props['size'], 32); | ||
| 394 | + final provider = _imageProvider(n.prop('url', '')); | ||
| 395 | + final fallback = n.prop('fallback', ''); | ||
| 396 | + final face = CircleAvatar( | ||
| 397 | + radius: size / 2, | ||
| 398 | + backgroundColor: t.component, | ||
| 399 | + backgroundImage: provider, | ||
| 400 | + onBackgroundImageError: provider == null ? null : (_, __) {}, | ||
| 401 | + child: provider == null | ||
| 402 | + ? Text( | ||
| 403 | + fallback.isNotEmpty | ||
| 404 | + ? fallback.substring(0, 1).toUpperCase() | ||
| 405 | + : '?', | ||
| 406 | + style: _style(t.textBody, t.onBg)) | ||
| 407 | + : null, | ||
| 408 | + ); | ||
| 409 | + final onClick = n.prop('onClick', ''); | ||
| 410 | + if (onClick.isEmpty) return face; | ||
| 411 | + return InkWell( | ||
| 412 | + onTap: () => _send(onClick), | ||
| 413 | + customBorder: const CircleBorder(), | ||
| 414 | + child: face, | ||
| 415 | + ); | ||
| 416 | + } | ||
| 417 | + | ||
| 418 | + case 'image': | ||
| 419 | + { | ||
| 420 | + final provider = _imageProvider(n.prop('src', '')); | ||
| 421 | + if (provider == null) return const SizedBox.shrink(); | ||
| 422 | + final maxW = _d(n.props['maxWidth'], 0); | ||
| 423 | + final maxH = _d(n.props['maxHeight'], 0); | ||
| 424 | + Widget img = Image( | ||
| 425 | + image: provider, | ||
| 426 | + fit: BoxFit.contain, | ||
| 427 | + // A half-written cache file, or one deleted under us: the decoder | ||
| 428 | + // throws during the build, and an exception in a build is a red | ||
| 429 | + // screen for the whole conversation rather than a gap where one | ||
| 430 | + // picture was. | ||
| 431 | + errorBuilder: (_, __, ___) => const SizedBox.shrink(), | ||
| 432 | + ); | ||
| 433 | + if (maxW > 0 || maxH > 0) { | ||
| 434 | + img = ConstrainedBox( | ||
| 435 | + constraints: BoxConstraints( | ||
| 436 | + maxWidth: maxW > 0 ? maxW : double.infinity, | ||
| 437 | + maxHeight: maxH > 0 ? maxH : double.infinity, | ||
| 438 | + ), | ||
| 439 | + child: img, | ||
| 440 | + ); | ||
| 441 | + } | ||
| 442 | + return _wrapTap(n.prop('onClick', ''), img); | ||
| 443 | + } | ||
| 191 | 444 | ||
| 192 | case 'entry': | 445 | case 'entry': |
| 193 | - final key = n.prop('key', ''); | 446 | + { |
| 194 | - final text = n.prop('text', ''); | 447 | + final key = n.prop('key', ''); |
| 195 | - final c = _controllers.putIfAbsent( | 448 | + final value = n.prop('text', ''); |
| 196 | - key, () => TextEditingController(text: text)); | 449 | + final c = _controllers.putIfAbsent( |
| 197 | - // Only when it actually differs: assigning unconditionally moves the | 450 | + key, () => TextEditingController(text: value)); |
| 198 | - // caret to the end on every keystroke, which is the classic way to | 451 | + // Only when it actually differs: assigning unconditionally moves the |
| 199 | - // make a controlled text field unusable. | 452 | + // caret to the end on every keystroke. |
| 200 | - if (c.text != text) { | 453 | + if (c.text != value) { |
| 201 | - c.value = c.value.copyWith( | 454 | + c.value = c.value.copyWith( |
| 202 | - text: text, | 455 | + text: value, |
| 203 | - selection: TextSelection.collapsed(offset: text.length), | 456 | + selection: TextSelection.collapsed(offset: value.length), |
| 457 | + ); | ||
| 458 | + } | ||
| 459 | + final field = TextField( | ||
| 460 | + controller: c, | ||
| 461 | + focusNode: _focus.putIfAbsent(key, FocusNode.new), | ||
| 462 | + style: _style(t.textBody, t.onBg), | ||
| 463 | + decoration: InputDecoration( | ||
| 464 | + hintText: n.prop('placeholder', ''), | ||
| 465 | + hintStyle: _style(t.textBody, t.dim), | ||
| 466 | + isDense: true, | ||
| 467 | + filled: true, | ||
| 468 | + fillColor: t.component, | ||
| 469 | + border: OutlineInputBorder( | ||
| 470 | + borderRadius: BorderRadius.circular(t.radiusS), | ||
| 471 | + borderSide: BorderSide.none, | ||
| 472 | + ), | ||
| 473 | + ), | ||
| 474 | + onChanged: (v) => _send(n.prop('onChange', ''), v), | ||
| 475 | + onSubmitted: (_) => _send(n.prop('onSubmit', '')), | ||
| 476 | + ); | ||
| 477 | + final w = _d(n.props['widthRequest'], 0); | ||
| 478 | + // A width request is a minimum in the screens' vocabulary, but here | ||
| 479 | + // it has to be a maximum too: an unconstrained TextField inside a | ||
| 480 | + // Wrap has no width at all to take. | ||
| 481 | + return w > 0 ? SizedBox(width: w, child: field) : Expanded(child: field); | ||
| 482 | + } | ||
| 483 | + | ||
| 484 | + case 'scroll': | ||
| 485 | + { | ||
| 486 | + Widget body = SingleChildScrollView( | ||
| 487 | + // The backlog reads from the bottom; a settings list from the top. | ||
| 488 | + reverse: n.prop('stickToBottom', false), | ||
| 489 | + child: Column( | ||
| 490 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 491 | + children: _spaced(kids, spacing, vertical: true)), | ||
| 204 | ); | 492 | ); |
| 493 | + body = Scrollbar(child: body); | ||
| 494 | + final h = _d(n.props['height'], 0); | ||
| 495 | + if (h > 0) return SizedBox(height: h, child: body); | ||
| 496 | + // No fixed height: take what the column has left. `reserve` is the | ||
| 497 | + // Clojure's way of saying the same thing to a backend that could not | ||
| 498 | + // do this, and is ignored here on purpose. | ||
| 499 | + return Expanded(child: body); | ||
| 205 | } | 500 | } |
| 206 | - final field = TextField( | 501 | + |
| 207 | - controller: c, | 502 | + /// A panel over the screen rather than a screen of its own. |
| 208 | - focusNode: _focus.putIfAbsent(key, FocusNode.new), | 503 | + case 'dialog': |
| 209 | - decoration: InputDecoration( | 504 | + return Card( |
| 210 | - hintText: n.prop('placeholder', ''), | 505 | + color: t.cardComponent, |
| 211 | - isDense: true, | 506 | + child: Padding( |
| 212 | - border: const OutlineInputBorder(), | 507 | + padding: const EdgeInsets.all(t.spaceS), |
| 508 | + child: Column( | ||
| 509 | + mainAxisSize: MainAxisSize.min, | ||
| 510 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 511 | + children: [ | ||
| 512 | + if (n.prop('title', '').isNotEmpty) | ||
| 513 | + Padding( | ||
| 514 | + padding: const EdgeInsets.only(bottom: t.spaceXxs), | ||
| 515 | + child: Text(n.prop('title', ''), | ||
| 516 | + style: _style(t.textTitle4, t.onCard) | ||
| 517 | + .copyWith(fontWeight: FontWeight.w600)), | ||
| 518 | + ), | ||
| 519 | + ..._spaced(kids, spacing, vertical: true), | ||
| 520 | + ], | ||
| 521 | + ), | ||
| 213 | ), | 522 | ), |
| 214 | - onChanged: (v) => _send(n.prop('onChange', ''), v), | ||
| 215 | - onSubmitted: (_) { | ||
| 216 | - final submit = n.prop('onSubmit', ''); | ||
| 217 | - if (submit.isNotEmpty) _send(submit); | ||
| 218 | - }, | ||
| 219 | ); | 523 | ); |
| 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 | 524 | ||
| 226 | default: | 525 | default: |
| 227 | // An unknown tag paints as itself rather than crashing or vanishing. | 526 | // 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 | 527 | // 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. | 528 | + // is what makes the boundary pleasant to work across. |
| 230 | return Container( | 529 | return Container( |
| 231 | padding: const EdgeInsets.all(4), | 530 | padding: const EdgeInsets.all(4), |
| 232 | color: Colors.orange.withValues(alpha: 0.3), | 531 | color: Colors.orange.withValues(alpha: 0.3), |
| @@ -235,17 +534,19 @@ class _NimAppState extends State<NimApp> { | |||
| 235 | } | 534 | } |
| 236 | } | 535 | } |
| 237 | 536 | ||
| 238 | - List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) { | 537 | + /// `margin`, `marginTop`, `marginBottom`, `marginRight` — the props the |
| 239 | - if (gap <= 0 || kids.length < 2) return kids; | 538 | + /// screens use to buy air without a wrapper each time. |
| 240 | - final out = <Widget>[]; | 539 | + Widget _margins(core.UiNode n, Widget child) { |
| 241 | - for (var i = 0; i < kids.length; i++) { | 540 | + final all = _d(n.props['margin'], 0); |
| 242 | - if (i > 0) { | 541 | + final top = _d(n.props['marginTop'], all); |
| 243 | - out.add(vertical | 542 | + final bottom = _d(n.props['marginBottom'], all); |
| 244 | - ? SizedBox(height: gap.toDouble()) | 543 | + final right = _d(n.props['marginRight'], all); |
| 245 | - : SizedBox(width: gap.toDouble())); | 544 | + final left = _d(n.props['marginLeft'], all); |
| 246 | - } | 545 | + if (top == 0 && bottom == 0 && right == 0 && left == 0) return child; |
| 247 | - out.add(kids[i]); | 546 | + return Padding( |
| 248 | - } | 547 | + padding: EdgeInsets.only( |
| 249 | - return out; | 548 | + top: top, bottom: bottom, right: right, left: left), |
| 549 | + child: child, | ||
| 550 | + ); | ||
| 250 | } | 551 | } |
| 251 | } | 552 | } |
added
flutter/lib/nim_theme.dart +45 -0 | new file mode 100644 | ||
| @@ -0,0 +1,45 @@ | ||
| 1 | +/// The design tokens, as Dart. | |
| 2 | +/// | |
| 3 | +/// The same numbers `flutter/src/frq/theme/tokens.cljd` carries, in the | |
| 4 | +/// language the renderer is written in. Duplicated rather than imported | |
| 5 | +/// because that file is ClojureDart and this is the half that is leaving it; | |
| 6 | +/// when the ClojureDart app goes, so does the other copy. | |
| 7 | +library; | |
| 8 | + | |
| 9 | +import 'package:flutter/material.dart'; | |
| 10 | + | |
| 11 | +const accent = Color(0xFFF4E3CF); | |
| 12 | +const onAccent = Color(0xFF000000); | |
| 13 | +const bg = Color(0xFF202833); | |
| 14 | +const onBg = Color(0xFFCCD1D7); | |
| 15 | +const component = Color(0xFF343C48); | |
| 16 | +const componentHover = Color(0xFF48505A); | |
| 17 | +const divider = Color(0x33CCD1D7); | |
| 18 | +const card = Color(0xFF2C3440); | |
| 19 | +const cardComponent = Color(0xFF3B4450); | |
| 20 | +const onCard = Color(0xFFFFFFFF); | |
| 21 | +const destructive = Color(0xFFFDA1A0); | |
| 22 | +const onDestructive = Color(0xFF000000); | |
| 23 | +const success = Color(0xFF92CF9C); | |
| 24 | + | |
| 25 | +/// `dim` is not a token of its own in the Clojure either — it is `onBg` at | |
| 26 | +/// the opacity a caption wants, and naming it here keeps that one decision in | |
| 27 | +/// one place. | |
| 28 | +const dim = Color(0x99CCD1D7); | |
| 29 | + | |
| 30 | +const radiusXs = 2.0; | |
| 31 | +const radiusS = 8.0; | |
| 32 | +const radiusM = 8.0; | |
| 33 | + | |
| 34 | +const spaceXxxs = 4.0; | |
| 35 | +const spaceXxs = 8.0; | |
| 36 | +const spaceXs = 12.0; | |
| 37 | +const spaceS = 16.0; | |
| 38 | +const spaceM = 24.0; | |
| 39 | + | |
| 40 | +/// libcosmic's typography, which was code rather than configuration there and | |
| 41 | +/// is code here for the same reason. | |
| 42 | +const textTitle3 = 24.0; // :title | |
| 43 | +const textTitle4 = 20.0; // :title-2 | |
| 44 | +const textBody = 14.0; // :label | |
| 45 | +const textCaption = 12.0; // :dim-label, :status, :spinner | |
| new file mode 100644 | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | +/// The design tokens, as Dart. | ||
| 2 | +/// | ||
| 3 | +/// The same numbers `flutter/src/frq/theme/tokens.cljd` carries, in the | ||
| 4 | +/// language the renderer is written in. Duplicated rather than imported | ||
| 5 | +/// because that file is ClojureDart and this is the half that is leaving it; | ||
| 6 | +/// when the ClojureDart app goes, so does the other copy. | ||
| 7 | +library; | ||
| 8 | + | ||
| 9 | +import 'package:flutter/material.dart'; | ||
| 10 | + | ||
| 11 | +const accent = Color(0xFFF4E3CF); | ||
| 12 | +const onAccent = Color(0xFF000000); | ||
| 13 | +const bg = Color(0xFF202833); | ||
| 14 | +const onBg = Color(0xFFCCD1D7); | ||
| 15 | +const component = Color(0xFF343C48); | ||
| 16 | +const componentHover = Color(0xFF48505A); | ||
| 17 | +const divider = Color(0x33CCD1D7); | ||
| 18 | +const card = Color(0xFF2C3440); | ||
| 19 | +const cardComponent = Color(0xFF3B4450); | ||
| 20 | +const onCard = Color(0xFFFFFFFF); | ||
| 21 | +const destructive = Color(0xFFFDA1A0); | ||
| 22 | +const onDestructive = Color(0xFF000000); | ||
| 23 | +const success = Color(0xFF92CF9C); | ||
| 24 | + | ||
| 25 | +/// `dim` is not a token of its own in the Clojure either — it is `onBg` at | ||
| 26 | +/// the opacity a caption wants, and naming it here keeps that one decision in | ||
| 27 | +/// one place. | ||
| 28 | +const dim = Color(0x99CCD1D7); | ||
| 29 | + | ||
| 30 | +const radiusXs = 2.0; | ||
| 31 | +const radiusS = 8.0; | ||
| 32 | +const radiusM = 8.0; | ||
| 33 | + | ||
| 34 | +const spaceXxxs = 4.0; | ||
| 35 | +const spaceXxs = 8.0; | ||
| 36 | +const spaceXs = 12.0; | ||
| 37 | +const spaceS = 16.0; | ||
| 38 | +const spaceM = 24.0; | ||
| 39 | + | ||
| 40 | +/// libcosmic's typography, which was code rather than configuration there and | ||
| 41 | +/// is code here for the same reason. | ||
| 42 | +const textTitle3 = 24.0; // :title | ||
| 43 | +const textTitle4 = 20.0; // :title-2 | ||
| 44 | +const textBody = 14.0; // :label | ||
| 45 | +const textCaption = 12.0; // :dim-label, :status, :spinner | ||
modified
justfile +49 -5 | @@ -382,10 +382,9 @@ nim-test file="": | ||
| 382 | 382 | |
| 383 | 383 | # The Nim core as a shared library, into build/nim. |
| 384 | 384 | # |
| 385 | -# `--mm:orc` rather than the default: this is a library loaded by a Dart | |
| 386 | -# process that owns its own lifetime, so reference counting with a cycle | |
| 387 | -# collector is the memory model that does not need a GC thread of its own or a | |
| 388 | -# stack it can scan. | |
| 385 | +# `--mm:orc` and not refc, and it is not a preference: refc gives each thread | |
| 386 | +# its own GC heap, so the Socket the reader and writer threads share is a ref | |
| 387 | +# from another heap and dereferencing it segfaults. ORC's heap is shared. | |
| 389 | 388 | # |
| 390 | 389 | # `-d:release` and not `-d:danger`: the bounds checks are what turn a |
| 391 | 390 | # malformed line off a socket into an exception instead of a read past the end |
| @@ -428,7 +427,52 @@ dart-test: | ||
| 428 | 427 | dart pub get |
| 429 | 428 | dart test -r expanded |
| 430 | 429 | |
| 431 | -# The real app, with the Nim core as its transport. | |
| 430 | +# The whole Nim stack against a real freeq: socket, state and screens. | |
| 431 | +# | |
| 432 | +# Connects, waits for the room list, opens a room and prints what the tree | |
| 433 | +# actually contains — through the FFI, so it is the path the window uses. | |
| 434 | +# Not in any suite: it needs a network and a running freeq. | |
| 435 | +nim-live *args: | |
| 436 | + #!/usr/bin/env bash | |
| 437 | + set -euo pipefail | |
| 438 | + cd "{{justfile_directory()}}" | |
| 439 | + if [ -z "${FRQ_DART:-}" ]; then | |
| 440 | + just nim-lib | |
| 441 | + exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-live "$@" | |
| 442 | + fi | |
| 443 | + shift || true | |
| 444 | + cd dart/frq_core | |
| 445 | + dart pub get >/dev/null | |
| 446 | + exec dart run tool/live_ui.dart "$@" | |
| 447 | + | |
| 448 | +# frq with Nim owning the state and the screens, rendered by Flutter. | |
| 449 | +# | |
| 450 | +# No ClojureDart on this path. `lib/main_nim.dart` asks the Nim core for a | |
| 451 | +# widget tree and paints it; the screens are ports of `common/frq/screens/`. | |
| 452 | +# | |
| 453 | +# just nim-ui build it | |
| 454 | +# just nim-ui run open the window | |
| 455 | +nim-ui action="build": | |
| 456 | + #!/usr/bin/env bash | |
| 457 | + set -euo pipefail | |
| 458 | + cd "{{justfile_directory()}}" | |
| 459 | + if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then | |
| 460 | + just nim-lib | |
| 461 | + exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ | |
| 462 | + --command just nim-ui "$@" | |
| 463 | + fi | |
| 464 | + cd flutter | |
| 465 | + flutter pub get | |
| 466 | + export LD_LIBRARY_PATH="${FRQ_OPENSSL_LIB:-}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" | |
| 467 | + runner=() | |
| 468 | + [ -e /run/current-system ] || runner=("$NIXGL") | |
| 469 | + case "{{action}}" in | |
| 470 | + build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim.dart ;; | |
| 471 | + run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim.dart ;; | |
| 472 | + *) echo "usage: just nim-ui [build|run]" >&2; exit 1 ;; | |
| 473 | + esac | |
| 474 | + | |
| 475 | +# The ClojureDart app, with the Nim core as its transport only. | |
| 432 | 476 | # |
| 433 | 477 | # This is the wiring that matters: `frq.main-nim` is `frq.main` with one line |
| 434 | 478 | # changed — `frq.net.nim/install!` where it says `frq.net.dart/install!`. Every |
| @@ -382,10 +382,9 @@ nim-test file="": | |||
| 382 | 382 | ||
| 383 | # The Nim core as a shared library, into build/nim. | 383 | # The Nim core as a shared library, into build/nim. |
| 384 | # | 384 | # |
| 385 | -# `--mm:orc` rather than the default: this is a library loaded by a Dart | 385 | +# `--mm:orc` and not refc, and it is not a preference: refc gives each thread |
| 386 | -# process that owns its own lifetime, so reference counting with a cycle | 386 | +# its own GC heap, so the Socket the reader and writer threads share is a ref |
| 387 | -# collector is the memory model that does not need a GC thread of its own or a | 387 | +# from another heap and dereferencing it segfaults. ORC's heap is shared. |
| 388 | -# stack it can scan. | ||
| 389 | # | 388 | # |
| 390 | # `-d:release` and not `-d:danger`: the bounds checks are what turn a | 389 | # `-d:release` and not `-d:danger`: the bounds checks are what turn a |
| 391 | # malformed line off a socket into an exception instead of a read past the end | 390 | # malformed line off a socket into an exception instead of a read past the end |
| @@ -428,7 +427,52 @@ dart-test: | |||
| 428 | dart pub get | 427 | dart pub get |
| 429 | dart test -r expanded | 428 | dart test -r expanded |
| 430 | 429 | ||
| 431 | -# The real app, with the Nim core as its transport. | 430 | +# The whole Nim stack against a real freeq: socket, state and screens. |
| 431 | +# | ||
| 432 | +# Connects, waits for the room list, opens a room and prints what the tree | ||
| 433 | +# actually contains — through the FFI, so it is the path the window uses. | ||
| 434 | +# Not in any suite: it needs a network and a running freeq. | ||
| 435 | +nim-live *args: | ||
| 436 | + #!/usr/bin/env bash | ||
| 437 | + set -euo pipefail | ||
| 438 | + cd "{{justfile_directory()}}" | ||
| 439 | + if [ -z "${FRQ_DART:-}" ]; then | ||
| 440 | + just nim-lib | ||
| 441 | + exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-live "$@" | ||
| 442 | + fi | ||
| 443 | + shift || true | ||
| 444 | + cd dart/frq_core | ||
| 445 | + dart pub get >/dev/null | ||
| 446 | + exec dart run tool/live_ui.dart "$@" | ||
| 447 | + | ||
| 448 | +# frq with Nim owning the state and the screens, rendered by Flutter. | ||
| 449 | +# | ||
| 450 | +# No ClojureDart on this path. `lib/main_nim.dart` asks the Nim core for a | ||
| 451 | +# widget tree and paints it; the screens are ports of `common/frq/screens/`. | ||
| 452 | +# | ||
| 453 | +# just nim-ui build it | ||
| 454 | +# just nim-ui run open the window | ||
| 455 | +nim-ui action="build": | ||
| 456 | + #!/usr/bin/env bash | ||
| 457 | + set -euo pipefail | ||
| 458 | + cd "{{justfile_directory()}}" | ||
| 459 | + if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then | ||
| 460 | + just nim-lib | ||
| 461 | + exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ | ||
| 462 | + --command just nim-ui "$@" | ||
| 463 | + fi | ||
| 464 | + cd flutter | ||
| 465 | + flutter pub get | ||
| 466 | + export LD_LIBRARY_PATH="${FRQ_OPENSSL_LIB:-}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" | ||
| 467 | + runner=() | ||
| 468 | + [ -e /run/current-system ] || runner=("$NIXGL") | ||
| 469 | + case "{{action}}" in | ||
| 470 | + build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim.dart ;; | ||
| 471 | + run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim.dart ;; | ||
| 472 | + *) echo "usage: just nim-ui [build|run]" >&2; exit 1 ;; | ||
| 473 | + esac | ||
| 474 | + | ||
| 475 | +# The ClojureDart app, with the Nim core as its transport only. | ||
| 432 | # | 476 | # |
| 433 | # This is the wiring that matters: `frq.main-nim` is `frq.main` with one line | 477 | # This is the wiring that matters: `frq.main-nim` is `frq.main` with one line |
| 434 | # changed — `frq.net.nim/install!` where it says `frq.net.dart/install!`. Every | 478 | # changed — `frq.net.nim/install!` where it says `frq.net.dart/install!`. Every |
modified
nim/src/frq/cells.nim +1 -1 | @@ -11,7 +11,7 @@ | ||
| 11 | 11 | ## documentation of what it obviously holds. |
| 12 | 12 | |
| 13 | 13 | import std/tables |
| 14 | -import model | |
| 14 | +import frq/[model] | |
| 15 | 15 | |
| 16 | 16 | type |
| 17 | 17 | Screen* = enum |
| @@ -11,7 +11,7 @@ | |||
| 11 | ## documentation of what it obviously holds. | 11 | ## documentation of what it obviously holds. |
| 12 | 12 | ||
| 13 | import std/tables | 13 | import std/tables |
| 14 | -import model | 14 | +import frq/[model] |
| 15 | 15 | ||
| 16 | type | 16 | type |
| 17 | Screen* = enum | 17 | Screen* = enum |
modified
nim/src/frq/conn.nim +58 -40 | @@ -15,8 +15,8 @@ | ||
| 15 | 15 | ## Threading as before: the socket thread shares nothing, and speaks in |
| 16 | 16 | ## channels. See irc.nim's comment for why ORC makes that the sane choice. |
| 17 | 17 | |
| 18 | -import std/[net, strutils] | |
| 19 | -import trace | |
| 18 | +import std/[net, os, strutils] | |
| 19 | +import frq/[trace] | |
| 20 | 20 | |
| 21 | 21 | type |
| 22 | 22 | ConnConfig* = object |
| @@ -28,71 +28,85 @@ var | ||
| 28 | 28 | inbound: Channel[string] |
| 29 | 29 | outbound: Channel[string] |
| 30 | 30 | events: Channel[string] ## "open" | "close: reason" | "error: reason" |
| 31 | - thread: Thread[ConnConfig] | |
| 31 | + reader: Thread[ConnConfig] | |
| 32 | + writer: Thread[int] | |
| 32 | 33 | running: bool |
| 34 | + shared: Socket | |
| 35 | + ## The socket both threads use. One reader and one writer on the same | |
| 36 | + ## OpenSSL connection is supported and is what every IRC client does; what | |
| 37 | + ## is NOT supported is two of either, which is why there are exactly two | |
| 38 | + ## threads and neither of them is the caller's. | |
| 33 | 39 | |
| 40 | +# Opened once, at module init. If you ever see this run twice, something is | |
| 41 | +# calling NimMain after the library constructor already has — see `frq_init`, | |
| 42 | +# which is empty for exactly that reason. | |
| 34 | 43 | inbound.open() |
| 35 | 44 | outbound.open() |
| 36 | 45 | events.open() |
| 37 | 46 | |
| 47 | + | |
| 48 | +proc writerBody(unused: int) {.thread.} = | |
| 49 | + ## Blocks on the queue, not on the socket. | |
| 50 | + ## | |
| 51 | + ## A thread of its own because the alternative does not work: a single | |
| 52 | + ## thread has to both wait for the server and notice what the client wants | |
| 53 | + ## to say, and on a TLS socket there is no reliable way to wait for one with | |
| 54 | + ## a bound on the other. `recvLine(timeout)` and `recv(timeout)` both block | |
| 55 | + ## in SSL_read past their timeout — select fires on a TLS *record*, which | |
| 56 | + ## need not hold a complete line — so registration deadlocked: CAP/NICK/USER | |
| 57 | + ## sat in the queue while the reader waited for a server that had nothing to | |
| 58 | + ## say until we sent them. | |
| 59 | + {.gcsafe.}: | |
| 60 | + while true: | |
| 61 | + let line = outbound.recv() # blocks until there is one | |
| 62 | + if not running: break | |
| 63 | + if shared.isNil: continue | |
| 64 | + try: | |
| 65 | + trace("conn.out", line) | |
| 66 | + shared.send(line & "\c\L") | |
| 67 | + except CatchableError as e: | |
| 68 | + events.send("error: " & e.msg) | |
| 69 | + break | |
| 70 | + | |
| 38 | 71 | proc readerBody(cfg: ConnConfig) {.thread.} = |
| 39 | 72 | {.gcsafe.}: |
| 40 | - var sock: Socket | |
| 41 | 73 | try: |
| 42 | 74 | trace("conn", "dialling " & cfg.host & ":" & $cfg.port & |
| 43 | 75 | (if cfg.tls: " over TLS" else: " plain")) |
| 44 | - sock = newSocket(buffered = true) | |
| 76 | + var sock = newSocket(buffered = true) | |
| 45 | 77 | if cfg.tls: |
| 46 | 78 | # CVerifyPeer: this carries a nick and, once SASL is wired, a token. |
| 47 | 79 | let ctx = newContext(verifyMode = CVerifyPeer) |
| 48 | 80 | ctx.wrapSocket(sock) |
| 49 | 81 | sock.connect(cfg.host, Port(cfg.port)) |
| 82 | + shared = sock | |
| 83 | + createThread(writer, writerBody, 0) | |
| 50 | 84 | events.send("open") |
| 51 | 85 | trace("conn", "connected") |
| 52 | 86 | |
| 53 | 87 | while running: |
| 54 | - # Writes FIRST, and this is not a preference: the client speaks first | |
| 55 | - # in IRC. Draining after the read deadlocked exactly once and | |
| 56 | - # completely — CAP/NICK/USER were queued before the socket finished | |
| 57 | - # connecting, the loop went straight into a read, and the server had | |
| 58 | - # nothing to say because we had not registered. Both sides waited. | |
| 59 | - # | |
| 60 | - # It also bounds write latency by nothing instead of by the read | |
| 61 | - # timeout, which matters for a keystroke. | |
| 62 | - while true: | |
| 63 | - let (ok, pending) = outbound.tryRecv() | |
| 64 | - if not ok: break | |
| 65 | - trace("conn.out", pending) | |
| 66 | - sock.send(pending & "\c\L") | |
| 67 | - | |
| 68 | 88 | var line: string |
| 69 | - var timedOut = false | |
| 70 | 89 | try: |
| 71 | - # A timeout rather than a second thread for the writer: it gives the | |
| 72 | - # outbound queue a look between lines at the price of one syscall | |
| 73 | - # every 200ms, and one thread is one thread to shut down cleanly. | |
| 74 | - line = sock.recvLine(timeout = 200) | |
| 75 | - except TimeoutError: | |
| 76 | - timedOut = true | |
| 77 | - except OSError as e: | |
| 78 | - events.send("error: " & e.msg) | |
| 90 | + line = sock.recvLine() | |
| 91 | + except CatchableError as e: | |
| 92 | + if running: events.send("error: " & e.msg) | |
| 79 | 93 | break |
| 80 | - | |
| 81 | - if not timedOut and line.len == 0: | |
| 82 | - # recvLine answering empty with no timeout is the peer going away. | |
| 83 | - events.send("close: ") | |
| 94 | + if line.len == 0: | |
| 95 | + if running: events.send("close: ") | |
| 84 | 96 | break |
| 85 | - | |
| 86 | - if line.len > 0: | |
| 87 | - trace("conn.in", line) | |
| 88 | - inbound.send(line) | |
| 97 | + trace("conn.in", line) | |
| 98 | + inbound.send(line) | |
| 89 | 99 | |
| 90 | 100 | except CatchableError as e: |
| 91 | 101 | trace("conn", "!! " & e.msg) |
| 92 | 102 | events.send("error: " & e.msg) |
| 93 | 103 | finally: |
| 94 | - if not sock.isNil: | |
| 95 | - try: sock.close() except CatchableError: discard | |
| 104 | + running = false | |
| 105 | + # Unblock the writer, which is sitting in `outbound.recv()`. | |
| 106 | + outbound.send("") | |
| 107 | + if not shared.isNil: | |
| 108 | + try: shared.close() except CatchableError: discard | |
| 109 | + shared = nil | |
| 96 | 110 | trace("conn", "reader done") |
| 97 | 111 | |
| 98 | 112 | proc open*(cfg: ConnConfig) = |
| @@ -102,7 +116,7 @@ proc open*(cfg: ConnConfig) = | ||
| 102 | 116 | while inbound.tryRecv()[0]: discard |
| 103 | 117 | while events.tryRecv()[0]: discard |
| 104 | 118 | running = true |
| 105 | - createThread(thread, readerBody, cfg) | |
| 119 | + createThread(reader, readerBody, cfg) | |
| 106 | 120 | |
| 107 | 121 | proc send*(line: string) = |
| 108 | 122 | if running: outbound.send(line) |
| @@ -110,7 +124,11 @@ proc send*(line: string) = | ||
| 110 | 124 | proc close*() = |
| 111 | 125 | if not running: return |
| 112 | 126 | running = false |
| 113 | - joinThread(thread) | |
| 127 | + # The reader is blocked in recvLine; closing the socket is what wakes it. | |
| 128 | + if not shared.isNil: | |
| 129 | + try: shared.close() except CatchableError: discard | |
| 130 | + outbound.send("") | |
| 131 | + joinThread(reader) | |
| 114 | 132 | trace("conn", "closed") |
| 115 | 133 | |
| 116 | 134 | proc tryLine*(): (bool, string) = inbound.tryRecv() |
| @@ -15,8 +15,8 @@ | |||
| 15 | ## Threading as before: the socket thread shares nothing, and speaks in | 15 | ## Threading as before: the socket thread shares nothing, and speaks in |
| 16 | ## channels. See irc.nim's comment for why ORC makes that the sane choice. | 16 | ## channels. See irc.nim's comment for why ORC makes that the sane choice. |
| 17 | 17 | ||
| 18 | -import std/[net, strutils] | 18 | +import std/[net, os, strutils] |
| 19 | -import trace | 19 | +import frq/[trace] |
| 20 | 20 | ||
| 21 | type | 21 | type |
| 22 | ConnConfig* = object | 22 | ConnConfig* = object |
| @@ -28,71 +28,85 @@ var | |||
| 28 | inbound: Channel[string] | 28 | inbound: Channel[string] |
| 29 | outbound: Channel[string] | 29 | outbound: Channel[string] |
| 30 | events: Channel[string] ## "open" | "close: reason" | "error: reason" | 30 | events: Channel[string] ## "open" | "close: reason" | "error: reason" |
| 31 | - thread: Thread[ConnConfig] | 31 | + reader: Thread[ConnConfig] |
| 32 | + writer: Thread[int] | ||
| 32 | running: bool | 33 | running: bool |
| 34 | + shared: Socket | ||
| 35 | + ## The socket both threads use. One reader and one writer on the same | ||
| 36 | + ## OpenSSL connection is supported and is what every IRC client does; what | ||
| 37 | + ## is NOT supported is two of either, which is why there are exactly two | ||
| 38 | + ## threads and neither of them is the caller's. | ||
| 33 | 39 | ||
| 40 | +# Opened once, at module init. If you ever see this run twice, something is | ||
| 41 | +# calling NimMain after the library constructor already has — see `frq_init`, | ||
| 42 | +# which is empty for exactly that reason. | ||
| 34 | inbound.open() | 43 | inbound.open() |
| 35 | outbound.open() | 44 | outbound.open() |
| 36 | events.open() | 45 | events.open() |
| 37 | 46 | ||
| 47 | + | ||
| 48 | +proc writerBody(unused: int) {.thread.} = | ||
| 49 | + ## Blocks on the queue, not on the socket. | ||
| 50 | + ## | ||
| 51 | + ## A thread of its own because the alternative does not work: a single | ||
| 52 | + ## thread has to both wait for the server and notice what the client wants | ||
| 53 | + ## to say, and on a TLS socket there is no reliable way to wait for one with | ||
| 54 | + ## a bound on the other. `recvLine(timeout)` and `recv(timeout)` both block | ||
| 55 | + ## in SSL_read past their timeout — select fires on a TLS *record*, which | ||
| 56 | + ## need not hold a complete line — so registration deadlocked: CAP/NICK/USER | ||
| 57 | + ## sat in the queue while the reader waited for a server that had nothing to | ||
| 58 | + ## say until we sent them. | ||
| 59 | + {.gcsafe.}: | ||
| 60 | + while true: | ||
| 61 | + let line = outbound.recv() # blocks until there is one | ||
| 62 | + if not running: break | ||
| 63 | + if shared.isNil: continue | ||
| 64 | + try: | ||
| 65 | + trace("conn.out", line) | ||
| 66 | + shared.send(line & "\c\L") | ||
| 67 | + except CatchableError as e: | ||
| 68 | + events.send("error: " & e.msg) | ||
| 69 | + break | ||
| 70 | + | ||
| 38 | proc readerBody(cfg: ConnConfig) {.thread.} = | 71 | proc readerBody(cfg: ConnConfig) {.thread.} = |
| 39 | {.gcsafe.}: | 72 | {.gcsafe.}: |
| 40 | - var sock: Socket | ||
| 41 | try: | 73 | try: |
| 42 | trace("conn", "dialling " & cfg.host & ":" & $cfg.port & | 74 | trace("conn", "dialling " & cfg.host & ":" & $cfg.port & |
| 43 | (if cfg.tls: " over TLS" else: " plain")) | 75 | (if cfg.tls: " over TLS" else: " plain")) |
| 44 | - sock = newSocket(buffered = true) | 76 | + var sock = newSocket(buffered = true) |
| 45 | if cfg.tls: | 77 | if cfg.tls: |
| 46 | # CVerifyPeer: this carries a nick and, once SASL is wired, a token. | 78 | # CVerifyPeer: this carries a nick and, once SASL is wired, a token. |
| 47 | let ctx = newContext(verifyMode = CVerifyPeer) | 79 | let ctx = newContext(verifyMode = CVerifyPeer) |
| 48 | ctx.wrapSocket(sock) | 80 | ctx.wrapSocket(sock) |
| 49 | sock.connect(cfg.host, Port(cfg.port)) | 81 | sock.connect(cfg.host, Port(cfg.port)) |
| 82 | + shared = sock | ||
| 83 | + createThread(writer, writerBody, 0) | ||
| 50 | events.send("open") | 84 | events.send("open") |
| 51 | trace("conn", "connected") | 85 | trace("conn", "connected") |
| 52 | 86 | ||
| 53 | while running: | 87 | while running: |
| 54 | - # Writes FIRST, and this is not a preference: the client speaks first | ||
| 55 | - # in IRC. Draining after the read deadlocked exactly once and | ||
| 56 | - # completely — CAP/NICK/USER were queued before the socket finished | ||
| 57 | - # connecting, the loop went straight into a read, and the server had | ||
| 58 | - # nothing to say because we had not registered. Both sides waited. | ||
| 59 | - # | ||
| 60 | - # It also bounds write latency by nothing instead of by the read | ||
| 61 | - # timeout, which matters for a keystroke. | ||
| 62 | - while true: | ||
| 63 | - let (ok, pending) = outbound.tryRecv() | ||
| 64 | - if not ok: break | ||
| 65 | - trace("conn.out", pending) | ||
| 66 | - sock.send(pending & "\c\L") | ||
| 67 | - | ||
| 68 | var line: string | 88 | var line: string |
| 69 | - var timedOut = false | ||
| 70 | try: | 89 | try: |
| 71 | - # A timeout rather than a second thread for the writer: it gives the | 90 | + line = sock.recvLine() |
| 72 | - # outbound queue a look between lines at the price of one syscall | 91 | + except CatchableError as e: |
| 73 | - # every 200ms, and one thread is one thread to shut down cleanly. | 92 | + if running: events.send("error: " & e.msg) |
| 74 | - line = sock.recvLine(timeout = 200) | ||
| 75 | - except TimeoutError: | ||
| 76 | - timedOut = true | ||
| 77 | - except OSError as e: | ||
| 78 | - events.send("error: " & e.msg) | ||
| 79 | break | 93 | break |
| 80 | - | 94 | + if line.len == 0: |
| 81 | - if not timedOut and line.len == 0: | 95 | + if running: events.send("close: ") |
| 82 | - # recvLine answering empty with no timeout is the peer going away. | ||
| 83 | - events.send("close: ") | ||
| 84 | break | 96 | break |
| 85 | - | 97 | + trace("conn.in", line) |
| 86 | - if line.len > 0: | 98 | + inbound.send(line) |
| 87 | - trace("conn.in", line) | ||
| 88 | - inbound.send(line) | ||
| 89 | 99 | ||
| 90 | except CatchableError as e: | 100 | except CatchableError as e: |
| 91 | trace("conn", "!! " & e.msg) | 101 | trace("conn", "!! " & e.msg) |
| 92 | events.send("error: " & e.msg) | 102 | events.send("error: " & e.msg) |
| 93 | finally: | 103 | finally: |
| 94 | - if not sock.isNil: | 104 | + running = false |
| 95 | - try: sock.close() except CatchableError: discard | 105 | + # Unblock the writer, which is sitting in `outbound.recv()`. |
| 106 | + outbound.send("") | ||
| 107 | + if not shared.isNil: | ||
| 108 | + try: shared.close() except CatchableError: discard | ||
| 109 | + shared = nil | ||
| 96 | trace("conn", "reader done") | 110 | trace("conn", "reader done") |
| 97 | 111 | ||
| 98 | proc open*(cfg: ConnConfig) = | 112 | proc open*(cfg: ConnConfig) = |
| @@ -102,7 +116,7 @@ proc open*(cfg: ConnConfig) = | |||
| 102 | while inbound.tryRecv()[0]: discard | 116 | while inbound.tryRecv()[0]: discard |
| 103 | while events.tryRecv()[0]: discard | 117 | while events.tryRecv()[0]: discard |
| 104 | running = true | 118 | running = true |
| 105 | - createThread(thread, readerBody, cfg) | 119 | + createThread(reader, readerBody, cfg) |
| 106 | 120 | ||
| 107 | proc send*(line: string) = | 121 | proc send*(line: string) = |
| 108 | if running: outbound.send(line) | 122 | if running: outbound.send(line) |
| @@ -110,7 +124,11 @@ proc send*(line: string) = | |||
| 110 | proc close*() = | 124 | proc close*() = |
| 111 | if not running: return | 125 | if not running: return |
| 112 | running = false | 126 | running = false |
| 113 | - joinThread(thread) | 127 | + # The reader is blocked in recvLine; closing the socket is what wakes it. |
| 128 | + if not shared.isNil: | ||
| 129 | + try: shared.close() except CatchableError: discard | ||
| 130 | + outbound.send("") | ||
| 131 | + joinThread(reader) | ||
| 114 | trace("conn", "closed") | 132 | trace("conn", "closed") |
| 115 | 133 | ||
| 116 | proc tryLine*(): (bool, string) = inbound.tryRecv() | 134 | proc tryLine*(): (bool, string) = inbound.tryRecv() |
modified
nim/src/frq/edits.nim +1 -1 | @@ -3,7 +3,7 @@ | ||
| 3 | 3 | ## From `common/frq/edits.cljc`. |
| 4 | 4 | |
| 5 | 5 | import std/[strutils, tables] |
| 6 | -import model | |
| 6 | +import frq/[model] | |
| 7 | 7 | |
| 8 | 8 | type |
| 9 | 9 | EditResult* = enum |
| @@ -3,7 +3,7 @@ | |||
| 3 | ## From `common/frq/edits.cljc`. | 3 | ## From `common/frq/edits.cljc`. |
| 4 | 4 | ||
| 5 | import std/[strutils, tables] | 5 | import std/[strutils, tables] |
| 6 | -import model | 6 | +import frq/[model] |
| 7 | 7 | ||
| 8 | type | 8 | type |
| 9 | EditResult* = enum | 9 | EditResult* = enum |
modified
nim/src/frq/reactions.nim +1 -1 | @@ -6,7 +6,7 @@ | ||
| 6 | 6 | ## its map happens to preserve insertion for small maps. |
| 7 | 7 | |
| 8 | 8 | import std/[strutils, tables] |
| 9 | -import model | |
| 9 | +import frq/[model] | |
| 10 | 10 | |
| 11 | 11 | func parseTally*(encoded: string): seq[Reaction] = |
| 12 | 12 | ## The server's tally of what is already on a message, as |
| @@ -6,7 +6,7 @@ | |||
| 6 | ## its map happens to preserve insertion for small maps. | 6 | ## its map happens to preserve insertion for small maps. |
| 7 | 7 | ||
| 8 | import std/[strutils, tables] | 8 | import std/[strutils, tables] |
| 9 | -import model | 9 | +import frq/[model] |
| 10 | 10 | ||
| 11 | func parseTally*(encoded: string): seq[Reaction] = | 11 | func parseTally*(encoded: string): seq[Reaction] = |
| 12 | ## The server's tally of what is already on a message, as | 12 | ## The server's tally of what is already on a message, as |
added
nim/src/frq/reducer.nim +373 -0 | new file mode 100644 | ||
| @@ -0,0 +1,373 @@ | ||
| 1 | +## Every event the screens can send, and what it does to the state. | |
| 2 | +## | |
| 3 | +## This is `common/frq/actions.cljc` and the reducers scattered through | |
| 4 | +## `frq.main` in one place. In the Clojure `frq.actions` is a table of | |
| 5 | +## closures the host installs, and it is a table precisely because the screens | |
| 6 | +## are compiled separately from the thing that answers them. Here they are the | |
| 7 | +## same program, so it is a case statement. | |
| 8 | +## | |
| 9 | +## Event ids are strings with a `:`-separated argument, because that is what | |
| 10 | +## crosses the FFI in a prop. `room.open:#test` rather than a structured | |
| 11 | +## payload: the alternative is a second serialisation to define and version, | |
| 12 | +## for arguments that are always one string. | |
| 13 | + | |
| 14 | +import std/[json, options, sequtils, strutils, tables] | |
| 15 | +import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock] | |
| 16 | +import frq/conn as tr | |
| 17 | + | |
| 18 | +proc split2(id: string): (string, string) = | |
| 19 | + ## `"room.open:#test"` → `("room.open", "#test")`. The argument may itself | |
| 20 | + ## contain colons — a reaction event carries an emoji and an id — so only | |
| 21 | + ## the first is a separator. | |
| 22 | + let i = id.find(':') | |
| 23 | + if i < 0: (id, "") else: (id[0 ..< i], id[i + 1 .. ^1]) | |
| 24 | + | |
| 25 | +proc setError(msg: string) = | |
| 26 | + app.error = msg | |
| 27 | + app.hasError = true | |
| 28 | + | |
| 29 | +proc send(line: string) = | |
| 30 | + trace("out", line) | |
| 31 | + tr.send(line) | |
| 32 | + | |
| 33 | +proc openRoom(name: string) = | |
| 34 | + app.rooms.ensureRoom(name) | |
| 35 | + var r = app.rooms[name] | |
| 36 | + r.accessed = nowMs() | |
| 37 | + app.rooms[name] = r | |
| 38 | + app.current = name | |
| 39 | + app.screen = scChat | |
| 40 | + app.atPresent = true | |
| 41 | + # Opening a room is reading it: the marker moves to the newest line here. | |
| 42 | + app.rooms[name] = app.rooms[name].markRead | |
| 43 | + | |
| 44 | +proc connectNow() = | |
| 45 | + if app.formHost.strip().len == 0: | |
| 46 | + setError("A server is required."); return | |
| 47 | + if app.formNick.strip().len == 0: | |
| 48 | + setError("A nickname is required."); return | |
| 49 | + app.connecting = true | |
| 50 | + app.hasError = false | |
| 51 | + app.status = "Connecting to " & app.formHost & ":" & app.formPort & | |
| 52 | + (if app.formTls: " over TLS" else: "") & "…" | |
| 53 | + let port = try: parseInt(app.formPort.strip()) | |
| 54 | + except ValueError: (if app.formTls: 6697 else: 6667) | |
| 55 | + tr.open(tr.ConnConfig(host: app.formHost.strip(), port: port, | |
| 56 | + tls: app.formTls)) | |
| 57 | + | |
| 58 | +proc sendDraft() = | |
| 59 | + let text = app.draft.strip() | |
| 60 | + if text.len == 0 or app.current.len == 0: return | |
| 61 | + | |
| 62 | + if app.editing.has: | |
| 63 | + # An edit is a fresh PRIVMSG tagged with what it replaces; the server | |
| 64 | + # rewrites the original and echoes the revision back. | |
| 65 | + send("@+draft/edit=" & app.editing.id & " PRIVMSG " & app.current & | |
| 66 | + " :" & text) | |
| 67 | + app.editing = EditTarget() | |
| 68 | + elif app.replyingTo.has: | |
| 69 | + send("@+draft/reply=" & app.replyingTo.id & " PRIVMSG " & app.current & | |
| 70 | + " :" & text) | |
| 71 | + app.replyingTo = ReplyTarget() | |
| 72 | + else: | |
| 73 | + send("PRIVMSG " & app.current & " :" & text) | |
| 74 | + | |
| 75 | + # Echoed locally, because the server does not send your own PRIVMSG back | |
| 76 | + # unless echo-message was negotiated — and every client that forgets this | |
| 77 | + # looks like it dropped the message. | |
| 78 | + var r = app.rooms[app.current] | |
| 79 | + var m = Message(frm: app.formNick, text: text, at: nowMs(), | |
| 80 | + localId: "local-" & $r.messages.len, pending: true) | |
| 81 | + m.imageUrl = app.attachment.url | |
| 82 | + r.messages.add m | |
| 83 | + app.rooms[app.current] = r.markRead | |
| 84 | + app.draft = "" | |
| 85 | + app.attachment = Attachment() | |
| 86 | + | |
| 87 | +proc dispatch*(event: JsonNode) = | |
| 88 | + let raw = event{"id"}.getStr() | |
| 89 | + let value = event{"value"}.getStr() | |
| 90 | + let (id, arg) = split2(raw) | |
| 91 | + | |
| 92 | + traced "event": "→ " & raw & | |
| 93 | + (if value.len > 0: " value=" & value.escape else: "") | |
| 94 | + | |
| 95 | + case id | |
| 96 | + # ------------------------------------------------------------ the form | |
| 97 | + of "mode.guest": app.authMode = amGuest | |
| 98 | + of "mode.bluesky": app.authMode = amBluesky | |
| 99 | + of "mode.app-password": app.authMode = amAppPassword | |
| 100 | + | |
| 101 | + of "host.change": app.formHost = value | |
| 102 | + of "port.change": app.formPort = value | |
| 103 | + of "nick.change": app.formNick = value | |
| 104 | + of "handle.change": app.formHandle = value | |
| 105 | + of "app-password.change": app.formAppPassword = value | |
| 106 | + | |
| 107 | + of "tls.toggle": | |
| 108 | + app.formTls = not app.formTls | |
| 109 | + # The port follows the tick, as the Clojure's :on-toggled does. | |
| 110 | + app.formPort = if app.formTls: "6697" else: "6667" | |
| 111 | + | |
| 112 | + of "session.forget": | |
| 113 | + app.brokerToken = "" | |
| 114 | + app.status = "Saved session forgotten." | |
| 115 | + | |
| 116 | + # --------------------------------------------------------- the connection | |
| 117 | + of "connect": connectNow() | |
| 118 | + | |
| 119 | + of "cancel", "disconnect": | |
| 120 | + tr.close() | |
| 121 | + app.connecting = false | |
| 122 | + app.screen = scConnect | |
| 123 | + app.status = "Not connected" | |
| 124 | + | |
| 125 | + of "error.dismiss": | |
| 126 | + app.error = "" | |
| 127 | + app.hasError = false | |
| 128 | + | |
| 129 | + # ------------------------------------------------------------- navigation | |
| 130 | + of "screen.connect": app.screen = scConnect | |
| 131 | + of "screen.chats": app.screen = scChats | |
| 132 | + of "screen.discover": app.screen = scDiscover | |
| 133 | + of "screen.settings": app.screen = scSettings | |
| 134 | + | |
| 135 | + of "room.open": openRoom(arg) | |
| 136 | + | |
| 137 | + of "room.join": | |
| 138 | + if arg.len > 0: | |
| 139 | + app.rooms.ensureRoom(arg) | |
| 140 | + var r = app.rooms[arg] | |
| 141 | + r.joining = true | |
| 142 | + app.rooms[arg] = r | |
| 143 | + send("JOIN " & arg) | |
| 144 | + openRoom(arg) | |
| 145 | + | |
| 146 | + of "room.leave": | |
| 147 | + if app.rooms.hasKey(arg): | |
| 148 | + if not dm(arg): send("PART " & arg) | |
| 149 | + app.rooms.del(arg) | |
| 150 | + if app.current == arg: | |
| 151 | + app.current = "" | |
| 152 | + app.screen = scChats | |
| 153 | + | |
| 154 | + of "join": | |
| 155 | + # `@nick` opens a DM, which needs no JOIN — there is nothing to be in. | |
| 156 | + let want = app.joinInput.strip() | |
| 157 | + if want.len > 0: | |
| 158 | + if want.startsWith("@"): | |
| 159 | + openRoom(want[1 .. ^1]) | |
| 160 | + else: | |
| 161 | + let name = if want.startsWith("#"): want else: "#" & want | |
| 162 | + app.rooms.ensureRoom(name) | |
| 163 | + send("JOIN " & name) | |
| 164 | + openRoom(name) | |
| 165 | + app.joinInput = "" | |
| 166 | + | |
| 167 | + of "join-input.change": app.joinInput = value | |
| 168 | + of "search.change": app.search = value | |
| 169 | + of "search.clear": app.search = "" | |
| 170 | + | |
| 171 | + # ---------------------------------------------------------- chat chrome | |
| 172 | + of "chat-list.toggle": app.hideChatList = not app.hideChatList | |
| 173 | + of "users.toggle": app.showUsers = not app.showUsers | |
| 174 | + of "overview.toggle": app.overview = not app.overview | |
| 175 | + of "join-part.toggle": app.hideJoinPart = not app.hideJoinPart | |
| 176 | + of "jump.present": | |
| 177 | + app.atPresent = true | |
| 178 | + app.jumpTick += 1 | |
| 179 | + | |
| 180 | + # ------------------------------------------------------------ the compose | |
| 181 | + of "draft.change": app.draft = value | |
| 182 | + of "send": sendDraft() | |
| 183 | + | |
| 184 | + of "reply.to": | |
| 185 | + let m = app.currentRoom.messageById(arg) | |
| 186 | + if m.isSome: | |
| 187 | + app.replyingTo = ReplyTarget(has: true, id: arg, frm: m.get.frm, | |
| 188 | + text: m.get.text) | |
| 189 | + app.editing = EditTarget() | |
| 190 | + of "reply.cancel": app.replyingTo = ReplyTarget() | |
| 191 | + | |
| 192 | + of "edit.start": | |
| 193 | + let m = app.currentRoom.messageById(arg) | |
| 194 | + if m.isSome and m.get.frm == app.formNick: | |
| 195 | + app.editing = EditTarget(has: true, room: app.current, id: arg) | |
| 196 | + app.replyingTo = ReplyTarget() | |
| 197 | + # The wording goes into the box: what is being rewritten is what the | |
| 198 | + # reader edits, not an empty field. | |
| 199 | + app.draft = m.get.text | |
| 200 | + of "edit.cancel": | |
| 201 | + app.editing = EditTarget() | |
| 202 | + app.draft = "" | |
| 203 | + | |
| 204 | + of "attachment.clear": app.attachment = Attachment() | |
| 205 | + | |
| 206 | + # -------------------------------------------------------------- reactions | |
| 207 | + of "react.open": | |
| 208 | + app.reacting = ReactTarget(has: true, room: app.current, id: arg) | |
| 209 | + of "react.close": app.reacting = ReactTarget() | |
| 210 | + | |
| 211 | + of "react.toggle": | |
| 212 | + # `id:emoji`, and the emoji may contain nothing colon-like so one more | |
| 213 | + # split is enough. | |
| 214 | + let (mid, emoji) = split2(arg) | |
| 215 | + if mid.len > 0 and emoji.len > 0: | |
| 216 | + let m = app.currentRoom.messageById(mid) | |
| 217 | + let on = if m.isSome: not m.get.mine(emoji, app.formNick) else: true | |
| 218 | + send("@+draft/react=" & emoji & ";+draft/reply=" & mid & | |
| 219 | + " TAGMSG " & app.current) | |
| 220 | + app.rooms.updateReaction(app.current, mid, emoji, app.formNick, on) | |
| 221 | + | |
| 222 | + of "goto": | |
| 223 | + app.jumpTo = arg | |
| 224 | + app.highlight = arg | |
| 225 | + | |
| 226 | + of "lightbox": | |
| 227 | + app.lightbox = Lightbox(has: true, url: arg, path: arg) | |
| 228 | + of "lightbox.close": app.lightbox = Lightbox() | |
| 229 | + | |
| 230 | + of "quit": discard # the host's business; the tree only says it was asked | |
| 231 | + | |
| 232 | + else: | |
| 233 | + trace("event", "!! no handler for " & raw.escape & " — ignored") | |
| 234 | + | |
| 235 | +# ------------------------------------------------------------------- drain | |
| 236 | +# | |
| 237 | +# The socket thread's output, turned into state. Called before a render, so | |
| 238 | +# the tree the renderer gets is built after every line that had arrived when | |
| 239 | +# it asked. | |
| 240 | + | |
| 241 | +proc note(room: string, m: Message) = | |
| 242 | + app.rooms.ensureRoom(room) | |
| 243 | + var r = app.rooms[room] | |
| 244 | + if seenMessage(r.messages, m.id, m.frm, m.text, app.formNick): return | |
| 245 | + r.messages.add m | |
| 246 | + r.lastActivity = nowMs() | |
| 247 | + app.rooms[room] = r.recount(app.formNick) | |
| 248 | + | |
| 249 | +proc drain*() = | |
| 250 | + while true: | |
| 251 | + let (ok, e) = tr.tryEvent() | |
| 252 | + if not ok: break | |
| 253 | + trace("status", e) | |
| 254 | + if e == "open": | |
| 255 | + # The client speaks first in IRC. CAP before registration, the order the | |
| 256 | + # server expects and the order `frq.main` used. | |
| 257 | + # | |
| 258 | + # No SASL yet: this registers as a guest. The Bluesky handshake is | |
| 259 | + # `frq.irc.handshake` and has not been ported, so the two signed-in | |
| 260 | + # modes on the connect screen reach this point and land as guests — | |
| 261 | + # which the connect screen does not yet say, and should. | |
| 262 | + send("CAP LS 302") | |
| 263 | + send("NICK " & app.formNick) | |
| 264 | + send("USER " & app.formNick & " 0 * :frq") | |
| 265 | + app.status = "Registering…" | |
| 266 | + elif e.startsWith("error:"): | |
| 267 | + app.connecting = false | |
| 268 | + setError(e[6 .. ^1].strip()) | |
| 269 | + app.status = "Not connected" | |
| 270 | + elif e.startsWith("close:"): | |
| 271 | + app.connecting = false | |
| 272 | + app.status = "Disconnected" | |
| 273 | + | |
| 274 | + while true: | |
| 275 | + let (ok, line) = tr.tryLine() | |
| 276 | + if not ok: break | |
| 277 | + let p = parseLine(line) | |
| 278 | + | |
| 279 | + # PING is the transport's housekeeping and the screens have no opinion. | |
| 280 | + if p.command == "PING": | |
| 281 | + send("PONG :" & (if p.params.len > 0: p.params[^1] else: "")) | |
| 282 | + continue | |
| 283 | + | |
| 284 | + let (tagMs, hasTime) = parseTimeTag(p.tags) | |
| 285 | + let at = if hasTime: tagMs else: nowMs() | |
| 286 | + let msgid = block: | |
| 287 | + let (v, ok2) = tagValue(p.tags, "msgid") | |
| 288 | + if ok2: v else: "" | |
| 289 | + | |
| 290 | + case p.command | |
| 291 | + of "CAP": | |
| 292 | + # Nothing is requested yet — no SASL, no message-tags of our own — so | |
| 293 | + # the negotiation is ended immediately. A CAP LS with no END leaves the | |
| 294 | + # server waiting and registration never completes. | |
| 295 | + if p.params.len >= 2 and p.params[1] == "LS": | |
| 296 | + send("CAP END") | |
| 297 | + | |
| 298 | + of "001": | |
| 299 | + app.connecting = false | |
| 300 | + app.status = "Connected as " & app.formNick | |
| 301 | + app.screen = scChats | |
| 302 | + send("JOIN #test") | |
| 303 | + | |
| 304 | + of "PRIVMSG": | |
| 305 | + if p.params.len >= 2: | |
| 306 | + let target = p.params[0] | |
| 307 | + let who = nickOf(p.prefix) | |
| 308 | + # A message to us rather than to a channel belongs in a buffer named | |
| 309 | + # for the sender: the target is our own nick and is nobody's room. | |
| 310 | + let room = if target.startsWith("#"): target else: who | |
| 311 | + var m = Message(id: msgid, frm: who, text: p.params[^1], at: at) | |
| 312 | + m.imageUrl = "" | |
| 313 | + let (rep, hasRep) = tagValue(p.tags, "+reply") | |
| 314 | + if hasRep: m.replyTo = rep | |
| 315 | + let (tally, hasTally) = tagValue(p.tags, "+freeq.at/reacts") | |
| 316 | + if hasTally: m.reactions = parseTally(tally) | |
| 317 | + note(room, m) | |
| 318 | + | |
| 319 | + of "JOIN": | |
| 320 | + if p.params.len >= 1: | |
| 321 | + let room = p.params[0] | |
| 322 | + app.rooms.ensureRoom(room) | |
| 323 | + var r = app.rooms[room] | |
| 324 | + let who = nickOf(p.prefix) | |
| 325 | + if who == app.formNick: | |
| 326 | + r.joined = true | |
| 327 | + r.joining = false | |
| 328 | + elif who notin r.users: | |
| 329 | + r.users.add who | |
| 330 | + app.rooms[room] = r | |
| 331 | + note(room, Message(frm: "*", text: who & " joined " & room, | |
| 332 | + at: at, system: true)) | |
| 333 | + | |
| 334 | + of "PART", "QUIT": | |
| 335 | + let who = nickOf(p.prefix) | |
| 336 | + let room = if p.params.len >= 1: p.params[0] else: app.current | |
| 337 | + if app.rooms.hasKey(room): | |
| 338 | + var r = app.rooms[room] | |
| 339 | + r.users = r.users.filterIt(it != who) | |
| 340 | + app.rooms[room] = r | |
| 341 | + note(room, Message(frm: "*", text: who & " left", at: at, | |
| 342 | + system: true)) | |
| 343 | + | |
| 344 | + of "353": | |
| 345 | + # NAMES: the membership, as a space-separated list in the trailing. | |
| 346 | + if p.params.len >= 2: | |
| 347 | + let room = p.params[^2] | |
| 348 | + if app.rooms.hasKey(room): | |
| 349 | + var r = app.rooms[room] | |
| 350 | + for u in p.params[^1].split(' '): | |
| 351 | + let nick = u.strip(chars = {'@', '+', '~', '&', '%', ' '}) | |
| 352 | + if nick.len > 0 and nick notin r.users: r.users.add nick | |
| 353 | + app.rooms[room] = r | |
| 354 | + | |
| 355 | + of "332": | |
| 356 | + if p.params.len >= 2 and app.rooms.hasKey(p.params[^2]): | |
| 357 | + var r = app.rooms[p.params[^2]] | |
| 358 | + r.topic = p.params[^1] | |
| 359 | + app.rooms[p.params[^2]] = r | |
| 360 | + | |
| 361 | + of "NOTICE": | |
| 362 | + if p.params.len >= 2: | |
| 363 | + note(if app.current.len > 0: app.current else: "#test", | |
| 364 | + Message(frm: "notice", text: p.params[^1], at: at, system: true)) | |
| 365 | + | |
| 366 | + of "432", "433", "436": | |
| 367 | + # Nickname refused — the likeliest way a guest connect fails and the | |
| 368 | + # least obvious, so it is named rather than shown as a numeric. | |
| 369 | + setError("That nickname is taken or invalid.") | |
| 370 | + app.connecting = false | |
| 371 | + | |
| 372 | + else: | |
| 373 | + trace("skip", p.command & " " & $p.params) | |
| new file mode 100644 | |||
| @@ -0,0 +1,373 @@ | |||
| 1 | +## Every event the screens can send, and what it does to the state. | ||
| 2 | +## | ||
| 3 | +## This is `common/frq/actions.cljc` and the reducers scattered through | ||
| 4 | +## `frq.main` in one place. In the Clojure `frq.actions` is a table of | ||
| 5 | +## closures the host installs, and it is a table precisely because the screens | ||
| 6 | +## are compiled separately from the thing that answers them. Here they are the | ||
| 7 | +## same program, so it is a case statement. | ||
| 8 | +## | ||
| 9 | +## Event ids are strings with a `:`-separated argument, because that is what | ||
| 10 | +## crosses the FFI in a prop. `room.open:#test` rather than a structured | ||
| 11 | +## payload: the alternative is a second serialisation to define and version, | ||
| 12 | +## for arguments that are always one string. | ||
| 13 | + | ||
| 14 | +import std/[json, options, sequtils, strutils, tables] | ||
| 15 | +import frq/[cells, model, rooms, reactions, edits, trace, ircparse, clock] | ||
| 16 | +import frq/conn as tr | ||
| 17 | + | ||
| 18 | +proc split2(id: string): (string, string) = | ||
| 19 | + ## `"room.open:#test"` → `("room.open", "#test")`. The argument may itself | ||
| 20 | + ## contain colons — a reaction event carries an emoji and an id — so only | ||
| 21 | + ## the first is a separator. | ||
| 22 | + let i = id.find(':') | ||
| 23 | + if i < 0: (id, "") else: (id[0 ..< i], id[i + 1 .. ^1]) | ||
| 24 | + | ||
| 25 | +proc setError(msg: string) = | ||
| 26 | + app.error = msg | ||
| 27 | + app.hasError = true | ||
| 28 | + | ||
| 29 | +proc send(line: string) = | ||
| 30 | + trace("out", line) | ||
| 31 | + tr.send(line) | ||
| 32 | + | ||
| 33 | +proc openRoom(name: string) = | ||
| 34 | + app.rooms.ensureRoom(name) | ||
| 35 | + var r = app.rooms[name] | ||
| 36 | + r.accessed = nowMs() | ||
| 37 | + app.rooms[name] = r | ||
| 38 | + app.current = name | ||
| 39 | + app.screen = scChat | ||
| 40 | + app.atPresent = true | ||
| 41 | + # Opening a room is reading it: the marker moves to the newest line here. | ||
| 42 | + app.rooms[name] = app.rooms[name].markRead | ||
| 43 | + | ||
| 44 | +proc connectNow() = | ||
| 45 | + if app.formHost.strip().len == 0: | ||
| 46 | + setError("A server is required."); return | ||
| 47 | + if app.formNick.strip().len == 0: | ||
| 48 | + setError("A nickname is required."); return | ||
| 49 | + app.connecting = true | ||
| 50 | + app.hasError = false | ||
| 51 | + app.status = "Connecting to " & app.formHost & ":" & app.formPort & | ||
| 52 | + (if app.formTls: " over TLS" else: "") & "…" | ||
| 53 | + let port = try: parseInt(app.formPort.strip()) | ||
| 54 | + except ValueError: (if app.formTls: 6697 else: 6667) | ||
| 55 | + tr.open(tr.ConnConfig(host: app.formHost.strip(), port: port, | ||
| 56 | + tls: app.formTls)) | ||
| 57 | + | ||
| 58 | +proc sendDraft() = | ||
| 59 | + let text = app.draft.strip() | ||
| 60 | + if text.len == 0 or app.current.len == 0: return | ||
| 61 | + | ||
| 62 | + if app.editing.has: | ||
| 63 | + # An edit is a fresh PRIVMSG tagged with what it replaces; the server | ||
| 64 | + # rewrites the original and echoes the revision back. | ||
| 65 | + send("@+draft/edit=" & app.editing.id & " PRIVMSG " & app.current & | ||
| 66 | + " :" & text) | ||
| 67 | + app.editing = EditTarget() | ||
| 68 | + elif app.replyingTo.has: | ||
| 69 | + send("@+draft/reply=" & app.replyingTo.id & " PRIVMSG " & app.current & | ||
| 70 | + " :" & text) | ||
| 71 | + app.replyingTo = ReplyTarget() | ||
| 72 | + else: | ||
| 73 | + send("PRIVMSG " & app.current & " :" & text) | ||
| 74 | + | ||
| 75 | + # Echoed locally, because the server does not send your own PRIVMSG back | ||
| 76 | + # unless echo-message was negotiated — and every client that forgets this | ||
| 77 | + # looks like it dropped the message. | ||
| 78 | + var r = app.rooms[app.current] | ||
| 79 | + var m = Message(frm: app.formNick, text: text, at: nowMs(), | ||
| 80 | + localId: "local-" & $r.messages.len, pending: true) | ||
| 81 | + m.imageUrl = app.attachment.url | ||
| 82 | + r.messages.add m | ||
| 83 | + app.rooms[app.current] = r.markRead | ||
| 84 | + app.draft = "" | ||
| 85 | + app.attachment = Attachment() | ||
| 86 | + | ||
| 87 | +proc dispatch*(event: JsonNode) = | ||
| 88 | + let raw = event{"id"}.getStr() | ||
| 89 | + let value = event{"value"}.getStr() | ||
| 90 | + let (id, arg) = split2(raw) | ||
| 91 | + | ||
| 92 | + traced "event": "→ " & raw & | ||
| 93 | + (if value.len > 0: " value=" & value.escape else: "") | ||
| 94 | + | ||
| 95 | + case id | ||
| 96 | + # ------------------------------------------------------------ the form | ||
| 97 | + of "mode.guest": app.authMode = amGuest | ||
| 98 | + of "mode.bluesky": app.authMode = amBluesky | ||
| 99 | + of "mode.app-password": app.authMode = amAppPassword | ||
| 100 | + | ||
| 101 | + of "host.change": app.formHost = value | ||
| 102 | + of "port.change": app.formPort = value | ||
| 103 | + of "nick.change": app.formNick = value | ||
| 104 | + of "handle.change": app.formHandle = value | ||
| 105 | + of "app-password.change": app.formAppPassword = value | ||
| 106 | + | ||
| 107 | + of "tls.toggle": | ||
| 108 | + app.formTls = not app.formTls | ||
| 109 | + # The port follows the tick, as the Clojure's :on-toggled does. | ||
| 110 | + app.formPort = if app.formTls: "6697" else: "6667" | ||
| 111 | + | ||
| 112 | + of "session.forget": | ||
| 113 | + app.brokerToken = "" | ||
| 114 | + app.status = "Saved session forgotten." | ||
| 115 | + | ||
| 116 | + # --------------------------------------------------------- the connection | ||
| 117 | + of "connect": connectNow() | ||
| 118 | + | ||
| 119 | + of "cancel", "disconnect": | ||
| 120 | + tr.close() | ||
| 121 | + app.connecting = false | ||
| 122 | + app.screen = scConnect | ||
| 123 | + app.status = "Not connected" | ||
| 124 | + | ||
| 125 | + of "error.dismiss": | ||
| 126 | + app.error = "" | ||
| 127 | + app.hasError = false | ||
| 128 | + | ||
| 129 | + # ------------------------------------------------------------- navigation | ||
| 130 | + of "screen.connect": app.screen = scConnect | ||
| 131 | + of "screen.chats": app.screen = scChats | ||
| 132 | + of "screen.discover": app.screen = scDiscover | ||
| 133 | + of "screen.settings": app.screen = scSettings | ||
| 134 | + | ||
| 135 | + of "room.open": openRoom(arg) | ||
| 136 | + | ||
| 137 | + of "room.join": | ||
| 138 | + if arg.len > 0: | ||
| 139 | + app.rooms.ensureRoom(arg) | ||
| 140 | + var r = app.rooms[arg] | ||
| 141 | + r.joining = true | ||
| 142 | + app.rooms[arg] = r | ||
| 143 | + send("JOIN " & arg) | ||
| 144 | + openRoom(arg) | ||
| 145 | + | ||
| 146 | + of "room.leave": | ||
| 147 | + if app.rooms.hasKey(arg): | ||
| 148 | + if not dm(arg): send("PART " & arg) | ||
| 149 | + app.rooms.del(arg) | ||
| 150 | + if app.current == arg: | ||
| 151 | + app.current = "" | ||
| 152 | + app.screen = scChats | ||
| 153 | + | ||
| 154 | + of "join": | ||
| 155 | + # `@nick` opens a DM, which needs no JOIN — there is nothing to be in. | ||
| 156 | + let want = app.joinInput.strip() | ||
| 157 | + if want.len > 0: | ||
| 158 | + if want.startsWith("@"): | ||
| 159 | + openRoom(want[1 .. ^1]) | ||
| 160 | + else: | ||
| 161 | + let name = if want.startsWith("#"): want else: "#" & want | ||
| 162 | + app.rooms.ensureRoom(name) | ||
| 163 | + send("JOIN " & name) | ||
| 164 | + openRoom(name) | ||
| 165 | + app.joinInput = "" | ||
| 166 | + | ||
| 167 | + of "join-input.change": app.joinInput = value | ||
| 168 | + of "search.change": app.search = value | ||
| 169 | + of "search.clear": app.search = "" | ||
| 170 | + | ||
| 171 | + # ---------------------------------------------------------- chat chrome | ||
| 172 | + of "chat-list.toggle": app.hideChatList = not app.hideChatList | ||
| 173 | + of "users.toggle": app.showUsers = not app.showUsers | ||
| 174 | + of "overview.toggle": app.overview = not app.overview | ||
| 175 | + of "join-part.toggle": app.hideJoinPart = not app.hideJoinPart | ||
| 176 | + of "jump.present": | ||
| 177 | + app.atPresent = true | ||
| 178 | + app.jumpTick += 1 | ||
| 179 | + | ||
| 180 | + # ------------------------------------------------------------ the compose | ||
| 181 | + of "draft.change": app.draft = value | ||
| 182 | + of "send": sendDraft() | ||
| 183 | + | ||
| 184 | + of "reply.to": | ||
| 185 | + let m = app.currentRoom.messageById(arg) | ||
| 186 | + if m.isSome: | ||
| 187 | + app.replyingTo = ReplyTarget(has: true, id: arg, frm: m.get.frm, | ||
| 188 | + text: m.get.text) | ||
| 189 | + app.editing = EditTarget() | ||
| 190 | + of "reply.cancel": app.replyingTo = ReplyTarget() | ||
| 191 | + | ||
| 192 | + of "edit.start": | ||
| 193 | + let m = app.currentRoom.messageById(arg) | ||
| 194 | + if m.isSome and m.get.frm == app.formNick: | ||
| 195 | + app.editing = EditTarget(has: true, room: app.current, id: arg) | ||
| 196 | + app.replyingTo = ReplyTarget() | ||
| 197 | + # The wording goes into the box: what is being rewritten is what the | ||
| 198 | + # reader edits, not an empty field. | ||
| 199 | + app.draft = m.get.text | ||
| 200 | + of "edit.cancel": | ||
| 201 | + app.editing = EditTarget() | ||
| 202 | + app.draft = "" | ||
| 203 | + | ||
| 204 | + of "attachment.clear": app.attachment = Attachment() | ||
| 205 | + | ||
| 206 | + # -------------------------------------------------------------- reactions | ||
| 207 | + of "react.open": | ||
| 208 | + app.reacting = ReactTarget(has: true, room: app.current, id: arg) | ||
| 209 | + of "react.close": app.reacting = ReactTarget() | ||
| 210 | + | ||
| 211 | + of "react.toggle": | ||
| 212 | + # `id:emoji`, and the emoji may contain nothing colon-like so one more | ||
| 213 | + # split is enough. | ||
| 214 | + let (mid, emoji) = split2(arg) | ||
| 215 | + if mid.len > 0 and emoji.len > 0: | ||
| 216 | + let m = app.currentRoom.messageById(mid) | ||
| 217 | + let on = if m.isSome: not m.get.mine(emoji, app.formNick) else: true | ||
| 218 | + send("@+draft/react=" & emoji & ";+draft/reply=" & mid & | ||
| 219 | + " TAGMSG " & app.current) | ||
| 220 | + app.rooms.updateReaction(app.current, mid, emoji, app.formNick, on) | ||
| 221 | + | ||
| 222 | + of "goto": | ||
| 223 | + app.jumpTo = arg | ||
| 224 | + app.highlight = arg | ||
| 225 | + | ||
| 226 | + of "lightbox": | ||
| 227 | + app.lightbox = Lightbox(has: true, url: arg, path: arg) | ||
| 228 | + of "lightbox.close": app.lightbox = Lightbox() | ||
| 229 | + | ||
| 230 | + of "quit": discard # the host's business; the tree only says it was asked | ||
| 231 | + | ||
| 232 | + else: | ||
| 233 | + trace("event", "!! no handler for " & raw.escape & " — ignored") | ||
| 234 | + | ||
| 235 | +# ------------------------------------------------------------------- drain | ||
| 236 | +# | ||
| 237 | +# The socket thread's output, turned into state. Called before a render, so | ||
| 238 | +# the tree the renderer gets is built after every line that had arrived when | ||
| 239 | +# it asked. | ||
| 240 | + | ||
| 241 | +proc note(room: string, m: Message) = | ||
| 242 | + app.rooms.ensureRoom(room) | ||
| 243 | + var r = app.rooms[room] | ||
| 244 | + if seenMessage(r.messages, m.id, m.frm, m.text, app.formNick): return | ||
| 245 | + r.messages.add m | ||
| 246 | + r.lastActivity = nowMs() | ||
| 247 | + app.rooms[room] = r.recount(app.formNick) | ||
| 248 | + | ||
| 249 | +proc drain*() = | ||
| 250 | + while true: | ||
| 251 | + let (ok, e) = tr.tryEvent() | ||
| 252 | + if not ok: break | ||
| 253 | + trace("status", e) | ||
| 254 | + if e == "open": | ||
| 255 | + # The client speaks first in IRC. CAP before registration, the order the | ||
| 256 | + # server expects and the order `frq.main` used. | ||
| 257 | + # | ||
| 258 | + # No SASL yet: this registers as a guest. The Bluesky handshake is | ||
| 259 | + # `frq.irc.handshake` and has not been ported, so the two signed-in | ||
| 260 | + # modes on the connect screen reach this point and land as guests — | ||
| 261 | + # which the connect screen does not yet say, and should. | ||
| 262 | + send("CAP LS 302") | ||
| 263 | + send("NICK " & app.formNick) | ||
| 264 | + send("USER " & app.formNick & " 0 * :frq") | ||
| 265 | + app.status = "Registering…" | ||
| 266 | + elif e.startsWith("error:"): | ||
| 267 | + app.connecting = false | ||
| 268 | + setError(e[6 .. ^1].strip()) | ||
| 269 | + app.status = "Not connected" | ||
| 270 | + elif e.startsWith("close:"): | ||
| 271 | + app.connecting = false | ||
| 272 | + app.status = "Disconnected" | ||
| 273 | + | ||
| 274 | + while true: | ||
| 275 | + let (ok, line) = tr.tryLine() | ||
| 276 | + if not ok: break | ||
| 277 | + let p = parseLine(line) | ||
| 278 | + | ||
| 279 | + # PING is the transport's housekeeping and the screens have no opinion. | ||
| 280 | + if p.command == "PING": | ||
| 281 | + send("PONG :" & (if p.params.len > 0: p.params[^1] else: "")) | ||
| 282 | + continue | ||
| 283 | + | ||
| 284 | + let (tagMs, hasTime) = parseTimeTag(p.tags) | ||
| 285 | + let at = if hasTime: tagMs else: nowMs() | ||
| 286 | + let msgid = block: | ||
| 287 | + let (v, ok2) = tagValue(p.tags, "msgid") | ||
| 288 | + if ok2: v else: "" | ||
| 289 | + | ||
| 290 | + case p.command | ||
| 291 | + of "CAP": | ||
| 292 | + # Nothing is requested yet — no SASL, no message-tags of our own — so | ||
| 293 | + # the negotiation is ended immediately. A CAP LS with no END leaves the | ||
| 294 | + # server waiting and registration never completes. | ||
| 295 | + if p.params.len >= 2 and p.params[1] == "LS": | ||
| 296 | + send("CAP END") | ||
| 297 | + | ||
| 298 | + of "001": | ||
| 299 | + app.connecting = false | ||
| 300 | + app.status = "Connected as " & app.formNick | ||
| 301 | + app.screen = scChats | ||
| 302 | + send("JOIN #test") | ||
| 303 | + | ||
| 304 | + of "PRIVMSG": | ||
| 305 | + if p.params.len >= 2: | ||
| 306 | + let target = p.params[0] | ||
| 307 | + let who = nickOf(p.prefix) | ||
| 308 | + # A message to us rather than to a channel belongs in a buffer named | ||
| 309 | + # for the sender: the target is our own nick and is nobody's room. | ||
| 310 | + let room = if target.startsWith("#"): target else: who | ||
| 311 | + var m = Message(id: msgid, frm: who, text: p.params[^1], at: at) | ||
| 312 | + m.imageUrl = "" | ||
| 313 | + let (rep, hasRep) = tagValue(p.tags, "+reply") | ||
| 314 | + if hasRep: m.replyTo = rep | ||
| 315 | + let (tally, hasTally) = tagValue(p.tags, "+freeq.at/reacts") | ||
| 316 | + if hasTally: m.reactions = parseTally(tally) | ||
| 317 | + note(room, m) | ||
| 318 | + | ||
| 319 | + of "JOIN": | ||
| 320 | + if p.params.len >= 1: | ||
| 321 | + let room = p.params[0] | ||
| 322 | + app.rooms.ensureRoom(room) | ||
| 323 | + var r = app.rooms[room] | ||
| 324 | + let who = nickOf(p.prefix) | ||
| 325 | + if who == app.formNick: | ||
| 326 | + r.joined = true | ||
| 327 | + r.joining = false | ||
| 328 | + elif who notin r.users: | ||
| 329 | + r.users.add who | ||
| 330 | + app.rooms[room] = r | ||
| 331 | + note(room, Message(frm: "*", text: who & " joined " & room, | ||
| 332 | + at: at, system: true)) | ||
| 333 | + | ||
| 334 | + of "PART", "QUIT": | ||
| 335 | + let who = nickOf(p.prefix) | ||
| 336 | + let room = if p.params.len >= 1: p.params[0] else: app.current | ||
| 337 | + if app.rooms.hasKey(room): | ||
| 338 | + var r = app.rooms[room] | ||
| 339 | + r.users = r.users.filterIt(it != who) | ||
| 340 | + app.rooms[room] = r | ||
| 341 | + note(room, Message(frm: "*", text: who & " left", at: at, | ||
| 342 | + system: true)) | ||
| 343 | + | ||
| 344 | + of "353": | ||
| 345 | + # NAMES: the membership, as a space-separated list in the trailing. | ||
| 346 | + if p.params.len >= 2: | ||
| 347 | + let room = p.params[^2] | ||
| 348 | + if app.rooms.hasKey(room): | ||
| 349 | + var r = app.rooms[room] | ||
| 350 | + for u in p.params[^1].split(' '): | ||
| 351 | + let nick = u.strip(chars = {'@', '+', '~', '&', '%', ' '}) | ||
| 352 | + if nick.len > 0 and nick notin r.users: r.users.add nick | ||
| 353 | + app.rooms[room] = r | ||
| 354 | + | ||
| 355 | + of "332": | ||
| 356 | + if p.params.len >= 2 and app.rooms.hasKey(p.params[^2]): | ||
| 357 | + var r = app.rooms[p.params[^2]] | ||
| 358 | + r.topic = p.params[^1] | ||
| 359 | + app.rooms[p.params[^2]] = r | ||
| 360 | + | ||
| 361 | + of "NOTICE": | ||
| 362 | + if p.params.len >= 2: | ||
| 363 | + note(if app.current.len > 0: app.current else: "#test", | ||
| 364 | + Message(frm: "notice", text: p.params[^1], at: at, system: true)) | ||
| 365 | + | ||
| 366 | + of "432", "433", "436": | ||
| 367 | + # Nickname refused — the likeliest way a guest connect fails and the | ||
| 368 | + # least obvious, so it is named rather than shown as a numeric. | ||
| 369 | + setError("That nickname is taken or invalid.") | ||
| 370 | + app.connecting = false | ||
| 371 | + | ||
| 372 | + else: | ||
| 373 | + trace("skip", p.command & " " & $p.params) | ||
modified
nim/src/frq/rooms.nim +1 -1 | @@ -12,7 +12,7 @@ | ||
| 12 | 12 | ## older than it and counts for nothing. |
| 13 | 13 | |
| 14 | 14 | import std/[algorithm, sequtils, strutils, tables] |
| 15 | -import model, clock | |
| 15 | +import frq/[model, clock] | |
| 16 | 16 | |
| 17 | 17 | const |
| 18 | 18 | overviewLimit* = 100 |
| @@ -12,7 +12,7 @@ | |||
| 12 | ## older than it and counts for nothing. | 12 | ## older than it and counts for nothing. |
| 13 | 13 | ||
| 14 | import std/[algorithm, sequtils, strutils, tables] | 14 | import std/[algorithm, sequtils, strutils, tables] |
| 15 | -import model, clock | 15 | +import frq/[model, clock] |
| 16 | 16 | ||
| 17 | const | 17 | const |
| 18 | overviewLimit* = 100 | 18 | overviewLimit* = 100 |
modified
nim/src/frq/screens/chat.nim +2 -2 | @@ -15,8 +15,8 @@ | ||
| 15 | 15 | import std/[algorithm, json, strutils, tables] |
| 16 | 16 | from std/unicode import runeLen, runeSubStr |
| 17 | 17 | import std/options |
| 18 | -import ../ui, ../cells, ../model, ../clock, ../reactions, ../textruns | |
| 19 | -from connect import errorNote | |
| 18 | +import frq/[ui, cells, model, clock, reactions, textruns] | |
| 19 | +from frq/screens/connect import errorNote | |
| 20 | 20 | |
| 21 | 21 | const |
| 22 | 22 | faceSize = 32 |
| @@ -15,8 +15,8 @@ | |||
| 15 | import std/[algorithm, json, strutils, tables] | 15 | import std/[algorithm, json, strutils, tables] |
| 16 | from std/unicode import runeLen, runeSubStr | 16 | from std/unicode import runeLen, runeSubStr |
| 17 | import std/options | 17 | import std/options |
| 18 | -import ../ui, ../cells, ../model, ../clock, ../reactions, ../textruns | 18 | +import frq/[ui, cells, model, clock, reactions, textruns] |
| 19 | -from connect import errorNote | 19 | +from frq/screens/connect import errorNote |
| 20 | 20 | ||
| 21 | const | 21 | const |
| 22 | faceSize = 32 | 22 | faceSize = 32 |
modified
nim/src/frq/screens/chats.nim +2 -2 | @@ -9,8 +9,8 @@ import std/[json, strutils, tables] | ||
| 9 | 9 | # `title` that is ambiguous against `ui.title`, which is the one this file |
| 10 | 10 | # means every time it says it. |
| 11 | 11 | from std/unicode import runeLen, runeSubStr |
| 12 | -import ../ui, ../cells, ../model, ../rooms | |
| 13 | -import frame, connect | |
| 12 | +import frq/[ui, cells, model, rooms] | |
| 13 | +import frq/screens/[frame, connect] | |
| 14 | 14 | |
| 15 | 15 | const listGutter = 16 |
| 16 | 16 | ## The scrollbar's room. Without it the cards sit under it and the last |
| @@ -9,8 +9,8 @@ import std/[json, strutils, tables] | |||
| 9 | # `title` that is ambiguous against `ui.title`, which is the one this file | 9 | # `title` that is ambiguous against `ui.title`, which is the one this file |
| 10 | # means every time it says it. | 10 | # means every time it says it. |
| 11 | from std/unicode import runeLen, runeSubStr | 11 | from std/unicode import runeLen, runeSubStr |
| 12 | -import ../ui, ../cells, ../model, ../rooms | 12 | +import frq/[ui, cells, model, rooms] |
| 13 | -import frame, connect | 13 | +import frq/screens/[frame, connect] |
| 14 | 14 | ||
| 15 | const listGutter = 16 | 15 | const listGutter = 16 |
| 16 | ## The scrollbar's room. Without it the cards sit under it and the last | 16 | ## The scrollbar's room. Without it the cards sit under it and the last |
modified
nim/src/frq/screens/connect.nim +1 -1 | @@ -9,7 +9,7 @@ | ||
| 9 | 9 | ## systematic difference between the two files. |
| 10 | 10 | |
| 11 | 11 | import std/json |
| 12 | -import ../ui, ../cells | |
| 12 | +import frq/[ui, cells] | |
| 13 | 13 | |
| 14 | 14 | const transportNote* = |
| 15 | 15 | "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener." |
| @@ -9,7 +9,7 @@ | |||
| 9 | ## systematic difference between the two files. | 9 | ## systematic difference between the two files. |
| 10 | 10 | ||
| 11 | import std/json | 11 | import std/json |
| 12 | -import ../ui, ../cells | 12 | +import frq/[ui, cells] |
| 13 | 13 | ||
| 14 | const transportNote* = | 14 | const transportNote* = |
| 15 | "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener." | 15 | "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener." |
modified
nim/src/frq/screens/frame.nim +1 -1 | @@ -5,7 +5,7 @@ | ||
| 5 | 5 | ## and neither screen owns them. |
| 6 | 6 | |
| 7 | 7 | import std/json |
| 8 | -import ../ui, ../cells | |
| 8 | +import frq/[ui, cells] | |
| 9 | 9 | |
| 10 | 10 | func tabBar*(s: State): Node = |
| 11 | 11 | hbox(%*{"spacing": 8}, |
| @@ -5,7 +5,7 @@ | |||
| 5 | ## and neither screen owns them. | 5 | ## and neither screen owns them. |
| 6 | 6 | ||
| 7 | import std/json | 7 | import std/json |
| 8 | -import ../ui, ../cells | 8 | +import frq/[ui, cells] |
| 9 | 9 | ||
| 10 | func tabBar*(s: State): Node = | 10 | func tabBar*(s: State): Node = |
| 11 | hbox(%*{"spacing": 8}, | 11 | hbox(%*{"spacing": 8}, |
modified
nim/src/frq/screens/settings.nim +2 -2 | @@ -4,8 +4,8 @@ | ||
| 4 | 4 | ## `common/frq/screens/settings.cljc`. |
| 5 | 5 | |
| 6 | 6 | import std/[json, tables] |
| 7 | -import ../ui, ../cells, ../model | |
| 8 | -import frame, connect | |
| 7 | +import frq/[ui, cells, model] | |
| 8 | +import frq/screens/[frame, connect] | |
| 9 | 9 | |
| 10 | 10 | func discoverScreen*(s: State): Node = |
| 11 | 11 | var body = @[dimLabel("Popular channels on freeq."), errorNote(s)] |
| @@ -4,8 +4,8 @@ | |||
| 4 | ## `common/frq/screens/settings.cljc`. | 4 | ## `common/frq/screens/settings.cljc`. |
| 5 | 5 | ||
| 6 | import std/[json, tables] | 6 | import std/[json, tables] |
| 7 | -import ../ui, ../cells, ../model | 7 | +import frq/[ui, cells, model] |
| 8 | -import frame, connect | 8 | +import frq/screens/[frame, connect] |
| 9 | 9 | ||
| 10 | func discoverScreen*(s: State): Node = | 10 | func discoverScreen*(s: State): Node = |
| 11 | var body = @[dimLabel("Popular channels on freeq."), errorNote(s)] | 11 | var body = @[dimLabel("Popular channels on freeq."), errorNote(s)] |
modified
nim/src/frq_core.nim +74 -17 | @@ -20,26 +20,32 @@ | ||
| 20 | 20 | ## per call, which is nothing against the network round trip that produced the |
| 21 | 21 | ## line being parsed. |
| 22 | 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`. | |
| 28 | - | |
| 29 | -import std/json | |
| 30 | -import frq/[ircparse, trace] | |
| 31 | -import frq/conn as tr | |
| 32 | - | |
| 33 | -proc NimMain() {.importc.} | |
| 23 | +## The UI half of this ABI — `frq_ui_render` and `frq_ui_dispatch` — is Nim | |
| 24 | +## owning the screens as well as the rules. The screens under it are ported | |
| 25 | +## from `common/frq/screens/` rather than reimagined, which is the difference | |
| 26 | +## between this and the experiment that was deleted for being a facsimile. | |
| 34 | 27 | |
| 35 | -var initialised = false | |
| 28 | +import std/[json, strutils] | |
| 29 | +import frq/[ircparse, trace, ui, cells, reducer] | |
| 30 | +import frq/conn as tr | |
| 31 | +import frq/screens/connect as scConnectScreen | |
| 32 | +import frq/screens/chats as scChatsScreen | |
| 33 | +import frq/screens/chat as scChatScreen | |
| 34 | +import frq/screens/settings as scSettingsScreen | |
| 36 | 35 | |
| 37 | 36 | proc frq_init*() {.exportc, dynlib.} = |
| 38 | - ## Set Nim's runtime up. Idempotent, because a binding that guesses wrong | |
| 39 | - ## about whether it has been called should be harmless rather than fatal. | |
| 40 | - if not initialised: | |
| 41 | - NimMain() | |
| 42 | - initialised = true | |
| 37 | + ## Kept for the ABI, and deliberately empty. | |
| 38 | + ## | |
| 39 | + ## It used to call `NimMain()`. On Linux `--app:lib` already emits a library | |
| 40 | + ## constructor that runs Nim's module initialisers at dlopen, so calling it | |
| 41 | + ## again ran every module's top-level code a SECOND time — which for | |
| 42 | + ## `conn.nim` meant `outbound.open()` on channels that were already open, | |
| 43 | + ## quietly resetting them. The reader thread then drained a different queue | |
| 44 | + ## from the one the writer filled, and nothing this client sent ever left. | |
| 45 | + ## | |
| 46 | + ## Nothing to do here, then, but the symbol stays: a binding that calls it | |
| 47 | + ## should keep working, and one that does not should not have to care. | |
| 48 | + discard | |
| 43 | 49 | |
| 44 | 50 | proc dup(s: string): cstring = |
| 45 | 51 | ## A copy of `s` that outlives this call, for the caller to `frq_free`. |
| @@ -137,3 +143,54 @@ proc frq_conn_event*(): cstring {.exportc, dynlib.} = | ||
| 137 | 143 | ## The next transport event — "open", "close: …", "error: …" — or null. |
| 138 | 144 | let (ok, e) = tr.tryEvent() |
| 139 | 145 | if ok: dup(e) else: nil |
| 146 | + | |
| 147 | + | |
| 148 | +# ------------------------------------------------------------------- the UI | |
| 149 | +# | |
| 150 | +# Nim owns the state and the screens; Dart owns the pixels. The only things | |
| 151 | +# crossing are a tree going out and an event id coming back. | |
| 152 | + | |
| 153 | +proc currentTree(): string = | |
| 154 | + ## Whichever screen the state says. `drain` first, so the tree Dart gets is | |
| 155 | + ## built after every line that had arrived when it asked — that is the whole | |
| 156 | + ## of the polling model, and why there is no callback into Dart. | |
| 157 | + drain() | |
| 158 | + let connected = app.status.startsWith("Connected") | |
| 159 | + let node = | |
| 160 | + case app.screen | |
| 161 | + of scChat: scChatScreen.chatScreen(app, connected) | |
| 162 | + of scChats: scChatsScreen.chatsScreen(app, connected) | |
| 163 | + of scDiscover: scSettingsScreen.discoverScreen(app) | |
| 164 | + of scSettings: scSettingsScreen.settingsScreen(app, connected, true) | |
| 165 | + of scConnect: scConnectScreen.connectScreen(app) | |
| 166 | + $node.toJson | |
| 167 | + | |
| 168 | +proc frq_ui_render*(): cstring {.exportc, dynlib.} = | |
| 169 | + ## The current screen as a widget tree, in JSON. | |
| 170 | + ## | |
| 171 | + ## Not pure: it drains the socket's queue first, so two calls with no | |
| 172 | + ## dispatch between can differ when a line arrived in the gap. That is how | |
| 173 | + ## the room fills, and it is why the renderer polls. | |
| 174 | + dup(currentTree()) | |
| 175 | + | |
| 176 | +proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} = | |
| 177 | + ## Apply an event and answer with the tree it produced. | |
| 178 | + ## | |
| 179 | + ## One call rather than dispatch-then-render, and not to save a crossing: it | |
| 180 | + ## makes the pair atomic, so there is no window in which Dart could render a | |
| 181 | + ## state nothing asked for. | |
| 182 | + if event != nil: | |
| 183 | + try: | |
| 184 | + dispatch(parseJson($event)) | |
| 185 | + except JsonParsingError: | |
| 186 | + discard | |
| 187 | + dup(currentTree()) | |
| 188 | + | |
| 189 | +proc frq_ui_poll*(): cstring {.exportc, dynlib.} = | |
| 190 | + ## The tree, for a renderer asking because time passed rather than because | |
| 191 | + ## anything happened. Same work as render; named for what the caller means. | |
| 192 | + dup(currentTree()) | |
| 193 | + | |
| 194 | +proc frq_ui_reset*() {.exportc, dynlib.} = | |
| 195 | + tr.close() | |
| 196 | + app = initState() | |
| @@ -20,26 +20,32 @@ | |||
| 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 | ## | 22 | ## |
| 23 | -## There was a second half to this ABI once — `frq_ui_render`, `frq_ui_dispatch` | 23 | +## The UI half of this ABI — `frq_ui_render` and `frq_ui_dispatch` — is Nim |
| 24 | -## and a state machine and screens behind them — from an experiment where Nim | 24 | +## owning the screens as well as the rules. The screens under it are ported |
| 25 | -## owned the UI as well. It is gone: it meant reimplementing screens that | 25 | +## from `common/frq/screens/` rather than reimagined, which is the difference |
| 26 | -## already exist and are far better, and the seam that actually wanted Nim | 26 | +## between this and the experiment that was deleted for being a facsimile. |
| 27 | -## under it was `frq.net`. | ||
| 28 | - | ||
| 29 | -import std/json | ||
| 30 | -import frq/[ircparse, trace] | ||
| 31 | -import frq/conn as tr | ||
| 32 | - | ||
| 33 | -proc NimMain() {.importc.} | ||
| 34 | 27 | ||
| 35 | -var initialised = false | 28 | +import std/[json, strutils] |
| 29 | +import frq/[ircparse, trace, ui, cells, reducer] | ||
| 30 | +import frq/conn as tr | ||
| 31 | +import frq/screens/connect as scConnectScreen | ||
| 32 | +import frq/screens/chats as scChatsScreen | ||
| 33 | +import frq/screens/chat as scChatScreen | ||
| 34 | +import frq/screens/settings as scSettingsScreen | ||
| 36 | 35 | ||
| 37 | proc frq_init*() {.exportc, dynlib.} = | 36 | proc frq_init*() {.exportc, dynlib.} = |
| 38 | - ## Set Nim's runtime up. Idempotent, because a binding that guesses wrong | 37 | + ## Kept for the ABI, and deliberately empty. |
| 39 | - ## about whether it has been called should be harmless rather than fatal. | 38 | + ## |
| 40 | - if not initialised: | 39 | + ## It used to call `NimMain()`. On Linux `--app:lib` already emits a library |
| 41 | - NimMain() | 40 | + ## constructor that runs Nim's module initialisers at dlopen, so calling it |
| 42 | - initialised = true | 41 | + ## again ran every module's top-level code a SECOND time — which for |
| 42 | + ## `conn.nim` meant `outbound.open()` on channels that were already open, | ||
| 43 | + ## quietly resetting them. The reader thread then drained a different queue | ||
| 44 | + ## from the one the writer filled, and nothing this client sent ever left. | ||
| 45 | + ## | ||
| 46 | + ## Nothing to do here, then, but the symbol stays: a binding that calls it | ||
| 47 | + ## should keep working, and one that does not should not have to care. | ||
| 48 | + discard | ||
| 43 | 49 | ||
| 44 | proc dup(s: string): cstring = | 50 | proc dup(s: string): cstring = |
| 45 | ## A copy of `s` that outlives this call, for the caller to `frq_free`. | 51 | ## A copy of `s` that outlives this call, for the caller to `frq_free`. |
| @@ -137,3 +143,54 @@ proc frq_conn_event*(): cstring {.exportc, dynlib.} = | |||
| 137 | ## The next transport event — "open", "close: …", "error: …" — or null. | 143 | ## The next transport event — "open", "close: …", "error: …" — or null. |
| 138 | let (ok, e) = tr.tryEvent() | 144 | let (ok, e) = tr.tryEvent() |
| 139 | if ok: dup(e) else: nil | 145 | if ok: dup(e) else: nil |
| 146 | + | ||
| 147 | + | ||
| 148 | +# ------------------------------------------------------------------- the UI | ||
| 149 | +# | ||
| 150 | +# Nim owns the state and the screens; Dart owns the pixels. The only things | ||
| 151 | +# crossing are a tree going out and an event id coming back. | ||
| 152 | + | ||
| 153 | +proc currentTree(): string = | ||
| 154 | + ## Whichever screen the state says. `drain` first, so the tree Dart gets is | ||
| 155 | + ## built after every line that had arrived when it asked — that is the whole | ||
| 156 | + ## of the polling model, and why there is no callback into Dart. | ||
| 157 | + drain() | ||
| 158 | + let connected = app.status.startsWith("Connected") | ||
| 159 | + let node = | ||
| 160 | + case app.screen | ||
| 161 | + of scChat: scChatScreen.chatScreen(app, connected) | ||
| 162 | + of scChats: scChatsScreen.chatsScreen(app, connected) | ||
| 163 | + of scDiscover: scSettingsScreen.discoverScreen(app) | ||
| 164 | + of scSettings: scSettingsScreen.settingsScreen(app, connected, true) | ||
| 165 | + of scConnect: scConnectScreen.connectScreen(app) | ||
| 166 | + $node.toJson | ||
| 167 | + | ||
| 168 | +proc frq_ui_render*(): cstring {.exportc, dynlib.} = | ||
| 169 | + ## The current screen as a widget tree, in JSON. | ||
| 170 | + ## | ||
| 171 | + ## Not pure: it drains the socket's queue first, so two calls with no | ||
| 172 | + ## dispatch between can differ when a line arrived in the gap. That is how | ||
| 173 | + ## the room fills, and it is why the renderer polls. | ||
| 174 | + dup(currentTree()) | ||
| 175 | + | ||
| 176 | +proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} = | ||
| 177 | + ## Apply an event and answer with the tree it produced. | ||
| 178 | + ## | ||
| 179 | + ## One call rather than dispatch-then-render, and not to save a crossing: it | ||
| 180 | + ## makes the pair atomic, so there is no window in which Dart could render a | ||
| 181 | + ## state nothing asked for. | ||
| 182 | + if event != nil: | ||
| 183 | + try: | ||
| 184 | + dispatch(parseJson($event)) | ||
| 185 | + except JsonParsingError: | ||
| 186 | + discard | ||
| 187 | + dup(currentTree()) | ||
| 188 | + | ||
| 189 | +proc frq_ui_poll*(): cstring {.exportc, dynlib.} = | ||
| 190 | + ## The tree, for a renderer asking because time passed rather than because | ||
| 191 | + ## anything happened. Same work as render; named for what the caller means. | ||
| 192 | + dup(currentTree()) | ||
| 193 | + | ||
| 194 | +proc frq_ui_reset*() {.exportc, dynlib.} = | ||
| 195 | + tr.close() | ||
| 196 | + app = initState() | ||