| One frontend where there were three, and a core that is not Clojure | 1 | ## 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 | 22 | ## |
| The renderer, and a transport bug that took four tries | 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. | |
| One frontend where there were three, and a core that is not Clojure | 27 | |
| Lay every screen out in a test, and fix what that found | 28 | import std/[json, strutils, tables] |
| Quality pass: reuse, dead weight, and two real costs | 29 | import frq/[ircparse, trace, ui, cells, reducer, model, rooms] |
| The renderer, and a transport bug that took four tries | 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 | |
| One frontend where there were three, and a core that is not Clojure | 35 | |
| 36 | proc frq_init*() {.exportc, dynlib.} = | |
| The renderer, and a transport bug that took four tries | 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 | |
| One frontend where there were three, and a core that is not Clojure | 49 | |
| 50 | proc dup(s: string): cstring = | |
| 51 | ## A copy of `s` that outlives this call, for the caller to `frq_free`. | |
| 52 | ## `allocShared0` and not `alloc0`: the Dart side may free it from a | |
| 53 | ## different thread than the one that made it. | |
| 54 | let n = s.len | |
| 55 | let p = cast[cstring](allocShared0(n + 1)) | |
| 56 | if n > 0: | |
| 57 | copyMem(p, unsafeAddr s[0], n) | |
| 58 | p | |
| 59 | ||
| 60 | proc frq_free*(p: cstring) {.exportc, dynlib.} = | |
| 61 | ## Free what one of the functions below returned. Null is fine. | |
| 62 | if p != nil: | |
| 63 | deallocShared(p) | |
| 64 | ||
| 65 | proc frq_version*(): cstring {.exportc, dynlib.} = | |
| 66 | ## Static storage, deliberately: this one is NOT freed, and is the only | |
| 67 | ## exception to the rule above. It exists so a binding can check at load | |
| 68 | ## time that the library it found is the one it was built against. | |
| 69 | "0.1.0" | |
| 70 | ||
| 71 | # ----------------------------------------------------------------- irc/parse | |
| 72 | ||
| 73 | proc frq_irc_parse_line*(line: cstring): cstring {.exportc, dynlib.} = | |
| 74 | ## An IRC line as JSON: `{raw, tags, account, prefix, command, params}`. | |
| 75 | ## | |
| 76 | ## `tags`, `account` and `prefix` are JSON null where the line carried none, | |
| 77 | ## which is the distinction `frq.irc.parse` draws with nil and every caller | |
| 78 | ## of it depends on — a PRIVMSG from a server with no prefix is not the same | |
| 79 | ## line as one from a nick. | |
| 80 | if line == nil: return dup("null") | |
| 81 | let p = parseLine($line) | |
| 82 | var o = newJObject() | |
| 83 | o["raw"] = %p.raw | |
| 84 | o["tags"] = if p.hasTags: %p.tags else: newJNull() | |
| 85 | o["account"] = if p.hasAccount: %p.account else: newJNull() | |
| 86 | o["prefix"] = if p.hasPrefix: %p.prefix else: newJNull() | |
| 87 | o["command"] = %p.command | |
| 88 | o["params"] = %p.params | |
| 89 | dup($o) | |
| 90 | ||
| 91 | proc frq_irc_tag_value*(tags, key: cstring): cstring {.exportc, dynlib.} = | |
| 92 | ## One tag's value, unescaped — or **null** where the tag is absent or | |
| 93 | ## empty, which IRCv3 says are the same thing. Null and not "" on purpose: | |
| 94 | ## see `tagValue`. | |
| 95 | if tags == nil or key == nil: return nil | |
| 96 | let (v, ok) = tagValue($tags, $key) | |
| 97 | if ok: dup(v) else: nil | |
| 98 | ||
| 99 | proc frq_irc_unescape_tag*(v: cstring): cstring {.exportc, dynlib.} = | |
| 100 | if v == nil: return nil | |
| 101 | dup(unescapeTag($v)) | |
| 102 | ||
| 103 | proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} = | |
| 104 | if v == nil: return dup("") | |
| 105 | dup(escapeTagValue($v)) | |
| 106 | ||
| 107 | proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} = | |
| 108 | if prefix == nil: return nil | |
| 109 | dup(nickOf($prefix)) | |
| Nim owns the screen, Dart owns the pixels | 110 | |
| Nim under the existing UI, not instead of it | 111 | # --------------------------------------------------------------- transport |
| 112 | # | |
| 113 | # `frq.net`'s three operations, for `frq.net.nim` to install. This is the | |
| 114 | # wiring that matters: the existing ClojureDart screens, cells and actions are | |
| 115 | # untouched, and only the socket underneath them becomes Nim. | |
| 116 | # | |
| 117 | # Polled rather than callback-driven, for the reason the UI is: a Dart callback | |
| 118 | # invoked from a foreign thread has to be marshalled onto the main isolate, and | |
| 119 | # a timer on the Dart side does the same job with no mechanism at all. | |
| 120 | ||
| 121 | proc frq_trace*(topic, msg: cstring) {.exportc, dynlib.} = | |
| 122 | ## Let the Dart side log through the same facility, so one FRQ_TRACE=1 gives | |
| 123 | ## one interleaved story instead of two half-ones in different places. | |
| 124 | if topic != nil and msg != nil: | |
| 125 | trace($topic, $msg) | |
| 126 | ||
| 127 | proc frq_conn_open*(host: cstring, port: cint, tls: cint) {.exportc, dynlib.} = | |
| 128 | if host == nil: return | |
| 129 | tr.open(tr.ConnConfig(host: $host, port: port.int, tls: tls != 0)) | |
| 130 | ||
| 131 | proc frq_conn_send*(line: cstring) {.exportc, dynlib.} = | |
| 132 | if line != nil: tr.send($line) | |
| 133 | ||
| 134 | proc frq_conn_close*() {.exportc, dynlib.} = | |
| 135 | tr.close() | |
| 136 | ||
| 137 | proc frq_conn_recv*(): cstring {.exportc, dynlib.} = | |
| 138 | ## The next line, or null when there is none waiting. Never blocks. | |
| 139 | let (ok, line) = tr.tryLine() | |
| 140 | if ok: dup(line) else: nil | |
| 141 | ||
| 142 | proc frq_conn_event*(): cstring {.exportc, dynlib.} = | |
| 143 | ## The next transport event — "open", "close: …", "error: …" — or null. | |
| 144 | let (ok, e) = tr.tryEvent() | |
| 145 | if ok: dup(e) else: nil | |
| The renderer, and a transport bug that took four tries | 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 | ||
| Lay every screen out in a test, and fix what that found | 194 | proc frq_ui_demo*() {.exportc, dynlib.} = |
| 195 | ## Fill a room with a representative conversation, for a test that wants to | |
| 196 | ## lay the chat screen out without a server. | |
| 197 | ## | |
| 198 | ## It exists because the chat screen is the one a script could not reach: a | |
| 199 | ## GUI on Wayland cannot be clicked, so every automated check stopped at the | |
| 200 | ## room list and the biggest screen in the app went out unlaid-out. The | |
| 201 | ## content is chosen to be awkward on purpose — a long unbroken URL, a very | |
| 202 | ## long word, an image, reactions, a reply, a system line, an edited line — | |
| 203 | ## because a layout bug is about what does not fit. | |
| 204 | app = initState() | |
| 205 | app.formNick = "me" | |
| 206 | app.rooms.ensureRoom("#test") | |
| 207 | var r = app.rooms["#test"] | |
| 208 | r.joined = true | |
| 209 | r.users = @["me", "alice", "bob"] | |
| 210 | r.topic = "a room" | |
| 211 | let t0 = 1_700_000_000_000'i64 | |
| 212 | r.messages = @[ | |
| 213 | Message(id: "1", frm: "*", text: "me joined #test", at: t0, system: true), | |
| 214 | Message(id: "2", frm: "alice", text: "hello there", at: t0 + 1000), | |
| 215 | Message(id: "3", frm: "bob", | |
| 216 | text: "see https://example.com/a/very/long/path/that/will/not/wrap/anywhere/at/all?q=1 for more", | |
| 217 | at: t0 + 2000), | |
| 218 | Message(id: "4", frm: "alice", | |
| 219 | text: "Supercalifragilisticexpialidociousssssssssssssssssssssssssssssssssssss", | |
| 220 | at: t0 + 3000), | |
| 221 | Message(id: "5", frm: "me", text: "a picture", at: t0 + 4000, | |
| 222 | imageUrl: "https://example.com/a.png"), | |
| 223 | Message(id: "6", frm: "bob", text: "answering you", at: t0 + 5000, | |
| 224 | replyTo: "5"), | |
| 225 | Message(id: "7", frm: "me", text: "edited line", at: t0 + 6000, | |
| 226 | edited: true, | |
| 227 | reactions: @[Reaction(emoji: "👍", nicks: @["me", "alice"]), | |
| 228 | Reaction(emoji: "🎉", nicks: @["bob"])]), | |
| 229 | # A different day, so a heading has to land between them. | |
| 230 | Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)] | |
| 231 | app.rooms["#test"] = r | |
| 232 | app.current = "#test" | |
| 233 | app.screen = scChat | |
| 234 | app.status = "Connected as me" | |
| 235 | ||
| The renderer, and a transport bug that took four tries | 236 | proc frq_ui_reset*() {.exportc, dynlib.} = |
| 237 | tr.close() | |
| 238 | app = initState() |