| One frontend where there were three, and a core that is not Clojure 438b247 nandi 20h ago | 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 1bb3f77 nandi 19h ago | 22 | ## |
| The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago | 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 438b247 nandi 20h ago | 27 | |
| Lay every screen out in a test, and fix what that found 36bdfc5 nandi 15h ago | 28 | import std/[json, strutils, tables] |
| Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 15h ago | 29 | import frq/[ircparse, trace, ui, cells, reducer, model, rooms] |
| The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago | 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 438b247 nandi 20h ago | 35 | |
| 36 | proc frq_init*() {.exportc, dynlib.} = |
| The renderer, and a transport bug that took four tries 58e6c97 nandi 17h ago | 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 438b247 nandi 20h ago | 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 43a02c2 nandi 19h ago | 110 | |
| Nim under the existing UI, not instead of it 56551a8 nandi 19h ago | 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 58e6c97 nandi 17h ago | 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 36bdfc5 nandi 15h ago | 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 |
| Four more modules into Nim: members, glyphs, emoji, store 3975b4b nandi 11h ago | 209 | r.users = {"me": "", "alice": "@", "bob": ""}.toTable |
| Lay every screen out in a test, and fix what that found 36bdfc5 nandi 15h ago | 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 58e6c97 nandi 17h ago | 236 | proc frq_ui_reset*() {.exportc, dynlib.} = |
| 237 | tr.close() |
| 238 | app = initState() |