nandi/frqpublic Fork 0
1d62d1a
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 window connects

`just nim-spike` now does what `just nim-live` did: opens a TLS connection to
irc.freeq.at, registers, joins #test and shows the room filling. Same Nim, same
events, same reducer — the GUI was always dispatching them, and what stood
between it and a connection was one missing library path.

That was the whole bug. libfrqcore.so links OpenSSL for the TLS on :6697, and
the `flutter-desktop` shell had no libssl on its loader path where the `dart`
shell did — so the live script worked and the window would have died inside
`newContext` with a SIGSEGV that says nothing about SSL. It is `FRQ_OPENSSL_LIB`
in that shell now, prepended by the recipe for the app it launches and nothing
else: setting LD_LIBRARY_PATH in the shell itself would have reached Flutter's
own nixGL, which does careful things to the loader path that are not ours to
disturb.

Two switches beside FRQ_TRACE, and they exist because a Wayland window cannot
be clicked from a script: FRQ_AUTOCONNECT=1 presses Connect on the first
render, FRQ_NICK overrides the nickname — two runs with the same one collide
and the second is refused. Without them the only way to check that the window
connects is to sit in front of it, which is not a check.

Proof, from the window rather than a script:

    auto      FRQ_AUTOCONNECT set — connecting as frq-gui-21893
    irc       dialling irc.freeq.at:6697 over TLS
    irc       connected
    irc.out   NICK/USER as frq-gui-21893
    irc.in    :irc.freeq.at 001 … :Welcome to irc.freeq.at (guest)
    irc.out   JOIN #test
    irc.in    :irc.freeq.at 332 … #test 6789

112 lines received, the backlog rendered, no Dart exception.

The compose box also sends on Enter now and keeps focus afterwards. Both were
missing and both are the difference between a demo and something typeable: the
field is rebuilt from a fresh tree on every keystroke, so without a FocusNode
held per key the caret goes nowhere after the first line — the same bug the
controllers already had, one layer up.

