Skip to content
Open
2 changes: 2 additions & 0 deletions src/ecro/bindings.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions src/ecro/command.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]))

Expand Down Expand Up @@ -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)

Expand Down
96 changes: 87 additions & 9 deletions src/ecro/key.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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))
Comment thread
kqnade marked this conversation as resolved.


(defn- skk-active?
"Return true if SKK minor mode is active in the current buffer."
[editor-state]
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions src/ecro/render.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)) "*" "")
Expand Down
39 changes: 34 additions & 5 deletions src/ecro/search.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 127 additions & 1 deletion test/ecro/key_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
Expand Down