¿Cómo colapsar deshacer la historia?

17

Estoy trabajando en un modo Emacs que le permite controlar Emacs con reconocimiento de voz. Uno de los problemas con los que me he encontrado es que la forma en que Emacs maneja deshacer no coincide con la forma en que esperaría que funcione cuando se controla por voz.

Cuando el usuario habla varias palabras y luego hace una pausa, eso se llama "enunciado". Un enunciado puede consistir en múltiples comandos para que Emacs se ejecute. A menudo, el reconocedor reconoce uno o más comandos dentro de un enunciado incorrectamente. En ese momento quiero poder decir "deshacer" y hacer que Emacs deshaga todas las acciones realizadas por el enunciado, no solo la última acción dentro del enunciado. En otras palabras, quiero que Emacs trate un enunciado como un solo comando en lo que respecta a deshacer, incluso cuando un enunciado consta de múltiples comandos. También me gustaría volver al punto exacto donde estaba antes del enunciado, he notado que Emacs normal deshacer no hace esto.

He configurado Emacs para recibir devoluciones de llamada al principio y al final de cada enunciado, para poder detectar la situación, solo necesito averiguar qué hacer Emacs. Lo ideal sería llamar a algo así (undo-start-collapsing)y luego, (undo-stop-collapsing)y todo lo que se haga en el medio se colapsaría mágicamente en un solo registro.

Revisé la documentación y encontré undo-boundary, pero es lo contrario de lo que quiero: necesito colapsar todas las acciones dentro de un enunciado en un registro de deshacer, no dividirlas. Puedo usar undo-boundaryentre enunciados para asegurarme de que las inserciones se consideren separadas (Emacs por defecto considera que las acciones de inserción consecutivas son una acción hasta cierto límite), pero eso es todo.

Otras complicaciones

  • Mi demonio de reconocimiento de voz envía algunos comandos a Emacs mediante la simulación de pulsaciones de teclas X11 y envía algunos a través de emacsclient -eesto, si se dice que (undo-collapse &rest ACTIONS)no hay un lugar central que pueda envolver.
  • Yo uso undo-tree, no estoy seguro si esto hace las cosas más complicadas. Idealmente, una solución funcionaría con undo-treeel comportamiento normal de deshacer de Emacs.
  • ¿Qué sucede si uno de los comandos dentro de un enunciado es "deshacer" o "rehacer"? Estoy pensando que podría cambiar la lógica de devolución de llamada para enviarlos siempre a Emacs como expresiones distintas para mantener las cosas más simples, entonces debería manejarse como lo haría si estuviera usando el teclado.
  • Objetivo de estiramiento: un enunciado puede contener un comando que cambia la ventana o el búfer activos actualmente. En este caso, está bien tener que decir "deshacer" una vez por separado en cada búfer, no necesito que sea tan elegante. Pero todos los comandos en un solo búfer todavía deberían estar agrupados, así que si digo "do-x do-y do-z switch-buffer do-a do-b do-c", entonces x, y, z deberían ser una deshacer registro en el búfer original y a, b, c debe ser un registro en el búfer conmutado.

¿Hay una forma fácil de hacer esto? AFAICT no hay nada incorporado, pero Emacs es vasto y profundo ...

Actualización: Terminé usando la solución de jhc a continuación con un pequeño código adicional. En el global before-change-hook, verifico si el búfer que se está cambiando está en una lista global de búferes modificados este enunciado, si no, entra en la lista y undo-collapse-beginse llama. Luego, al final de la emisión, repito todos los búferes en la lista y llamo undo-collapse-end. Código a continuación (md- agregado antes de los nombres de funciones para propósitos de espacio de nombres):