Verified: 43 Nim, 29 Dart, 7 widget tests, and the window itself against the
real server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T20:14:27-07:00 Browse files
1d62d1a parent: 35994d4
modified flake.nix +8 -0
@@ -595,6 +595,14 @@
595595 # pure Dart over the XDG directories.
596596 buildInputs = [ pkgs.gtk3 pkgs.glib ];
597597
598+ # Where libfrqcore.so's OpenSSL lives. A named variable and NOT
599+ # LD_LIBRARY_PATH, deliberately: this shell also runs Flutter
600+ # through nixGL, which does its own careful things to the loader
601+ # path, and a blanket LD_LIBRARY_PATH here is the sort of thing
602+ # that breaks GL on one machine and not another. The `nim-spike`
603+ # recipe prepends this for the app it launches and nothing else.
604+ FRQ_OPENSSL_LIB = lib.makeLibraryPath [ pkgs.openssl ];
605+
598606 # Flutter paints through GL, and off NixOS the driver that can do
599607 # that is the host's, not the store's.
600608 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";
@@ -595,6 +595,14 @@
595 # pure Dart over the XDG directories.595 # pure Dart over the XDG directories.
596 buildInputs = [ pkgs.gtk3 pkgs.glib ];596 buildInputs = [ pkgs.gtk3 pkgs.glib ];
597 597
598+ # Where libfrqcore.so's OpenSSL lives. A named variable and NOT
599+ # LD_LIBRARY_PATH, deliberately: this shell also runs Flutter
600+ # through nixGL, which does its own careful things to the loader
601+ # path, and a blanket LD_LIBRARY_PATH here is the sort of thing
602+ # that breaks GL on one machine and not another. The `nim-spike`
603+ # recipe prepends this for the app it launches and nothing else.
604+ FRQ_OPENSSL_LIB = lib.makeLibraryPath [ pkgs.openssl ];
605+
598 # Flutter paints through GL, and off NixOS the driver that can do606 # Flutter paints through GL, and off NixOS the driver that can do
599 # that is the host's, not the store's.607 # that is the host's, not the store's.
600 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";608 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";
modified flutter/lib/nim_renderer.dart +19 -2
@@ -51,8 +51,17 @@ class _NimAppState extends State<NimApp> {
5151 // comment survives three languages now.
5252 final _controllers = <String, TextEditingController>{};
5353
54- void _send(String id, [String value = '']) =>
55- setState(() => _tree = core.dispatch(id, value));
54+ // One focus node per keyed entry, for the same reason as the controllers.
55+ // Without it, sending with Enter drops focus and the next line is typed
56+ // into nothing — the field is rebuilt from a fresh tree every time.
57+ final _focus = <String, FocusNode>{};
58+
59+ void _send(String id, [String value = '']) {
60+ setState(() => _tree = core.dispatch(id, value));
61+ // Enter in the compose box clears the draft in Nim and rebuilds the
62+ // field; putting focus back is what makes a second line typeable.
63+ if (id == 'send') _focus['draft']?.requestFocus();
64+ }
5665
5766 @override
5867 void dispose() {
@@ -60,6 +69,9 @@ class _NimAppState extends State<NimApp> {
6069 for (final c in _controllers.values) {
6170 c.dispose();
6271 }
72+ for (final f in _focus.values) {
73+ f.dispose();
74+ }
6375 super.dispose();
6476 }
6577
@@ -193,12 +205,17 @@ class _NimAppState extends State<NimApp> {
193205 }
194206 final field = TextField(
195207 controller: c,
208+ focusNode: _focus.putIfAbsent(key, FocusNode.new),
196209 decoration: InputDecoration(
197210 hintText: n.prop('placeholder', ''),
198211 isDense: true,
199212 border: const OutlineInputBorder(),
200213 ),
201214 onChanged: (v) => _send(n.prop('onChange', ''), v),
215+ onSubmitted: (_) {
216+ final submit = n.prop('onSubmit', '');
217+ if (submit.isNotEmpty) _send(submit);
218+ },
202219 );
203220 final w = n.prop('widthRequest', 0);
204221 // A width request is a minimum in the screens' vocabulary, but here it
@@ -51,8 +51,17 @@ class _NimAppState extends State<NimApp> {
51 // comment survives three languages now.51 // comment survives three languages now.
52 final _controllers = <String, TextEditingController>{};52 final _controllers = <String, TextEditingController>{};
53 53
54- void _send(String id, [String value = '']) =>54+ // One focus node per keyed entry, for the same reason as the controllers.
55- setState(() => _tree = core.dispatch(id, value));55+ // Without it, sending with Enter drops focus and the next line is typed
56+ // into nothing — the field is rebuilt from a fresh tree every time.
57+ final _focus = <String, FocusNode>{};
58+
59+ void _send(String id, [String value = '']) {
60+ setState(() => _tree = core.dispatch(id, value));
61+ // Enter in the compose box clears the draft in Nim and rebuilds the
62+ // field; putting focus back is what makes a second line typeable.
63+ if (id == 'send') _focus['draft']?.requestFocus();
64+ }
56 65
57 @override66 @override
58 void dispose() {67 void dispose() {
@@ -60,6 +69,9 @@ class _NimAppState extends State<NimApp> {
60 for (final c in _controllers.values) {69 for (final c in _controllers.values) {
61 c.dispose();70 c.dispose();
62 }71 }
72+ for (final f in _focus.values) {
73+ f.dispose();
74+ }
63 super.dispose();75 super.dispose();
64 }76 }
65 77
@@ -193,12 +205,17 @@ class _NimAppState extends State<NimApp> {
193 }205 }
194 final field = TextField(206 final field = TextField(
195 controller: c,207 controller: c,
208+ focusNode: _focus.putIfAbsent(key, FocusNode.new),
196 decoration: InputDecoration(209 decoration: InputDecoration(
197 hintText: n.prop('placeholder', ''),210 hintText: n.prop('placeholder', ''),
198 isDense: true,211 isDense: true,
199 border: const OutlineInputBorder(),212 border: const OutlineInputBorder(),
200 ),213 ),
201 onChanged: (v) => _send(n.prop('onChange', ''), v),214 onChanged: (v) => _send(n.prop('onChange', ''), v),
215+ onSubmitted: (_) {
216+ final submit = n.prop('onSubmit', '');
217+ if (submit.isNotEmpty) _send(submit);
218+ },
202 );219 );
203 final w = n.prop('widthRequest', 0);220 final w = n.prop('widthRequest', 0);
204 // A width request is a minimum in the screens' vocabulary, but here it221 // A width request is a minimum in the screens' vocabulary, but here it
modified justfile +4 -0
@@ -454,6 +454,10 @@ nim-spike action="run":
454454 fi
455455 cd flutter
456456 flutter pub get
457+ # The Nim core links OpenSSL for the TLS on :6697, and the process that
458+ # dlopens it has to be able to find one. Prepended here rather than set in
459+ # the shell, so nixGL's own loader path is left alone.
460+ export LD_LIBRARY_PATH="${FRQ_OPENSSL_LIB:-}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
457461 runner=()
458462 [ -e /run/current-system ] || runner=("$NIXGL")
459463 case "{{action}}" in
@@ -454,6 +454,10 @@ nim-spike action="run":
454 fi454 fi
455 cd flutter455 cd flutter
456 flutter pub get456 flutter pub get
457+ # The Nim core links OpenSSL for the TLS on :6697, and the process that
458+ # dlopens it has to be able to find one. Prepended here rather than set in
459+ # the shell, so nixGL's own loader path is left alone.
460+ export LD_LIBRARY_PATH="${FRQ_OPENSSL_LIB:-}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
457 runner=()461 runner=()
458 [ -e /run/current-system ] || runner=("$NIXGL")462 [ -e /run/current-system ] || runner=("$NIXGL")
459 case "{{action}}" in463 case "{{action}}" in
modified nim/README.md +14 -1
@@ -88,13 +88,26 @@ the chat screen caps the backlog at fifty rows for exactly this reason, and
8888 nothing has measured what a real one costs.
8989
9090 ```bash
91-just nim-spike # the window
91+just nim-spike # the window; click Connect
92+FRQ_TRACE=1 FRQ_AUTOCONNECT=1 just nim-spike # ...connecting on its own
9293 just nim-spike-test # 7 widget tests: real taps, real widgets
9394 just nim-live # connect to a real freeq and say a line
9495 just nim-bench # what the boundary costs
9596 FRQ_TRACE=1 just nim-live # ...and every line on the wire
9697 ```
9798
99+Two switches, both for the same reason — a GUI on Wayland cannot be clicked
100+from a script, so without them the only way to check the window connects is to
101+sit in front of it. `FRQ_AUTOCONNECT=1` presses Connect on the first render and
102+`FRQ_NICK` overrides the nickname, because two runs with the same one collide
103+on the server and the second is refused.
104+
105+The GUI needs OpenSSL on its loader path, which the `flutter-desktop` shell
106+provides as `FRQ_OPENSSL_LIB` and the `nim-spike` recipe prepends for the app
107+alone. Not set as `LD_LIBRARY_PATH` in the shell itself: that shell also runs
108+Flutter through nixGL, which does its own careful things to the loader path,
109+and a blanket setting there breaks GL on some machines and not others.
110+
98111 ## Status
99112
100113 `frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the
@@ -88,13 +88,26 @@ the chat screen caps the backlog at fifty rows for exactly this reason, and
88 nothing has measured what a real one costs.88 nothing has measured what a real one costs.
89 89
90 ```bash90 ```bash
91-just nim-spike # the window91+just nim-spike # the window; click Connect
92+FRQ_TRACE=1 FRQ_AUTOCONNECT=1 just nim-spike # ...connecting on its own
92 just nim-spike-test # 7 widget tests: real taps, real widgets93 just nim-spike-test # 7 widget tests: real taps, real widgets
93 just nim-live # connect to a real freeq and say a line94 just nim-live # connect to a real freeq and say a line
94 just nim-bench # what the boundary costs95 just nim-bench # what the boundary costs
95 FRQ_TRACE=1 just nim-live # ...and every line on the wire96 FRQ_TRACE=1 just nim-live # ...and every line on the wire
96 ```97 ```
97 98
99+Two switches, both for the same reason — a GUI on Wayland cannot be clicked
100+from a script, so without them the only way to check the window connects is to
101+sit in front of it. `FRQ_AUTOCONNECT=1` presses Connect on the first render and
102+`FRQ_NICK` overrides the nickname, because two runs with the same one collide
103+on the server and the second is refused.
104+
105+The GUI needs OpenSSL on its loader path, which the `flutter-desktop` shell
106+provides as `FRQ_OPENSSL_LIB` and the `nim-spike` recipe prepends for the app
107+alone. Not set as `LD_LIBRARY_PATH` in the shell itself: that shell also runs
108+Flutter through nixGL, which does its own careful things to the loader path,
109+and a blanket setting there breaks GL on some machines and not others.
110+
98 ## Status111 ## Status
99 112
100 `frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the113 `frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the
modified nim/src/frq/screens/chat.nim +1 -1
@@ -39,5 +39,5 @@ func chatScreen*(s: State): Node =
3939 n("vbox", %*{"spacing": 4}, rows))),
4040 hbox(%*{"spacing": 8},
4141 entry("draft", s.draft, "Message " & s.channel, "draft.change",
42- width = 460),
42+ width = 460, onSubmit = "send"),
4343 button("Send", "send", "primary")))
@@ -39,5 +39,5 @@ func chatScreen*(s: State): Node =
39 n("vbox", %*{"spacing": 4}, rows))),39 n("vbox", %*{"spacing": 4}, rows))),
40 hbox(%*{"spacing": 8},40 hbox(%*{"spacing": 8},
41 entry("draft", s.draft, "Message " & s.channel, "draft.change",41 entry("draft", s.draft, "Message " & s.channel, "draft.change",
42- width = 460),42+ width = 460, onSubmit = "send"),
43 button("Send", "send", "primary")))43 button("Send", "send", "primary")))
modified nim/src/frq/state.nim +25 -1
@@ -11,7 +11,7 @@
1111 ## function of this record; an event is the only way it changes; nothing else
1212 ## crosses the boundary. That is what lets the renderer stay dumb.
1313
14-import std/[json, strutils]
14+import std/[json, os, strutils]
1515 import trace, ircparse, irc
1616
1717 type
@@ -220,3 +220,27 @@ proc drain*() =
220220 else:
221221 # Everything else is the MOTD and friends — traced, not shown.
222222 trace("irc.skip", p.command & " " & $p.params)
223+
224+
225+# -------------------------------------------------------------- autoconnect
226+#
227+# `FRQ_AUTOCONNECT=1` presses Connect as soon as the first screen is asked
228+# for. In the same spirit as FRQ_TRACE and for the same reason: a GUI on
229+# Wayland cannot be clicked from a script, so without this the only way to
230+# check that the window connects is to sit in front of it. It also makes
231+# `just nim-spike` a one-command demo.
232+#
233+# `FRQ_NICK` overrides the nickname, because two runs with the same one
234+# collide on the server and the second is refused.
235+
236+var autoconnectDone = false
237+
238+proc maybeAutoconnect*() =
239+ if autoconnectDone: return
240+ autoconnectDone = true
241+ let want = getEnv("FRQ_AUTOCONNECT")
242+ if want.len == 0 or want == "0": return
243+ let nick = getEnv("FRQ_NICK")
244+ if nick.len > 0: app.formNick = nick
245+ trace("auto", "FRQ_AUTOCONNECT set — connecting as " & app.formNick)
246+ dispatch(%*{"id": "connect"})
@@ -11,7 +11,7 @@
11 ## function of this record; an event is the only way it changes; nothing else11 ## function of this record; an event is the only way it changes; nothing else
12 ## crosses the boundary. That is what lets the renderer stay dumb.12 ## crosses the boundary. That is what lets the renderer stay dumb.
13 13
14-import std/[json, strutils]14+import std/[json, os, strutils]
15 import trace, ircparse, irc15 import trace, ircparse, irc
16 16
17 type17 type
@@ -220,3 +220,27 @@ proc drain*() =
220 else:220 else:
221 # Everything else is the MOTD and friends — traced, not shown.221 # Everything else is the MOTD and friends — traced, not shown.
222 trace("irc.skip", p.command & " " & $p.params)222 trace("irc.skip", p.command & " " & $p.params)
223+
224+
225+# -------------------------------------------------------------- autoconnect
226+#
227+# `FRQ_AUTOCONNECT=1` presses Connect as soon as the first screen is asked
228+# for. In the same spirit as FRQ_TRACE and for the same reason: a GUI on
229+# Wayland cannot be clicked from a script, so without this the only way to
230+# check that the window connects is to sit in front of it. It also makes
231+# `just nim-spike` a one-command demo.
232+#
233+# `FRQ_NICK` overrides the nickname, because two runs with the same one
234+# collide on the server and the second is refused.
235+
236+var autoconnectDone = false
237+
238+proc maybeAutoconnect*() =
239+ if autoconnectDone: return
240+ autoconnectDone = true
241+ let want = getEnv("FRQ_AUTOCONNECT")
242+ if want.len == 0 or want == "0": return
243+ let nick = getEnv("FRQ_NICK")
244+ if nick.len > 0: app.formNick = nick
245+ trace("auto", "FRQ_AUTOCONNECT set — connecting as " & app.formNick)
246+ dispatch(%*{"id": "connect"})
modified nim/src/frq/ui.nim +5 -1
@@ -69,7 +69,8 @@ func button*(text: string, onClick: string, kind = "default"): Node =
6969 ## `onClick` is an event id, not a closure. See the module comment.
7070 n("button", %*{"label": text, "kind": kind, "onClick": onClick})
7171
72-func entry*(key, text, placeholder, onChange: string, width = 0): Node =
72+func entry*(key, text, placeholder, onChange: string, width = 0,
73+ onSubmit = ""): Node =
7374 ## Every entry carries a key, and for the reason the Clojure's comment
7475 ## gives: a renderer that keeps a text controller per field needs a stable
7576 ## name for it, and without one the host and the port shared a controller
@@ -77,6 +78,9 @@ func entry*(key, text, placeholder, onChange: string, width = 0): Node =
7778 var p = %*{"key": key, "text": text, "placeholder": placeholder,
7879 "onChange": onChange}
7980 if width > 0: p["widthRequest"] = %width
81+ # Enter, where the field has something to do with it. A compose box that
82+ # only sends on a button click is one nobody can type into at speed.
83+ if onSubmit.len > 0: p["onSubmit"] = %onSubmit
8084 n("entry", p)
8185
8286 func checkbutton*(text: string, active: bool, onToggled: string): Node =
@@ -69,7 +69,8 @@ func button*(text: string, onClick: string, kind = "default"): Node =
69 ## `onClick` is an event id, not a closure. See the module comment.69 ## `onClick` is an event id, not a closure. See the module comment.
70 n("button", %*{"label": text, "kind": kind, "onClick": onClick})70 n("button", %*{"label": text, "kind": kind, "onClick": onClick})
71 71
72-func entry*(key, text, placeholder, onChange: string, width = 0): Node =72+func entry*(key, text, placeholder, onChange: string, width = 0,
73+ onSubmit = ""): Node =
73 ## Every entry carries a key, and for the reason the Clojure's comment74 ## Every entry carries a key, and for the reason the Clojure's comment
74 ## gives: a renderer that keeps a text controller per field needs a stable75 ## gives: a renderer that keeps a text controller per field needs a stable
75 ## name for it, and without one the host and the port shared a controller76 ## name for it, and without one the host and the port shared a controller
@@ -77,6 +78,9 @@ func entry*(key, text, placeholder, onChange: string, width = 0): Node =
77 var p = %*{"key": key, "text": text, "placeholder": placeholder,78 var p = %*{"key": key, "text": text, "placeholder": placeholder,
78 "onChange": onChange}79 "onChange": onChange}
79 if width > 0: p["widthRequest"] = %width80 if width > 0: p["widthRequest"] = %width
81+ # Enter, where the field has something to do with it. A compose box that
82+ # only sends on a button click is one nobody can type into at speed.
83+ if onSubmit.len > 0: p["onSubmit"] = %onSubmit
80 n("entry", p)84 n("entry", p)
81 85
82 func checkbutton*(text: string, active: bool, onToggled: string): Node =86 func checkbutton*(text: string, active: bool, onToggled: string): Node =
modified nim/src/frq_core.nim +1 -0
@@ -104,6 +104,7 @@ proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
104104 # coming back. See `frq/ui.nim`.
105105
106106 proc currentTree(): string =
107+ maybeAutoconnect()
107108 ## Whichever screen the state says. `drain` first, so the tree Dart gets is
108109 ## built after every line that had arrived when it asked — that is the whole
109110 ## of the polling model, and it is why there is no callback into Dart.
@@ -104,6 +104,7 @@ proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
104 # coming back. See `frq/ui.nim`.104 # coming back. See `frq/ui.nim`.
105 105
106 proc currentTree(): string =106 proc currentTree(): string =
107+ maybeAutoconnect()
107 ## Whichever screen the state says. `drain` first, so the tree Dart gets is108 ## Whichever screen the state says. `drain` first, so the tree Dart gets is
108 ## built after every line that had arrived when it asked — that is the whole109 ## built after every line that had arrived when it asked — that is the whole
109 ## of the polling model, and it is why there is no callback into Dart.110 ## of the polling model, and it is why there is no callback into Dart.