Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Deshacer y rehacer

Archivos: crates/lineup-core/src/history.rs y crates/lineup-app/src/app/edit.rs

Todo cambio en una canción se puede deshacer con Ctrl+Z y rehacer con Ctrl+Mayús+Z o Ctrl+Y, y el menú Edición nombra el paso que desharía («Deshacer: mover clip»). Este capítulo trata de cómo se guarda el historial, y de cuándo termina un paso y empieza el siguiente.

Dos formas de deshacer

Hay dos formas clásicas de construir el deshacer.

El patrón comando registra cada cambio como una instrucción que sabe revertirse a sí misma: «añadir la nota Do4 en el paso 3» se deshace con «quitar la nota Do4 del paso 3». El historial es una lista de instrucciones. Usa muy poca memoria, pero cada tipo de cambio necesita su propia operación inversa, escrita a mano, y cada una es un lugar nuevo para equivocarse. Piensa en lo que necesitaría deshacer «eliminar patrón»: volver a poner el patrón en su antigua posición, con su nombre y su longitud y las notas de cada canal, volver a poner cada clip que lo usaba y renumerar de nuevo los clips de los patrones que van detrás. Si se olvida un detalle, deshacer devuelve en silencio una canción ligeramente distinta.

Las instantáneas guardan una copia completa del proyecto de antes de cada paso que se puede deshacer. Deshacer vuelve a poner la copia. No hay nada que revertir, así que no hay nada que revertir mal: haga lo que haga el cambio, la copia es exactamente lo que había antes. El coste es la memoria y el tiempo de hacer una copia.

Ferrous Lineup usa instantáneas. Un NoteEvent ocupa 24 bytes, y una canción tiene unos cientos o unos miles, así que una copia de todo el proyecto son algunos kilobytes. Incluso un proyecto grande con 10.000 notas guarda unos 240 KB de notas, y hacer una copia tarda mucho menos de un milisegundo. La aplicación guarda como mucho 256 MiB de instantáneas: una canción pequeña obtiene los 100 pasos completos, mientras que una muy grande obtiene al menos 10 (que pueden pasarse del presupuesto). Es un precio justo por un deshacer que es correcto por construcción. (Uno de los ejercicios muestra cómo abaratar mucho las copias, si un proyecto llega a ser lo bastante grande para necesitarlo.)

History<T>

El historial vive en lineup-core, y no sabe nada de proyectos:

