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

Four screens, in Nim

connect, discover, settings and chats, transcribed from their ClojureDart
originals rather than sketched — the same copy, the same fields, the same
stable wrappers, so the two can be read side by side and a disagreement is a
bug rather than an opinion. This is what the deleted spike should have been.

Under them, `cells.nim`: all 44 atoms as one record. The Clojure keeps them
apart because a screen there watches the individual cells it read; here the
whole tree is rebuilt from the whole state on every event, so that grain buys
nothing and one record is easier to reason about. The comments that record a
decision came with their fields — why the attachment is held apart from the
draft, why `jump-to` is cleared the frame after it is set, why the overview
owes the reader a way back.

`ui.nim` grew the rest of the vocabulary: separator, spacer, text, link,
image, avatar, reaction, status, emoji, dialog. That is all 22 tags the real
screens use, against the 12 the spike covered — the gap was most of why its
chat screen was a facsimile.

The terminal branches are dropped rather than translated. `conversation-row`
had two layouts and one of them was for a frontend that no longer exists.

One real porting bug, found by a test rather than by eye: `previewLine`
truncated by byte where Clojure's `subs` truncates by character, so a preview
full of emoji got cut mid-codepoint into mojibake instead of an ellipsis. It
is runes now, and there is a test with a hundred emoji in it.

