From 6501996f2288bb82276747ec0f382121be290554 Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 21:46:07 +0900 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20feat:=20integrate=20forward?= =?UTF-8?q?=20incremental=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/bindings.clj | 1 + src/ecro/command.clj | 7 +++++++ src/ecro/key.clj | 38 +++++++++++++++++++++++++++++--------- src/ecro/render.clj | 11 +++++++++-- test/ecro/key_test.clj | 22 +++++++++++++++++++++- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/ecro/bindings.clj b/src/ecro/bindings.clj index 2f6e6aa..259742b 100644 --- a/src/ecro/bindings.clj +++ b/src/ecro/bindings.clj @@ -13,6 +13,7 @@ (-> (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-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..c5d5866 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,12 @@ :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 :list-buffers) (state/list-buffers editor-state) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index b9a7027..c636359 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -8,6 +8,7 @@ [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] @@ -114,6 +115,23 @@ :else state))) +(defn- handle-isearch-key + "Handle a key event while incremental search is active." + [editor-state key-code] + (cond + (= key-code 13) + (dissoc editor-state :isearch) + + (>= key-code 32) + (let [isearch (search/isearch-add-char (:isearch editor-state) (char key-code)) + buf (search/isearch-execute isearch (:current-buffer editor-state))] + (-> editor-state + (assoc :isearch isearch) + (state/assoc-current-buffer buf))) + + :else editor-state)) + + (defn- skk-active? "Return true if SKK minor mode is active in the current buffer." [editor-state] @@ -213,15 +231,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) + (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..dc5f37d 100644 --- a/src/ecro/render.clj +++ b/src/ecro/render.clj @@ -55,8 +55,15 @@ (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 "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/test/ecro/key_test.clj b/test/ecro/key_test.clj index fcc58c2..4387d12 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,25 @@ (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-minibuffer-switch-to-buffer (testing "minibuffer Enter switches to named buffer" (let [state {:minibuffer {:buffer {:text "other.clj"} From 808a97403c66a859dc3759d20e53f2c5e1601a66 Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 21:48:35 +0900 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20feat:=20integrate=20backward?= =?UTF-8?q?=20incremental=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/bindings.clj | 1 + src/ecro/command.clj | 6 ++++++ src/ecro/render.clj | 5 ++++- test/ecro/key_test.clj | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/ecro/bindings.clj b/src/ecro/bindings.clj index 259742b..045d8b5 100644 --- a/src/ecro/bindings.clj +++ b/src/ecro/bindings.clj @@ -13,6 +13,7 @@ (-> (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) diff --git a/src/ecro/command.clj b/src/ecro/command.clj index c5d5866..6f531c7 100644 --- a/src/ecro/command.clj +++ b/src/ecro/command.clj @@ -64,6 +64,12 @@ :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/render.clj b/src/ecro/render.clj index dc5f37d..9c7e6a8 100644 --- a/src/ecro/render.clj +++ b/src/ecro/render.clj @@ -57,7 +57,10 @@ [state] (cond (:isearch state) - (str "I-search: " (get-in state [:isearch :pattern])) + (str (if (= :backward (get-in state [:isearch :direction])) + "I-search backward: " + "I-search: ") + (get-in state [:isearch :pattern])) (:minibuffer state) (let [mb (:minibuffer state)] diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index 4387d12..1cf8ee0 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -65,6 +65,21 @@ (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-minibuffer-switch-to-buffer (testing "minibuffer Enter switches to named buffer" (let [state {:minibuffer {:buffer {:text "other.clj"} From a13160560ece6f1f9865d00feebe6dbd180757d3 Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 21:50:21 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20feat:=20handle=20backspace=20?= =?UTF-8?q?in=20incremental=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/key.clj | 7 +++++++ src/ecro/search.clj | 8 +++++++- test/ecro/key_test.clj | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index c636359..bb81f71 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -122,6 +122,13 @@ (= key-code 13) (dissoc editor-state :isearch) + (= 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))) + (>= key-code 32) (let [isearch (search/isearch-add-char (:isearch editor-state) (char key-code)) buf (search/isearch-execute isearch (:current-buffer editor-state))] diff --git a/src/ecro/search.clj b/src/ecro/search.clj index f151a61..a8957d6 100644 --- a/src/ecro/search.clj +++ b/src/ecro/search.clj @@ -35,6 +35,12 @@ (update state :pattern str ch)) +(defn isearch-delete-char + "Remove the last character from the i-search pattern." + [state] + (update state :pattern #(subs % 0 (max 0 (dec (count %)))))) + + (defn isearch-execute "Execute i-search with current pattern. Returns updated buffer." [state buf] @@ -45,7 +51,7 @@ :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))) + (assoc buf :point start-point)))) (defn isearch-cancel diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index 1cf8ee0..5079e99 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -80,6 +80,20 @@ (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-minibuffer-switch-to-buffer (testing "minibuffer Enter switches to named buffer" (let [state {:minibuffer {:buffer {:text "other.clj"} From 038d34d7b9f2e8a961afbf03d86ad4ef1d0fe354 Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 21:51:40 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=A8=20feat:=20integrate=20increment?= =?UTF-8?q?al=20search=20cancellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/key.clj | 7 +++++++ test/ecro/key_test.clj | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index bb81f71..60ab5fb 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -122,6 +122,13 @@ (= 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))] diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index 5079e99..71e3992 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -94,6 +94,21 @@ (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-minibuffer-switch-to-buffer (testing "minibuffer Enter switches to named buffer" (let [state {:minibuffer {:buffer {:text "other.clj"} From 7259b8f6bcaf06340244058da250007308b0a326 Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 22:15:55 +0900 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20handle=20non-BMP=20?= =?UTF-8?q?Unicode=20in=20incremental=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/key.clj | 19 ++++++++++++++----- test/ecro/key_test.clj | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index 60ab5fb..6bdee5d 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -115,6 +115,13 @@ :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- handle-isearch-key "Handle a key event while incremental search is active." [editor-state key-code] @@ -137,11 +144,13 @@ (state/assoc-current-buffer buf))) (>= key-code 32) - (let [isearch (search/isearch-add-char (:isearch editor-state) (char key-code)) - buf (search/isearch-execute isearch (:current-buffer editor-state))] - (-> editor-state - (assoc :isearch isearch) - (state/assoc-current-buffer buf))) + (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)) diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index 71e3992..d273a32 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -109,6 +109,18 @@ (is (nil? (:isearch canceled)))))) +(deftest test-incremental-search-non-bmp-character + (testing "a non-BMP Unicode code point is added to the query without throwing" + (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)] + (is (= "😀" (get-in searched [:isearch :pattern]))) + (is (= 1 (get-in searched [:current-buffer :point])))))) + + (deftest test-minibuffer-switch-to-buffer (testing "minibuffer Enter switches to named buffer" (let [state {:minibuffer {:buffer {:text "other.clj"} From 17dffd551c4b9bc65d3aaaef789cfeb1f7f1637b Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 22:17:57 +0900 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20ignore=20terminal?= =?UTF-8?q?=20sentinel=20keys=20in=20incremental=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/key.clj | 10 ++++++++++ test/ecro/key_test.clj | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index 6bdee5d..5ad9053 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -122,6 +122,13 @@ (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- handle-isearch-key "Handle a key event while incremental search is active." [editor-state key-code] @@ -143,6 +150,9 @@ (assoc :isearch isearch) (state/assoc-current-buffer buf))) + (terminal-sentinel-key-code? key-code) + editor-state + (>= key-code 32) (if-let [text (code-point-string key-code)] (let [isearch (search/isearch-add-char (:isearch editor-state) text) diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index d273a32..566c454 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -121,6 +121,21 @@ (is (= 1 (get-in searched [: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-minibuffer-switch-to-buffer (testing "minibuffer Enter switches to named buffer" (let [state {:minibuffer {:buffer {:text "other.clj"} From 478d0b8181ad56e9c0c9d07b99b197f1298ff776 Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 22:19:31 +0900 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20ignore=20Ctrl=20and?= =?UTF-8?q?=20Alt=20chords=20in=20incremental=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/key.clj | 13 ++++++++++--- test/ecro/key_test.clj | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index 5ad9053..e2a78c9 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -15,6 +15,12 @@ [ecro.state :as state])) +(def control-modifier 1) + + +(def alt-modifier 2) + + (def shift-modifier 4) @@ -131,7 +137,7 @@ (defn- handle-isearch-key "Handle a key event while incremental search is active." - [editor-state key-code] + [editor-state key-code modifiers] (cond (= key-code 13) (dissoc editor-state :isearch) @@ -153,7 +159,8 @@ (terminal-sentinel-key-code? key-code) editor-state - (>= key-code 32) + (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))] @@ -265,7 +272,7 @@ "Handle a key event and return updated state." [editor-state key-code modifiers] (if (:isearch editor-state) - (handle-isearch-key editor-state key-code) + (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)] diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index 566c454..549caed 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -136,6 +136,20 @@ (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-minibuffer-switch-to-buffer (testing "minibuffer Enter switches to named buffer" (let [state {:minibuffer {:buffer {:text "other.clj"} From 2650c2c57a974c40dbece55559000ab00f286384 Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 22:22:41 +0900 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20repeat=20incrementa?= =?UTF-8?q?l=20search=20with=20C-s=20and=20C-r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/key.clj | 17 +++++++++++++++++ src/ecro/search.clj | 12 ++++++++++++ test/ecro/key_test.clj | 16 ++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index e2a78c9..1233cda 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -135,6 +135,17 @@ (<= 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)] + (-> 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] @@ -156,6 +167,12 @@ (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 diff --git a/src/ecro/search.clj b/src/ecro/search.clj index a8957d6..e95c2f2 100644 --- a/src/ecro/search.clj +++ b/src/ecro/search.clj @@ -54,6 +54,18 @@ (assoc buf :point start-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 "Cancel i-search and restore original point." [state buf] diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index 549caed..6bd966d 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -150,6 +150,22 @@ (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) + previous-match (key/handle-key next-match (int \r) 1)] + (is (= 4 (get-in next-match [:current-buffer :point]))) + (is (= 0 (get-in previous-match [:current-buffer :point]))) + (is (= "f" (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"} From e87dd2851de0bfdff952121cbb54f2a732d56ba7 Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 22:32:54 +0900 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20delete=20non-BMP=20?= =?UTF-8?q?characters=20as=20one=20unit=20in=20incremental=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/search.clj | 6 +++++- test/ecro/key_test.clj | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ecro/search.clj b/src/ecro/search.clj index e95c2f2..ead9164 100644 --- a/src/ecro/search.clj +++ b/src/ecro/search.clj @@ -38,7 +38,11 @@ (defn isearch-delete-char "Remove the last character from the i-search pattern." [state] - (update state :pattern #(subs % 0 (max 0 (dec (count %)))))) + (update state :pattern + (fn [^String pattern] + (if (empty? pattern) + pattern + (subs pattern 0 (.offsetByCodePoints pattern (count pattern) -1)))))) (defn isearch-execute diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index 6bd966d..b4ad3bc 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -110,15 +110,18 @@ (deftest test-incremental-search-non-bmp-character - (testing "a non-BMP Unicode code point is added to the query without throwing" + (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)] + 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 (= 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 From fa88b22c593c8211011e39054fd7e7522b990a2e Mon Sep 17 00:00:00 2001 From: kqnade Date: Sun, 9 Aug 2026 22:36:12 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20preserve=20isearch?= =?UTF-8?q?=20anchor=20across=20repeated=20searches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ecro/key.clj | 3 ++- src/ecro/search.clj | 17 ++++++++++++----- test/ecro/key_test.clj | 6 ++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/ecro/key.clj b/src/ecro/key.clj index 1233cda..f1783ef 100644 --- a/src/ecro/key.clj +++ b/src/ecro/key.clj @@ -140,7 +140,8 @@ (let [isearch (assoc (:isearch editor-state) :direction direction) buf (search/isearch-repeat isearch (:current-buffer editor-state) - direction)] + direction) + isearch (assoc isearch :anchor-point (:point buf))] (-> editor-state (assoc :isearch isearch) (state/assoc-current-buffer buf)))) diff --git a/src/ecro/search.clj b/src/ecro/search.clj index ead9164..ee103d1 100644 --- a/src/ecro/search.clj +++ b/src/ecro/search.clj @@ -49,13 +49,20 @@ "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))) - (assoc buf :point start-point)))) + :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 diff --git a/test/ecro/key_test.clj b/test/ecro/key_test.clj index b4ad3bc..55a29d3 100644 --- a/test/ecro/key_test.clj +++ b/test/ecro/key_test.clj @@ -162,10 +162,12 @@ 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) - previous-match (key/handle-key next-match (int \r) 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 (= "f" (get-in previous-match [:isearch :pattern]))) + (is (= "fo" (get-in previous-match [:isearch :pattern]))) (is (= :backward (get-in previous-match [:isearch :direction]))))))