#![allow(unused)]
fn main() {
struct Step<T> {
    label: &'static str,
    state: T,
}

pub struct History<T> {
    /// The oldest step is at the front, the newest at the back. A
    /// `VecDeque` can drop the oldest step from the front cheaply.
    undo: VecDeque<Step<T>>,
    redo: Vec<Step<T>>,
    limit: usize,
    /// Whether the newest undo step still takes in new changes.
    open: bool,
}

impl<T> History<T> {
    pub fn new(limit: usize) -> Self { ... }
    pub fn record(&mut self, label: &'static str, before: T) { ... }
    pub fn is_open(&self) -> bool { ... }
    pub fn close(&mut self) { ... }
    pub fn reopen(&mut self, label: &'static str) -> bool { ... }
    pub fn undo(&mut self, current: &mut T) -> Option<&'static str> { ... }
    pub fn redo(&mut self, current: &mut T) -> Option<&'static str> { ... }
    pub fn undo_label(&self) -> Option<&'static str> { ... }
    pub fn redo_label(&self) -> Option<&'static str> { ... }
    pub fn clear(&mut self) { ... }
}
}

History<T> es genérico: T es aquello que se está recordando. La aplicación usa un History<Project>; las pruebas usan un History<i32>, lo que las hace cortas y fáciles de leer:

#![allow(unused)]
fn main() {
#[test]
fn changes_join_the_open_step() {
    let mut history = History::default();
    let mut value = 0;
    // A drag: three changes before the mouse button is released.
    for new in [1, 2, 3] {
        history.record("drag", value);
        value = new;
    }
    history.close();
    history.undo(&mut value);
    assert_eq!(value, 0, "one undo goes back to before the drag");
}
}

Mira la línea impl: impl<T> History<T>, sin ningún trait bound sobre T. Ni siquiera Clone. El historial nunca copia un T; solo mueve los valores que recibe de una lista a otra. Quien llama hace la copia (self.project.clone()) y se la entrega. Si un método de History llamara alguna vez a .clone() sobre un estado, el compilador se negaría con «no method named clone found for type parameter T», y tendríamos que escribir impl<T: Clone>. Un tipo genérico sin bounds es una promesa fuerte: el código no puede hacer nada con un T salvo guardarlo, moverlo y soltarlo.

El compilador hace una copia separada del código para cada T con el que se usa (esto es la monomorfización), así que un History<Project> es tan rápido como si se hubiera escrito a mano para Project.

Las etiquetas &'static str

Cada paso tiene una etiqueta, como "move clip", para el menú Edición. Su tipo es &'static str: una referencia a texto que vive durante todo el programa. Cada string literal escrito en el código tiene este tipo, porque el texto se guarda dentro del propio archivo del programa. Así que una etiqueta no cuesta nada: no se construye ningún String, no se reserva nada, y el historial no guarda ningún préstamo que pueda terminar demasiado pronto. El precio es que una etiqueta no se puede crear en tiempo de ejecución («rename pattern Beat»), porque ese texto no existiría hasta que el programa lo construyera.

Las etiquetas se escriben en inglés, y el menú Edición las traduce al mostrarlas, así que el historial no necesita saber el idioma de la aplicación (ver Dos idiomas).

VecDeque: una lista con dos extremos baratos

Cuando ya hay limit pasos, registrar uno más olvida el más antiguo. En un Vec, quitar el primer elemento (remove(0)) mueve todos los demás un lugar hacia abajo. Un VecDeque («cola de dos extremos», se dice «vec deck») es un ring buffer, como las colas entre los hilos (ver Dos hilos): lleva la cuenta de dónde está su frente, así que push_back y pop_front son ambos baratos.

El límite no es una constante en la aplicación. History::new lo recibe, y undo_limit_for lo calcula a partir del tamaño del proyecto:

#![allow(unused)]
fn main() {
const UNDO_BYTES: usize = 256 * 1024 * 1024;
const MIN_UNDO_STEPS: usize = 10;

pub(super) fn undo_limit_for(project: &Project) -> usize {
    limit_for_bytes(project.heap_bytes())
}

fn limit_for_bytes(bytes: usize) -> usize {
    (UNDO_BYTES / bytes.max(1)).clamp(MIN_UNDO_STEPS, DEFAULT_LIMIT)
}
}

heap_bytes cuenta las notas, los clips, las marcas y los nombres, las cosas cuyo número crece con la canción. Un proyecto pequeño divide 256 MiB entre unos pocos kilobytes y obtiene más que DEFAULT_LIMIT, así que se limita a 100. Un proyecto enorme obtiene menos, pero nunca menos de 10, para que deshacer no desaparezca solo porque la canción es larga. Abrir un archivo construye un History nuevo con el límite de ese proyecto. History::default() significa 100, que es lo que usan las pruebas pequeñas.

#![allow(unused)]
fn main() {
pub fn record(&mut self, label: &'static str, before: T) {
    self.redo.clear();
    if self.open {
        return;
    }
    self.undo.push_back(Step { label, state: before });
    if self.undo.len() > self.limit {
        self.undo.pop_front();
    }
    self.open = true;
}
}

Cada cambio nuevo vacía la lista de rehacer. Después de deshacer tres pasos y cambiar algo, esos tres pasos pertenecen a un futuro que ya no existe. (Unos pocos programas guardan un árbol entero de historiales, para no perder nunca ninguna rama; la mayoría simplemente descarta el futuro antiguo, como hace este.)

La lista de rehacer es un Vec normal, porque los pasos solo se añaden y se quitan por su final.

mem::swap: deshacer sin copiar

#![allow(unused)]
fn main() {
pub fn undo(&mut self, current: &mut T) -> Option<&'static str> {
    let mut step = self.undo.pop_back()?;
    // Now `current` holds the old state and `step` the newer one.
    std::mem::swap(current, &mut step.state);
    let label = step.label;
    self.redo.push(step);
    self.open = false;
    Some(label)
}
}

pop_back() devuelve un Option, y el operador ? funciona sobre Option en una función que devuelve un Option: si no hay nada que deshacer, undo devuelve None al momento y no cambia nada.

std::mem::swap intercambia los valores que hay detrás de dos referencias &mut. Después, current (el proyecto de la aplicación) guarda la versión más antigua, y el paso guarda la versión de la que acabamos de salir, lista para rehacer. No se copia ni se reserva nada: un Project es sobre todo Vecs, y un Vec es un puntero, una longitud y una capacidad, así que intercambiar dos proyectos intercambia unos pocos de esos valores pequeños, sea cual sea el tamaño de la canción. El borrow checker es lo que hace esto sencillo: swap necesita dos referencias &mut, y dos referencias &mut nunca pueden apuntar al mismo valor, así que swap puede intercambiar los bytes de los dos valores sin comprobar nada.

redo es la imagen especular: saca de redo y mete en undo.

¿Cuándo termina un paso?

Arrastrar un clip por la lista de reproducción lo mueve un poco en muchos fotogramas, y cada movimiento es una edición. Si cada uno fuera su propio paso, deshacer el arrastre necesitaría tantas pulsaciones de Ctrl+Z como fotogramas hubiera. Así que el historial mantiene el paso más nuevo abierto, y record no hace nada mientras lo esté: el paso ya guarda el proyecto de antes de que empezara el arrastre, que es exactamente adonde debería volver deshacer.

La aplicación decide cuándo termina un paso, porque solo ella sabe qué está haciendo el ratón. Llama al historial al principio y al final de cada fotograma:

#![allow(unused)]
fn main() {
/// Called at the start of each frame. A press of a mouse button
/// always begins a new undo step.
pub(super) fn start_frame_for_undo(&mut self, ctx: &egui::Context) {
    if ctx.input(|input| input.pointer.any_pressed()) {
        self.close_step();
    }
}

/// Called at the end of each frame. While a mouse button is held (a
/// drag), a text field has the keyboard (typing a name) or a take is
/// being recorded, changes join one undo step; once all of them are
/// over, the step ends.
pub(super) fn end_frame_for_undo(&mut self, ctx: &egui::Context) {
    let holding = ctx.input(|input| input.pointer.any_down());
    if !holding && !ctx.text_edit_focused() && !self.is_recording() {
        self.close_step();
    } else if self.step_unsynced {
        // The step goes on, but its last change may still be waiting
        // for its turn: a drag held still, or the last key typed, has
        // no next edit to carry it to the audio thread. Send it when
        // it is due, and make sure a frame comes then.
        self.sync_step_if_due();
        if self.step_unsynced {
            ctx.request_repaint_after(SYNC_INTERVAL.saturating_sub(self.last_sync.elapsed()));
        }
    }
}
}

Sigue unos cuantos gestos a través de estas dos funciones:

  • Un clic en un paso. egui informa del clic en el fotograma en que se suelta el botón. La edición de ese fotograma abre un paso, y el final del mismo fotograma lo cierra, porque ya no hay ningún botón pulsado: un cambio, un paso.
  • Un arrastre. La pulsación cierra lo que estuviera abierto. Cada fotograma del arrastre edita el proyecto mientras el botón está pulsado, así que las ediciones se unen en un paso. El fotograma en que se suelta el botón lo cierra.
  • Escribir un nombre de patrón. Mientras el campo de texto tiene el foco del teclado, cada letra se une al mismo paso. Hacer clic en otro sitio le quita el foco y lo cierra, así que deshacer devuelve todo el nombre antiguo de una vez.
  • Escribir un tempo. Hacer clic en la casilla de BPM la convierte en un campo de texto, así que se aplica la misma regla: todo lo escrito ahí es un paso, que termina cuando sales de la casilla.
  • Grabar una toma. Cada nota tocada mientras se graba se une a un mismo paso, así que un deshacer quita la toma entera.

start_frame_for_undo importa en un caso: un clic que empieza cuando un campo de texto aún tiene el foco. La pulsación cierra el paso de escritura, para que el cambio del clic no se le una.

La rama else if trata del sonido, no de deshacer. Mientras un paso está abierto, los cambios llegan al hilo de audio como mucho cada 50 ms, llevados por la edición siguiente (ver Decirle al hilo de audio lo que ha cambiado). Un arrastre que se queda quieto no hace ninguna edición siguiente, así que el fotograma envía lo que aún espera cuando toca, y request_repaint_after le pide a egui un fotograma en ese momento aunque nada se mueva.

Cerrar un paso es close_step:

#![allow(unused)]
fn main() {
fn close_step(&mut self) {
    self.finish_step_sync();
    if self.history.is_open() {
        self.file.project_reverted();
    }
    self.history.close();
}
}

Envía lo que al hilo de audio aún le falte, y vuelve a calcular el asterisco de cambios sin guardar (una nota arrastrada lejos y de vuelta deja la canción como estaba guardada). Después el historial deja de aceptar cambios en el paso.

Varios clics, un paso

Algunos cambios llegan de clic en clic pero van juntos. Marcar un tempo con golpes es un clic por pulso; retocar el volumen maestro o el número de vueltas es un clic o un arrastre por retoque. Deshacer debería quitarlos todos de una vez. reopen deja que la aplicación vuelva a abrir el paso más nuevo:

#![allow(unused)]
fn main() {
/// Opens the newest step again, if it is called `label` and nothing
/// was undone since, so the next change joins it: taps of a tempo,
/// each a click of its own, make one step. Returns whether it did.
pub fn reopen(&mut self, label: &'static str) -> bool {
    if self.redo.is_empty() && self.undo_label() == Some(label) {
        self.open = true;
    }
    self.open
}
}

y la aplicación la llama justo antes de la edición:

#![allow(unused)]
fn main() {
pub(super) fn set_master_volume(&mut self, master: f32) {
    const STEP: &str = "master volume";
    self.history.reopen(STEP);
    self.edit(STEP, |project| project.mixer.master = master);
}
}

Si el último paso fue otro cambio, o se deshizo algo desde entonces, la etiqueta no coincide y empieza un paso nuevo como siempre.

Todo pasa por edit

El campo del nombre del patrón muestra lo estricta que es la regla «cada cambio pasa por edit». El campo de texto de egui quiere un &mut String para cambiar. Darle el nombre del propio proyecto cambiaría el proyecto directamente, a espaldas del historial: sin paso de deshacer y sin mensaje al hilo de audio tampoco. Así que el campo edita una copia, y la copia pasa por edit cuando ha cambiado:

#![allow(unused)]
fn main() {
// The field edits a copy, so the change can go through `edit`.
let mut name = self.project.patterns()[self.pattern].name.clone();
if ui.text_edit_singleline(&mut name).changed() {
    self.edit_pattern("rename pattern", |pattern| pattern.name = name);
}
}

El tipo Project ayuda aquí: sus listas son privadas, y solo sus métodos pueden cambiarlas (ver El proyecto). Cualquier cosa que cambie un proyecto necesita un &mut Project, y en la aplicación el único sitio que lo entrega es edit.

Atajos de teclado

#![allow(unused)]
fn main() {
const UNDO: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z);
const REDO: KeyboardShortcut =
    KeyboardShortcut::new(Modifiers::COMMAND.plus(Modifiers::SHIFT), Key::Z);
/// The other common shortcut for redo, used on Windows.
const REDO_Y: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Y);