114 Nim tests. 1,538 lines of Nim. Still ahead: chat.cljc (1,508) and app.cljc
(398), which need reactions, replies, edits, members and the emoji catalogue
under them first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T20:49:47-07:00 Browse files
c69a09b parent: d333b6f
added nim/src/frq/cells.nim +193 -0
new file mode 100644
@@ -0,0 +1,193 @@
1+## Every piece of state the screens read.
2+##
3+## `common/frq/cells.cljc`, as one record. The Clojure holds 44 separate atoms
4+## because a screen there watches individual cells and re-renders on the ones
5+## it read; here the whole tree is rebuilt from the whole state on every
6+## event, so the grain that mattered there buys nothing and one record is
7+## easier to reason about.
8+##
9+## The comments are kept from the original wherever they record a decision,
10+## because they are the reason a field is shaped the way it is rather than
11+## documentation of what it obviously holds.
12+
13+import std/tables
14+import model
15+
16+type
17+ Screen* = enum
18+ scConnect = "connect", scChats = "chats", scChat = "chat",
19+ scDiscover = "discover", scSettings = "settings"
20+
21+ AuthMode* = enum
22+ amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"
23+
24+ UploadStatus* = enum
25+ usUploading = "uploading", usReady = "ready"
26+
27+ Attachment* = object
28+ ## The picture waiting to go out with the next line.
29+ ##
30+ ## Held apart from the draft rather than written into it. A link pasted
31+ ## into the entry is a line of unreadable text in the middle of whatever
32+ ## the reader was typing, and it puts their cursor somewhere they did not
33+ ## put it. The picture is a picture until it is sent; the draft stays
34+ ## theirs.
35+ ##
36+ ## One at a time — a second paste replaces the first, which is what a
37+ ## reader who pasted the wrong thing means by pasting the right one.
38+ has*: bool
39+ path*: string ## the copy on disk, which is what the preview paints
40+ url*: string ## where freeq serves it, once the upload has landed
41+ status*: UploadStatus
42+
43+ EditTarget* = object
44+ ## The message the draft is a rewrite of, or absent when the box is being
45+ ## used for something new. Only the id is kept: what is being rewritten is
46+ ## in the box, and the line on screen is the thing it will replace.
47+ has*: bool
48+ room*: string
49+ id*: string
50+
51+ ReplyTarget* = object
52+ ## The message the draft is answering. Held whole rather than as an id
53+ ## alone so the compose bar can say who is being answered without going
54+ ## looking for them.
55+ has*: bool
56+ id*, frm*, text*: string
57+
58+ Lightbox* = object
59+ has*: bool
60+ path*, url*: string
61+
62+ ReactTarget* = object
63+ ## What the emoji picker is choosing for. The picker is a panel over the
64+ ## compose bar rather than a screen: what is being reacted to has to stay
65+ ## in sight.
66+ has*: bool
67+ room*, id*: string
68+
69+ ProfileView* = object
70+ ## Who is being looked at — `actor` is the DID or handle, absent for a
71+ ## guest.
72+ has*: bool
73+ nick*, actor*: string
74+
75+ State* = object
76+ screen*: Screen
77+ status*: string
78+ error*: string
79+ hasError*: bool
80+ connecting*: bool
81+
82+ # The connect form.
83+ authMode*: AuthMode
84+ formHost*, formPort*, formNick*: string
85+ formTls*: bool
86+ formHandle*, formAppPassword*: string
87+
88+ # The durable half of an OAuth sign-in. The web-token beside it is
89+ # single-use, so a reconnect mints a fresh one from this rather than
90+ # replaying the old.
91+ brokerToken*: string
92+ apiBearer*: string
93+ loginUrl*: string ## shown while the browser is open
94+
95+ # Rooms.
96+ rooms*: OrderedTable[string, Room]
97+ current*: string
98+ joinInput*: string
99+ search*: string
100+
101+ # The compose bar and its three companions.
102+ draft*: string
103+ editing*: EditTarget
104+ replyingTo*: ReplyTarget
105+ attachment*: Attachment
106+
107+ # Chat chrome.
108+ showUsers*: bool
109+ hideChatList*: bool
110+ overview*: bool
111+ atPresent*: bool
112+ hideJoinPart*: bool
113+ ## Comings and goings, hidden or not. A quiet room reads better with
114+ ## them — they are how you notice someone arriving — and a busy one
115+ ## drowns in them, so it is the reader's call.
116+
117+ # Emoji picker.
118+ emojiGroup*: string
119+ emojiSearch*: string
120+ reacting*: ReactTarget
121+ reactionHover*: ReactTarget
122+
123+ # Navigation within a conversation.
124+ highlight*: string
125+ jumpTo*: string
126+ ## The message a "go to" is aiming at. Set for the frame that scrolls to
127+ ## it and taken off again — a target that stayed set would pin the view
128+ ## there and take scrolling away from the reader.
129+ jumpTick*: int
130+ overviewReturn*: string
131+ ## The room the reader was in when a line in the overview took them
132+ ## somewhere else. The strip is the one place in the app that moves you
133+ ## without your having asked to leave where you were, so it is the one
134+ ## place that owes you the way back.
135+
136+ lightbox*: Lightbox
137+ savedTo*: string
138+ ## What the lightbox's Save has to say for itself: where a picture
139+ ## landed, or "failed". Saving is a file appearing somewhere off screen,
140+ ## so the one thing the reader needs back is where — a button that only
141+ ## stops looking pressed has told them nothing.
142+ savedFailed*: bool
143+
144+ imagePicker*: string
145+ profileViewing*: ProfileView
146+ profileTick*: int
147+
148+ # The window, polled from the host.
149+ windowWidth*, windowHeight*: int
150+ ## The height is for the pictures in the conversation: a preview sized
151+ ## against the window is a picture on a laptop and a thumbnail on a
152+ ## phone, where one fixed height is only ever right on one of them.
153+
154+const
155+ defaultHost* = "irc.freeq.at"
156+ defaultPort* = "6697"
157+
158+ wideWidth* = 900
159+ ## Where the second pane starts paying for itself. Below this a 320pt list
160+ ## beside a conversation leaves the messages narrower than the phone
161+ ## layout they were written for.
162+
163+ popularChannels*: seq[(string, string)] = @[
164+ ("#general", "General discussion"),
165+ ("#test", "Test channel"),
166+ ("#freeq", "freeq development & support"),
167+ ("#dev", "Programming & technology"),
168+ ("#music", "Music recommendations"),
169+ ("#random", "Off-topic chat")]
170+
171+func initState*(): State =
172+ State(screen: scConnect,
173+ status: "Not connected",
174+ authMode: amGuest,
175+ formHost: defaultHost,
176+ formPort: defaultPort,
177+ formTls: true,
178+ formNick: "frq-guest",
179+ atPresent: true,
180+ rooms: initOrderedTable[string, Room]())
181+
182+func wide*(s: State): bool =
183+ ## Past this the room list and the conversation are both on screen instead
184+ ## of taking turns.
185+ s.windowWidth >= wideWidth
186+
187+func currentRoom*(s: State): Room =
188+ if s.current.len > 0 and s.rooms.hasKey(s.current): s.rooms[s.current]
189+ else: initRoom("")
190+
191+var app* = initState()
192+ ## The one mutable thing. Named `app` rather than `state` because `state` is
193+ ## ambiguous against unittest's own inside a test module.
new file mode 100644
@@ -0,0 +1,193 @@
1+## Every piece of state the screens read.
2+##
3+## `common/frq/cells.cljc`, as one record. The Clojure holds 44 separate atoms
4+## because a screen there watches individual cells and re-renders on the ones
5+## it read; here the whole tree is rebuilt from the whole state on every
6+## event, so the grain that mattered there buys nothing and one record is
7+## easier to reason about.
8+##
9+## The comments are kept from the original wherever they record a decision,
10+## because they are the reason a field is shaped the way it is rather than
11+## documentation of what it obviously holds.
12+
13+import std/tables
14+import model
15+
16+type
17+ Screen* = enum
18+ scConnect = "connect", scChats = "chats", scChat = "chat",
19+ scDiscover = "discover", scSettings = "settings"
20+
21+ AuthMode* = enum
22+ amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"
23+
24+ UploadStatus* = enum
25+ usUploading = "uploading", usReady = "ready"
26+
27+ Attachment* = object
28+ ## The picture waiting to go out with the next line.
29+ ##
30+ ## Held apart from the draft rather than written into it. A link pasted
31+ ## into the entry is a line of unreadable text in the middle of whatever
32+ ## the reader was typing, and it puts their cursor somewhere they did not
33+ ## put it. The picture is a picture until it is sent; the draft stays
34+ ## theirs.
35+ ##
36+ ## One at a time — a second paste replaces the first, which is what a
37+ ## reader who pasted the wrong thing means by pasting the right one.
38+ has*: bool
39+ path*: string ## the copy on disk, which is what the preview paints
40+ url*: string ## where freeq serves it, once the upload has landed
41+ status*: UploadStatus
42+
43+ EditTarget* = object
44+ ## The message the draft is a rewrite of, or absent when the box is being
45+ ## used for something new. Only the id is kept: what is being rewritten is
46+ ## in the box, and the line on screen is the thing it will replace.
47+ has*: bool
48+ room*: string
49+ id*: string
50+
51+ ReplyTarget* = object
52+ ## The message the draft is answering. Held whole rather than as an id
53+ ## alone so the compose bar can say who is being answered without going
54+ ## looking for them.
55+ has*: bool
56+ id*, frm*, text*: string
57+
58+ Lightbox* = object
59+ has*: bool
60+ path*, url*: string
61+
62+ ReactTarget* = object
63+ ## What the emoji picker is choosing for. The picker is a panel over the
64+ ## compose bar rather than a screen: what is being reacted to has to stay
65+ ## in sight.
66+ has*: bool
67+ room*, id*: string
68+
69+ ProfileView* = object
70+ ## Who is being looked at — `actor` is the DID or handle, absent for a
71+ ## guest.
72+ has*: bool
73+ nick*, actor*: string
74+
75+ State* = object
76+ screen*: Screen
77+ status*: string
78+ error*: string
79+ hasError*: bool
80+ connecting*: bool
81+
82+ # The connect form.
83+ authMode*: AuthMode
84+ formHost*, formPort*, formNick*: string
85+ formTls*: bool
86+ formHandle*, formAppPassword*: string
87+
88+ # The durable half of an OAuth sign-in. The web-token beside it is
89+ # single-use, so a reconnect mints a fresh one from this rather than
90+ # replaying the old.
91+ brokerToken*: string
92+ apiBearer*: string
93+ loginUrl*: string ## shown while the browser is open
94+
95+ # Rooms.
96+ rooms*: OrderedTable[string, Room]
97+ current*: string
98+ joinInput*: string
99+ search*: string
100+
101+ # The compose bar and its three companions.
102+ draft*: string
103+ editing*: EditTarget
104+ replyingTo*: ReplyTarget
105+ attachment*: Attachment
106+
107+ # Chat chrome.
108+ showUsers*: bool
109+ hideChatList*: bool
110+ overview*: bool
111+ atPresent*: bool
112+ hideJoinPart*: bool
113+ ## Comings and goings, hidden or not. A quiet room reads better with
114+ ## them — they are how you notice someone arriving — and a busy one
115+ ## drowns in them, so it is the reader's call.
116+
117+ # Emoji picker.
118+ emojiGroup*: string
119+ emojiSearch*: string
120+ reacting*: ReactTarget
121+ reactionHover*: ReactTarget
122+
123+ # Navigation within a conversation.
124+ highlight*: string
125+ jumpTo*: string
126+ ## The message a "go to" is aiming at. Set for the frame that scrolls to
127+ ## it and taken off again — a target that stayed set would pin the view
128+ ## there and take scrolling away from the reader.
129+ jumpTick*: int
130+ overviewReturn*: string
131+ ## The room the reader was in when a line in the overview took them
132+ ## somewhere else. The strip is the one place in the app that moves you
133+ ## without your having asked to leave where you were, so it is the one
134+ ## place that owes you the way back.
135+
136+ lightbox*: Lightbox
137+ savedTo*: string
138+ ## What the lightbox's Save has to say for itself: where a picture
139+ ## landed, or "failed". Saving is a file appearing somewhere off screen,
140+ ## so the one thing the reader needs back is where — a button that only
141+ ## stops looking pressed has told them nothing.
142+ savedFailed*: bool
143+
144+ imagePicker*: string
145+ profileViewing*: ProfileView
146+ profileTick*: int
147+
148+ # The window, polled from the host.
149+ windowWidth*, windowHeight*: int
150+ ## The height is for the pictures in the conversation: a preview sized
151+ ## against the window is a picture on a laptop and a thumbnail on a
152+ ## phone, where one fixed height is only ever right on one of them.
153+
154+const
155+ defaultHost* = "irc.freeq.at"
156+ defaultPort* = "6697"
157+
158+ wideWidth* = 900
159+ ## Where the second pane starts paying for itself. Below this a 320pt list
160+ ## beside a conversation leaves the messages narrower than the phone
161+ ## layout they were written for.
162+
163+ popularChannels*: seq[(string, string)] = @[
164+ ("#general", "General discussion"),
165+ ("#test", "Test channel"),
166+ ("#freeq", "freeq development & support"),
167+ ("#dev", "Programming & technology"),
168+ ("#music", "Music recommendations"),
169+ ("#random", "Off-topic chat")]
170+
171+func initState*(): State =
172+ State(screen: scConnect,
173+ status: "Not connected",
174+ authMode: amGuest,
175+ formHost: defaultHost,
176+ formPort: defaultPort,
177+ formTls: true,
178+ formNick: "frq-guest",
179+ atPresent: true,
180+ rooms: initOrderedTable[string, Room]())
181+
182+func wide*(s: State): bool =
183+ ## Past this the room list and the conversation are both on screen instead
184+ ## of taking turns.
185+ s.windowWidth >= wideWidth
186+
187+func currentRoom*(s: State): Room =
188+ if s.current.len > 0 and s.rooms.hasKey(s.current): s.rooms[s.current]
189+ else: initRoom("")
190+
191+var app* = initState()
192+ ## The one mutable thing. Named `app` rather than `state` because `state` is
193+ ## ambiguous against unittest's own inside a test module.
added nim/src/frq/screens/chats.nim +98 -0
new file mode 100644
@@ -0,0 +1,98 @@
1+## The conversation list.
2+##
3+## Transcribed from `common/frq/screens/chats.cljc`. The terminal branches are
4+## dropped rather than translated — there is no terminal frontend any more, so
5+## `conversation-row`'s two layouts collapse to the one a window uses.
6+
7+import std/[json, strutils, tables]
8+# `runeLen` and `runeSubStr` only: a plain `import std/unicode` brings a
9+# `title` that is ambiguous against `ui.title`, which is the one this file
10+# means every time it says it.
11+from std/unicode import runeLen, runeSubStr
12+import ../ui, ../cells, ../model, ../rooms
13+import frame, connect
14+
15+const listGutter = 16
16+ ## The scrollbar's room. Without it the cards sit under it and the last
17+ ## character of a preview is behind the thumb.
18+
19+func previewLine*(text: string): string =
20+ ## The last line of a conversation, as one line.
21+ ##
22+ ## A pasted shell script or a long link is a card's worth of text otherwise,
23+ ## and the cards stop reading as a list of rooms.
24+ var line = newStringOfCap(text.len)
25+ var inSpace = false
26+ for c in text:
27+ if c in {' ', '\t', '\n', '\r'}:
28+ if not inSpace: line.add ' '
29+ inSpace = true
30+ else:
31+ line.add c
32+ inSpace = false
33+ # Runes and not bytes. Clojure's `count` and `subs` are characters, and a
34+ # byte slice at 59 can land in the middle of one — which for a preview full
35+ # of emoji is a truncation that produces mojibake rather than an ellipsis.
36+ if line.runeLen > 60: line.runeSubStr(0, 59) & "" else: line
37+
38+func conversationRow(r: Room): Node =
39+ var badges = vbox(%*{"key": "badges"})
40+ # A room we have not joined is one the reader can see but is not in — worth
41+ # saying, because Open and Close both work on it either way.
42+ let away = not dm(r.name) and not r.joined
43+ if away or r.unread > 0:
44+ var row = hbox(%*{"spacing": 12, "wrap": false})
45+ if away:
46+ row.children.add status("not joined", live = false)
47+ if r.unread > 0:
48+ row.children.add label((if r.mention: "◆ @ " else: "") & $r.unread)
49+ badges.children.add row
50+
51+ n("vbox", %*{"key": r.name, "marginRight": listGutter}, @[
52+ card(
53+ title2(r.name),
54+ badges,
55+ dimLabel(previewLine(r.lastPreview)),
56+ hbox(%*{"spacing": 8, "wrap": false},
57+ button("Open", "room.open:" & r.name),
58+ button("Close", "room.leave:" & r.name)))])
59+
60+func chatsScreen*(s: State, connected: bool): Node =
61+ let buffers = channelList(s.rooms, s.search)
62+
63+ var head = vbox(%*{"key": "head", "spacing": 8, "marginRight": listGutter},
64+ title(if connected: "Logged in as " & s.formNick else: "Chats"),
65+ errorNote(s))
66+
67+ # `@nick` opens a DM and `#room` joins a channel, and the button says which
68+ # so the reader is not guessing what Enter will do.
69+ var joinRow = hbox(%*{"spacing": 8, "align": "end"},
70+ button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"),
71+ entry("join-input", s.joinInput, "#channel or @nick", "join-input.change",
72+ onSubmit = "join"))
73+
74+ var searchRow = hbox(%*{"spacing": 8, "align": "end"})
75+ if s.search.len > 0:
76+ searchRow.children.add button("", "search.clear")
77+ searchRow.children.add entry("search", s.search, "Search channels",
78+ "search.change")
79+
80+ head.children.add card(vbox(%*{"spacing": 8}, joinRow, searchRow))
81+
82+ var body: Node
83+ if buffers.len > 0:
84+ body = vbox(%*{"spacing": 8})
85+ for b in buffers:
86+ body.children.add conversationRow(b)
87+ else:
88+ body = n("vbox", %*{"marginRight": listGutter}, @[
89+ card(dimLabel("No conversations yet — join a channel."))])
90+
91+ vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true},
92+ head,
93+ vbox(%*{"key": "list", "fillHeight": true},
94+ scroll(%*{"scrollKey": "chats-list", "orientation": "vertical",
95+ "reserve": belowList(s)}, body)),
96+ vbox(%*{"key": "foot", "spacing": 8},
97+ separator(),
98+ tabBar(s)))
new file mode 100644
@@ -0,0 +1,98 @@
1+## The conversation list.
2+##
3+## Transcribed from `common/frq/screens/chats.cljc`. The terminal branches are
4+## dropped rather than translated — there is no terminal frontend any more, so
5+## `conversation-row`'s two layouts collapse to the one a window uses.
6+
7+import std/[json, strutils, tables]
8+# `runeLen` and `runeSubStr` only: a plain `import std/unicode` brings a
9+# `title` that is ambiguous against `ui.title`, which is the one this file
10+# means every time it says it.
11+from std/unicode import runeLen, runeSubStr
12+import ../ui, ../cells, ../model, ../rooms
13+import frame, connect
14+
15+const listGutter = 16
16+ ## The scrollbar's room. Without it the cards sit under it and the last
17+ ## character of a preview is behind the thumb.
18+
19+func previewLine*(text: string): string =
20+ ## The last line of a conversation, as one line.
21+ ##
22+ ## A pasted shell script or a long link is a card's worth of text otherwise,
23+ ## and the cards stop reading as a list of rooms.
24+ var line = newStringOfCap(text.len)
25+ var inSpace = false
26+ for c in text:
27+ if c in {' ', '\t', '\n', '\r'}:
28+ if not inSpace: line.add ' '
29+ inSpace = true
30+ else:
31+ line.add c
32+ inSpace = false
33+ # Runes and not bytes. Clojure's `count` and `subs` are characters, and a
34+ # byte slice at 59 can land in the middle of one — which for a preview full
35+ # of emoji is a truncation that produces mojibake rather than an ellipsis.
36+ if line.runeLen > 60: line.runeSubStr(0, 59) & "" else: line
37+
38+func conversationRow(r: Room): Node =
39+ var badges = vbox(%*{"key": "badges"})
40+ # A room we have not joined is one the reader can see but is not in — worth
41+ # saying, because Open and Close both work on it either way.
42+ let away = not dm(r.name) and not r.joined
43+ if away or r.unread > 0:
44+ var row = hbox(%*{"spacing": 12, "wrap": false})
45+ if away:
46+ row.children.add status("not joined", live = false)
47+ if r.unread > 0:
48+ row.children.add label((if r.mention: "◆ @ " else: "") & $r.unread)
49+ badges.children.add row
50+
51+ n("vbox", %*{"key": r.name, "marginRight": listGutter}, @[
52+ card(
53+ title2(r.name),
54+ badges,
55+ dimLabel(previewLine(r.lastPreview)),
56+ hbox(%*{"spacing": 8, "wrap": false},
57+ button("Open", "room.open:" & r.name),
58+ button("Close", "room.leave:" & r.name)))])
59+
60+func chatsScreen*(s: State, connected: bool): Node =
61+ let buffers = channelList(s.rooms, s.search)
62+
63+ var head = vbox(%*{"key": "head", "spacing": 8, "marginRight": listGutter},
64+ title(if connected: "Logged in as " & s.formNick else: "Chats"),
65+ errorNote(s))
66+
67+ # `@nick` opens a DM and `#room` joins a channel, and the button says which
68+ # so the reader is not guessing what Enter will do.
69+ var joinRow = hbox(%*{"spacing": 8, "align": "end"},
70+ button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"),
71+ entry("join-input", s.joinInput, "#channel or @nick", "join-input.change",
72+ onSubmit = "join"))
73+
74+ var searchRow = hbox(%*{"spacing": 8, "align": "end"})
75+ if s.search.len > 0:
76+ searchRow.children.add button("", "search.clear")
77+ searchRow.children.add entry("search", s.search, "Search channels",
78+ "search.change")
79+
80+ head.children.add card(vbox(%*{"spacing": 8}, joinRow, searchRow))
81+
82+ var body: Node
83+ if buffers.len > 0:
84+ body = vbox(%*{"spacing": 8})
85+ for b in buffers:
86+ body.children.add conversationRow(b)
87+ else:
88+ body = n("vbox", %*{"marginRight": listGutter}, @[
89+ card(dimLabel("No conversations yet — join a channel."))])
90+
91+ vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true},
92+ head,
93+ vbox(%*{"key": "list", "fillHeight": true},
94+ scroll(%*{"scrollKey": "chats-list", "orientation": "vertical",
95+ "reserve": belowList(s)}, body)),
96+ vbox(%*{"key": "foot", "spacing": 8},
97+ separator(),
98+ tabBar(s)))
added nim/src/frq/screens/connect.nim +119 -0
new file mode 100644
@@ -0,0 +1,119 @@
1+## The connect screen.
2+##
3+## A faithful transcription of `common/frq/screens/connect.cljc` — the same
4+## copy, the same fields, the same three modes — so the two can be put side by
5+## side and any disagreement is a bug rather than an opinion.
6+##
7+## Where the Clojure derefs a cell this reads a field, and where it puts a
8+## closure in `:on-click` this puts an event id. That substitution is the only
9+## systematic difference between the two files.
10+
11+import std/json
12+import ../ui, ../cells
13+
14+const transportNote* =
15+ "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener."
16+
17+func errorNote*(s: State): Node =
18+ ## Always a node, never nothing.
19+ ##
20+ ## A conditional child that disappears shifts every sibling after it, and a
21+ ## renderer matching children by position would patch the header into a card
22+ ## when an error appeared mid-screen. A stable wrapper with a stable key
23+ ## keeps the shape of the tree fixed and only its contents changing.
24+ result = vbox(%*{"key": "error-note", "spacing": 6})
25+ if s.hasError:
26+ result.children.add card(
27+ label("" & s.error),
28+ button("Dismiss", "error.dismiss"))
29+
30+func modeTabs(s: State): Node =
31+ hbox(%*{"spacing": 8},
32+ button("Guest", "mode.guest",
33+ if s.authMode == amGuest: "primary" else: "default"),
34+ button("Bluesky", "mode.bluesky",
35+ if s.authMode == amBluesky: "primary" else: "default"),
36+ button("App password", "mode.app-password",
37+ if s.authMode == amAppPassword: "primary" else: "default"))
38+
39+func serverFields(s: State): Node =
40+ ## Every entry carries a key. A renderer that keeps a text controller per
41+ ## field needs a stable name for it, and without one the host and the port
42+ ## shared a controller and both showed the port.
43+ vbox(%*{"spacing": 6},
44+ label("Server"),
45+ hbox(%*{"spacing": 8},
46+ entry("host", s.formHost, "host", "host.change", width = 220),
47+ entry("port", s.formPort, "6697", "port.change", width = 90)),
48+ checkbutton("TLS", s.formTls, "tls.toggle"))
49+
50+func authFields(s: State): Node =
51+ case s.authMode
52+ of amBluesky:
53+ result = vbox(%*{"spacing": 6},
54+ title2("Sign in with Bluesky"),
55+ dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " &
56+ "hands back a token; no password passes through frq."),
57+ label("Handle"),
58+ entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
59+ width = 320))
60+ # Both of these are stable wrappers for the reason `errorNote` is one.
61+ var remembered = vbox(%*{"key": "remembered", "spacing": 4})
62+ if s.brokerToken.len > 0:
63+ remembered.children.add vbox(%*{"spacing": 4},
64+ dimLabel("Session remembered — Connect will not need the browser."),
65+ button("Forget saved session", "session.forget"))
66+ result.children.add remembered
67+
68+ var login = vbox(%*{"key": "login-url", "spacing": 4})
69+ if s.loginUrl.len > 0:
70+ login.children.add vbox(%*{"spacing": 4},
71+ dimLabel("If the browser did not open, visit:"),
72+ label(s.loginUrl))
73+ result.children.add login
74+
75+ of amAppPassword:
76+ result = vbox(%*{"spacing": 6},
77+ title2("Sign in with an app password"),
78+ dimLabel("No browser. Your app password goes to your own PDS; freeq " &
79+ "is handed the session it mints."),
80+ label("Handle"),
81+ entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
82+ width = 320),
83+ label("App password"),
84+ entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx",
85+ "app-password.change", width = 320),
86+ dimLabel("Make one at bsky.app → Settings → App Passwords."))
87+
88+ of amGuest:
89+ result = vbox(%*{"spacing": 6},
90+ title2("Connect as guest"),
91+ label("Nick"),
92+ entry("nick", s.formNick, "your nick", "nick.change", width = 320))
93+
94+func connectAction(s: State): Node =
95+ if s.connecting:
96+ # Cancel beside the spinner. A connection that never completes — a host
97+ # that does not answer, a TLS handshake that hangs — is otherwise a
98+ # spinner with no way out but killing the window.
99+ hbox(%*{"spacing": 8},
100+ spinner(),
101+ dimLabel(s.status),
102+ button("Cancel", "cancel"))
103+ else:
104+ hbox(%*{"spacing": 8},
105+ button("Connect", "connect", "primary"),
106+ dimLabel(s.status))
107+
108+func connectScreen*(s: State): Node =
109+ page(%*{"maxWidth": 520},
110+ title("frq"),
111+ dimLabel("freeq client — guest, or your Bluesky identity."),
112+ errorNote(s),
113+ card(
114+ modeTabs(s),
115+ authFields(s),
116+ serverFields(s),
117+ separator(),
118+ connectAction(s)),
119+ dimLabel(transportNote))
new file mode 100644
@@ -0,0 +1,119 @@
1+## The connect screen.
2+##
3+## A faithful transcription of `common/frq/screens/connect.cljc` — the same
4+## copy, the same fields, the same three modes — so the two can be put side by
5+## side and any disagreement is a bug rather than an opinion.
6+##
7+## Where the Clojure derefs a cell this reads a field, and where it puts a
8+## closure in `:on-click` this puts an event id. That substitution is the only
9+## systematic difference between the two files.
10+
11+import std/json
12+import ../ui, ../cells
13+
14+const transportNote* =
15+ "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener."
16+
17+func errorNote*(s: State): Node =
18+ ## Always a node, never nothing.
19+ ##
20+ ## A conditional child that disappears shifts every sibling after it, and a
21+ ## renderer matching children by position would patch the header into a card
22+ ## when an error appeared mid-screen. A stable wrapper with a stable key
23+ ## keeps the shape of the tree fixed and only its contents changing.
24+ result = vbox(%*{"key": "error-note", "spacing": 6})
25+ if s.hasError:
26+ result.children.add card(
27+ label("" & s.error),
28+ button("Dismiss", "error.dismiss"))
29+
30+func modeTabs(s: State): Node =
31+ hbox(%*{"spacing": 8},
32+ button("Guest", "mode.guest",
33+ if s.authMode == amGuest: "primary" else: "default"),
34+ button("Bluesky", "mode.bluesky",
35+ if s.authMode == amBluesky: "primary" else: "default"),
36+ button("App password", "mode.app-password",
37+ if s.authMode == amAppPassword: "primary" else: "default"))
38+
39+func serverFields(s: State): Node =
40+ ## Every entry carries a key. A renderer that keeps a text controller per
41+ ## field needs a stable name for it, and without one the host and the port
42+ ## shared a controller and both showed the port.
43+ vbox(%*{"spacing": 6},
44+ label("Server"),
45+ hbox(%*{"spacing": 8},
46+ entry("host", s.formHost, "host", "host.change", width = 220),
47+ entry("port", s.formPort, "6697", "port.change", width = 90)),
48+ checkbutton("TLS", s.formTls, "tls.toggle"))
49+
50+func authFields(s: State): Node =
51+ case s.authMode
52+ of amBluesky:
53+ result = vbox(%*{"spacing": 6},
54+ title2("Sign in with Bluesky"),
55+ dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " &
56+ "hands back a token; no password passes through frq."),
57+ label("Handle"),
58+ entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
59+ width = 320))
60+ # Both of these are stable wrappers for the reason `errorNote` is one.
61+ var remembered = vbox(%*{"key": "remembered", "spacing": 4})
62+ if s.brokerToken.len > 0:
63+ remembered.children.add vbox(%*{"spacing": 4},
64+ dimLabel("Session remembered — Connect will not need the browser."),
65+ button("Forget saved session", "session.forget"))
66+ result.children.add remembered
67+
68+ var login = vbox(%*{"key": "login-url", "spacing": 4})
69+ if s.loginUrl.len > 0:
70+ login.children.add vbox(%*{"spacing": 4},
71+ dimLabel("If the browser did not open, visit:"),
72+ label(s.loginUrl))
73+ result.children.add login
74+
75+ of amAppPassword:
76+ result = vbox(%*{"spacing": 6},
77+ title2("Sign in with an app password"),
78+ dimLabel("No browser. Your app password goes to your own PDS; freeq " &
79+ "is handed the session it mints."),
80+ label("Handle"),
81+ entry("handle", s.formHandle, "alice.bsky.social", "handle.change",
82+ width = 320),
83+ label("App password"),
84+ entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx",
85+ "app-password.change", width = 320),
86+ dimLabel("Make one at bsky.app → Settings → App Passwords."))
87+
88+ of amGuest:
89+ result = vbox(%*{"spacing": 6},
90+ title2("Connect as guest"),
91+ label("Nick"),
92+ entry("nick", s.formNick, "your nick", "nick.change", width = 320))
93+
94+func connectAction(s: State): Node =
95+ if s.connecting:
96+ # Cancel beside the spinner. A connection that never completes — a host
97+ # that does not answer, a TLS handshake that hangs — is otherwise a
98+ # spinner with no way out but killing the window.
99+ hbox(%*{"spacing": 8},
100+ spinner(),
101+ dimLabel(s.status),
102+ button("Cancel", "cancel"))
103+ else:
104+ hbox(%*{"spacing": 8},
105+ button("Connect", "connect", "primary"),
106+ dimLabel(s.status))
107+
108+func connectScreen*(s: State): Node =
109+ page(%*{"maxWidth": 520},
110+ title("frq"),
111+ dimLabel("freeq client — guest, or your Bluesky identity."),
112+ errorNote(s),
113+ card(
114+ modeTabs(s),
115+ authFields(s),
116+ serverFields(s),
117+ separator(),
118+ connectAction(s)),
119+ dimLabel(transportNote))
added nim/src/frq/screens/frame.nim +49 -0
new file mode 100644
@@ -0,0 +1,49 @@
1+## The tab bar, and the frame the three tabbed screens share.
2+##
3+## From `common/frq/screens/chats.cljc` and `settings.cljc`, which is where
4+## they live in the Clojure — pulled together here because they are one idea
5+## and neither screen owns them.
6+
7+import std/json
8+import ../ui, ../cells
9+
10+func tabBar*(s: State): Node =
11+ hbox(%*{"spacing": 8},
12+ button("Chats", "screen.chats",
13+ if s.screen == scChats: "primary" else: "default"),
14+ button("Discover", "screen.discover",
15+ if s.screen == scDiscover: "primary" else: "default"),
16+ button("Settings", "screen.settings",
17+ if s.screen == scSettings: "primary" else: "default"))
18+
19+func belowList*(s: State): int =
20+ ## How many points the strip under the list needs, so the scroll view knows
21+ ## what to reserve. A separator and a row of tabs.
22+ ##
23+ ## Counted in chrome rows rather than points in the Clojure, because a
24+ ## terminal's row is one cell and a window's is 34. There is no terminal any
25+ ## more, so this is the window's number.
26+ 46
27+
28+func tabScreen*(s: State, title0, scrollKey: string,
29+ body: varargs[Node]): Node =
30+ ## One of the three screens the tab bar moves between: the title at the top,
31+ ## the tabs pinned at the bottom, and the body scrolling between them.
32+ ##
33+ ## The same shape for all three, so switching tabs moves nothing but the
34+ ## middle. As pages they were centred columns of their own widths with the
35+ ## tabs wherever the content happened to end, and every switch resized the
36+ ## screen under the pointer.
37+ var list = vbox(%*{"key": "list", "fillHeight": true})
38+ var sc = scroll(%*{"scrollKey": scrollKey, "orientation": "vertical",
39+ "reserve": belowList(s), "spacing": 8})
40+ for b in body:
41+ if not b.isNil: sc.children.add b
42+ list.children.add sc
43+
44+ vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true},
45+ title(title0),
46+ list,
47+ vbox(%*{"key": "foot", "spacing": 8},
48+ separator(),
49+ tabBar(s)))
new file mode 100644
@@ -0,0 +1,49 @@
1+## The tab bar, and the frame the three tabbed screens share.
2+##
3+## From `common/frq/screens/chats.cljc` and `settings.cljc`, which is where
4+## they live in the Clojure — pulled together here because they are one idea
5+## and neither screen owns them.
6+
7+import std/json
8+import ../ui, ../cells
9+
10+func tabBar*(s: State): Node =
11+ hbox(%*{"spacing": 8},
12+ button("Chats", "screen.chats",
13+ if s.screen == scChats: "primary" else: "default"),
14+ button("Discover", "screen.discover",
15+ if s.screen == scDiscover: "primary" else: "default"),
16+ button("Settings", "screen.settings",
17+ if s.screen == scSettings: "primary" else: "default"))
18+
19+func belowList*(s: State): int =
20+ ## How many points the strip under the list needs, so the scroll view knows
21+ ## what to reserve. A separator and a row of tabs.
22+ ##
23+ ## Counted in chrome rows rather than points in the Clojure, because a
24+ ## terminal's row is one cell and a window's is 34. There is no terminal any
25+ ## more, so this is the window's number.
26+ 46
27+
28+func tabScreen*(s: State, title0, scrollKey: string,
29+ body: varargs[Node]): Node =
30+ ## One of the three screens the tab bar moves between: the title at the top,
31+ ## the tabs pinned at the bottom, and the body scrolling between them.
32+ ##
33+ ## The same shape for all three, so switching tabs moves nothing but the
34+ ## middle. As pages they were centred columns of their own widths with the
35+ ## tabs wherever the content happened to end, and every switch resized the
36+ ## screen under the pointer.
37+ var list = vbox(%*{"key": "list", "fillHeight": true})
38+ var sc = scroll(%*{"scrollKey": scrollKey, "orientation": "vertical",
39+ "reserve": belowList(s), "spacing": 8})
40+ for b in body:
41+ if not b.isNil: sc.children.add b
42+ list.children.add sc
43+
44+ vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true},
45+ title(title0),
46+ list,
47+ vbox(%*{"key": "foot", "spacing": 8},
48+ separator(),
49+ tabBar(s)))
added nim/src/frq/screens/settings.nim +59 -0
new file mode 100644
@@ -0,0 +1,59 @@
1+## Discover and Settings — the small two.
2+##
3+## A list of rooms to join and a page of switches, transcribed from
4+## `common/frq/screens/settings.cljc`.
5+
6+import std/[json, tables]
7+import ../ui, ../cells, ../model
8+import frame, connect
9+
10+func discoverScreen*(s: State): Node =
11+ var body = @[dimLabel("Popular channels on freeq."), errorNote(s)]
12+ for (name, blurb) in popularChannels:
13+ let joined = s.rooms.hasKey(name) and s.rooms[name].joined
14+ body.add n("card", %*{"key": name}, @[
15+ title2(name),
16+ dimLabel(blurb),
17+ button(if joined: "Open" else: "Join",
18+ (if joined: "room.open:" else: "room.join:") & name)])
19+ tabScreen(s, "Discover", "discover-list", body)
20+
21+func settingsScreen*(s: State, connected: bool, desktop: bool): Node =
22+ var identity = vbox(%*{"key": "identity", "spacing": 6})
23+ if s.formHandle.len > 0:
24+ identity.children.add label(s.formHandle)
25+ if s.brokerToken.len > 0:
26+ identity.children.add button("Forget Bluesky session", "session.forget",
27+ "destructive")
28+ else:
29+ identity.children.add dimLabel("Guest — not signed in.")
30+
31+ # Nothing to disconnect from when there is no connection — the way back to
32+ # the connect screen is what is wanted then.
33+ var action = vbox(%*{"key": "connection-action"})
34+ action.children.add(
35+ if connected: button("Disconnect", "disconnect", "destructive")
36+ else: button("Back to connect", "screen.connect"))
37+
38+ var about = card(
39+ title2("frq"),
40+ dimLabel("freeq client — Nim over Flutter, on Android, Linux and the web."))
41+ # No Quit where there is nothing to quit: closing an app is a window's idea,
42+ # and Android has its own way of leaving one.
43+ if desktop:
44+ about.children.add button("Quit", "quit")
45+
46+ tabScreen(s, "Settings", "settings-list", [
47+ card(
48+ title2("Connection"),
49+ status(s.status, live = connected),
50+ identity,
51+ separator(),
52+ action),
53+ card(
54+ title2("Messages"),
55+ checkbutton("Hide join/part messages", s.hideJoinPart,
56+ "join-part.toggle"),
57+ dimLabel("Hides other people arriving, leaving and quitting. The " &
58+ "people panel still follows who is here.")),
59+ about])
new file mode 100644
@@ -0,0 +1,59 @@
1+## Discover and Settings — the small two.
2+##
3+## A list of rooms to join and a page of switches, transcribed from
4+## `common/frq/screens/settings.cljc`.
5+
6+import std/[json, tables]
7+import ../ui, ../cells, ../model
8+import frame, connect
9+
10+func discoverScreen*(s: State): Node =
11+ var body = @[dimLabel("Popular channels on freeq."), errorNote(s)]
12+ for (name, blurb) in popularChannels:
13+ let joined = s.rooms.hasKey(name) and s.rooms[name].joined
14+ body.add n("card", %*{"key": name}, @[
15+ title2(name),
16+ dimLabel(blurb),
17+ button(if joined: "Open" else: "Join",
18+ (if joined: "room.open:" else: "room.join:") & name)])
19+ tabScreen(s, "Discover", "discover-list", body)
20+
21+func settingsScreen*(s: State, connected: bool, desktop: bool): Node =
22+ var identity = vbox(%*{"key": "identity", "spacing": 6})
23+ if s.formHandle.len > 0:
24+ identity.children.add label(s.formHandle)
25+ if s.brokerToken.len > 0:
26+ identity.children.add button("Forget Bluesky session", "session.forget",
27+ "destructive")
28+ else:
29+ identity.children.add dimLabel("Guest — not signed in.")
30+
31+ # Nothing to disconnect from when there is no connection — the way back to
32+ # the connect screen is what is wanted then.
33+ var action = vbox(%*{"key": "connection-action"})
34+ action.children.add(
35+ if connected: button("Disconnect", "disconnect", "destructive")
36+ else: button("Back to connect", "screen.connect"))
37+
38+ var about = card(
39+ title2("frq"),
40+ dimLabel("freeq client — Nim over Flutter, on Android, Linux and the web."))
41+ # No Quit where there is nothing to quit: closing an app is a window's idea,
42+ # and Android has its own way of leaving one.
43+ if desktop:
44+ about.children.add button("Quit", "quit")
45+
46+ tabScreen(s, "Settings", "settings-list", [
47+ card(
48+ title2("Connection"),
49+ status(s.status, live = connected),
50+ identity,
51+ separator(),
52+ action),
53+ card(
54+ title2("Messages"),
55+ checkbutton("Hide join/part messages", s.hideJoinPart,
56+ "join-part.toggle"),
57+ dimLabel("Hides other people arriving, leaving and quitting. The " &
58+ "people panel still follows who is here.")),
59+ about])
modified nim/src/frq/ui.nim +53 -0
@@ -90,3 +90,56 @@ func scroll*(props: JsonNode, children: varargs[Node]): Node =
9090 ## A list that is taller than the room it has. The renderer decides how that
9191 ## is done; the tree only says that it is expected.
9292 n("scroll", props, @children)
93+
94+# ------------------------------------------------- the rest of the vocabulary
95+#
96+# Every tag the real screens use. `frq.hiccup` interprets exactly these into
97+# Flutter widgets today, so a tree emitted here describes the same screen it
98+# describes there — which is what makes the port a transcription rather than a
99+# redesign.
100+
101+func separator*(): Node = n("separator")
102+
103+func spacer*(size: int): Node = n("spacer", %*{"size": size})
104+
105+func text*(body: string): Node =
106+ ## Prose, as opposed to a `label`: wraps, and is the thing a message is.
107+ n("text", %*{"text": body})
108+
109+func link*(label, url: string): Node =
110+ n("link", %*{"label": label, "url": url})
111+
112+func image*(src: string, maxWidth = 0, maxHeight = 0, onClick = ""): Node =
113+ var p = %*{"src": src}
114+ if maxWidth > 0: p["maxWidth"] = %maxWidth
115+ if maxHeight > 0: p["maxHeight"] = %maxHeight
116+ if onClick.len > 0: p["onClick"] = %onClick
117+ n("image", p)
118+
119+func avatar*(url, fallback: string, size = 24): Node =
120+ ## A profile picture, or the letter to draw where there is none. The
121+ ## fallback is here rather than in the renderer because which letter is a
122+ ## question about the nick, and the nick is the tree's business.
123+ n("avatar", %*{"url": url, "fallback": fallback, "size": size})
124+
125+func reaction*(emoji: string, count: int, mine: bool, onClick: string): Node =
126+ ## A pill under a message. `mine` is what makes it look pressed, and is why
127+ ## this is not just a button with a count in it.
128+ n("reaction", %*{"emoji": emoji, "count": count, "mine": mine,
129+ "onClick": onClick})
130+
131+func status*(label: string, live = false): Node =
132+ ## A connection indicator. `live` is what makes the dot beside it green, and
133+ ## is why this is not a plain label.
134+ n("status", %*{"label": label, "live": live})
135+
136+func emoji*(glyph, onClick: string): Node =
137+ n("emoji", %*{"glyph": glyph, "onClick": onClick})
138+
139+func dialog*(title: string, props: JsonNode, children: varargs[Node]): Node =
140+ ## A panel over the screen rather than a screen of its own. `frq.screens.app`
141+ ## floats it where there is a pointer and draws it in place where there is
142+ ## not; the tree says only that it is a dialog.
143+ var p = if props.isNil: newJObject() else: props
144+ p["title"] = %title
145+ n("dialog", p, @children)
@@ -90,3 +90,56 @@ func scroll*(props: JsonNode, children: varargs[Node]): Node =
90 ## A list that is taller than the room it has. The renderer decides how that90 ## A list that is taller than the room it has. The renderer decides how that
91 ## is done; the tree only says that it is expected.91 ## is done; the tree only says that it is expected.
92 n("scroll", props, @children)92 n("scroll", props, @children)
93+
94+# ------------------------------------------------- the rest of the vocabulary
95+#
96+# Every tag the real screens use. `frq.hiccup` interprets exactly these into
97+# Flutter widgets today, so a tree emitted here describes the same screen it
98+# describes there — which is what makes the port a transcription rather than a
99+# redesign.
100+
101+func separator*(): Node = n("separator")
102+
103+func spacer*(size: int): Node = n("spacer", %*{"size": size})
104+
105+func text*(body: string): Node =
106+ ## Prose, as opposed to a `label`: wraps, and is the thing a message is.
107+ n("text", %*{"text": body})
108+
109+func link*(label, url: string): Node =
110+ n("link", %*{"label": label, "url": url})
111+
112+func image*(src: string, maxWidth = 0, maxHeight = 0, onClick = ""): Node =
113+ var p = %*{"src": src}
114+ if maxWidth > 0: p["maxWidth"] = %maxWidth
115+ if maxHeight > 0: p["maxHeight"] = %maxHeight
116+ if onClick.len > 0: p["onClick"] = %onClick
117+ n("image", p)
118+
119+func avatar*(url, fallback: string, size = 24): Node =
120+ ## A profile picture, or the letter to draw where there is none. The
121+ ## fallback is here rather than in the renderer because which letter is a
122+ ## question about the nick, and the nick is the tree's business.
123+ n("avatar", %*{"url": url, "fallback": fallback, "size": size})
124+
125+func reaction*(emoji: string, count: int, mine: bool, onClick: string): Node =
126+ ## A pill under a message. `mine` is what makes it look pressed, and is why
127+ ## this is not just a button with a count in it.
128+ n("reaction", %*{"emoji": emoji, "count": count, "mine": mine,
129+ "onClick": onClick})
130+
131+func status*(label: string, live = false): Node =
132+ ## A connection indicator. `live` is what makes the dot beside it green, and
133+ ## is why this is not a plain label.
134+ n("status", %*{"label": label, "live": live})
135+
136+func emoji*(glyph, onClick: string): Node =
137+ n("emoji", %*{"glyph": glyph, "onClick": onClick})
138+
139+func dialog*(title: string, props: JsonNode, children: varargs[Node]): Node =
140+ ## A panel over the screen rather than a screen of its own. `frq.screens.app`
141+ ## floats it where there is a pointer and draws it in place where there is
142+ ## not; the tree says only that it is a dialog.
143+ var p = if props.isNil: newJObject() else: props
144+ p["title"] = %title
145+ n("dialog", p, @children)
added nim/tests/tcells.nim +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+## The state record, and the few questions it answers itself.
2+
3+import std/[tables, unittest]
4+import frq/[cells, model]
5+
6+suite "initState":
7+ test "starts on the connect screen, not connected":
8+ let s = initState()
9+ check s.screen == scConnect
10+ check s.status == "Not connected"
11+ check not s.connecting
12+
13+ test "the form is filled in with the defaults the screen shows":
14+ let s = initState()
15+ check s.formHost == defaultHost
16+ check s.formPort == defaultPort
17+ check s.formTls
18+ check s.formNick == "frq-guest"
19+ check s.authMode == amGuest
20+
21+ test "at-present, because a fresh conversation is at its end":
22+ check initState().atPresent
23+
24+ test "nothing is attached, replied to or being edited":
25+ let s = initState()
26+ check not s.attachment.has
27+ check not s.replyingTo.has
28+ check not s.editing.has
29+ check not s.lightbox.has
30+
31+suite "wide":
32+ test "a phone-width window is not wide":
33+ var s = initState(); s.windowWidth = 420
34+ check not s.wide
35+ test "past wideWidth it is":
36+ var s = initState(); s.windowWidth = wideWidth
37+ check s.wide
38+ test "a window that has not reported yet is not":
39+ # windowWidth starts at 0, and guessing wide would put a 320pt list beside
40+ # a conversation on a phone.
41+ check not initState().wide
42+
43+suite "currentRoom":
44+ test "the room `current` names":
45+ var s = initState()
46+ s.rooms["#test"] = initRoom("#test")
47+ s.current = "#test"
48+ check s.currentRoom.name == "#test"
49+
50+ test "an empty room where nothing is current":
51+ # A value rather than an Option: every screen that asks is about to read
52+ # `.messages`, and an empty list is the honest answer for "no room".
53+ check initState().currentRoom.name == ""
54+
55+ test "an empty room where `current` names one that is gone":
56+ var s = initState(); s.current = "#vanished"
57+ check s.currentRoom.name == ""
58+
59+suite "the constants the screens share":
60+ test "popular channels are the discover list":
61+ check popularChannels.len == 6
62+ check popularChannels[0][0] == "#general"
new file mode 100644
@@ -0,0 +1,62 @@
1+## The state record, and the few questions it answers itself.
2+
3+import std/[tables, unittest]
4+import frq/[cells, model]
5+
6+suite "initState":
7+ test "starts on the connect screen, not connected":
8+ let s = initState()
9+ check s.screen == scConnect
10+ check s.status == "Not connected"
11+ check not s.connecting
12+
13+ test "the form is filled in with the defaults the screen shows":
14+ let s = initState()
15+ check s.formHost == defaultHost
16+ check s.formPort == defaultPort
17+ check s.formTls
18+ check s.formNick == "frq-guest"
19+ check s.authMode == amGuest
20+
21+ test "at-present, because a fresh conversation is at its end":
22+ check initState().atPresent
23+
24+ test "nothing is attached, replied to or being edited":
25+ let s = initState()
26+ check not s.attachment.has
27+ check not s.replyingTo.has
28+ check not s.editing.has
29+ check not s.lightbox.has
30+
31+suite "wide":
32+ test "a phone-width window is not wide":
33+ var s = initState(); s.windowWidth = 420
34+ check not s.wide
35+ test "past wideWidth it is":
36+ var s = initState(); s.windowWidth = wideWidth
37+ check s.wide
38+ test "a window that has not reported yet is not":
39+ # windowWidth starts at 0, and guessing wide would put a 320pt list beside
40+ # a conversation on a phone.
41+ check not initState().wide
42+
43+suite "currentRoom":
44+ test "the room `current` names":
45+ var s = initState()
46+ s.rooms["#test"] = initRoom("#test")
47+ s.current = "#test"
48+ check s.currentRoom.name == "#test"
49+
50+ test "an empty room where nothing is current":
51+ # A value rather than an Option: every screen that asks is about to read
52+ # `.messages`, and an empty list is the honest answer for "no room".
53+ check initState().currentRoom.name == ""
54+
55+ test "an empty room where `current` names one that is gone":
56+ var s = initState(); s.current = "#vanished"
57+ check s.currentRoom.name == ""
58+
59+suite "the constants the screens share":
60+ test "popular channels are the discover list":
61+ check popularChannels.len == 6
62+ check popularChannels[0][0] == "#general"
added nim/tests/tscreens.nim +235 -0
new file mode 100644
@@ -0,0 +1,235 @@
1+## The screens, as pure functions of the state.
2+##
3+## These are the tests the ClojureDart screens never had and could not easily
4+## have: a screen there is hiccup over cells a host installs, so exercising one
5+## means standing up a host. Here it is a function from a record to a tree.
6+
7+import std/[json, sequtils, strutils, tables, unicode, unittest]
8+import frq/[ui, cells, model]
9+import frq/screens/connect as cs
10+import frq/screens/settings as ss
11+
12+proc find*(node: Node, tag: string): seq[Node] =
13+ if node.isNil: return
14+ if node.tag == tag: result.add node
15+ for c in node.children: result.add c.find(tag)
16+
17+proc labels(node: Node, tag: string): seq[string] =
18+ node.find(tag).mapIt(it.props{"label"}.getStr())
19+
20+proc keys(node: Node, tag: string): seq[string] =
21+ node.find(tag).mapIt(it.props{"key"}.getStr())
22+
23+suite "the connect screen":
24+ setup:
25+ var s = initState()
26+
27+ test "is a page with the title and the transport note":
28+ let t = cs.connectScreen(s)
29+ check t.tag == "page"
30+ check "frq" in t.labels("title")
31+ check cs.transportNote in t.labels("dim-label")
32+
33+ test "is pure — twice with no change is the same tree":
34+ check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson
35+
36+ test "guest is the default, and shows a nick field":
37+ let t = cs.connectScreen(s)
38+ check "Connect as guest" in t.labels("title-2")
39+ check "nick" in t.keys("entry")
40+ check "handle" notin t.keys("entry")
41+
42+ test "bluesky shows a handle and its own copy":
43+ s.authMode = amBluesky
44+ let t = cs.connectScreen(s)
45+ check "Sign in with Bluesky" in t.labels("title-2")
46+ check "handle" in t.keys("entry")
47+ check "nick" notin t.keys("entry")
48+
49+ test "app-password asks for both, and says where to make one":
50+ s.authMode = amAppPassword
51+ let t = cs.connectScreen(s)
52+ check "handle" in t.keys("entry")
53+ check "app-password" in t.keys("entry")
54+ check t.labels("dim-label").anyIt("App Passwords" in it)
55+
56+ test "a remembered session offers to be forgotten, and only then":
57+ s.authMode = amBluesky
58+ check "Forget saved session" notin cs.connectScreen(s).labels("button")
59+ s.brokerToken = "tok"
60+ check "Forget saved session" in cs.connectScreen(s).labels("button")
61+
62+ test "the login URL is shown while the browser is open":
63+ s.authMode = amBluesky
64+ s.loginUrl = "https://auth.freeq.at/x"
65+ let t = cs.connectScreen(s)
66+ check "https://auth.freeq.at/x" in t.labels("label")
67+
68+ test "the error note keeps its place whether or not there is an error":
69+ # The bug the stable wrapper exists for: a renderer matching children by
70+ # position would patch the header into a card when the error appeared.
71+ let before = cs.connectScreen(s).children.mapIt(it.tag)
72+ s.hasError = true
73+ s.error = "nope"
74+ check cs.connectScreen(s).children.mapIt(it.tag) == before
75+ check "Dismiss" in cs.connectScreen(s).labels("button")
76+
77+ test "the remembered and login-url wrappers hold their place too":
78+ s.authMode = amBluesky
79+ let bare = cs.connectScreen(s)
80+ s.brokerToken = "tok"
81+ s.loginUrl = "https://x"
82+ check cs.connectScreen(s).keys("vbox").filterIt(it.len > 0) ==
83+ bare.keys("vbox").filterIt(it.len > 0)
84+
85+ test "connecting swaps Connect for a spinner and a way out":
86+ s.connecting = true
87+ let t = cs.connectScreen(s)
88+ check t.find("spinner").len == 1
89+ check "Connect" notin t.labels("button")
90+ check "Cancel" in t.labels("button")
91+
92+suite "discover":
93+ setup:
94+ var s = initState()
95+
96+ test "lists the popular channels with their blurbs":
97+ let t = ss.discoverScreen(s)
98+ check t.labels("title-2") == popularChannels.mapIt(it[0])
99+ check "#general" in t.keys("card")
100+
101+ test "offers Join for a room we are not in and Open for one we are":
102+ check ss.discoverScreen(s).labels("button").countIt(it == "Join") ==
103+ popularChannels.len
104+ var r = initRoom("#test"); r.joined = true
105+ s.rooms["#test"] = r
106+ let t = ss.discoverScreen(s)
107+ check "Open" in t.labels("button")
108+ check t.labels("button").countIt(it == "Join") == popularChannels.len - 1
109+
110+ test "the tab bar is under it, with Discover selected":
111+ let t = ss.discoverScreen(s)
112+ s.screen = scDiscover
113+ let sel = ss.discoverScreen(s).find("button")
114+ .filterIt(it.props{"kind"}.getStr() == "primary")
115+ .mapIt(it.props{"label"}.getStr())
116+ check "Discover" in sel
117+ check t.find("scroll").len == 1
118+
119+suite "settings":
120+ setup:
121+ var s = initState()
122+
123+ test "a guest is told so":
124+ check "Guest — not signed in." in
125+ ss.settingsScreen(s, connected = false, desktop = true).labels("dim-label")
126+
127+ test "a signed-in handle is shown, and can be forgotten":
128+ s.formHandle = "alice.bsky.social"
129+ s.brokerToken = "tok"
130+ let t = ss.settingsScreen(s, connected = true, desktop = true)
131+ check "alice.bsky.social" in t.labels("label")
132+ check "Forget Bluesky session" in t.labels("button")
133+
134+ test "Disconnect when connected, Back to connect when not":
135+ check "Disconnect" in
136+ ss.settingsScreen(s, connected = true, desktop = true).labels("button")
137+ check "Back to connect" in
138+ ss.settingsScreen(s, connected = false, desktop = true).labels("button")
139+
140+ test "Quit only where there is a window to quit":
141+ check "Quit" in
142+ ss.settingsScreen(s, connected = false, desktop = true).labels("button")
143+ check "Quit" notin
144+ ss.settingsScreen(s, connected = false, desktop = false).labels("button")
145+
146+ test "the join/part switch reflects the cell":
147+ let off = ss.settingsScreen(s, false, true).find("checkbutton")[0]
148+ check not off.props{"active"}.getBool()
149+ s.hideJoinPart = true
150+ let on = ss.settingsScreen(s, false, true).find("checkbutton")[0]
151+ check on.props{"active"}.getBool()
152+
153+ test "the status line is live only when connected":
154+ check ss.settingsScreen(s, true, true).find("status")[0]
155+ .props{"live"}.getBool()
156+ check not ss.settingsScreen(s, false, true).find("status")[0]
157+ .props{"live"}.getBool()
158+
159+import frq/screens/chats as ch
160+
161+suite "previewLine":
162+ test "collapses whitespace so a card reads as one line":
163+ check ch.previewLine("a\n b\tc") == "a b c"
164+ test "truncates a pasted script rather than growing the card":
165+ let long = "x".repeat(200)
166+ check ch.previewLine(long).runeLen == 60
167+ check ch.previewLine(long).endsWith("")
168+
169+ test "truncates by character, not by byte":
170+ # A byte slice at 59 lands inside a multi-byte character and produces
171+ # mojibake where an ellipsis was wanted. The first draft did exactly that.
172+ let emoji = "😀".repeat(100)
173+ let got = ch.previewLine(emoji)
174+ check got.runeLen == 60
175+ check got.validateUtf8 == -1
176+ test "leaves a short line alone":
177+ check ch.previewLine("hello there") == "hello there"
178+
179+suite "the chats screen":
180+ setup:
181+ var s = initState()
182+ var r = initRoom("#test")
183+ r.joined = true
184+ r.messages = @[Message(frm: "alice", text: "hello")]
185+ s.rooms["#test"] = r
186+
187+ test "an empty list says so instead of showing nothing":
188+ var empty = initState()
189+ check "No conversations yet — join a channel." in
190+ ch.chatsScreen(empty, false).labels("dim-label")
191+
192+ test "a room is a card with its name and last line":
193+ let t = ch.chatsScreen(s, true)
194+ check "#test" in t.labels("title-2")
195+ check "alice: hello" in t.labels("dim-label")
196+
197+ test "the title says who we are when connected":
198+ check "Logged in as frq-guest" in ch.chatsScreen(s, true).labels("title")
199+ check "Chats" in ch.chatsScreen(s, false).labels("title")
200+
201+ test "an unread count shows, with a mention marked differently":
202+ var u = s.rooms["#test"]
203+ u.unread = 3
204+ s.rooms["#test"] = u
205+ check "● 3" in ch.chatsScreen(s, true).labels("label")
206+ u.mention = true
207+ s.rooms["#test"] = u
208+ check "◆ @ 3" in ch.chatsScreen(s, true).labels("label")
209+
210+ test "a channel we are not in is badged, a DM never is":
211+ var away = s.rooms["#test"]
212+ away.joined = false
213+ s.rooms["#test"] = away
214+ check "not joined" in ch.chatsScreen(s, true).labels("status")
215+ var dmr = initRoom("alice")
216+ s.rooms["alice"] = dmr
217+ # Still only the one badge: a DM has nothing to join.
218+ check ch.chatsScreen(s, true).labels("status").countIt(it == "not joined") == 1
219+
220+ test "the button says Message for a nick and Join for a channel":
221+ s.joinInput = "#room"
222+ check "Join" in ch.chatsScreen(s, true).labels("button")
223+ s.joinInput = "@alice"
224+ check "Message" in ch.chatsScreen(s, true).labels("button")
225+
226+ test "the search box offers a clear only when there is something to clear":
227+ check "" notin ch.chatsScreen(s, true).labels("button")
228+ s.search = "te"
229+ check "" in ch.chatsScreen(s, true).labels("button")
230+
231+ test "search filters the list":
232+ s.rooms["#other"] = initRoom("#other")
233+ s.search = "oth"
234+ let names = ch.chatsScreen(s, true).labels("title-2")
235+ check names == @["#other"]
new file mode 100644
@@ -0,0 +1,235 @@
1+## The screens, as pure functions of the state.
2+##
3+## These are the tests the ClojureDart screens never had and could not easily
4+## have: a screen there is hiccup over cells a host installs, so exercising one
5+## means standing up a host. Here it is a function from a record to a tree.
6+
7+import std/[json, sequtils, strutils, tables, unicode, unittest]
8+import frq/[ui, cells, model]
9+import frq/screens/connect as cs
10+import frq/screens/settings as ss
11+
12+proc find*(node: Node, tag: string): seq[Node] =
13+ if node.isNil: return
14+ if node.tag == tag: result.add node
15+ for c in node.children: result.add c.find(tag)
16+
17+proc labels(node: Node, tag: string): seq[string] =
18+ node.find(tag).mapIt(it.props{"label"}.getStr())
19+
20+proc keys(node: Node, tag: string): seq[string] =
21+ node.find(tag).mapIt(it.props{"key"}.getStr())
22+
23+suite "the connect screen":
24+ setup:
25+ var s = initState()
26+
27+ test "is a page with the title and the transport note":
28+ let t = cs.connectScreen(s)
29+ check t.tag == "page"
30+ check "frq" in t.labels("title")
31+ check cs.transportNote in t.labels("dim-label")
32+
33+ test "is pure — twice with no change is the same tree":
34+ check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson
35+
36+ test "guest is the default, and shows a nick field":
37+ let t = cs.connectScreen(s)
38+ check "Connect as guest" in t.labels("title-2")
39+ check "nick" in t.keys("entry")
40+ check "handle" notin t.keys("entry")
41+
42+ test "bluesky shows a handle and its own copy":
43+ s.authMode = amBluesky
44+ let t = cs.connectScreen(s)
45+ check "Sign in with Bluesky" in t.labels("title-2")
46+ check "handle" in t.keys("entry")
47+ check "nick" notin t.keys("entry")
48+
49+ test "app-password asks for both, and says where to make one":
50+ s.authMode = amAppPassword
51+ let t = cs.connectScreen(s)
52+ check "handle" in t.keys("entry")
53+ check "app-password" in t.keys("entry")
54+ check t.labels("dim-label").anyIt("App Passwords" in it)
55+
56+ test "a remembered session offers to be forgotten, and only then":
57+ s.authMode = amBluesky
58+ check "Forget saved session" notin cs.connectScreen(s).labels("button")
59+ s.brokerToken = "tok"
60+ check "Forget saved session" in cs.connectScreen(s).labels("button")
61+
62+ test "the login URL is shown while the browser is open":
63+ s.authMode = amBluesky
64+ s.loginUrl = "https://auth.freeq.at/x"
65+ let t = cs.connectScreen(s)
66+ check "https://auth.freeq.at/x" in t.labels("label")
67+
68+ test "the error note keeps its place whether or not there is an error":
69+ # The bug the stable wrapper exists for: a renderer matching children by
70+ # position would patch the header into a card when the error appeared.
71+ let before = cs.connectScreen(s).children.mapIt(it.tag)
72+ s.hasError = true
73+ s.error = "nope"
74+ check cs.connectScreen(s).children.mapIt(it.tag) == before
75+ check "Dismiss" in cs.connectScreen(s).labels("button")
76+
77+ test "the remembered and login-url wrappers hold their place too":
78+ s.authMode = amBluesky
79+ let bare = cs.connectScreen(s)
80+ s.brokerToken = "tok"
81+ s.loginUrl = "https://x"
82+ check cs.connectScreen(s).keys("vbox").filterIt(it.len > 0) ==
83+ bare.keys("vbox").filterIt(it.len > 0)
84+
85+ test "connecting swaps Connect for a spinner and a way out":
86+ s.connecting = true
87+ let t = cs.connectScreen(s)
88+ check t.find("spinner").len == 1
89+ check "Connect" notin t.labels("button")
90+ check "Cancel" in t.labels("button")
91+
92+suite "discover":
93+ setup:
94+ var s = initState()
95+
96+ test "lists the popular channels with their blurbs":
97+ let t = ss.discoverScreen(s)
98+ check t.labels("title-2") == popularChannels.mapIt(it[0])
99+ check "#general" in t.keys("card")
100+
101+ test "offers Join for a room we are not in and Open for one we are":
102+ check ss.discoverScreen(s).labels("button").countIt(it == "Join") ==
103+ popularChannels.len
104+ var r = initRoom("#test"); r.joined = true
105+ s.rooms["#test"] = r
106+ let t = ss.discoverScreen(s)
107+ check "Open" in t.labels("button")
108+ check t.labels("button").countIt(it == "Join") == popularChannels.len - 1
109+
110+ test "the tab bar is under it, with Discover selected":
111+ let t = ss.discoverScreen(s)
112+ s.screen = scDiscover
113+ let sel = ss.discoverScreen(s).find("button")
114+ .filterIt(it.props{"kind"}.getStr() == "primary")
115+ .mapIt(it.props{"label"}.getStr())
116+ check "Discover" in sel
117+ check t.find("scroll").len == 1
118+
119+suite "settings":
120+ setup:
121+ var s = initState()
122+
123+ test "a guest is told so":
124+ check "Guest — not signed in." in
125+ ss.settingsScreen(s, connected = false, desktop = true).labels("dim-label")
126+
127+ test "a signed-in handle is shown, and can be forgotten":
128+ s.formHandle = "alice.bsky.social"
129+ s.brokerToken = "tok"
130+ let t = ss.settingsScreen(s, connected = true, desktop = true)
131+ check "alice.bsky.social" in t.labels("label")
132+ check "Forget Bluesky session" in t.labels("button")
133+
134+ test "Disconnect when connected, Back to connect when not":
135+ check "Disconnect" in
136+ ss.settingsScreen(s, connected = true, desktop = true).labels("button")
137+ check "Back to connect" in
138+ ss.settingsScreen(s, connected = false, desktop = true).labels("button")
139+
140+ test "Quit only where there is a window to quit":
141+ check "Quit" in
142+ ss.settingsScreen(s, connected = false, desktop = true).labels("button")
143+ check "Quit" notin
144+ ss.settingsScreen(s, connected = false, desktop = false).labels("button")
145+
146+ test "the join/part switch reflects the cell":
147+ let off = ss.settingsScreen(s, false, true).find("checkbutton")[0]
148+ check not off.props{"active"}.getBool()
149+ s.hideJoinPart = true
150+ let on = ss.settingsScreen(s, false, true).find("checkbutton")[0]
151+ check on.props{"active"}.getBool()
152+
153+ test "the status line is live only when connected":
154+ check ss.settingsScreen(s, true, true).find("status")[0]
155+ .props{"live"}.getBool()
156+ check not ss.settingsScreen(s, false, true).find("status")[0]
157+ .props{"live"}.getBool()
158+
159+import frq/screens/chats as ch
160+
161+suite "previewLine":
162+ test "collapses whitespace so a card reads as one line":
163+ check ch.previewLine("a\n b\tc") == "a b c"
164+ test "truncates a pasted script rather than growing the card":
165+ let long = "x".repeat(200)
166+ check ch.previewLine(long).runeLen == 60
167+ check ch.previewLine(long).endsWith("")
168+
169+ test "truncates by character, not by byte":
170+ # A byte slice at 59 lands inside a multi-byte character and produces
171+ # mojibake where an ellipsis was wanted. The first draft did exactly that.
172+ let emoji = "😀".repeat(100)
173+ let got = ch.previewLine(emoji)
174+ check got.runeLen == 60
175+ check got.validateUtf8 == -1
176+ test "leaves a short line alone":
177+ check ch.previewLine("hello there") == "hello there"
178+
179+suite "the chats screen":
180+ setup:
181+ var s = initState()
182+ var r = initRoom("#test")
183+ r.joined = true
184+ r.messages = @[Message(frm: "alice", text: "hello")]
185+ s.rooms["#test"] = r
186+
187+ test "an empty list says so instead of showing nothing":
188+ var empty = initState()
189+ check "No conversations yet — join a channel." in
190+ ch.chatsScreen(empty, false).labels("dim-label")
191+
192+ test "a room is a card with its name and last line":
193+ let t = ch.chatsScreen(s, true)
194+ check "#test" in t.labels("title-2")
195+ check "alice: hello" in t.labels("dim-label")
196+
197+ test "the title says who we are when connected":
198+ check "Logged in as frq-guest" in ch.chatsScreen(s, true).labels("title")
199+ check "Chats" in ch.chatsScreen(s, false).labels("title")
200+
201+ test "an unread count shows, with a mention marked differently":
202+ var u = s.rooms["#test"]
203+ u.unread = 3
204+ s.rooms["#test"] = u
205+ check "● 3" in ch.chatsScreen(s, true).labels("label")
206+ u.mention = true
207+ s.rooms["#test"] = u
208+ check "◆ @ 3" in ch.chatsScreen(s, true).labels("label")
209+
210+ test "a channel we are not in is badged, a DM never is":
211+ var away = s.rooms["#test"]
212+ away.joined = false
213+ s.rooms["#test"] = away
214+ check "not joined" in ch.chatsScreen(s, true).labels("status")
215+ var dmr = initRoom("alice")
216+ s.rooms["alice"] = dmr
217+ # Still only the one badge: a DM has nothing to join.
218+ check ch.chatsScreen(s, true).labels("status").countIt(it == "not joined") == 1
219+
220+ test "the button says Message for a nick and Join for a channel":
221+ s.joinInput = "#room"
222+ check "Join" in ch.chatsScreen(s, true).labels("button")
223+ s.joinInput = "@alice"
224+ check "Message" in ch.chatsScreen(s, true).labels("button")
225+
226+ test "the search box offers a clear only when there is something to clear":
227+ check "" notin ch.chatsScreen(s, true).labels("button")
228+ s.search = "te"
229+ check "" in ch.chatsScreen(s, true).labels("button")
230+
231+ test "search filters the list":
232+ s.rooms["#other"] = initRoom("#other")
233+ s.search = "oth"
234+ let names = ch.chatsScreen(s, true).labels("title-2")
235+ check names == @["#other"]