diff --git a/src/ecro/bindings.clj b/src/ecro/bindings.clj index 2f6e6aa..045d8b5 100644 --- a/src/ecro/bindings.clj +++ b/src/ecro/bindings.clj @@ -13,6 +13,8 @@ (-> (keymap/make-keymap) (keymap/define-key ["C-a"] :move-beginning-of-line) (keymap/define-key ["C-e"] :move-end-of-line) + (keymap/define-key ["C-r"] :isearch-backward) + (keymap/define-key ["C-s"] :isearch-forward) (keymap/define-key ["C-k"] :kill-line) (keymap/define-key ["C-z"] :undo) (keymap/define-key ["C-S-z"] :redo) diff --git a/src/ecro/command.clj b/src/ecro/command.clj index 83ce02b..6f531c7 100644 --- a/src/ecro/command.clj +++ b/src/ecro/command.clj @@ -8,6 +8,7 @@ [ecro.mode :as mode] [ecro.notification :as notification] [ecro.scroll :as scroll] + [ecro.search :as search] [ecro.state :as state] [ecro.undo :as undo])) @@ -57,6 +58,18 @@ :minibuffer (ecro.minibuffer/prompt-for "Write file: " :write-file) :key-sequence []) + (= command :isearch-forward) + (assoc editor-state + :isearch (assoc (search/make-isearch :forward) + :start-point (:point buf)) + :key-sequence []) + + (= command :isearch-backward) + (assoc editor-state + :isearch (assoc (search/make-isearch :backward) + :start-point (:point buf)) + :key-sequence []) + (= command :list-buffers) (state/list-buffers editor-state) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index b9a7027..f1783ef 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -8,12 +8,19 @@ [ecro.native :as native] [ecro.render :as render] [ecro.scroll :as scroll] + [ecro.search :as search] [ecro.skk.input :as skk-input] [ecro.skk.sources :as skk-sources] [ecro.skk.state :as skk-state] [ecro.state :as state])) +(def control-modifier 1) + + +(def alt-modifier 2) + + (def shift-modifier 4) @@ -114,6 +121,75 @@ :else state))) +(defn- code-point-string + "Convert a valid Unicode code point to a string." + [key-code] + (when (Character/isValidCodePoint key-code) + (String. (Character/toChars key-code)))) + + +(defn- terminal-sentinel-key-code? + "Return true for non-character key codes reserved by the terminal adapter." + [key-code] + (or (<= 1001 key-code 1010) + (<= 2001 key-code 2255))) + + +(defn- repeat-isearch + [editor-state direction] + (let [isearch (assoc (:isearch editor-state) :direction direction) + buf (search/isearch-repeat isearch + (:current-buffer editor-state) + direction) + isearch (assoc isearch :anchor-point (:point buf))] + (-> editor-state + (assoc :isearch isearch) + (state/assoc-current-buffer buf)))) + + +(defn- handle-isearch-key + "Handle a key event while incremental search is active." + [editor-state key-code modifiers] + (cond + (= key-code 13) + (dissoc editor-state :isearch) + + (= key-code 27) + (let [buf (search/isearch-cancel (:isearch editor-state) + (:current-buffer editor-state))] + (-> editor-state + (dissoc :isearch) + (state/assoc-current-buffer buf))) + + (= key-code 127) + (let [isearch (search/isearch-delete-char (:isearch editor-state)) + buf (search/isearch-execute isearch (:current-buffer editor-state))] + (-> editor-state + (assoc :isearch isearch) + (state/assoc-current-buffer buf))) + + (and (= key-code (int \s)) (= modifiers control-modifier)) + (repeat-isearch editor-state :forward) + + (and (= key-code (int \r)) (= modifiers control-modifier)) + (repeat-isearch editor-state :backward) + + (terminal-sentinel-key-code? key-code) + editor-state + + (and (>= key-code 32) + (zero? (bit-and modifiers (bit-or control-modifier alt-modifier)))) + (if-let [text (code-point-string key-code)] + (let [isearch (search/isearch-add-char (:isearch editor-state) text) + buf (search/isearch-execute isearch (:current-buffer editor-state))] + (-> editor-state + (assoc :isearch isearch) + (state/assoc-current-buffer buf))) + editor-state) + + :else editor-state)) + + (defn- skk-active? "Return true if SKK minor mode is active in the current buffer." [editor-state] @@ -213,15 +289,17 @@ (defn handle-key "Handle a key event and return updated state." [editor-state key-code modifiers] - (if (:minibuffer editor-state) - (handle-minibuffer-key editor-state key-code) - (let [key-str (key-name key-code modifiers)] - (if (and (skk-active? editor-state) - (not (seq (:key-sequence editor-state))) - (not (= "ESC" key-str))) - (or (skk-handle-key editor-state key-str key-code) - (handle-regular-key editor-state key-code modifiers)) - (handle-regular-key editor-state key-code modifiers))))) + (if (:isearch editor-state) + (handle-isearch-key editor-state key-code modifiers) + (if (:minibuffer editor-state) + (handle-minibuffer-key editor-state key-code) + (let [key-str (key-name key-code modifiers)] + (if (and (skk-active? editor-state) + (not (seq (:key-sequence editor-state))) + (not (= "ESC" key-str))) + (or (skk-handle-key editor-state key-str key-code) + (handle-regular-key editor-state key-code modifiers)) + (handle-regular-key editor-state key-code modifiers)))))) (defn process-event diff --git a/src/ecro/render.clj b/src/ecro/render.clj index 7cee62c..9c7e6a8 100644 --- a/src/ecro/render.clj +++ b/src/ecro/render.clj @@ -55,8 +55,18 @@ (defn status-line "Build the status line string from editor state." [state] - (if-let [mb (:minibuffer state)] - (str (:prompt mb) (:text (:buffer mb))) + (cond + (:isearch state) + (str (if (= :backward (get-in state [:isearch :direction])) + "I-search backward: " + "I-search: ") + (get-in state [:isearch :pattern])) + + (:minibuffer state) + (let [mb (:minibuffer state)] + (str (:prompt mb) (:text (:buffer mb)))) + + :else (let [buf (:current-buffer state) name (or (:name buf) "*scratch*") modified (if (not= (:text buf) (:saved-text buf)) "*" "") diff --git a/src/ecro/search.clj b/src/ecro/search.clj index f151a61..ee103d1 100644 --- a/src/ecro/search.clj +++ b/src/ecro/search.clj @@ -35,17 +35,46 @@ (update state :pattern str ch)) +(defn isearch-delete-char + "Remove the last character from the i-search pattern." + [state] + (update state :pattern + (fn [^String pattern] + (if (empty? pattern) + pattern + (subs pattern 0 (.offsetByCodePoints pattern (count pattern) -1)))))) + + (defn isearch-execute "Execute i-search with current pattern. Returns updated buffer." [state buf] (let [pattern (:pattern state) - start-point (or (:start-point state) (:point buf))] + start-point (or (:start-point state) (:point buf)) + anchor-point (:anchor-point state) + search-point (if (some? anchor-point) + (if (= :backward (:direction state)) + (inc anchor-point) + anchor-point) + start-point) + fallback-point (or anchor-point start-point)] (if (seq pattern) (let [result (case (:direction state) - :forward (search-forward (assoc buf :point start-point) pattern) - :backward (search-backward (assoc buf :point start-point) pattern))] - (or result (assoc buf :point start-point))) - buf))) + :forward (search-forward (assoc buf :point search-point) pattern) + :backward (search-backward (assoc buf :point search-point) pattern))] + (or result (assoc buf :point fallback-point))) + (assoc buf :point fallback-point)))) + + +(defn isearch-repeat + "Repeat the current i-search from the current match in direction." + [state buf direction] + (let [pattern (:pattern state) + point (:point buf) + result (when (seq pattern) + (case direction + :forward (search-forward (assoc buf :point (inc point)) pattern) + :backward (search-backward (assoc buf :point point) pattern)))] + (or result buf))) (defn isearch-cancel diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index fcc58c2..55a29d3 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -4,7 +4,8 @@ [clojure.test :refer :all] [ecro.bindings :as bindings] [ecro.buffer :as b] - [ecro.key :as key])) + [ecro.key :as key] + [ecro.render :as render])) (deftest test-key-name-control-shift-and-control-slash @@ -45,6 +46,131 @@ (is (= "Find file: " (get-in new-state [:minibuffer :prompt])))))) +(deftest test-forward-incremental-search-integration + (testing "C-s searches as characters are typed and RET accepts the match" + (let [state {:current-buffer (assoc (b/make-buffer "test") + :text "hello world") + :keymap bindings/default-keymap + :key-sequence []} + started (key/handle-key state (int \s) 1) + with-w (key/handle-key started (int \w) 0) + with-wo (key/handle-key with-w (int \o) 0) + accepted (key/handle-key with-wo 13 0)] + (is (= {:pattern "" :direction :forward :start-point 0} + (:isearch started))) + (is (= 6 (get-in with-w [:current-buffer :point]))) + (is (= "wo" (get-in with-wo [:isearch :pattern]))) + (is (= "I-search: wo" (render/status-line with-wo))) + (is (= 6 (get-in accepted [:current-buffer :point]))) + (is (nil? (:isearch accepted)))))) + + +(deftest test-backward-incremental-search-integration + (testing "C-r searches backward as characters are typed" + (let [state {:current-buffer (assoc (b/make-buffer "test") + :text "foo bar foo" + :point 11) + :keymap bindings/default-keymap + :key-sequence []} + started (key/handle-key state (int \r) 1) + searched (key/handle-key started (int \b) 0)] + (is (= :backward (get-in started [:isearch :direction]))) + (is (= "b" (get-in searched [:isearch :pattern]))) + (is (= 4 (get-in searched [:current-buffer :point]))) + (is (= "I-search backward: b" (render/status-line searched)))))) + + +(deftest test-incremental-search-backspace + (testing "BS removes the last query character and recomputes the match" + (let [state {:current-buffer (assoc (b/make-buffer "test") + :text "hello world") + :keymap bindings/default-keymap + :key-sequence []} + started (key/handle-key state (int \s) 1) + searched (key/handle-key started (int \w) 0) + cleared (key/handle-key searched 127 0)] + (is (= 6 (get-in searched [:current-buffer :point]))) + (is (= "" (get-in cleared [:isearch :pattern]))) + (is (= 0 (get-in cleared [:current-buffer :point])))))) + + +(deftest test-incremental-search-cancel + (testing "ESC cancels search and restores the starting point" + (let [state {:current-buffer (assoc (b/make-buffer "test") + :text "hello world" + :point 2) + :keymap bindings/default-keymap + :key-sequence []} + started (key/handle-key state (int \s) 1) + searched (key/handle-key started (int \w) 0) + canceled (key/handle-key searched 27 0)] + (is (= 6 (get-in searched [:current-buffer :point]))) + (is (= 2 (get-in canceled [:current-buffer :point]))) + (is (nil? (:isearch canceled)))))) + + +(deftest test-incremental-search-non-bmp-character + (testing "a non-BMP code point can be added and removed as one character" + (let [state {:current-buffer (assoc (b/make-buffer "test") + :text "a😀b") + :keymap bindings/default-keymap + :key-sequence []} + started (key/handle-key state (int \s) 1) + searched (key/handle-key started 0x1F600 0) + cleared (key/handle-key searched 127 0)] + (is (= "😀" (get-in searched [:isearch :pattern]))) + (is (= 1 (get-in searched [:current-buffer :point]))) + (is (= "" (get-in cleared [:isearch :pattern]))) + (is (= 0 (get-in cleared [:current-buffer :point])))))) + + +(deftest test-incremental-search-ignores-terminal-sentinel-codes + (testing "navigation and function key sentinels do not enter the query" + (let [state {:current-buffer (assoc (b/make-buffer "test") + :text "hello world") + :keymap bindings/default-keymap + :key-sequence []} + started (key/handle-key state (int \s) 1) + searched (key/handle-key started (int \w) 0) + after-specials (reduce #(key/handle-key %1 %2 0) + searched + [1001 1004 1005 1010 2001])] + (is (= "w" (get-in after-specials [:isearch :pattern]))) + (is (= 6 (get-in after-specials [:current-buffer :point])))))) + + +(deftest test-incremental-search-classifies-modifiers + (testing "Shift text is accepted while unrelated Ctrl and Alt chords are ignored" + (let [state {:current-buffer (assoc (b/make-buffer "test") :text "W") + :keymap bindings/default-keymap + :key-sequence []} + started (key/handle-key state (int \s) 1) + shifted (key/handle-key started (int \W) key/shift-modifier) + after-chords (-> shifted + (key/handle-key (int \g) 1) + (key/handle-key (int \x) 2))] + (is (= "W" (get-in after-chords [:isearch :pattern]))) + (is (= 0 (get-in after-chords [:current-buffer :point])))))) + + +(deftest test-incremental-search-repeat-controls + (testing "C-s and C-r repeat the query without entering command characters" + (let [state {:current-buffer (assoc (b/make-buffer "test") + :text "foo foo foo") + :keymap bindings/default-keymap + :key-sequence []} + started (key/handle-key state (int \s) 1) + searched (key/handle-key started (int \f) 0) + next-match (key/handle-key searched (int \s) 1) + refined (key/handle-key next-match (int \o) 0) + previous-match (key/handle-key refined (int \r) 1)] + (is (= 4 (get-in next-match [:current-buffer :point]))) + (is (= 4 (get-in refined [:current-buffer :point]))) + (is (= 0 (get-in previous-match [:current-buffer :point]))) + (is (= "fo" (get-in previous-match [:isearch :pattern]))) + (is (= :backward (get-in previous-match [:isearch :direction])))))) + + (deftest test-minibuffer-switch-to-buffer (testing "minibuffer Enter switches to named buffer" (let [state {:minibuffer {:buffer {:text "other.clj"}