nandi/frqpublic Fork 0
4925e54c9cbf2c2fa55214ac147d458c00384a20
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.

frq_core.nim · 245 lines · 10.5 KBNim Blame HistoryRaw
One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday1## The C ABI, and nothing else.
2##
3## Every exported symbol is here so there is one file to read when asking what
4## the Dart side can call. The logic lives under `frq/` in ordinary Nim with
5## ordinary Nim types, which is what lets the tests test the rules rather than
6## the marshalling.
7##
8## Two conventions, both of which the bindings in `flutter/src/frq/core/`
9## wrap so no call site has to remember them:
10##
11## * Every returned string is the **caller's** to free, with `frq_free`. Nim's
12## allocator is not Dart's, so a `free()` on this side of the boundary is
13## undefined behaviour rather than a leak you can live with.
14## * `frq_init` runs once before anything else. Nim's runtime needs setting up
15## and `--app:lib` does not do it for you on every platform.
16##
17## Answers that are not a single string come back as JSON. A struct would mean
18## both sides agreeing on a memory layout, and a field added later would be a
19## version skew that segfaults rather than one that fails; JSON costs a parse
20## per call, which is nothing against the network round trip that produced the
21## line being parsed.
Delete the spike that owned the screens 1bb3f77 nandi yesterday22##
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday23## 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.
One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday27
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday28import std/[json, strutils, tables]
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday29import frq/[ircparse, trace, ui, cells, reducer, model, rooms]
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday30import frq/conn as tr
31import frq/screens/connect as scConnectScreen
32import frq/screens/chats as scChatsScreen
33import frq/screens/chat as scChatScreen
34import frq/screens/settings as scSettingsScreen
One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday35
36proc frq_init*() {.exportc, dynlib.} =
Sign in with Bluesky, through the broker bdd2c3c nandi 23h ago37 ## Reads the saved sign-in, and otherwise stays out of the way.
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday38 ##
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 ##
Sign in with Bluesky, through the broker bdd2c3c nandi 23h ago46 ## So it stayed empty for a long time. What it does now is the one thing
47 ## that genuinely belongs before the first frame and cannot go in
48 ## `initState`, which is a `func` and touches no disk: restoring the broker
49 ## token, so the connect screen opens saying the session is remembered
50 ## rather than offering a login page the reader does not need.
51 ##
52 ## `frq_ui_reset` deliberately does not do this. It is the tests' entry
53 ## point, and a suite that picked up whoever is signed in on the machine
54 ## running it would pass or fail by accident.
55 reducer.restore()
One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday56
57proc dup(s: string): cstring =
58 ## A copy of `s` that outlives this call, for the caller to `frq_free`.
59 ## `allocShared0` and not `alloc0`: the Dart side may free it from a
60 ## different thread than the one that made it.
61 let n = s.len
62 let p = cast[cstring](allocShared0(n + 1))
63 if n > 0:
64 copyMem(p, unsafeAddr s[0], n)
65 p
66
67proc frq_free*(p: cstring) {.exportc, dynlib.} =
68 ## Free what one of the functions below returned. Null is fine.
69 if p != nil:
70 deallocShared(p)
71
72proc frq_version*(): cstring {.exportc, dynlib.} =
73 ## Static storage, deliberately: this one is NOT freed, and is the only
74 ## exception to the rule above. It exists so a binding can check at load
75 ## time that the library it found is the one it was built against.
76 "0.1.0"
77
78# ----------------------------------------------------------------- irc/parse
79
80proc frq_irc_parse_line*(line: cstring): cstring {.exportc, dynlib.} =
81 ## An IRC line as JSON: `{raw, tags, account, prefix, command, params}`.
82 ##
83 ## `tags`, `account` and `prefix` are JSON null where the line carried none,
84 ## which is the distinction `frq.irc.parse` draws with nil and every caller
85 ## of it depends on — a PRIVMSG from a server with no prefix is not the same
86 ## line as one from a nick.
87 if line == nil: return dup("null")
88 let p = parseLine($line)
89 var o = newJObject()
90 o["raw"] = %p.raw
91 o["tags"] = if p.hasTags: %p.tags else: newJNull()
92 o["account"] = if p.hasAccount: %p.account else: newJNull()
93 o["prefix"] = if p.hasPrefix: %p.prefix else: newJNull()
94 o["command"] = %p.command
95 o["params"] = %p.params
96 dup($o)
97
98proc frq_irc_tag_value*(tags, key: cstring): cstring {.exportc, dynlib.} =
99 ## One tag's value, unescaped — or **null** where the tag is absent or
100 ## empty, which IRCv3 says are the same thing. Null and not "" on purpose:
101 ## see `tagValue`.
102 if tags == nil or key == nil: return nil
103 let (v, ok) = tagValue($tags, $key)
104 if ok: dup(v) else: nil
105
106proc frq_irc_unescape_tag*(v: cstring): cstring {.exportc, dynlib.} =
107 if v == nil: return nil
108 dup(unescapeTag($v))
109
110proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} =
111 if v == nil: return dup("")
112 dup(escapeTagValue($v))
113
114proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
115 if prefix == nil: return nil
116 dup(nickOf($prefix))
Nim owns the screen, Dart owns the pixels 43a02c2 nandi yesterday117
Nim under the existing UI, not instead of it 56551a8 nandi yesterday118# --------------------------------------------------------------- transport
119#
120# `frq.net`'s three operations, for `frq.net.nim` to install. This is the
121# wiring that matters: the existing ClojureDart screens, cells and actions are
122# untouched, and only the socket underneath them becomes Nim.
123#
124# Polled rather than callback-driven, for the reason the UI is: a Dart callback
125# invoked from a foreign thread has to be marshalled onto the main isolate, and
126# a timer on the Dart side does the same job with no mechanism at all.
127
128proc frq_trace*(topic, msg: cstring) {.exportc, dynlib.} =
129 ## Let the Dart side log through the same facility, so one FRQ_TRACE=1 gives
130 ## one interleaved story instead of two half-ones in different places.
131 if topic != nil and msg != nil:
132 trace($topic, $msg)
133
134proc frq_conn_open*(host: cstring, port: cint, tls: cint) {.exportc, dynlib.} =
135 if host == nil: return
136 tr.open(tr.ConnConfig(host: $host, port: port.int, tls: tls != 0))
137
138proc frq_conn_send*(line: cstring) {.exportc, dynlib.} =
139 if line != nil: tr.send($line)
140
141proc frq_conn_close*() {.exportc, dynlib.} =
142 tr.close()
143
144proc frq_conn_recv*(): cstring {.exportc, dynlib.} =
145 ## The next line, or null when there is none waiting. Never blocks.
146 let (ok, line) = tr.tryLine()
147 if ok: dup(line) else: nil
148
149proc frq_conn_event*(): cstring {.exportc, dynlib.} =
150 ## The next transport event — "open", "close: …", "error: …" — or null.
151 let (ok, e) = tr.tryEvent()
152 if ok: dup(e) else: nil
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday153
154
155# ------------------------------------------------------------------- the UI
156#
157# Nim owns the state and the screens; Dart owns the pixels. The only things
158# crossing are a tree going out and an event id coming back.
159
160proc currentTree(): string =
161 ## Whichever screen the state says. `drain` first, so the tree Dart gets is
162 ## built after every line that had arrived when it asked — that is the whole
163 ## of the polling model, and why there is no callback into Dart.
164 drain()
165 let connected = app.status.startsWith("Connected")
166 let node =
167 case app.screen
168 of scChat: scChatScreen.chatScreen(app, connected)
169 of scChats: scChatsScreen.chatsScreen(app, connected)
170 of scDiscover: scSettingsScreen.discoverScreen(app)
171 of scSettings: scSettingsScreen.settingsScreen(app, connected, true)
172 of scConnect: scConnectScreen.connectScreen(app)
173 $node.toJson
174
175proc frq_ui_render*(): cstring {.exportc, dynlib.} =
176 ## The current screen as a widget tree, in JSON.
177 ##
178 ## Not pure: it drains the socket's queue first, so two calls with no
179 ## dispatch between can differ when a line arrived in the gap. That is how
180 ## the room fills, and it is why the renderer polls.
181 dup(currentTree())
182
183proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
184 ## Apply an event and answer with the tree it produced.
185 ##
186 ## One call rather than dispatch-then-render, and not to save a crossing: it
187 ## makes the pair atomic, so there is no window in which Dart could render a
188 ## state nothing asked for.
189 if event != nil:
190 try:
191 dispatch(parseJson($event))
192 except JsonParsingError:
193 discard
194 dup(currentTree())
195
196proc frq_ui_poll*(): cstring {.exportc, dynlib.} =
197 ## The tree, for a renderer asking because time passed rather than because
198 ## anything happened. Same work as render; named for what the caller means.
199 dup(currentTree())
200
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday201proc frq_ui_demo*() {.exportc, dynlib.} =
202 ## Fill a room with a representative conversation, for a test that wants to
203 ## lay the chat screen out without a server.
204 ##
205 ## It exists because the chat screen is the one a script could not reach: a
206 ## GUI on Wayland cannot be clicked, so every automated check stopped at the
207 ## room list and the biggest screen in the app went out unlaid-out. The
208 ## content is chosen to be awkward on purpose — a long unbroken URL, a very
209 ## long word, an image, reactions, a reply, a system line, an edited line —
210 ## because a layout bug is about what does not fit.
211 app = initState()
212 app.formNick = "me"
213 app.rooms.ensureRoom("#test")
214 var r = app.rooms["#test"]
215 r.joined = true
Four more modules into Nim: members, glyphs, emoji, store 3975b4b nandi yesterday216 r.users = {"me": "", "alice": "@", "bob": ""}.toTable
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday217 r.topic = "a room"
218 let t0 = 1_700_000_000_000'i64
219 r.messages = @[
220 Message(id: "1", frm: "*", text: "me joined #test", at: t0, system: true),
221 Message(id: "2", frm: "alice", text: "hello there", at: t0 + 1000),
222 Message(id: "3", frm: "bob",
223 text: "see https://example.com/a/very/long/path/that/will/not/wrap/anywhere/at/all?q=1 for more",
224 at: t0 + 2000),
225 Message(id: "4", frm: "alice",
226 text: "Supercalifragilisticexpialidociousssssssssssssssssssssssssssssssssssss",
227 at: t0 + 3000),
228 Message(id: "5", frm: "me", text: "a picture", at: t0 + 4000,
229 imageUrl: "https://example.com/a.png"),
230 Message(id: "6", frm: "bob", text: "answering you", at: t0 + 5000,
231 replyTo: "5"),
232 Message(id: "7", frm: "me", text: "edited line", at: t0 + 6000,
233 edited: true,
234 reactions: @[Reaction(emoji: "👍", nicks: @["me", "alice"]),
235 Reaction(emoji: "🎉", nicks: @["bob"])]),
236 # A different day, so a heading has to land between them.
237 Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)]
238 app.rooms["#test"] = r
239 app.current = "#test"
240 app.screen = scChat
241 app.status = "Connected as me"
242
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday243proc frq_ui_reset*() {.exportc, dynlib.} =
244 tr.close()
245 app = initState()