I'm working on an Emacs mode that lets you control Emacs with speech recognition. One of the problems I've ran into is that the way Emacs handles undo doesn't match how you would expect it to work when controlling by voice.
When the user speaks several words and then pauses, that's called an 'utterance.' An utterance may consist of multiple commands for Emacs to execute. It's often the case that the recognizer recognizes one or more commands within an utterance incorrectly. At that point I want to be able to say "undo" and have Emacs undo all actions done by the utterance, not just the last action within the utterance. In other words, I want Emacs to treat an utterance as a single command as far as undo is concerned, even when an utterance consists of multiple commands. I'd also like point to go back to exactly where it was before the utterance, I've noticed normal Emacs undo doesn't do this.
I have setup Emacs to get callbacks at the beginning and end of each utterance, so I can detect the situation, I just need to figure out what to have Emacs do. Ideally I'd call something like (undo-start-collapsing)
and then (undo-stop-collapsing)
and anything done inbetween would be magically collapsed into one record.
I did some trawling through the documentation and found undo-boundary
, but it's the opposite of what I want -- I need to collapse all the actions within an utterance into one undo record, not split them up. I can use undo-boundary
between utterances to make sure insertions are considered separate (Emacs by default considers consecutive insert actions to be one action up to some limit), but that's it.
Other complications:
- My speech recognition daemon sends some commands to Emacs by simulating X11 keypresses and sends some via
emacsclient -e
so, if there were say an(undo-collapse &rest ACTIONS)
there's no central place I can wrap. - I use
undo-tree
, not sure if this makes things more complicated. Ideally a solution would work withundo-tree
and Emacs' normal undo behavior. - What if one of the commands within an utterance is "undo" or "redo"? I'm thinking I could change the callback logic to always send these to Emacs as distinct utterances to keep things simpler, then it should be handled just like it would if I were using the keyboard.
- Stretch goal: An utterance may contain a command that switches the currently active window or buffer. In this case it's fine to have to say "undo" once separately in each buffer, I don't need it to be that fancy. But all the commands in a single buffer should still be grouped, so if I say "do-x do-y do-z switch-buffer do-a do-b do-c" then x,y,z should be one undo record in the original buffer and a,b,c should be one record in the switched to buffer.
Is there an easy way to do this? AFAICT there is nothing built-in but Emacs is vast and deep...
Update: I ended up using jhc's solution below with a little extra code. In the global before-change-hook
I check if the buffer being changed is in a global list of buffers modified this utterance, if not it goes into the list and undo-collapse-begin
is called. Then at the end of the utterance I iterate all the buffers in the list and call undo-collapse-end
. Code below (md- added before function names for namespacing purposes):
(defvar md-utterance-changed-buffers nil)
(defvar-local md-collapse-undo-marker nil)
(defun md-undo-collapse-begin (marker)
"Mark the beginning of a collapsible undo block.
This must be followed with a call to undo-collapse-end with a marker
eq to this one.
Taken from jch's stackoverflow answer here:
http://emacs.stackexchange.com/a/7560/2301
"
(push marker buffer-undo-list))
(defun md-undo-collapse-end (marker)
"Collapse undo history until a matching marker.
Taken from jch's stackoverflow answer here:
http://emacs.stackexchange.com/a/7560/2301"
(cond
((eq (car buffer-undo-list) marker)
(setq buffer-undo-list (cdr buffer-undo-list)))
(t
(let ((l buffer-undo-list))
(while (not (eq (cadr l) marker))
(cond
((null (cdr l))
(error "md-undo-collapse-end with no matching marker"))
((eq (cadr l) nil)
(setf (cdr l) (cddr l)))
(t (setq l (cdr l)))))
;; remove the marker
(setf (cdr l) (cddr l))))))
(defmacro md-with-undo-collapse (&rest body)
"Execute body, then collapse any resulting undo boundaries.
Taken from jch's stackoverflow answer here:
http://emacs.stackexchange.com/a/7560/2301"
(declare (indent 0))
(let ((marker (list 'apply 'identity nil)) ; build a fresh list
(buffer-var (make-symbol "buffer")))
`(let ((,buffer-var (current-buffer)))
(unwind-protect
(progn
(md-undo-collapse-begin ',marker)
,@body)
(with-current-buffer ,buffer-var
(md-undo-collapse-end ',marker))))))
(defun md-check-undo-before-change (beg end)
"When a modification is detected, we push the current buffer
onto a list of buffers modified this utterance."
(unless (or
;; undo itself causes buffer modifications, we
;; don't want to trigger on those
undo-in-progress
;; we only collapse utterances, not general actions
(not md-in-utterance)
;; ignore undo disabled buffers
(eq buffer-undo-list t)
;; ignore read only buffers
buffer-read-only
;; ignore buffers we already marked
(memq (current-buffer) md-utterance-changed-buffers)
;; ignore buffers that have been killed
(not (buffer-name)))
(push (current-buffer) md-utterance-changed-buffers)
(setq md-collapse-undo-marker (list 'apply 'identity nil))
(undo-boundary)
(md-undo-collapse-begin md-collapse-undo-marker)))
(defun md-pre-utterance-undo-setup ()
(setq md-utterance-changed-buffers nil)
(setq md-collapse-undo-marker nil))
(defun md-post-utterance-collapse-undo ()
(unwind-protect
(dolist (i md-utterance-changed-buffers)
;; killed buffers have a name of nil, no point
;; in undoing those
(when (buffer-name i)
(with-current-buffer i
(condition-case nil
(md-undo-collapse-end md-collapse-undo-marker)
(error (message "Couldn't undo in buffer %S" i))))))
(setq md-utterance-changed-buffers nil)
(setq md-collapse-undo-marker nil)))
(defun md-force-collapse-undo ()
"Forces undo history to collapse, we invoke when the user is
trying to do an undo command so the undo itself is not collapsed."
(when (memq (current-buffer) md-utterance-changed-buffers)
(md-undo-collapse-end md-collapse-undo-marker)
(setq md-utterance-changed-buffers (delq (current-buffer) md-utterance-changed-buffers))))
(defun md-resume-collapse-after-undo ()
"After the 'undo' part of the utterance has passed, we still want to
collapse anything that comes after."
(when md-in-utterance
(md-check-undo-before-change nil nil)))
(defun md-enable-utterance-undo ()
(setq md-utterance-changed-buffers nil)
(when (featurep 'undo-tree)
(advice-add #'md-force-collapse-undo :before #'undo-tree-undo)
(advice-add #'md-resume-collapse-after-undo :after #'undo-tree-undo)
(advice-add #'md-force-collapse-undo :before #'undo-tree-redo)
(advice-add #'md-resume-collapse-after-undo :after #'undo-tree-redo))
(advice-add #'md-force-collapse-undo :before #'undo)
(advice-add #'md-resume-collapse-after-undo :after #'undo)
(add-hook 'before-change-functions #'md-check-undo-before-change)
(add-hook 'md-start-utterance-hooks #'md-pre-utterance-undo-setup)
(add-hook 'md-end-utterance-hooks #'md-post-utterance-collapse-undo))
(defun md-disable-utterance-undo ()
;;(md-force-collapse-undo)
(when (featurep 'undo-tree)
(advice-remove #'md-force-collapse-undo :before #'undo-tree-undo)
(advice-remove #'md-resume-collapse-after-undo :after #'undo-tree-undo)
(advice-remove #'md-force-collapse-undo :before #'undo-tree-redo)
(advice-remove #'md-resume-collapse-after-undo :after #'undo-tree-redo))
(advice-remove #'md-force-collapse-undo :before #'undo)
(advice-remove #'md-resume-collapse-after-undo :after #'undo)
(remove-hook 'before-change-functions #'md-check-undo-before-change)
(remove-hook 'md-start-utterance-hooks #'md-pre-utterance-undo-setup)
(remove-hook 'md-end-utterance-hooks #'md-post-utterance-collapse-undo))
(md-enable-utterance-undo)
;; (md-disable-utterance-undo)
buffer-undo-list
as a marker -- perhaps an entry of the form(apply FUN-NAME . ARGS)
? Then to undo an utterance you repeatedly callundo
until find your next marker. But I suspect there are all sorts of complications here. :)