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

GNU ELPA

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 | --------------------------------------------------------------------------------