nandi/frqpublic Fork 0
58e6c97
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-18T22:14:53-07:00 Browse files
58e6c97 parent: 87a28cc
modified dart/frq_core/lib/frq_core.dart +79 -0
@@ -247,3 +247,82 @@ String? connRecv() => _takeString(
247247 /// The next transport event — `open`, `close: …`, `error: …` — or null.
248248 String? connEvent() => _takeString(
249249 _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event')());
250+
251+
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 @@
11 /// The renderer: a Nim widget tree, walked into Flutter widgets.
22 ///
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.
66 ///
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+
1013 import 'dart:async';
14+import 'dart:io';
1115
1216 import 'package:flutter/material.dart';
1317 import 'package:frq_core/frq_core.dart' as core;
1418
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+
1821 class NimApp extends StatefulWidget {
1922 const NimApp({super.key});
2023 @override
@@ -25,41 +28,33 @@ class _NimAppState extends State<NimApp> {
2528 late core.UiNode _tree = core.render();
2629 Timer? _poll;
2730
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+
2842 @override
2943 void initState() {
3044 super.initState();
3145 // 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.
3647 _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);
4251 }
4352 });
4453 }
4554
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-
5955 void _send(String id, [String value = '']) {
56+ if (id.isEmpty) return;
6057 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.
6358 if (id == 'send') _focus['draft']?.requestFocus();
6459 }
6560
@@ -78,155 +73,459 @@ class _NimAppState extends State<NimApp> {
7873 @override
7974 Widget build(BuildContext context) => MaterialApp(
8075 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+ ),
8488 ),
89+ home: Scaffold(backgroundColor: t.bg, body: SafeArea(child: _build(_tree))),
8590 );
8691
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+
87137 Widget _build(core.UiNode n) {
88138 final kids = n.children.map(_build).toList();
139+ final spacing = _d(n.props['spacing'], 0);
89140
90141 switch (n.tag) {
91142 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+ ),
100154 ),
101155 ),
102156 );
103157
104158 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+ }
109172
110173 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,
133201 ),
134- ),
135- );
202+ );
203+ }
136204
205+ // Container::Card in the Clojure: padding 12, fills its width.
137206 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),
144214 ),
215+ child: Column(
216+ crossAxisAlignment: CrossAxisAlignment.start,
217+ children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
218+ vertical: true)),
145219 );
146220
147221 case 'title':
148222 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));
150225
151226 case 'title-2':
152227 return Padding(
153- padding: const EdgeInsets.only(top: 8, bottom: 4),
228+ padding: const EdgeInsets.only(top: t.spaceXxs, bottom: t.spaceXxxs),
154229 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)),
157232 );
158233
159234 case 'label':
160- return Text(n.prop('label', ''));
235+ return Text(n.prop('label', ''), style: _style(t.textBody, t.onBg));
161236
162237 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+ }
167265
168266 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+ );
173299
174300 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+ }
182320
183321 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+ }
191444
192445 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)),
204492 );
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);
205500 }
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+ ),
213522 ),
214- onChanged: (v) => _send(n.prop('onChange', ''), v),
215- onSubmitted: (_) {
216- final submit = n.prop('onSubmit', '');
217- if (submit.isNotEmpty) _send(submit);
218- },
219523 );
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;
225524
226525 default:
227526 // An unknown tag paints as itself rather than crashing or vanishing.
228527 // 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.
230529 return Container(
231530 padding: const EdgeInsets.all(4),
232531 color: Colors.orange.withValues(alpha: 0.3),
@@ -235,17 +534,19 @@ class _NimAppState extends State<NimApp> {
235534 }
236535 }
237536
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+ );
250551 }
251552 }
@@ -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 vocabulary3+/// 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. Nim4+/// 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. If7+/// 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 the8+/// 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 the19+import 'nim_theme.dart' as t;
16-/// whole tree is rebuilt — which is what Flutter does anyway, and is why the20+
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 @override23 @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 @override42 @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 no45 // 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 to46+ // 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 the49+ 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 @override73 @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: the174+ {
112- // three mode buttons are wider than the 520-point page, and a Row175+ // 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 "these177+ // 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 is303+ 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 the450+ key, () => TextEditingController(text: value));
198- // caret to the end on every keystroke, which is the classic way to451+ // 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, which527 // 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(vertical542+ 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="":
382382
383383 # The Nim core as a shared library, into build/nim.
384384 #
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.
389388 #
390389 # `-d:release` and not `-d:danger`: the bounds checks are what turn a
391390 # malformed line off a socket into an exception instead of a read past the end
@@ -428,7 +427,52 @@ dart-test:
428427 dart pub get
429428 dart test -r expanded
430429
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.
432476 #
433477 # This is the wiring that matters: `frq.main-nim` is `frq.main` with one line
434478 # 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 Dart385+# `--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 cycle386+# 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 a387+# 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 a389 # `-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 end390 # malformed line off a socket into an exception instead of a read past the end
@@ -428,7 +427,52 @@ dart-test:
428 dart pub get427 dart pub get
429 dart test -r expanded428 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 line477 # 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!`. Every478 # 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 @@
1111 ## documentation of what it obviously holds.
1212
1313 import std/tables
14-import model
14+import frq/[model]
1515
1616 type
1717 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/tables13 import std/tables
14-import model14+import frq/[model]
15 15
16 type16 type
17 Screen* = enum17 Screen* = enum
modified nim/src/frq/conn.nim +58 -40
@@ -15,8 +15,8 @@
1515 ## Threading as before: the socket thread shares nothing, and speaks in
1616 ## channels. See irc.nim's comment for why ORC makes that the sane choice.
1717
18-import std/[net, strutils]
19-import trace
18+import std/[net, os, strutils]
19+import frq/[trace]
2020
2121 type
2222 ConnConfig* = object
@@ -28,71 +28,85 @@ var
2828 inbound: Channel[string]
2929 outbound: Channel[string]
3030 events: Channel[string] ## "open" | "close: reason" | "error: reason"
31- thread: Thread[ConnConfig]
31+ reader: Thread[ConnConfig]
32+ writer: Thread[int]
3233 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.
3339
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.
3443 inbound.open()
3544 outbound.open()
3645 events.open()
3746
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+
3871 proc readerBody(cfg: ConnConfig) {.thread.} =
3972 {.gcsafe.}:
40- var sock: Socket
4173 try:
4274 trace("conn", "dialling " & cfg.host & ":" & $cfg.port &
4375 (if cfg.tls: " over TLS" else: " plain"))
44- sock = newSocket(buffered = true)
76+ var sock = newSocket(buffered = true)
4577 if cfg.tls:
4678 # CVerifyPeer: this carries a nick and, once SASL is wired, a token.
4779 let ctx = newContext(verifyMode = CVerifyPeer)
4880 ctx.wrapSocket(sock)
4981 sock.connect(cfg.host, Port(cfg.port))
82+ shared = sock
83+ createThread(writer, writerBody, 0)
5084 events.send("open")
5185 trace("conn", "connected")
5286
5387 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-
6888 var line: string
69- var timedOut = false
7089 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)
7993 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: ")
8496 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)
8999
90100 except CatchableError as e:
91101 trace("conn", "!! " & e.msg)
92102 events.send("error: " & e.msg)
93103 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
96110 trace("conn", "reader done")
97111
98112 proc open*(cfg: ConnConfig) =
@@ -102,7 +116,7 @@ proc open*(cfg: ConnConfig) =
102116 while inbound.tryRecv()[0]: discard
103117 while events.tryRecv()[0]: discard
104118 running = true
105- createThread(thread, readerBody, cfg)
119+ createThread(reader, readerBody, cfg)
106120
107121 proc send*(line: string) =
108122 if running: outbound.send(line)
@@ -110,7 +124,11 @@ proc send*(line: string) =
110124 proc close*() =
111125 if not running: return
112126 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)
114132 trace("conn", "closed")
115133
116134 proc tryLine*(): (bool, string) = inbound.tryRecv()
@@ -15,8 +15,8 @@
15 ## Threading as before: the socket thread shares nothing, and speaks in15 ## 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 trace19+import frq/[trace]
20 20
21 type21 type
22 ConnConfig* = object22 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: bool33 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: string88 var line: string
69- var timedOut = false
70 try:89 try:
71- # A timeout rather than a second thread for the writer: it gives the90+ line = sock.recvLine()
72- # outbound queue a look between lines at the price of one syscall91+ 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 break93 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 break96 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: discard105+ # 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]: discard116 while inbound.tryRecv()[0]: discard
103 while events.tryRecv()[0]: discard117 while events.tryRecv()[0]: discard
104 running = true118 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: return125 if not running: return
112 running = false126 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 @@
33 ## From `common/frq/edits.cljc`.
44
55 import std/[strutils, tables]
6-import model
6+import frq/[model]
77
88 type
99 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 model6+import frq/[model]
7 7
8 type8 type
9 EditResult* = enum9 EditResult* = enum
modified nim/src/frq/reactions.nim +1 -1
@@ -6,7 +6,7 @@
66 ## its map happens to preserve insertion for small maps.
77
88 import std/[strutils, tables]
9-import model
9+import frq/[model]
1010
1111 func parseTally*(encoded: string): seq[Reaction] =
1212 ## 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 model9+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, as12 ## 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 @@
1212 ## older than it and counts for nothing.
1313
1414 import std/[algorithm, sequtils, strutils, tables]
15-import model, clock
15+import frq/[model, clock]
1616
1717 const
1818 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, clock15+import frq/[model, clock]
16 16
17 const17 const
18 overviewLimit* = 10018 overviewLimit* = 100
modified nim/src/frq/screens/chat.nim +2 -2
@@ -15,8 +15,8 @@
1515 import std/[algorithm, json, strutils, tables]
1616 from std/unicode import runeLen, runeSubStr
1717 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
2020
2121 const
2222 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, runeSubStr16 from std/unicode import runeLen, runeSubStr
17 import std/options17 import std/options
18-import ../ui, ../cells, ../model, ../clock, ../reactions, ../textruns18+import frq/[ui, cells, model, clock, reactions, textruns]
19-from connect import errorNote19+from frq/screens/connect import errorNote
20 20
21 const21 const
22 faceSize = 3222 faceSize = 32
modified nim/src/frq/screens/chats.nim +2 -2
@@ -9,8 +9,8 @@ import std/[json, strutils, tables]
99 # `title` that is ambiguous against `ui.title`, which is the one this file
1010 # means every time it says it.
1111 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]
1414
1515 const listGutter = 16
1616 ## 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 file9 # `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, runeSubStr11 from std/unicode import runeLen, runeSubStr
12-import ../ui, ../cells, ../model, ../rooms12+import frq/[ui, cells, model, rooms]
13-import frame, connect13+import frq/screens/[frame, connect]
14 14
15 const listGutter = 1615 const listGutter = 16
16 ## The scrollbar's room. Without it the cards sit under it and the last16 ## 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 @@
99 ## systematic difference between the two files.
1010
1111 import std/json
12-import ../ui, ../cells
12+import frq/[ui, cells]
1313
1414 const transportNote* =
1515 "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/json11 import std/json
12-import ../ui, ../cells12+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 @@
55 ## and neither screen owns them.
66
77 import std/json
8-import ../ui, ../cells
8+import frq/[ui, cells]
99
1010 func tabBar*(s: State): Node =
1111 hbox(%*{"spacing": 8},
@@ -5,7 +5,7 @@
5 ## and neither screen owns them.5 ## and neither screen owns them.
6 6
7 import std/json7 import std/json
8-import ../ui, ../cells8+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 @@
44 ## `common/frq/screens/settings.cljc`.
55
66 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]
99
1010 func discoverScreen*(s: State): Node =
1111 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, ../model7+import frq/[ui, cells, model]
8-import frame, connect8+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 @@
2020 ## per call, which is nothing against the network round trip that produced the
2121 ## line being parsed.
2222 ##
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.
3427
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
3635
3736 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
4349
4450 proc dup(s: string): cstring =
4551 ## 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.} =
137143 ## The next transport event — "open", "close: …", "error: …" — or null.
138144 let (ok, e) = tr.tryEvent()
139145 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 the20 ## 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 Nim24+## 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 that25+## from `common/frq/screens/` rather than reimagined, which is the difference
26-## already exist and are far better, and the seam that actually wanted Nim26+## 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 = false28+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 wrong37+ ## 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 = true41+ ## 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: nil145 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()