(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)
Joseph Garvin
fuente
No conozco un mecanismo incorporado para esto. Es posible que pueda insertar sus propias entradas buffer-undo-listcomo marcador, ¿quizás una entrada del formulario (apply FUN-NAME . ARGS)? Luego, para deshacer un enunciado, llame repetidamente undohasta encontrar su próximo marcador. Pero sospecho que hay todo tipo de complicaciones aquí. :)
glucas
Eliminar los límites parecería una mejor apuesta.
jch
¿Funciona manipular buffer-undo-list si estoy usando undo-tree? Lo veo referenciado en la fuente de deshacer árbol, así que supongo que sí, pero dar sentido a todo el modo sería un gran esfuerzo.
Joseph Garvin
@JosephGarvin También estoy interesado en controlar Emacs con el habla. ¿Tienes alguna fuente disponible?
PythonNut
@PythonNut: sí :) github.com/jgarvin/mandimus el paquete está incompleto ... y el código también está parcialmente en mi repositorio joe-etc: p Pero lo uso todo el día y funciona.
Joseph Garvin

Respuestas:

13

Curiosamente, parece que no hay una función integrada para hacer eso.

El siguiente código funciona insertando un marcador único buffer-undo-listen el comienzo de un bloque plegable, y eliminando todos los límites ( nilelementos) al final de un bloque, y luego eliminando el marcador. En caso de que algo salga mal, el marcador tiene la forma (apply identity nil)de garantizar que no haga nada si permanece en la lista de deshacer.

Idealmente, debe usar la with-undo-collapsemacro, no las funciones subyacentes. Como mencionó que no puede hacer el ajuste, asegúrese de pasar a los marcadores de funciones de bajo nivel que son eq, no solo equal.

Si el código invocado cambia los búferes, debe asegurarse de que undo-collapse-endse llame en el mismo búfer que undo-collapse-begin. En ese caso, solo las entradas de deshacer en el búfer inicial se contraerán.

(defun 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."
  (push marker buffer-undo-list))

