kmacro-x-mc.el (18370B)
1 ;;; kmacro-x-mc.el --- Multiple cursors implemented with kmacros -*- lexical-binding: t; -*- 2 3 ;; Copyright (C) 2022-2025 Wojciech Siewierski 4 5 ;; Author: Wojciech Siewierski 6 ;; URL: https://github.com/vifon/kmacro-x.el 7 ;; Keywords: convenience 8 ;; Version: 0.9 9 10 ;; This program is free software; you can redistribute it and/or modify 11 ;; it under the terms of the GNU General Public License as published by 12 ;; the Free Software Foundation, either version 3 of the License, or 13 ;; (at your option) any later version. 14 15 ;; This program is distributed in the hope that it will be useful, 16 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 17 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 ;; GNU General Public License for more details. 19 20 ;; You should have received a copy of the GNU General Public License 21 ;; along with this program. If not, see <https://www.gnu.org/licenses/>. 22 23 ;;; Commentary: 24 25 ;; Efficient multiple cursors implemented using keyboard macros. 26 27 ;; Loosely inspired by https://github.com/corytertel/macrursors and 28 ;; obviously https://github.com/magnars/multiple-cursors.el 29 30 ;;; Code: 31 32 (require 'kmacro-x) 33 34 (defgroup kmacro-x-mc nil 35 "Multiple cursors implemented with keyboard macros." 36 :group 'kmacro-x) 37 38 (defvar kmacro-x-mc-mode) 39 40 (defface kmacro-x-mc-cursor-face 41 '((t (:inverse-video t))) 42 "The face used for the fake cursors created in `kmacro-x-mc-mode'.") 43 44 (defface kmacro-x-mc-main-cursor-face 45 '((t (:underline t))) 46 "The face used for the original cursor when using `kmacro-x-mc-mode'.") 47 48 (defcustom kmacro-x-mc-mark-whole-symbol nil 49 "When invoking `kmacro-x-mc-mark-next' with no region, mark the whole symbol. 50 51 If nil, keep point in place and put the mark at the end of the symbol. 52 53 If t, move the point to the end of the symbol and put the mark at its beginning. 54 55 Example, with | being the point and ^ being the mark: 56 57 exam|ple-symbol 58 ^ 59 60 example-symbol| 61 ^" 62 :type 'boolean) 63 64 (defcustom kmacro-x-mc-preserve-last-macro t 65 "If non-nil, preserve the previous value of `last-kbd-macro'. 66 67 If non-nil, `kmacro-x-mc-mode' saves the user's own `last-kbd-macro' to 68 restore it later. The macro recorded by `kmacro-x-mc-mode' is then 69 discarded. This allows the user to ignore the inner workings of 70 `kmacro-x-mc-mode' as the plain keyboard macros are not disturbed. 71 72 If nil, `kmacro-x-mc-mode' behaves like a regular keyboard macro and 73 keeps the recorded macro in `last-kbd-macro'. The previous value is 74 pushed on `kmacro-ring'. The macro recorded by `kmacro-x-mc-mode' can 75 then be reused." 76 :type 'boolean) 77 78 (defcustom kmacro-x-mc-pre-apply-hook nil 79 "Functions to run before applying the recorded keyboard macro to cursors." 80 :type 'hook) 81 82 (defcustom kmacro-x-mc-post-apply-hook nil 83 "Functions to run after applying the recorded keyboard macro to cursors." 84 :type 'hook) 85 86 (defcustom kmacro-x-mc-live-preview nil 87 "Experimental live previews of the edits performed within the cursors. 88 89 Changes performed outside of the cursors are not displayed, but 90 will be replicated as usual. 91 92 Turning this on makes it no longer possible to move the cursor 93 onto the fake cursors / selections. While technically a bug, 94 this scenario is so rare and so difficult to get right (from the 95 user's perspective), this is just acknowledged here." 96 :type 'boolean) 97 98 (defcustom kmacro-x-mc-safe-commands '(kmacro-x-mc-mark-next 99 kmacro-x-mc-mark-previous 100 kmacro-x-mc-unmark-next 101 kmacro-x-mc-unmark-previous) 102 "List of commands allowed to run consecutively while marking cursors. 103 104 Used by `kmacro-x-mc--mark'." 105 :type '(repeat symbol)) 106 107 (defcustom kmacro-x-mc-mode-lighter 108 `(" mc:" (:eval (format ,(propertize "%d" 'face 'font-lock-warning-face) 109 (length kmacro-x-mc-cursors)))) 110 "What to display in the mode line while `kmacro-x-mc-mode' is active." 111 :type '(sexp)) 112 (put 'kmacro-x-mc-mode-lighter 'risky-local-variable t) 113 114 (defvar-local kmacro-x-mc-regexp nil 115 "A regexp matching the intended cursor positions.") 116 117 (defvar-local kmacro-x-mc-cursors nil 118 "The overlays for displaying and keeping the cursor positions. 119 120 `kmacro-x-mc-mode' initializes it with the original cursor's 121 overlay which can be recognized by it having its `face' property 122 set to `kmacro-x-mc-main-cursor-face'. The other (\"fake\") 123 cursors use `kmacro-x-mc-cursor-face' instead.") 124 125 (defvar-local kmacro-x-mc-main-cursor nil 126 "The overlay for the original cursor. 127 128 Duplicated from `kmacro-x-mc-cursors' so it can be accessed at 129 all times without searching through this list.") 130 131 132 (defun kmacro-x-mc--modification-hook-live-preview 133 (ov after-p _beg _end &optional _length) 134 "Overlay modification-hook updating all the other overlays. 135 136 OV and AFTER-P (and other ignored arguments) are passed according 137 to the modification-hook overlay API." 138 (when after-p 139 (dolist (cursor kmacro-x-mc-cursors) 140 (unless (kmacro-x-mc--main-cursor-p cursor) 141 (overlay-put cursor 'display 142 (buffer-substring (overlay-start ov) 143 (overlay-end ov))))))) 144 145 (defun kmacro-x-mc--make-main-cursor (beg end) 146 "Create an overlay representing the main cursor from BEG to END." 147 148 ;; With `kmacro-x-mc-live-preview' inverting the *-advance arguments 149 ;; of `make-overlay' it's possible to use the overlay 150 ;; modification-hooks to capture the edits at the cursor boundaries. 151 ;; With `kmacro-x-mc-live-preview' being nil, the difference is only 152 ;; visual and this setting here is subjectively the most visually 153 ;; pleasing one (according to the author). 154 (let* ((front-advance (not kmacro-x-mc-live-preview)) 155 (rear-advance kmacro-x-mc-live-preview) 156 (ov (make-overlay beg end nil front-advance rear-advance))) 157 (overlay-put ov 'face 'kmacro-x-mc-main-cursor-face) 158 (overlay-put ov 'offsets (cons (- (point) beg) 159 (- (mark) beg))) 160 (when kmacro-x-mc-live-preview 161 (let ((hooks (list #'kmacro-x-mc--modification-hook-live-preview))) 162 (overlay-put ov 'modification-hooks hooks) 163 (overlay-put ov 'insert-in-front-hooks hooks) 164 (overlay-put ov 'insert-behind-hooks hooks))) 165 ov)) 166 167 (defun kmacro-x-mc--make-cursor (beg end) 168 "Create an overlay representing a fake cursor from BEG to END." 169 (let ((ov (make-overlay beg end nil t nil))) 170 (overlay-put ov 'face 'kmacro-x-mc-cursor-face) 171 172 ;; Store the offsets per cursor as mouse-created cursors 173 ;; have different offsets. 174 (overlay-put ov 'offsets 175 (overlay-get kmacro-x-mc-main-cursor 'offsets)) 176 177 ;; Store whether the region should be active for this 178 ;; cursor. This allows to properly replicate the 179 ;; region-sensitive commands such as using `DEL' to delete 180 ;; the whole region without affecting the `kill-ring' (like 181 ;; `C-w' would). 182 (overlay-put ov 'region-active (region-active-p)) 183 184 ov)) 185 186 (defun kmacro-x-mc--mark (prefix &optional backwards) 187 "Create a new fake cursor for `kmacro-x-mc-mode'. 188 189 Used internally by `kmacro-x-mc-mark-next' and 190 `kmacro-x-mc-mark-previous'. 191 192 With the PREFIX argument, replace the last cursor and prefix it 193 with the new one, essentially skipping one occurence of a match. 194 195 If `kmacro-x-mc-mode' isn't enabled yet, PREFIX instead inverts 196 the `kmacro-x-mc-mark-whole-symbol' setting. 197 198 Enables `kmacro-x-mc-mode' if not enabled yet, for the necessary 199 initialization such as setting `kmacro-x-mc-regexp', 200 `kmacro-x-mc-cursors' and `kmacro-x-mc-main-cursor'. 201 202 If BACKWARDS is non-nil, searches backwards. 203 Otherwise searches forward." 204 205 ;; Sanity check to prevent defining cursors with inconsistent 206 ;; starting conditions. 207 (when (and kmacro-x-mc-mode 208 (not (memq last-command kmacro-x-mc-safe-commands))) 209 (user-error "This command can only be used when starting a bulk edit")) 210 211 (unless kmacro-x-mc-mode 212 (let ((kmacro-x-mc-mark-whole-symbol 213 ;; On the first call, use `prefix' to invert the 214 ;; `kmacro-x-mc-mark-whole-symbol' setting and then set it 215 ;; to nil to disable any behavior it would enable on the 216 ;; subsequent calls instead. 217 (if prefix 218 (prog1 219 (not kmacro-x-mc-mark-whole-symbol) 220 (setq prefix nil)) 221 kmacro-x-mc-mark-whole-symbol))) 222 (kmacro-x-mc-mode 1))) 223 224 ;; This function is always being called while a macro is being 225 ;; recorded, but we do not want it to become a part of the macro. 226 (cancel-kbd-macro-events) 227 228 (save-excursion 229 ;; Start the search right after/behind the last cursor. 230 ;; The original cursor is the "zeroth" cursor, so the list is 231 ;; never empty and both appending and prepending to it makes sense 232 ;; at all times once `kmacro-x-mc-mode' is active (it initializes 233 ;; it with the original cursor). 234 (let ((ov (car (if backwards 235 kmacro-x-mc-cursors 236 (last kmacro-x-mc-cursors))))) 237 (goto-char (if backwards 238 (overlay-start ov) 239 (overlay-end ov)))) 240 241 ;; Create a new cursor. 242 (if (let ((case-fold-search nil)) 243 (if backwards 244 (search-backward-regexp kmacro-x-mc-regexp nil 'noerror) 245 (search-forward-regexp kmacro-x-mc-regexp nil 'noerror))) 246 (let ((ov (kmacro-x-mc--make-cursor (match-beginning 0) 247 (match-end 0)))) 248 (if prefix 249 ;; Replace the first or last cursor with the new one. 250 (let ((old-cursor (if backwards 251 kmacro-x-mc-cursors 252 (last kmacro-x-mc-cursors)))) 253 (delete-overlay (car old-cursor)) 254 (setcar old-cursor ov)) 255 ;; Either append or prepend the new cursor. 256 (if backwards 257 (push ov kmacro-x-mc-cursors) 258 (setq kmacro-x-mc-cursors 259 (append kmacro-x-mc-cursors (list ov)))))) 260 (message "No further matches")))) 261 262 ;;;###autoload 263 (defun kmacro-x-mc-mark-next (&optional prefix) 264 "Create a new fake cursor forward. 265 266 When region is active, create the cursor at the next occurrence 267 of the selection. When region is inactive, look for the symbol 268 at point instead. 269 270 With the PREFIX argument, replace the last cursor and prefix it 271 with the new one, essentially skipping one occurence of a match. 272 273 If `kmacro-x-mc-mode' isn't enabled yet, PREFIX instead inverts 274 the `kmacro-x-mc-mark-whole-symbol' setting. 275 276 Activates `kmacro-x-mc-mode' with its keymap being used to either 277 apply or abort the bulk edit. 278 279 See also: `kmacro-x-mc-mark-previous'" 280 (interactive "P") 281 (kmacro-x-mc--mark prefix)) 282 283 ;;;###autoload 284 (defun kmacro-x-mc-mark-previous (&optional prefix) 285 "Create a new fake cursor backwards. 286 287 See `kmacro-x-mc-mark-next' for the details and the PREFIX 288 argument behavior." 289 (interactive "P") 290 (kmacro-x-mc--mark prefix 'backwards)) 291 292 (defun kmacro-x-mc--main-cursor-p (cursor) 293 "Check whether CURSOR is the main cursor and not a fake one." 294 (eq (overlay-get cursor 'face) 295 'kmacro-x-mc-main-cursor-face)) 296 297 (defun kmacro-x-mc--apply-cursor (cursor) 298 "Apply the recorded macro to CURSOR (from `kmacro-x-mc-cursors'). 299 300 CURSOR is internally an overlay." 301 (overlay-put cursor 'display nil) 302 (goto-char (+ (overlay-start cursor) 303 (car (overlay-get cursor 'offsets)))) 304 (push-mark (+ (overlay-start cursor) 305 (cdr (overlay-get cursor 'offsets)))) 306 (when (overlay-get cursor 'region-active) 307 (activate-mark)) 308 (call-last-kbd-macro)) 309 310 (defun kmacro-x-mc-apply () 311 "Apply the recoded macro for each cursor." 312 (interactive) 313 (when defining-kbd-macro 314 (end-kbd-macro)) 315 (run-hooks 'kmacro-x-mc-pre-apply-hook) 316 (let ((marker (point-marker))) 317 (dolist (ov kmacro-x-mc-cursors) 318 (unless (kmacro-x-mc--main-cursor-p ov) 319 (kmacro-x-mc--apply-cursor ov))) 320 ;; Push the original point to the `mark-ring', so it's easy to 321 ;; return there if needed. 322 (push-mark marker) 323 (set-marker marker nil)) 324 (run-hooks 'kmacro-x-mc-post-apply-hook) 325 (kmacro-x-mc-mode 0)) 326 327 (defun kmacro-x-mc-apply-one () 328 "Apply the recoded macro to one fake cursor. 329 330 Keeps `kmacro-x-mc-mode' active to allow calling it more times, 331 but stops the macro recording. 332 333 Use `kmacro-x-mc-apply' to apply the macro to all the 334 remaining cursors." 335 (interactive) 336 (when defining-kbd-macro 337 (end-kbd-macro)) 338 (catch 'cursor 339 (dolist (ov kmacro-x-mc-cursors) 340 (unless (kmacro-x-mc--main-cursor-p ov) 341 (kmacro-x-mc--apply-cursor ov) 342 (delete-overlay ov) 343 (setq kmacro-x-mc-cursors 344 (delete ov kmacro-x-mc-cursors)) 345 (throw 'cursor ov))))) 346 347 (defun kmacro-x-mc-quit () 348 "Cancel the macro recording, disable `kmacro-x-mc-mode'. 349 350 If region is active, merely deactivate it instead." 351 (interactive) 352 (if (region-active-p) 353 (deactivate-mark) 354 (kmacro-x-mc-mode 0) 355 356 ;; Make sure the macro recording is cancelled. Since this command 357 ;; is bound to `C-g', it might be a good idea to call the original 358 ;; `C-g' command anyway. 359 (keyboard-quit))) 360 361 (defun kmacro-x-mc--unmark (&optional backwards) 362 "Remove the outermost fake cursor in `kmacro-x-mc-mode'. 363 364 If BACKWARDS is non-nil, removes the first fake cursor in 365 `kmacro-x-mc-cursors' (i.e. the outermost one created with 366 `kmacro-x-mc-mark-previous'). Otherwise removes the last one. 367 368 Used internally by `kmacro-x-mc-unmark-next' and 369 `kmacro-x-mc-unmark-previous'." 370 (unless kmacro-x-mc-mode 371 (user-error "kmacro-x-mc-mode is not active")) 372 (cancel-kbd-macro-events) 373 (let ((ov (car (if backwards 374 kmacro-x-mc-cursors 375 (last kmacro-x-mc-cursors))))) 376 (if (kmacro-x-mc--main-cursor-p ov) 377 (message "No more cursors to remove") 378 (delete-overlay ov) 379 (setq kmacro-x-mc-cursors (delq ov kmacro-x-mc-cursors))))) 380 381 (defun kmacro-x-mc-unmark-next () 382 "Delete next fake cursor." 383 (interactive) 384 (kmacro-x-mc--unmark)) 385 386 (defun kmacro-x-mc-unmark-previous () 387 "Delete previous fake cursor." 388 (interactive) 389 (kmacro-x-mc--unmark t)) 390 391 (defvar kmacro-x-mc-mode-map 392 (let ((map (make-sparse-keymap))) 393 (define-key map (kbd "C-g") #'kmacro-x-mc-quit) 394 (define-key map (kbd "RET") #'kmacro-x-mc-apply) 395 (define-key map (kbd "M-RET") #'kmacro-x-mc-apply-one) 396 map)) 397 398 (defvar kmacro-x-mc--bounds-override nil 399 "An alternate boundaries of the edited text element. 400 401 Unless it's non-nil, the boundaries of the symbol at point are 402 used. This variable can override this behavior in commands using 403 different semantics, for instance mouse-placed cursors.") 404 405 (define-minor-mode kmacro-x-mc-mode 406 "Record a keyboard macro to apply with multiple cursors. 407 408 Usually invoked from `kmacro-x-mc-mark-next', not by the 409 user directory. 410 411 \\{kmacro-x-mc-mode-map}" 412 :lighter kmacro-x-mc-mode-lighter 413 (if kmacro-x-mc-mode 414 (let ((bounds (if (use-region-p) 415 (cons (region-beginning) (region-end)) 416 (or kmacro-x-mc--bounds-override 417 (bounds-of-thing-at-point 'symbol))))) 418 419 (let ((regexp (regexp-quote 420 (buffer-substring-no-properties 421 (car bounds) 422 (cdr bounds))))) 423 (setq-local kmacro-x-mc-regexp 424 (if (use-region-p) 425 regexp 426 (concat "\\_<" regexp "\\_>")))) 427 428 (unless (use-region-p) 429 (if kmacro-x-mc-mark-whole-symbol 430 (progn 431 (push-mark (car bounds)) 432 (goto-char (cdr bounds))) 433 ;; The end of the symbol is as good of a place as any for 434 ;; the mark. Definitely better than whatever random 435 ;; position it was before. 436 (push-mark (cdr bounds)))) 437 438 (let ((ov (kmacro-x-mc--make-main-cursor (car bounds) 439 (cdr bounds)))) 440 (setq-local kmacro-x-mc-main-cursor ov) 441 (setq-local kmacro-x-mc-cursors (list ov))) 442 443 (push `(kmacro-x-mc-mode . ,kmacro-x-mc-mode-map) 444 minor-mode-overriding-map-alist) 445 446 (when kmacro-x-mc-preserve-last-macro 447 ;; Save the user's own `last-kbd-macro' and the associated 448 ;; counter to restore them later. When `last-kbd-macro' is 449 ;; nil, `kmacro-ring' isn't modified, no extra handling 450 ;; is needed. 451 (setq-local kmacro-x-mc-stored-last-macro (kmacro-ring-head)) 452 (setq last-kbd-macro nil)) 453 454 (start-kbd-macro nil)) 455 456 (setq minor-mode-overriding-map-alist 457 (assq-delete-all #'kmacro-x-mc-mode 458 minor-mode-overriding-map-alist)) 459 460 (mapc #'delete-overlay kmacro-x-mc-cursors) 461 (kill-local-variable 'kmacro-x-mc-regexp) 462 (kill-local-variable 'kmacro-x-mc-main-cursor) 463 (kill-local-variable 'kmacro-x-mc-cursors) 464 465 (when (bound-and-true-p kmacro-x-mc-stored-last-macro) 466 ;; Restore the previous value of `last-kbd-macro' and its counter. 467 (kmacro-split-ring-element kmacro-x-mc-stored-last-macro) 468 (kill-local-variable 'kmacro-x-mc-stored-last-macro)))) 469 470 471 (defvar-local kmacro-x-mc-change-group nil) 472 473 (defun kmacro-x-mc-undo-amalgamate-advice (&rest _) 474 "Amalgamate all the edits created with `kmacro-x-mc-mode' active." 475 (if kmacro-x-mc-mode 476 (progn 477 (undo-boundary) 478 (setq-local kmacro-x-mc-change-group (prepare-change-group)) 479 (activate-change-group kmacro-x-mc-change-group)) 480 (accept-change-group kmacro-x-mc-change-group) 481 (undo-amalgamate-change-group kmacro-x-mc-change-group) 482 (kill-local-variable 'kmacro-x-mc-change-group))) 483 484 ;;;###autoload 485 (define-minor-mode kmacro-x-mc-atomic-undo-mode 486 "Undo the whole `kmacro-x-mc-mode' bulk operation at once." 487 :global t 488 :require 'kmacro-x-mc 489 (if kmacro-x-mc-atomic-undo-mode 490 (advice-add #'kmacro-x-mc-mode :after 491 #'kmacro-x-mc-undo-amalgamate-advice) 492 (advice-remove #'kmacro-x-mc-mode 493 #'kmacro-x-mc-undo-amalgamate-advice))) 494 495 (provide 'kmacro-x-mc) 496 ;;; kmacro-x-mc.el ends here