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

Tab finishes the name you started

What Tab does in every IRC client, and the rules are theirs: one match taken
whole, several taken as far as they agree so the reader types another letter
and asks again rather than being handed somebody at random, none leaving the
draft alone. Case is ignored on the way in and the nick's own case is what
lands, because people type `nan` and mean `nandi.uk`. A name at the start of a
line is being addressed and gets `nick: `; anywhere else it is a mention and
gets a space — which is the shape freeq's own backlog already has.

`frq.members/complete-nick` is pure and shared, so the terminal and the two
windows all complete the same way. What the composer supplies is who is in the
room.

The renderer has the awkward half. A TextField never sees Tab: Flutter reads
it as focus traversal and has moved on before the field hears anything, so the
key is claimed a level up in a `Focus` that answers `handled`. `KeyDownEvent`
only — a held key repeats, and every repeat would complete again against the
word the last one just finished — and only where an `:on-tab` was asked for,
so every other box on every screen still tabs to the next field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-17T19:36:11-07:00 Browse files
8c38fb8 parent: 8dac959
modified common/frq/members.cljc +49 -0
@@ -121,3 +121,52 @@
121121
122122 (defn member-count [m channel]
123123 (count (get-in m [channel :users])))
124+
125+(defn- common-prefix
126+ "The longest string every one of `ss` starts with."
127+ [ss]
128+ (reduce (fn [a b]
129+ (let [n (min (count a) (count b))]
130+ (loop [i 0]
131+ (if (and (< i n)
132+ (= (str/lower-case (subs a i (inc i)))
133+ (str/lower-case (subs b i (inc i)))))
134+ (recur (inc i))
135+ (subs a 0 i)))))
136+ ss))
137+
138+(defn complete-nick
139+ "`text` with its last word completed against `nicks`, and where the caret
140+ should end up. Nil when there is nothing to complete.
141+
142+ What Tab does in every IRC client, and the rules are theirs. One match is
143+ taken whole. Several are taken as far as they agree — the reader types
144+ another letter and asks again, rather than being given somebody at random.
145+ None leaves the draft alone.
146+
147+ A name at the start of a line is addressed, so it gets `nick: `; anywhere
148+ else it is mentioned mid-sentence and gets a plain space. That is the
149+ convention freeq's own messages already follow — `eve: watch pubtoons.com`
150+ reads as talking TO eve.
151+
152+ Case is ignored when matching and the nick's own case is what lands: people
153+ type `nan<tab>` and mean `nandi.uk`."
154+ [text nicks]
155+ (let [text (str text)
156+ cut (inc (max (.lastIndexOf text " ") (.lastIndexOf text "\n")))
157+ word (subs text cut)]
158+ (when (seq word)
159+ (let [lower (str/lower-case word)
160+ matches (->> nicks
161+ (map str)
162+ (filter #(str/starts-with? (str/lower-case %) lower))
163+ sort
164+ vec)]
165+ (when (seq matches)
166+ (let [done (if (= 1 (count matches))
167+ (str (first matches) (if (zero? cut) ": " " "))
168+ (common-prefix matches))]
169+ ;; Nothing to add is not worth a redraw: several names that agree
170+ ;; only as far as what was already typed.
171+ (when (> (count done) (count word))
172+ (str (subs text 0 cut) done))))))))
@@ -121,3 +121,52 @@
121 121
122 (defn member-count [m channel]122 (defn member-count [m channel]
123 (count (get-in m [channel :users])))123 (count (get-in m [channel :users])))
124+
125+(defn- common-prefix
126+ "The longest string every one of `ss` starts with."
127+ [ss]
128+ (reduce (fn [a b]
129+ (let [n (min (count a) (count b))]
130+ (loop [i 0]
131+ (if (and (< i n)
132+ (= (str/lower-case (subs a i (inc i)))
133+ (str/lower-case (subs b i (inc i)))))
134+ (recur (inc i))
135+ (subs a 0 i)))))
136+ ss))
137+
138+(defn complete-nick
139+ "`text` with its last word completed against `nicks`, and where the caret
140+ should end up. Nil when there is nothing to complete.
141+
142+ What Tab does in every IRC client, and the rules are theirs. One match is
143+ taken whole. Several are taken as far as they agree — the reader types
144+ another letter and asks again, rather than being given somebody at random.
145+ None leaves the draft alone.
146+
147+ A name at the start of a line is addressed, so it gets `nick: `; anywhere
148+ else it is mentioned mid-sentence and gets a plain space. That is the
149+ convention freeq's own messages already follow — `eve: watch pubtoons.com`
150+ reads as talking TO eve.
151+
152+ Case is ignored when matching and the nick's own case is what lands: people
153+ type `nan<tab>` and mean `nandi.uk`."
154+ [text nicks]
155+ (let [text (str text)
156+ cut (inc (max (.lastIndexOf text " ") (.lastIndexOf text "\n")))
157+ word (subs text cut)]
158+ (when (seq word)
159+ (let [lower (str/lower-case word)
160+ matches (->> nicks
161+ (map str)
162+ (filter #(str/starts-with? (str/lower-case %) lower))
163+ sort
164+ vec)]
165+ (when (seq matches)
166+ (let [done (if (= 1 (count matches))
167+ (str (first matches) (if (zero? cut) ": " " "))
168+ (common-prefix matches))]
169+ ;; Nothing to add is not worth a redraw: several names that agree
170+ ;; only as far as what was already typed.
171+ (when (> (count done) (count word))
172+ (str (subs text 0 cut) done))))))))
modified common/frq/screens/chat.cljc +11 -0
@@ -17,6 +17,7 @@
1717 [frq.clock :as clock]
1818 [frq.emoji :as emoji]
1919 [frq.glyphs :as glyphs]
20+ [frq.members :as members]
2021 [frq.metrics :refer [chrome-row chrome-scale terminal? terminal-face?]]
2122 [frq.rooms :as rooms]
2223 [frq.screens.chats :refer [preview-line tab-bar]]
@@ -1493,6 +1494,16 @@
14931494 :placeholder "Message"
14941495 :on-change #(reset! cells/draft %)
14951496 :on-paste-empty actions/paste-image!
1497+ ;; Tab completes the name being typed, the way every IRC client
1498+ ;; does. The rule is `frq.members/complete-nick`'s; all this
1499+ ;; knows is who is in the room and that the draft is the
1500+ ;; authority for what the box says.
1501+ :on-tab (fn []
1502+ (when-let [done (members/complete-nick
1503+ @cells/draft
1504+ (map :nick (actions/member-list
1505+ (str @cells/current))))]
1506+ (reset! cells/draft done)))
14961507 :on-activate actions/send-draft!}]
14971508 ;; Standard, for the reason the Join button beside the room box is:
14981509 ;; the accent says a thing is on, and Send is an action rather than a
@@ -17,6 +17,7 @@
17 [frq.clock :as clock]17 [frq.clock :as clock]
18 [frq.emoji :as emoji]18 [frq.emoji :as emoji]
19 [frq.glyphs :as glyphs]19 [frq.glyphs :as glyphs]
20+ [frq.members :as members]
20 [frq.metrics :refer [chrome-row chrome-scale terminal? terminal-face?]]21 [frq.metrics :refer [chrome-row chrome-scale terminal? terminal-face?]]
21 [frq.rooms :as rooms]22 [frq.rooms :as rooms]
22 [frq.screens.chats :refer [preview-line tab-bar]]23 [frq.screens.chats :refer [preview-line tab-bar]]
@@ -1493,6 +1494,16 @@
1493 :placeholder "Message"1494 :placeholder "Message"
1494 :on-change #(reset! cells/draft %)1495 :on-change #(reset! cells/draft %)
1495 :on-paste-empty actions/paste-image!1496 :on-paste-empty actions/paste-image!
1497+ ;; Tab completes the name being typed, the way every IRC client
1498+ ;; does. The rule is `frq.members/complete-nick`'s; all this
1499+ ;; knows is who is in the room and that the draft is the
1500+ ;; authority for what the box says.
1501+ :on-tab (fn []
1502+ (when-let [done (members/complete-nick
1503+ @cells/draft
1504+ (map :nick (actions/member-list
1505+ (str @cells/current))))]
1506+ (reset! cells/draft done)))
1496 :on-activate actions/send-draft!}]1507 :on-activate actions/send-draft!}]
1497 ;; Standard, for the reason the Join button beside the room box is:1508 ;; Standard, for the reason the Join button beside the room box is:
1498 ;; the accent says a thing is on, and Send is an action rather than a1509 ;; the accent says a thing is on, and Send is an action rather than a
modified flutter/src/frq/hiccup.cljd +35 -8
@@ -31,6 +31,9 @@
3131 ;; list that carries `TargetPlatform` and not the getter beside it.
3232 ["package:flutter/foundation.dart" :as fnd]
3333 ["package:flutter/gestures.dart" :as g]
34+ ;; `LogicalKeyboardKey` and the key events, for the one key the
35+ ;; composer wants before Flutter's focus traversal gets it.
36+ ["package:flutter/services.dart" :as sv]
3437 ["package:flutter/material.dart" :as m]
3538 [cljd.flutter :as f]
3639 [frq.theme :as t]))
@@ -1097,6 +1100,7 @@
10971100 :entry
10981101 (let [on-change (:on-change p)
10991102 on-activate (:on-activate p)
1103+ on-tab (:on-tab p)
11001104 rows (:rows p)
11011105 w (width-of p)
11021106 field (m/TextField
@@ -1126,14 +1130,37 @@
11261130 .borderRadius (m/BorderRadius.circular t/radius-s)
11271131 .borderSide (m/BorderSide .color t/accent
11281132 .width 1.0))))]
1129- ;; No Expanded when there is no width: Expanded in a Column expands
1130- ;; along the main axis, which is vertical, and an entry in a card
1131- ;; would grow to fill the card. A bare field is right there — a
1132- ;; Column hands its children bounded width — and a row wants the
1133- ;; width-request the caller already writes.
1134- (if w
1135- (m/SizedBox .width w .child field)
1136- field))
1133+ ;; Tab, claimed before Flutter's focus traversal has it. A
1134+ ;; TextField does not see Tab at all the framework reads it as
1135+ ;; "move to the next widget" and moves so completing a nick with it
1136+ ;; means catching the key one level up and saying it was handled.
1137+ ;;
1138+ ;; `KeyDownEvent` and not every event: a key that is held down
1139+ ;; repeats, and each repeat would complete again against the word the
1140+ ;; last one just finished.
1141+ ;;
1142+ ;; Only where something asked. An entry with no `:on-tab` returns
1143+ ;; `ignored` and Tab still moves the focus, which is what every other
1144+ ;; box on every screen should keep doing.
1145+ (let [field (if-not on-tab
1146+ field
1147+ (m/Focus
1148+ .onKeyEvent
1149+ (fn [_ ^sv/KeyEvent event]
1150+ (if (and (dart/is? event sv/KeyDownEvent)
1151+ (= (.-logicalKey event)
1152+ sv/LogicalKeyboardKey.tab))
1153+ (do (on-tab) m/KeyEventResult.handled)
1154+ m/KeyEventResult.ignored))
1155+ .child field))]
1156+ ;; No Expanded when there is no width: Expanded in a Column expands
1157+ ;; along the main axis, which is vertical, and an entry in a card
1158+ ;; would grow to fill the card. A bare field is right there a
1159+ ;; Column hands its children bounded width and a row wants the
1160+ ;; width-request the caller already writes.
1161+ (if w
1162+ (m/SizedBox .width w .child field)
1163+ field)))
11371164
11381165 :emoji
11391166 (m/Text (str (:emoji p "")) .style (t/emoji-style (dbl (:size p) 16.0)))
@@ -31,6 +31,9 @@
31 ;; list that carries `TargetPlatform` and not the getter beside it.31 ;; list that carries `TargetPlatform` and not the getter beside it.
32 ["package:flutter/foundation.dart" :as fnd]32 ["package:flutter/foundation.dart" :as fnd]
33 ["package:flutter/gestures.dart" :as g]33 ["package:flutter/gestures.dart" :as g]
34+ ;; `LogicalKeyboardKey` and the key events, for the one key the
35+ ;; composer wants before Flutter's focus traversal gets it.
36+ ["package:flutter/services.dart" :as sv]
34 ["package:flutter/material.dart" :as m]37 ["package:flutter/material.dart" :as m]
35 [cljd.flutter :as f]38 [cljd.flutter :as f]
36 [frq.theme :as t]))39 [frq.theme :as t]))
@@ -1097,6 +1100,7 @@
1097 :entry1100 :entry
1098 (let [on-change (:on-change p)1101 (let [on-change (:on-change p)
1099 on-activate (:on-activate p)1102 on-activate (:on-activate p)
1103+ on-tab (:on-tab p)
1100 rows (:rows p)1104 rows (:rows p)
1101 w (width-of p)1105 w (width-of p)
1102 field (m/TextField1106 field (m/TextField
@@ -1126,14 +1130,37 @@
1126 .borderRadius (m/BorderRadius.circular t/radius-s)1130 .borderRadius (m/BorderRadius.circular t/radius-s)
1127 .borderSide (m/BorderSide .color t/accent1131 .borderSide (m/BorderSide .color t/accent
1128 .width 1.0))))]1132 .width 1.0))))]
1129- ;; No Expanded when there is no width: Expanded in a Column expands1133+ ;; Tab, claimed before Flutter's focus traversal has it. A
1130- ;; along the main axis, which is vertical, and an entry in a card1134+ ;; TextField does not see Tab at all the framework reads it as
1131- ;; would grow to fill the card. A bare field is right there — a1135+ ;; "move to the next widget" and moves so completing a nick with it
1132- ;; Column hands its children bounded width — and a row wants the1136+ ;; means catching the key one level up and saying it was handled.
1133- ;; width-request the caller already writes.1137+ ;;
1134- (if w1138+ ;; `KeyDownEvent` and not every event: a key that is held down
1135- (m/SizedBox .width w .child field)1139+ ;; repeats, and each repeat would complete again against the word the
1136- field))1140+ ;; last one just finished.
1141+ ;;
1142+ ;; Only where something asked. An entry with no `:on-tab` returns
1143+ ;; `ignored` and Tab still moves the focus, which is what every other
1144+ ;; box on every screen should keep doing.
1145+ (let [field (if-not on-tab
1146+ field
1147+ (m/Focus
1148+ .onKeyEvent
1149+ (fn [_ ^sv/KeyEvent event]
1150+ (if (and (dart/is? event sv/KeyDownEvent)
1151+ (= (.-logicalKey event)
1152+ sv/LogicalKeyboardKey.tab))
1153+ (do (on-tab) m/KeyEventResult.handled)
1154+ m/KeyEventResult.ignored))
1155+ .child field))]
1156+ ;; No Expanded when there is no width: Expanded in a Column expands
1157+ ;; along the main axis, which is vertical, and an entry in a card
1158+ ;; would grow to fill the card. A bare field is right there a
1159+ ;; Column hands its children bounded width and a row wants the
1160+ ;; width-request the caller already writes.
1161+ (if w
1162+ (m/SizedBox .width w .child field)
1163+ field)))
1137 1164
1138 :emoji1165 :emoji
1139 (m/Text (str (:emoji p "")) .style (t/emoji-style (dbl (:size p) 16.0)))1166 (m/Text (str (:emoji p "")) .style (t/emoji-style (dbl (:size p) 16.0)))