(defun undo-collapse-end (marker)
  "Collapse undo history until a matching marker."
  (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 "undo-collapse-end with no matching marker"))
           ((null (cadr l))
            (setf (cdr l) (cddr l)))
           (t (setq l (cdr l)))))
       ;; remove the marker
       (setf (cdr l) (cddr l))))))

 (defmacro with-undo-collapse (&rest body)
  "Execute body, then collapse any resulting undo boundaries."
  (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
              (undo-collapse-begin ',marker)
              ,@body)
         (with-current-buffer ,buffer-var
           (undo-collapse-end ',marker))))))

Aquí hay un ejemplo de uso:

(defun test-no-collapse ()
  (interactive)
  (insert "toto")
  (undo-boundary)
  (insert "titi"))

(defun test-collapse ()
  (interactive)
  (with-undo-collapse
    (insert "toto")
    (undo-boundary)
    (insert "titi")))
jch
fuente
Entiendo por qué su marcador es una lista nueva, pero ¿hay alguna razón para esos elementos específicos?
Malabarba
@Malabarba se debe a que una entrada (apply identity nil)no hará nada si la llama primitive-undo, no romperá nada si por alguna razón se deja en la lista.
jch
Actualicé mi pregunta para incluir el código que agregué. ¡Gracias!
Joseph Garvin el
¿Alguna razón para hacer en (eq (cadr l) nil)lugar de (null (cadr l))?
ideasman42
@ ideasman42 modificado de acuerdo a su sugerencia.
jch
3

Algunos cambios en la maquinaria de deshacer "recientemente" rompieron un truco que viper-modeestaba usando para hacer este tipo de colapso (para los curiosos, se usa en el siguiente caso: cuando presionas ESCpara finalizar una inserción / reemplazo / edición, Viper quiere colapsar todo cambiar a un solo paso de deshacer).

Para arreglarlo limpiamente, introdujimos una nueva función undo-amalgamate-change-group(que corresponde más o menos a su undo-stop-collapsing) y reutiliza la existente prepare-change-grouppara marcar el comienzo (es decir, corresponde más o menos a su undo-start-collapsing).

Como referencia, aquí está el nuevo código Viper correspondiente:

(viper-deflocalvar viper--undo-change-group-handle nil)
(put 'viper--undo-change-group-handle 'permanent-local t)

(defun viper-adjust-undo ()
  (when viper--undo-change-group-handle
    (undo-amalgamate-change-group
     (prog1 viper--undo-change-group-handle
       (setq viper--undo-change-group-handle nil)))))

(defun viper-set-complex-command-for-undo ()
  (and (listp buffer-undo-list)
       (not viper--undo-change-group-handle)
       (setq viper--undo-change-group-handle
             (prepare-change-group))))

Esta nueva función aparecerá en Emacs-26, por lo que si desea usarla mientras tanto, puede copiar su definición (requiere cl-lib):

(defun undo-amalgamate-change-group (handle)
  "Amalgamate changes in change-group since HANDLE.
Remove all undo boundaries between the state of HANDLE and now.
HANDLE is as returned by `prepare-change-group'."
  (dolist (elt handle)
    (with-current-buffer (car elt)
      (setq elt (cdr elt))
      (when (consp buffer-undo-list)
        (let ((old-car (car-safe elt))
              (old-cdr (cdr-safe elt)))
          (unwind-protect
              (progn
                ;; Temporarily truncate the undo log at ELT.
                (when (consp elt)
                  (setcar elt t) (setcdr elt nil))
                (when
                    (or (null elt)        ;The undo-log was empty.
                        ;; `elt' is still in the log: normal case.
                        (eq elt (last buffer-undo-list))
                        ;; `elt' is not in the log any more, but that's because
                        ;; the log is "all new", so we should remove all
                        ;; boundaries from it.
                        (not (eq (last buffer-undo-list) (last old-cdr))))
                  (cl-callf (lambda (x) (delq nil x))
                      (if (car buffer-undo-list)
                          buffer-undo-list
                        ;; Preserve the undo-boundaries at either ends of the
                        ;; change-groups.
                        (cdr buffer-undo-list)))))
            ;; Reset the modified cons cell ELT to its original content.
            (when (consp elt)
              (setcar elt old-car)
              (setcdr elt old-cdr))))))))
Stefan
fuente
Investigué undo-amalgamate-change-groupy no parece haber una forma conveniente de usar esto como la with-undo-collapsemacro definida en esta página, ya atomic-change-groupque no funciona de una manera que permita llamar al grupo con undo-amalgamate-change-group.
ideasman42
Por supuesto, no lo usa con atomic-change-group: lo usa con prepare-change-group, lo que devuelve el identificador que luego debe pasar undo-amalgamate-change-groupcuando haya terminado.
Stefan
¿No sería útil una macro que se ocupe de esto? (with-undo-amalgamate ...)que maneja las cosas del grupo de cambio. De lo contrario, esto es un poco complicado para colapsar algunas operaciones.
ideasman42
Hasta ahora solo la usa viper IIRC y Viper no podría usar una macro así porque las dos llamadas ocurren en comandos separados, por lo que no hay necesidad de llorar. Pero sería trivial escribir una macro así, por supuesto.
Stefan
1
¿Podría esta macro ser escrita e incluida en emacs? Mientras que para un desarrollador experimentado es trivial, para alguien que quiere colapsar su historial de deshacer y no sabe por dónde empezar, es un tiempo perder el tiempo en línea y tropezar con este hilo ... luego tener que averiguar cuál es la mejor respuesta: cuando no tienen la experiencia suficiente para poder contarlo. Agregué
ideasman42
2

Aquí hay una with-undo-collapsemacro que usa la función Emacs-26 change-groups.

Esto es atomic-change-groupcon un cambio de una línea, agregando undo-amalgamate-change-group.

Tiene las ventajas de que:

  • No necesita manipular los datos de deshacer directamente.
  • Asegura que los datos de deshacer no se truncan.
(defmacro with-undo-collapse (&rest body)
  "Like `progn' but perform BODY with undo collapsed."
  (declare (indent 0) (debug t))
  (let ((handle (make-symbol "--change-group-handle--"))
        (success (make-symbol "--change-group-success--")))
    `(let ((,handle (prepare-change-group))
            ;; Don't truncate any undo data in the middle of this.
            (undo-outer-limit nil)
            (undo-limit most-positive-fixnum)
            (undo-strong-limit most-positive-fixnum)
            (,success nil))
       (unwind-protect
         (progn
           (activate-change-group ,handle)
           (prog1 ,(macroexp-progn body)
             (setq ,success t)))
         (if ,success
           (progn
             (accept-change-group ,handle)
             (undo-amalgamate-change-group ,handle))
           (cancel-change-group ,handle))))))
ideasman42
fuente