pub(super) fn edit_shortcuts(&mut self, ctx: &egui::Context) {
    let pressed = |shortcut| ctx.input_mut(|input| input.consume_shortcut(&shortcut));
    // Ctrl+Z would also match Ctrl+Shift+Z, so redo is checked first.
    if pressed(REDO) || pressed(REDO_Y) {
        self.redo();
    } else if pressed(UNDO) {
        self.undo();
    }
}
}

Modifiers::COMMAND es Ctrl en Linux y Windows y ⌘ en un Mac. El consume_shortcut de egui es indulgente con las teclas de más: un atajo sin Mayús también encaja cuando Mayús está pulsado. Normalmente eso es lo que quieres (en muchos teclados + necesita Mayús, y Ctrl++ debería seguir funcionando), pero aquí significa que Ctrl+Mayús+Z contaría también como Ctrl+Z. Comprobar primero rehacer, y usar else if, lo resuelve.

|| se detiene en el primer true, así que si se pulsó Ctrl+Mayús+Z, Ctrl+Y ni siquiera se mira. «Consume» significa que la pulsación se saca de la entrada de egui, para que nada más en el mismo fotograma reaccione a ella.

Estos atajos se omiten mientras un campo de texto tiene el teclado. Los campos de texto de egui tienen su propio deshacer para escribir, y allí Ctrl+Z debe deshacer las últimas letras, no el último cambio de la canción.

El menú Edición muestra los mismos atajos, escritos como los escribe el sistema («Ctrl+Z» en Linux, «⌘Z» en un Mac), y pone la etiqueta traducida en el elemento, «Deshacer: mover clip».

Deshacer y el asterisco de cambios sin guardar

El título de la ventana muestra un asterisco cuando el proyecto difiere del archivo guardado. No compara los dos proyectos en cada fotograma: una edición pone una marca, y la comparación ocurre la próxima vez que se calcula el título. Deshacer y rehacer también ponen la marca, para que puedan hacer desaparecer el asterisco cuando el proyecto vuelve a ser igual que el guardado. Un simple dirty: bool puesto en cada edición habría mantenido el asterisco después de deshacer. Guardar muestra el resto.

Abrir un archivo, o Archivo > Nuevo, sustituye todo el proyecto y vacía el historial. Deshacer metiéndote en otra canción sería desconcertante, y la pregunta «¿Guardar cambios?» ya protege lo que dejas atrás.