nandi/frqpublic Fork 0
e20f5ab18600ed3639ac883cb91c590ab278724c
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 · 250 lines · 10.8 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]
Restartable syscalls, because retrying cannot win dcd31d9 nandi 14h ago29import frq/[ircparse, trace, ui, cells, reducer, model, rooms, eintr]
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 yesterday37 ## 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 yesterday46 ## 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.
Restartable syscalls, because retrying cannot win dcd31d9 nandi 14h ago55 # Before anything opens a socket. The Dart VM's profiler signals every
56 # thread in this process about a thousand times a second, and a syscall
57 # interrupted by one fails rather than resuming unless its handler says
58 # otherwise — which is why signing in reported `Interrupted system call`.
59 restartableSyscalls()
Sign in with Bluesky, through the broker bdd2c3c nandi yesterday60 reducer.restore()
One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday61
62proc dup(s: string): cstring =
63 ## A copy of `s` that outlives this call, for the caller to `frq_free`.
64 ## `allocShared0` and not `alloc0`: the Dart side may free it from a
65 ## different thread than the one that made it.
66 let n = s.len
67 let p = cast[cstring](allocShared0(n + 1))
68 if n > 0:
69 copyMem(p, unsafeAddr s[0], n)
70 p
71
72proc frq_free*(p: cstring) {.exportc, dynlib.} =
73 ## Free what one of the functions below returned. Null is fine.
74 if p != nil:
75 deallocShared(p)
76
77proc frq_version*(): cstring {.exportc, dynlib.} =
78 ## Static storage, deliberately: this one is NOT freed, and is the only
79 ## exception to the rule above. It exists so a binding can check at load
80 ## time that the library it found is the one it was built against.
81 "0.1.0"
82
83# ----------------------------------------------------------------- irc/parse
84
85proc frq_irc_parse_line*(line: cstring): cstring {.exportc, dynlib.} =
86 ## An IRC line as JSON: `{raw, tags, account, prefix, command, params}`.
87 ##
88 ## `tags`, `account` and `prefix` are JSON null where the line carried none,
89 ## which is the distinction `frq.irc.parse` draws with nil and every caller
90 ## of it depends on — a PRIVMSG from a server with no prefix is not the same
91 ## line as one from a nick.
92 if line == nil: return dup("null")
93 let p = parseLine($line)
94 var o = newJObject()
95 o["raw"] = %p.raw
96 o["tags"] = if p.hasTags: %p.tags else: newJNull()
97 o["account"] = if p.hasAccount: %p.account else: newJNull()
98 o["prefix"] = if p.hasPrefix: %p.prefix else: newJNull()
99 o["command"] = %p.command
100 o["params"] = %p.params
101 dup($o)
102
103proc frq_irc_tag_value*(tags, key: cstring): cstring {.exportc, dynlib.} =
104 ## One tag's value, unescaped — or **null** where the tag is absent or
105 ## empty, which IRCv3 says are the same thing. Null and not "" on purpose:
106 ## see `tagValue`.
107 if tags == nil or key == nil: return nil
108 let (v, ok) = tagValue($tags, $key)
109 if ok: dup(v) else: nil
110
111proc frq_irc_unescape_tag*(v: cstring): cstring {.exportc, dynlib.} =
112 if v == nil: return nil
113 dup(unescapeTag($v))
114
115proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} =
116 if v == nil: return dup("")
117 dup(escapeTagValue($v))
118
119proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
120 if prefix == nil: return nil
121 dup(nickOf($prefix))
Nim owns the screen, Dart owns the pixels 43a02c2 nandi yesterday122
Nim under the existing UI, not instead of it 56551a8 nandi yesterday123# --------------------------------------------------------------- transport
124#
125# `frq.net`'s three operations, for `frq.net.nim` to install. This is the
126# wiring that matters: the existing ClojureDart screens, cells and actions are
127# untouched, and only the socket underneath them becomes Nim.
128#
129# Polled rather than callback-driven, for the reason the UI is: a Dart callback
130# invoked from a foreign thread has to be marshalled onto the main isolate, and
131# a timer on the Dart side does the same job with no mechanism at all.
132
133proc frq_trace*(topic, msg: cstring) {.exportc, dynlib.} =
134 ## Let the Dart side log through the same facility, so one FRQ_TRACE=1 gives
135 ## one interleaved story instead of two half-ones in different places.
136 if topic != nil and msg != nil:
137 trace($topic, $msg)
138
139proc frq_conn_open*(host: cstring, port: cint, tls: cint) {.exportc, dynlib.} =
140 if host == nil: return
141 tr.open(tr.ConnConfig(host: $host, port: port.int, tls: tls != 0))
142
143proc frq_conn_send*(line: cstring) {.exportc, dynlib.} =
144 if line != nil: tr.send($line)
145
146proc frq_conn_close*() {.exportc, dynlib.} =
147 tr.close()
148
149proc frq_conn_recv*(): cstring {.exportc, dynlib.} =
150 ## The next line, or null when there is none waiting. Never blocks.
151 let (ok, line) = tr.tryLine()
152 if ok: dup(line) else: nil
153
154proc frq_conn_event*(): cstring {.exportc, dynlib.} =
155 ## The next transport event — "open", "close: …", "error: …" — or null.
156 let (ok, e) = tr.tryEvent()
157 if ok: dup(e) else: nil
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday158
159
160# ------------------------------------------------------------------- the UI
161#
162# Nim owns the state and the screens; Dart owns the pixels. The only things
163# crossing are a tree going out and an event id coming back.
164
165proc currentTree(): string =
166 ## Whichever screen the state says. `drain` first, so the tree Dart gets is
167 ## built after every line that had arrived when it asked — that is the whole
168 ## of the polling model, and why there is no callback into Dart.
169 drain()
170 let connected = app.status.startsWith("Connected")
171 let node =
172 case app.screen
173 of scChat: scChatScreen.chatScreen(app, connected)
174 of scChats: scChatsScreen.chatsScreen(app, connected)
175 of scDiscover: scSettingsScreen.discoverScreen(app)
176 of scSettings: scSettingsScreen.settingsScreen(app, connected, true)
177 of scConnect: scConnectScreen.connectScreen(app)
178 $node.toJson
179
180proc frq_ui_render*(): cstring {.exportc, dynlib.} =
181 ## The current screen as a widget tree, in JSON.
182 ##
183 ## Not pure: it drains the socket's queue first, so two calls with no
184 ## dispatch between can differ when a line arrived in the gap. That is how
185 ## the room fills, and it is why the renderer polls.
186 dup(currentTree())
187
188proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} =
189 ## Apply an event and answer with the tree it produced.
190 ##
191 ## One call rather than dispatch-then-render, and not to save a crossing: it
192 ## makes the pair atomic, so there is no window in which Dart could render a
193 ## state nothing asked for.
194 if event != nil:
195 try:
196 dispatch(parseJson($event))
197 except JsonParsingError:
198 discard
199 dup(currentTree())
200
201proc frq_ui_poll*(): cstring {.exportc, dynlib.} =
202 ## The tree, for a renderer asking because time passed rather than because
203 ## anything happened. Same work as render; named for what the caller means.
204 dup(currentTree())
205
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday206proc frq_ui_demo*() {.exportc, dynlib.} =
207 ## Fill a room with a representative conversation, for a test that wants to
208 ## lay the chat screen out without a server.
209 ##
210 ## It exists because the chat screen is the one a script could not reach: a
211 ## GUI on Wayland cannot be clicked, so every automated check stopped at the
212 ## room list and the biggest screen in the app went out unlaid-out. The
213 ## content is chosen to be awkward on purpose — a long unbroken URL, a very
214 ## long word, an image, reactions, a reply, a system line, an edited line —
215 ## because a layout bug is about what does not fit.
216 app = initState()
217 app.formNick = "me"
218 app.rooms.ensureRoom("#test")
219 var r = app.rooms["#test"]
220 r.joined = true
Four more modules into Nim: members, glyphs, emoji, store 3975b4b nandi yesterday221 r.users = {"me": "", "alice": "@", "bob": ""}.toTable
Lay every screen out in a test, and fix what that found 36bdfc5 nandi yesterday222 r.topic = "a room"
223 let t0 = 1_700_000_000_000'i64
224 r.messages = @[
225 Message(id: "1", frm: "*", text: "me joined #test", at: t0, system: true),
226 Message(id: "2", frm: "alice", text: "hello there", at: t0 + 1000),
227 Message(id: "3", frm: "bob",
228 text: "see https://example.com/a/very/long/path/that/will/not/wrap/anywhere/at/all?q=1 for more",
229 at: t0 + 2000),
230 Message(id: "4", frm: "alice",
231 text: "Supercalifragilisticexpialidociousssssssssssssssssssssssssssssssssssss",
232 at: t0 + 3000),
233 Message(id: "5", frm: "me", text: "a picture", at: t0 + 4000,
234 imageUrl: "https://example.com/a.png"),
235 Message(id: "6", frm: "bob", text: "answering you", at: t0 + 5000,
236 replyTo: "5"),
237 Message(id: "7", frm: "me", text: "edited line", at: t0 + 6000,
238 edited: true,
239 reactions: @[Reaction(emoji: "👍", nicks: @["me", "alice"]),
240 Reaction(emoji: "🎉", nicks: @["bob"])]),
241 # A different day, so a heading has to land between them.
242 Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)]
243 app.rooms["#test"] = r
244 app.current = "#test"
245 app.screen = scChat
246 app.status = "Connected as me"
247
The renderer, and a transport bug that took four tries 58e6c97 nandi yesterday248proc frq_ui_reset*() {.exportc, dynlib.} =
249 tr.close()
250 app = initState()