├── README.org
└── isearch-mb.el
/README.org:
--------------------------------------------------------------------------------
1 | #+title: isearch-mb --- Control isearch from the minibuffer
2 |
3 | #+html:

4 |
5 | This Emacs package provides an alternative isearch UI based on the
6 | minibuffer. This allows editing the search string in arbitrary ways
7 | without any special maneuver; unlike standard isearch, cursor motion
8 | commands do not end the search. Moreover, the search status
9 | information in the echo area and some keybindings are slightly
10 | simplified.
11 |
12 | isearch-mb is part of [[https://elpa.gnu.org/packages/isearch-mb.html][GNU ELPA]] and can be installed with
13 | =M-x package-install RET isearch-mb RET=. To activate it, type
14 | =M-x isearch-mb-mode RET=.
15 |
16 | ** Keybindings
17 |
18 | During a search, =isearch-mb-minibuffer-map= is active. By default, it
19 | includes the following commands:
20 |
21 | - =C-s=, =↓=: Repeat search forwards.
22 | - =C-r=, =↑=: Repeat search backwards.
23 | - =M-<=: Go to first match (or /n/-th match with numeric argument).
24 | - =M->=: Go to last match (or /n/-th last match with numeric argument).
25 | - =C-v=, ==: Search forward from the bottom of the window.
26 | - =M-v=, ==: Search backward from the top of the window.
27 | - =M-%=: Replace occurrences of the search string.
28 | - =C-M-%=: Replace occurrences of the search string (regexp mode).
29 | - =M-s= prefix: similar to standard isearch.
30 |
31 | Everything else works as in a plain minibuffer. For instance, =RET=
32 | ends the search normally and =C-g= cancels it.
33 |
34 | ** Some customization ideas
35 |
36 | isearch provides a myriad of customization options, and most of them
37 | make just as much sense when using isearch-mb. The following are some
38 | uncontroversial improvements of the defaults:
39 |
40 | #+begin_src emacs-lisp
41 | (setq-default
42 | ;; Show match count next to the minibuffer prompt
43 | isearch-lazy-count t
44 | ;; Don't be stingy with history; default is to keep just 16 entries
45 | search-ring-max 200
46 | regexp-search-ring-max 200)
47 | #+end_src
48 |
49 | Note that since isearch-mb relies on a regular minibuffer, you can use
50 | you favorite tool to browse the history of previous search strings
51 | (say, the =consult-history= command from the excellent [[https://github.com/minad/consult][Consult]]
52 | package).
53 |
54 | Using regexp search by default is a popular option as well:
55 |
56 | #+begin_src emacs-lisp
57 | (global-set-key (kbd "C-s") 'isearch-forward-regexp)
58 | (global-set-key (kbd "C-r") 'isearch-backward-regexp)
59 | #+end_src
60 |
61 | Another handy option is to enable lax whitespace matching in one of
62 | the two variations indicated below. You can still toggle strict
63 | whitespace matching with =M-s SPC= during a search, or escape a space
64 | with a backslash to match it literally.
65 |
66 | #+begin_src emacs-lisp
67 | (setq-default
68 | isearch-regexp-lax-whitespace t
69 | ;; Swiper style: space matches any sequence of characters in a line.
70 | search-whitespace-regexp ".*?"
71 | ;; Alternative: space matches whitespace, newlines and punctuation.
72 | search-whitespace-regexp "\\W+")
73 | #+end_src
74 |
75 | Finally, you may want to check out the [[https://github.com/astoff/isearch-mb/wiki][isearch-mb wiki]] for additional
76 | tips and tricks.
77 |
78 | ** Interaction with other isearch extensions
79 |
80 | Some third-party isearch extensions require a bit of configuration in
81 | order to work with isearch-mb. There are three cases to consider:
82 |
83 | - *Commands that start a search* in a special state shouldn't require
84 | extra configuration. This includes PDF Tools, Embark, etc.
85 |
86 | - *Commands that operate during a search session* should be added to
87 | the list =isearch-mb--with-buffer=. Examples of this case are
88 | [[https://github.com/fourier/loccur#isearch-integration][=loccur-isearch=]] and [[https://github.com/minad/consult][=consult-isearch=]].
89 |
90 | #+begin_src emacs-lisp
91 | (add-to-list 'isearch-mb--with-buffer #'loccur-isearch)
92 | (define-key isearch-mb-minibuffer-map (kbd "C-o") #'loccur-isearch)
93 |
94 | (add-to-list 'isearch-mb--with-buffer #'consult-isearch)
95 | (define-key isearch-mb-minibuffer-map (kbd "M-r") #'consult-isearch)
96 | #+end_src
97 |
98 | Most isearch commands that are not made available by default in
99 | isearch-mb can also be used in this fashion:
100 |
101 | #+begin_src emacs-lisp
102 | (add-to-list 'isearch-mb--with-buffer #'isearch-yank-word)
103 | (define-key isearch-mb-minibuffer-map (kbd "M-s C-w") #'isearch-yank-word)
104 | #+end_src
105 |
106 | - *Commands that end the isearch session* should be added to the list
107 | =isearch-mb--after-exit=. Examples of this case are
108 | [[https://github.com/abo-abo/avy][=avy-isearch=]] and [[https://github.com/minad/consult][=consult-line=]]:
109 |
110 | #+begin_src emacs-lisp
111 | (add-to-list 'isearch-mb--after-exit #'avy-isearch)
112 | (define-key isearch-mb-minibuffer-map (kbd "C-'") #'avy-isearch)
113 |
114 | (add-to-list 'isearch-mb--after-exit #'consult-line)
115 | (define-key isearch-mb-minibuffer-map (kbd "M-s l") #'consult-line)
116 | #+end_src
117 |
118 | Arranging for motion commands to quit the search, as in standard
119 | isearch, is out of the scope of this package, but you can define
120 | your own commands to emulate that effect. Here is one possibility:
121 |
122 | #+begin_src emacs-lisp
123 | (defun move-end-of-line-maybe-ending-isearch (arg)
124 | "End search and move to end of line, but only if already at the end of the minibuffer."
125 | (interactive "p")
126 | (if (eobp)
127 | (isearch-mb--after-exit
128 | (lambda ()
129 | (move-end-of-line arg)
130 | (isearch-done)))
131 | (move-end-of-line arg)))
132 |
133 | (define-key isearch-mb-minibuffer-map (kbd "C-e") 'move-end-of-line-maybe-ending-isearch)
134 | #+end_src
135 |
136 | ** Contributing
137 |
138 | Discussions, suggestions and code contributions are welcome! Since
139 | this package is part of GNU ELPA, contributions require a copyright
140 | assignment to the FSF.
141 |
--------------------------------------------------------------------------------
/isearch-mb.el:
--------------------------------------------------------------------------------
1 | ;;; isearch-mb.el --- Control isearch from the minibuffer -*- lexical-binding: t; -*-
2 |
3 | ;; Copyright (C) 2021-2024 Free Software Foundation, Inc.
4 |
5 | ;; Author: Augusto Stoffel
6 | ;; URL: https://github.com/astoff/isearch-mb
7 | ;; Keywords: matching
8 | ;; Package-Requires: ((emacs "27.1"))
9 | ;; Version: 0.8
10 |
11 | ;; This program is free software; you can redistribute it and/or modify
12 | ;; it under the terms of the GNU General Public License as published by
13 | ;; the Free Software Foundation, either version 3 of the License, or
14 | ;; (at your option) any later version.
15 |
16 | ;; This program is distributed in the hope that it will be useful,
17 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
18 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 | ;; GNU General Public License for more details.
20 |
21 | ;; You should have received a copy of the GNU General Public License
22 | ;; along with this program. If not, see .
23 |
24 | ;;; Commentary:
25 |
26 | ;; This package provides an alternative isearch UI based on the
27 | ;; minibuffer. This allows editing the search string in arbitrary
28 | ;; ways without any special maneuver; unlike standard isearch, cursor
29 | ;; motion commands do not end the search. Moreover, the search status
30 | ;; information in the echo area and some keybindings are slightly
31 | ;; simplified.
32 |
33 | ;; To use the package, simply activate `isearch-mb-mode'.
34 |
35 | ;;; Code:
36 |
37 | (eval-when-compile
38 | (require 'cl-lib)
39 | (require 'subr-x))
40 |
41 | (defgroup isearch-mb nil
42 | "Control isearch from the minibuffer."
43 | :group 'isearch)
44 |
45 | (defvar isearch-mb--with-buffer
46 | '(isearch-beginning-of-buffer
47 | isearch-end-of-buffer
48 | isearch-occur
49 | isearch-repeat-backward
50 | isearch-repeat-forward
51 | isearch-toggle-case-fold
52 | isearch-toggle-char-fold
53 | isearch-toggle-invisible
54 | isearch-toggle-lax-whitespace
55 | isearch-toggle-regexp
56 | isearch-toggle-symbol
57 | isearch-toggle-word
58 | isearch-exit
59 | isearch-delete-char)
60 | "List of commands to execute in the search buffer.")
61 |
62 | (defvar isearch-mb--after-exit
63 | '(isearch-query-replace
64 | isearch-query-replace-regexp
65 | isearch-highlight-regexp
66 | isearch-highlight-lines-matching-regexp
67 | isearch-abort)
68 | "List of commands to execute after exiting the minibuffer.")
69 |
70 | (defvar isearch-mb--no-search
71 | '(next-history-element previous-history-element)
72 | "List of commands that shouldn't trigger a search.")
73 |
74 | (defvar isearch-mb-minibuffer-map
75 | (let ((map (make-composed-keymap nil minibuffer-local-map)))
76 | (define-key map [remap next-line-or-history-element] #'isearch-repeat-forward)
77 | (define-key map [remap previous-line-or-history-element] #'isearch-repeat-backward)
78 | (define-key map [remap minibuffer-beginning-of-buffer] #'isearch-beginning-of-buffer)
79 | (define-key map [remap end-of-buffer] #'isearch-end-of-buffer)
80 | (define-key map [remap scroll-up-command] 'isearch-mb-scroll-up-command)
81 | (define-key map [remap scroll-down-command] 'isearch-mb-scroll-down-command)
82 | (define-key map [remap query-replace] #'isearch-query-replace)
83 | (define-key map [remap query-replace-regexp] #'isearch-query-replace-regexp)
84 | (define-key map "\C-j" #'newline)
85 | (define-key map "\C-s" #'isearch-repeat-forward)
86 | (define-key map "\C-r" #'isearch-repeat-backward)
87 | (define-key map "\M-s'" #'isearch-toggle-char-fold)
88 | (define-key map "\M-s " #'isearch-toggle-lax-whitespace)
89 | (define-key map "\M-s_" #'isearch-toggle-symbol)
90 | (define-key map "\M-sc" #'isearch-toggle-case-fold)
91 | (define-key map "\M-shr" #'isearch-highlight-regexp)
92 | (define-key map "\M-shl" #'isearch-highlight-lines-matching-regexp)
93 | (define-key map "\M-si" #'isearch-toggle-invisible)
94 | (define-key map "\M-so" #'isearch-occur)
95 | (define-key map "\M-sr" #'isearch-toggle-regexp)
96 | (define-key map "\M-sw" #'isearch-toggle-word)
97 | map)
98 | "Minibuffer keymap used by isearch-mb.")
99 |
100 | (defvar isearch-mb--prompt-overlay nil
101 | "Overlay for minibuffer prompt updates.")
102 |
103 | ;; Variables introduced in Emacs 28
104 | (defvar isearch-motion-changes-direction nil)
105 | (defvar isearch-repeat-on-direction-change nil)
106 | (defvar isearch-forward-thing-at-point '(region url symbol sexp))
107 |
108 | (defun isearch-mb--after-change (_beg _end _len)
109 | "Hook to run from the minibuffer to update the isearch state."
110 | (let ((string (minibuffer-contents))
111 | (cursor-in-echo-area t))
112 | (with-minibuffer-selected-window
113 | (setq isearch-string (substring-no-properties string))
114 | (isearch-update-from-string-properties string)
115 | ;; Backtrack to barrier and search, unless `this-command' is
116 | ;; special or the search regexp is invalid.
117 | (if (or (and (symbolp this-command)
118 | (memq this-command isearch-mb--no-search))
119 | (and isearch-regexp
120 | (condition-case err
121 | (prog1 nil (string-match-p isearch-string ""))
122 | (invalid-regexp
123 | (prog1 t (setq isearch-error (cadr err)))))))
124 | (isearch-update)
125 | (goto-char isearch-barrier)
126 | (setq isearch-adjusted t isearch-success t)
127 | (isearch-search-and-update)))))
128 |
129 | (defun isearch-mb--post-command-hook ()
130 | "Hook to make the minibuffer reflect the isearch state."
131 | (unless isearch--current-buffer
132 | (throw 'isearch-mb--continue '(ignore)))
133 | (let ((inhibit-modification-hooks t))
134 | ;; We never update `isearch-message'. If it's not empty, then
135 | ;; isearch changed the search string on its own volition.
136 | (unless (string-empty-p isearch-message)
137 | (setq isearch-message "")
138 | (delete-minibuffer-contents)
139 | (insert isearch-string))
140 | (set-text-properties (minibuffer-prompt-end) (point-max) nil)
141 | (when-let ((fail-pos (isearch-fail-pos)))
142 | (add-text-properties (+ (minibuffer-prompt-end) fail-pos)
143 | (point-max)
144 | '(face isearch-fail)))
145 | (when isearch-error
146 | (isearch-mb--message isearch-error))))
147 |
148 | (defun isearch-mb--message (message)
149 | "Display a momentary MESSAGE."
150 | (let ((message-log-max nil))
151 | (message (propertize (concat " [" message "]")
152 | 'face 'minibuffer-prompt))))
153 |
154 | (defun isearch-mb--update-prompt (&rest _)
155 | "Update the minibuffer prompt according to search status."
156 | (when isearch-mb--prompt-overlay
157 | (let ((count (isearch-lazy-count-format))
158 | (len (or (overlay-get isearch-mb--prompt-overlay 'isearch-mb--len) 0)))
159 | (overlay-put isearch-mb--prompt-overlay
160 | 'isearch-mb--len (max len (length count)))
161 | (overlay-put isearch-mb--prompt-overlay
162 | 'before-string
163 | (concat count ;; Count is padded so that it only grows.
164 | (make-string (max 0 (- len (length count))) ?\ )
165 | (capitalize
166 | (or (isearch--describe-regexp-mode
167 | isearch-regexp-function)
168 | "")))))))
169 |
170 | (defun isearch-mb--add-defaults ()
171 | "Add default search strings to future history."
172 | (setq minibuffer-default
173 | (with-minibuffer-selected-window
174 | (thread-last isearch-forward-thing-at-point
175 | (mapcar #'thing-at-point)
176 | (delq nil)
177 | (delete-dups)
178 | (mapcar (if isearch-regexp 'regexp-quote 'identity))))))
179 |
180 | (defun isearch-mb--with-buffer (&rest args)
181 | "Evaluate ARGS in the search buffer.
182 | Intended as an advice for isearch commands."
183 | (if (minibufferp)
184 | (let ((enable-recursive-minibuffers t)
185 | (cursor-in-echo-area t))
186 | (with-minibuffer-selected-window
187 | (apply args)))
188 | (apply args)))
189 |
190 | ;; Special motion commands normally handled in `isearch-pre-command-hook'.
191 | (dolist (symbol '(scroll-up-command scroll-down-command))
192 | (defalias (intern (concat "isearch-mb-" (symbol-name symbol)))
193 | (let ((fun (pcase (get symbol 'isearch-motion)
194 | (`(,motion . ,direction)
195 | (lambda ()
196 | (let ((current-direction (if isearch-forward 'forward 'backward)))
197 | (funcall motion)
198 | (setq isearch-just-started t)
199 | (let ((isearch-repeat-on-direction-change nil))
200 | (isearch-repeat direction)
201 | (when (and isearch-success
202 | (not isearch-motion-changes-direction)
203 | (not (eq direction current-direction)))
204 | (isearch-repeat current-direction))))))
205 | (_ symbol)))) ;; Emacs < 28
206 | (lambda () (interactive) (isearch-mb--with-buffer fun)))
207 | (format "Perform motion of `%s' in the search buffer." symbol)))
208 |
209 | (defun isearch-mb--after-exit (&rest args)
210 | "Evaluate ARGS after quitting isearch-mb.
211 | Intended as an advice for commands that quit isearch and use the
212 | minibuffer."
213 | (throw 'isearch-mb--continue args))
214 |
215 | (defun isearch-mb--session ()
216 | "Read search string from the minibuffer."
217 | (remove-hook 'pre-command-hook 'isearch-pre-command-hook)
218 | (remove-hook 'post-command-hook 'isearch-post-command-hook)
219 | (setq overriding-terminal-local-map nil)
220 | (condition-case nil
221 | (apply
222 | (catch 'isearch-mb--continue
223 | (cl-letf (((cdr isearch-mode-map) nil)
224 | ((symbol-function #'isearch--momentary-message) #'isearch-mb--message)
225 | ;; Setting `isearch-message-function' currently disables lazy
226 | ;; count, so we need this as a workaround.
227 | ((symbol-function #'isearch-message) #'isearch-mb--update-prompt)
228 | (minibuffer-default-add-function #'isearch-mb--add-defaults)
229 | (wstart nil))
230 | (minibuffer-with-setup-hook
231 | (lambda ()
232 | (add-hook 'after-change-functions #'isearch-mb--after-change nil 'local)
233 | (add-hook 'post-command-hook #'isearch-mb--post-command-hook nil 'local)
234 | (add-hook 'minibuffer-exit-hook
235 | (lambda () (setq wstart (window-start (minibuffer-selected-window))))
236 | nil 'local)
237 | (setq-local tool-bar-map isearch-tool-bar-map)
238 | (setq isearch-mb--prompt-overlay (make-overlay (point-min) (point-min)
239 | (current-buffer) t t))
240 | (isearch-mb--update-prompt)
241 | (isearch-mb--post-command-hook))
242 | (unwind-protect
243 | (progn
244 | (dolist (fun isearch-mb--with-buffer)
245 | (advice-add fun :around #'isearch-mb--with-buffer))
246 | (dolist (fun isearch-mb--after-exit)
247 | (advice-add fun :around #'isearch-mb--after-exit))
248 | (read-from-minibuffer
249 | "I-search: " nil isearch-mb-minibuffer-map nil
250 | (if isearch-regexp 'regexp-search-ring 'search-ring) nil t)
251 | ;; Undo a possible recenter after quitting the minibuffer.
252 | (set-window-start nil wstart))
253 | (dolist (fun isearch-mb--after-exit)
254 | (advice-remove fun #'isearch-mb--after-exit))
255 | (dolist (fun isearch-mb--with-buffer)
256 | (advice-remove fun #'isearch-mb--with-buffer))))
257 | (if isearch-mode '(isearch-done) '(ignore)))))
258 | (quit (if isearch-mode (isearch-cancel) (signal 'quit nil)))))
259 |
260 | (defun isearch-mb--setup ()
261 | "Arrange to start isearch-mb after this command, if applicable."
262 | (unless (minibufferp)
263 | ;; When `with-isearch-suspended' is involved, this hook may run
264 | ;; more than once, hence the test for `isearch-mode'.
265 | (run-with-idle-timer 0 nil (lambda () (when isearch-mode (isearch-mb--session))))))
266 |
267 | ;;;###autoload
268 | (define-minor-mode isearch-mb-mode
269 | "Control isearch from the minibuffer.
270 |
271 | During an isearch-mb session, the following keys are available:
272 | \\{isearch-mb-minibuffer-map}"
273 | :global t
274 | (if isearch-mb-mode
275 | (add-hook 'isearch-mode-hook #'isearch-mb--setup)
276 | (remove-hook 'isearch-mode-hook #'isearch-mb--setup)))
277 |
278 | (provide 'isearch-mb)
279 | ;;; isearch-mb.el ends here
280 |
--------------------------------------------------------------------------------