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>
8c38fb8 parent: 8dac959 modified
common/frq/members.cljc +49 -0 | @@ -121,3 +121,52 @@ | ||
| 121 | 121 | |
| 122 | 122 | (defn member-count [m channel] |
| 123 | 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)))))))) | |
| @@ -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 @@ | ||
| 17 | 17 | [frq.clock :as clock] |
| 18 | 18 | [frq.emoji :as emoji] |
| 19 | 19 | [frq.glyphs :as glyphs] |
| 20 | + [frq.members :as members] | |
| 20 | 21 | [frq.metrics :refer [chrome-row chrome-scale terminal? terminal-face?]] |
| 21 | 22 | [frq.rooms :as rooms] |
| 22 | 23 | [frq.screens.chats :refer [preview-line tab-bar]] |
| @@ -1493,6 +1494,16 @@ | ||
| 1493 | 1494 | :placeholder "Message" |
| 1494 | 1495 | :on-change #(reset! cells/draft %) |
| 1495 | 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 | 1507 | :on-activate actions/send-draft!}] |
| 1497 | 1508 | ;; Standard, for the reason the Join button beside the room box is: |
| 1498 | 1509 | ;; 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 a | 1509 | ;; 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 @@ | ||
| 31 | 31 | ;; list that carries `TargetPlatform` and not the getter beside it. |
| 32 | 32 | ["package:flutter/foundation.dart" :as fnd] |
| 33 | 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 | 37 | ["package:flutter/material.dart" :as m] |
| 35 | 38 | [cljd.flutter :as f] |
| 36 | 39 | [frq.theme :as t])) |
| @@ -1097,6 +1100,7 @@ | ||
| 1097 | 1100 | :entry |
| 1098 | 1101 | (let [on-change (:on-change p) |
| 1099 | 1102 | on-activate (:on-activate p) |
| 1103 | + on-tab (:on-tab p) | |
| 1100 | 1104 | rows (:rows p) |
| 1101 | 1105 | w (width-of p) |
| 1102 | 1106 | field (m/TextField |
| @@ -1126,14 +1130,37 @@ | ||
| 1126 | 1130 | .borderRadius (m/BorderRadius.circular t/radius-s) |
| 1127 | 1131 | .borderSide (m/BorderSide .color t/accent |
| 1128 | 1132 | .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))) | |
| 1137 | 1164 | |
| 1138 | 1165 | :emoji |
| 1139 | 1166 | (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 | :entry | 1100 | :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/TextField | 1106 | 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/accent | 1131 | .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 expands | 1133 | + ;; Tab, claimed before Flutter's focus traversal has it. A |
| 1130 | - ;; along the main axis, which is vertical, and an entry in a card | 1134 | + ;; 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 — a | 1135 | + ;; "move to the next widget" and moves — so completing a nick with it |
| 1132 | - ;; Column hands its children bounded width — and a row wants the | 1136 | + ;; means catching the key one level up and saying it was handled. |
| 1133 | - ;; width-request the caller already writes. | 1137 | + ;; |
| 1134 | - (if w | 1138 | + ;; `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 | :emoji | 1165 | :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))) |