Add autocomplete
Closes #10, #11
... | ... |
@@ -1,6 +1,6 @@ |
1 | 1 |
(ns dmr.events |
2 | 2 |
(:require |
3 |
- [clojure.string :refer [lower-case split join]] |
|
3 |
+ [clojure.string :refer [lower-case split join starts-with?]] |
|
4 | 4 |
[re-frame.core :as re-frame] |
5 | 5 |
[dmr.db :as db] |
6 | 6 |
[day8.re-frame.tracing :refer-macros [fn-traced]])) |
... | ... |
@@ -20,17 +20,30 @@ |
20 | 20 |
(defn normalize-cmdline [cmdline] |
21 | 21 |
(-> cmdline single-space lower-case)) |
22 | 22 |
|
23 |
+(defn- concatv [m] |
|
24 |
+ (->> m vals (apply concat))) |
|
25 |
+ |
|
26 |
+(defn autocomplete-suggestions [db current] |
|
27 |
+ (let [entities (db :entities) |
|
28 |
+ cur (normalize-cmdline current) |
|
29 |
+ best-match #(< (count %1) (count %2))] |
|
30 |
+ (when (not-empty cur) |
|
31 |
+ (->> (concatv entities) |
|
32 |
+ (filter #(-> % :name normalize-cmdline (starts-with? cur))) |
|
33 |
+ (map :name) |
|
34 |
+ (sort best-match))))) |
|
35 |
+ |
|
23 | 36 |
(re-frame/reg-event-db |
24 | 37 |
::update-cmdline |
25 | 38 |
(fn-traced [db [_ current]] |
26 |
- (assoc-in db [:cmdline :current] current))) |
|
39 |
+ (-> db |
|
40 |
+ (assoc-in [:cmdline :current] current) |
|
41 |
+ (assoc-in [:cmdline :suggestions] {:options (autocomplete-suggestions db current) |
|
42 |
+ :selected nil})))) |
|
27 | 43 |
|
28 | 44 |
(defn get-using [f coll] |
29 | 45 |
(->> coll (filter f) first)) |
30 | 46 |
|
31 |
-(defn- concatv [m] |
|
32 |
- (->> m vals (apply concat))) |
|
33 |
- |
|
34 | 47 |
(defn execute [db cmdline] |
35 | 48 |
(let [append-history #(update-in db [:cmdline :history] conj {:input cmdline :output %}) |
36 | 49 |
entities (db :entities) |
... | ... |
@@ -63,7 +76,7 @@ |
63 | 76 |
orig (some-> cs :selected-history :orig) |
64 | 77 |
idx (some-> cs :selected-history :index) |
65 | 78 |
history-end (- (count history) 1) |
66 |
- historical-input #(-> history (get %) :input) |
|
79 |
+ historical-input #(-> history (nth %) :input) |
|
67 | 80 |
next (case direction :backward dec :forward inc) |
68 | 81 |
at-first? (= idx history-end) |
69 | 82 |
at-last? (= idx 0) |
... | ... |
@@ -73,8 +86,8 @@ |
73 | 86 |
(cond |
74 | 87 |
(empty? history) {:current current :selected-history nil} |
75 | 88 |
(and at-first? going-forward?) {:current orig :selected-history nil} |
76 |
- (and at-last? going-backward?) {} ; do nothing |
|
77 |
- (and not-cycling? going-forward?) {} ; do nothing |
|
89 |
+ (and at-last? going-backward?) nil ; do nothing |
|
90 |
+ (and not-cycling? going-forward?) nil ; do nothing |
|
78 | 91 |
(and not-cycling? going-backward?) {:current (historical-input history-end) |
79 | 92 |
:selected-history {:orig current |
80 | 93 |
:index history-end}} |
... | ... |
@@ -82,6 +95,26 @@ |
82 | 95 |
:selected-history {:orig orig |
83 | 96 |
:index (next idx)}}))) |
84 | 97 |
|
98 |
+(defn cycle-suggestions [cmd direction] |
|
99 |
+ (let [{:keys [options selected]} (cmd :suggestions) |
|
100 |
+ next (case direction :backward dec :forward inc) |
|
101 |
+ nopts (count options) |
|
102 |
+ next-selected (cond |
|
103 |
+ (nil? selected) (case direction |
|
104 |
+ :backward (- nopts 1) |
|
105 |
+ :forward 0) |
|
106 |
+ (>= (next selected) nopts) 0 |
|
107 |
+ (< (next selected) 0) (- nopts 1) |
|
108 |
+ :else (next selected))] |
|
109 |
+ (when (not-empty options) |
|
110 |
+ {:suggestions {:options options :selected next-selected}}))) |
|
111 |
+ |
|
112 |
+(def null-suggestions {:options nil :selected nil}) |
|
113 |
+ |
|
114 |
+(defn complete-suggestion [{:keys [suggestions]}] |
|
115 |
+ (let [{:keys [options selected]} suggestions] |
|
116 |
+ {:current (nth options selected) :suggestions null-suggestions})) |
|
117 |
+ |
|
85 | 118 |
(re-frame/reg-event-db |
86 | 119 |
::prompt-keypress |
87 | 120 |
(re-frame/path :cmdline) |
... | ... |
@@ -89,11 +122,28 @@ |
89 | 122 |
(let [direction (case (.-key event) |
90 | 123 |
"ArrowUp" :backward |
91 | 124 |
"ArrowDown" :forward |
92 |
- nil)] |
|
93 |
- (if direction |
|
94 |
- (do (.preventDefault event) |
|
95 |
- (merge cmd (cycle-history cmd direction))) |
|
96 |
- cmd)))) |
|
125 |
+ "ArrowRight" :right |
|
126 |
+ nil) |
|
127 |
+ tab (= (.-key event) "Tab") |
|
128 |
+ escape (= (.-key event) "Escape")] |
|
129 |
+ (println (.-key event) direction (contains? #{:backward :forward} direction)) |
|
130 |
+ (cond |
|
131 |
+ (contains? #{:backward :forward} direction) (do (.preventDefault event) |
|
132 |
+ (if (some-> cmd :suggestions :options) |
|
133 |
+ (merge cmd (cycle-suggestions cmd direction)) |
|
134 |
+ (merge cmd (cycle-history cmd direction)))) |
|
135 |
+ (and (= :right direction) |
|
136 |
+ (some-> cmd :suggestions :selected)) (do (.preventDefault event) |
|
137 |
+ (merge cmd (complete-suggestion cmd))) |
|
138 |
+ (and tab |
|
139 |
+ (= 1 (some-> cmd :suggestions :options count)) |
|
140 |
+ (some-> cmd :suggestions :selected)) (do (.preventDefault event) |
|
141 |
+ (merge cmd (complete-suggestion cmd))) |
|
142 |
+ tab (do (.preventDefault event) |
|
143 |
+ (merge cmd (cycle-suggestions cmd :forward))) |
|
144 |
+ (and escape (some-> cmd :suggestions :options)) (do (.preventDefault event) |
|
145 |
+ (merge cmd {:suggestions null-suggestions})) |
|
146 |
+ :else cmd)))) |
|
97 | 147 |
|
98 | 148 |
(re-frame/reg-event-fx |
99 | 149 |
::submit-cmd |
... | ... |
@@ -102,8 +152,9 @@ |
102 | 152 |
cmdline (some-> db :cmdline :current)] |
103 | 153 |
{:db |
104 | 154 |
(-> db |
105 |
- (update-in [:cmdline :current] (constantly nil)) |
|
106 |
- (update-in [:cmdline :selected-history] (constantly nil)) |
|
155 |
+ (assoc-in [:cmdline :current] nil) |
|
156 |
+ (assoc-in [:cmdline :selected-history] nil) |
|
157 |
+ (assoc-in [:cmdline :suggestions] {:options nil :selected nil}) |
|
107 | 158 |
(execute cmdline)) |
108 | 159 |
|
109 | 160 |
; dispatch-later to ensure that the DOM has already been updated, |
... | ... |
@@ -202,4 +202,17 @@ |
202 | 202 |
:padding 0 |
203 | 203 |
:width (calc "100% - 2ch")} |
204 | 204 |
[:&:focus |
205 |
- {:outline :none}]]) |
|
205 |
+ {:outline :none}]] |
|
206 |
+ [:ul.suggestions |
|
207 |
+ {:position :absolute |
|
208 |
+ :padding-left 0 |
|
209 |
+ :padding-right 0 |
|
210 |
+ :bottom 0 |
|
211 |
+ :left 0 |
|
212 |
+ :margin-bottom :1.2rem |
|
213 |
+ :background-color (colorscheme :light0-soft)} |
|
214 |
+ [:li {:list-style :none |
|
215 |
+ :padding-left :3ch |
|
216 |
+ :padding-right :1ch} |
|
217 |
+ [(s/& :.selected) |
|
218 |
+ {:background-color (colorscheme :neutral-purple)}]]]) |
... | ... |
@@ -8,24 +8,6 @@ |
8 | 8 |
[dmr.subs :as subs] |
9 | 9 |
[dmr.events :as events])) |
10 | 10 |
|
11 |
-(defn prompt [] |
|
12 |
- (let [cmdline (re-frame/subscribe [::subs/cmdline]) |
|
13 |
- update-cmdline #(re-frame/dispatch-sync |
|
14 |
- [::events/update-cmdline (-> % .-target .-value)]) |
|
15 |
- key-pressed #(re-frame/dispatch-sync [::events/prompt-keypress %]) |
|
16 |
- submit-cmd #(do (.preventDefault %) |
|
17 |
- (re-frame/dispatch [::events/submit-cmd (-> % .-target)]))] |
|
18 |
- [:form {:class (styles/prompt-style) |
|
19 |
- :on-submit submit-cmd} |
|
20 |
- [:input {:value @cmdline |
|
21 |
- :on-change update-cmdline |
|
22 |
- :on-keyDown key-pressed |
|
23 |
- :type "text"}]])) |
|
24 |
- |
|
25 |
-(defn title [] |
|
26 |
- (let [name (re-frame/subscribe [::subs/name])] |
|
27 |
- [:h1 "Welcome to " @name])) |
|
28 |
- |
|
29 | 11 |
; TODO: move this functionto a separate 'utils' module |
30 | 12 |
(defn ordinal [n] |
31 | 13 |
(let [final-digit (mod n 10) |
... | ... |
@@ -58,6 +40,33 @@ |
58 | 40 |
|
59 | 41 |
(defn- enumerated [& colls] (apply zip (range) colls)) |
60 | 42 |
|
43 |
+ |
|
44 |
+(defn prompt [] |
|
45 |
+ (let [cmdline (re-frame/subscribe [::subs/cmdline]) |
|
46 |
+ suggestions (re-frame/subscribe [::subs/cmdline-suggestions]) |
|
47 |
+ update-cmdline #(re-frame/dispatch-sync |
|
48 |
+ [::events/update-cmdline (-> % .-target .-value)]) |
|
49 |
+ key-pressed #(re-frame/dispatch-sync [::events/prompt-keypress %]) |
|
50 |
+ submit-cmd #(do (.preventDefault %) |
|
51 |
+ (re-frame/dispatch [::events/submit-cmd (-> % .-target)]))] |
|
52 |
+ [:form {:class (styles/prompt-style) |
|
53 |
+ :on-submit submit-cmd} |
|
54 |
+ [:input {:value @cmdline |
|
55 |
+ :on-change update-cmdline |
|
56 |
+ :on-keyDown key-pressed |
|
57 |
+ :type "text"}] |
|
58 |
+ |
|
59 |
+ [:ul.suggestions |
|
60 |
+ (let [{:keys [options selected]} @suggestions] |
|
61 |
+ (for [[idx x] (enumerated options)] |
|
62 |
+ ^{:key x} [:li |
|
63 |
+ (when (= idx selected) {:class :selected}) |
|
64 |
+ x]))]])) |
|
65 |
+ |
|
66 |
+(defn title [] |
|
67 |
+ (let [name (re-frame/subscribe [::subs/name])] |
|
68 |
+ [:h1 "Welcome to " @name])) |
|
69 |
+ |
|
61 | 70 |
(defn- property-list [& props] |
62 | 71 |
(into |
63 | 72 |
[:ul {:class (styles/property-list)}] |