kmacro-x

a fork of vifon's kmacro-x
git clone https://git.trogloxene.org/kmacro-x.git
Log | Files | Refs | README | LICENSE

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