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

Undo and redo

Files: crates/lineup-core/src/history.rs and crates/lineup-app/src/app/edit.rs

Every change to a song can be undone with Ctrl+Z and redone with Ctrl+Shift+Z or Ctrl+Y, and the Edit menu names the step it would undo (“Undo move clip”). This chapter is about how the history is kept, and when one step ends and the next begins.

Two ways to undo

There are two classic ways to build undo.

The command pattern records every change as an instruction that knows how to reverse itself: “add note C4 at step 3” is undone by “remove note C4 at step 3”. The history is a list of instructions. It uses very little memory, but every kind of change needs its own reverse, written by hand, and each one is a new place to make a mistake. Think of what undoing “delete pattern” would need: put the pattern back at its old position, with its name and length and the notes of every channel, put back every clip that used it, and renumber the clips of the patterns after it again. If one detail is forgotten, undo quietly gives back a slightly different song.

Snapshots keep a whole copy of the project from before each undoable step. Undo puts the copy back. There is nothing to reverse, so there is nothing to reverse wrongly: whatever the change did, the copy is exactly what was there before. The cost is memory, and the time to make a copy.

Ferrous Lineup uses snapshots. A NoteEvent takes 24 bytes, and a song has a few hundred or a few thousand of them, so a copy of a whole project is some kilobytes. Even a big project with 10,000 notes holds about 240 KB of notes, and making one copy takes far less than a millisecond. The app keeps at most 256 MiB of snapshots: a small song gets the full 100 steps, while a very large one still gets at least 10 (which can go over the budget). That is a fair price for undo that is correct by construction. (One of the exercises shows how to make the copies much cheaper, if a project ever gets big enough to need it.)

History<T>

The history lives in lineup-core, and it does not know about projects at all:

#![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> is generic: T is whatever is being remembered. The app uses a History<Project>; the tests use a History<i32>, which makes them short and easy to read:

#![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");
}
}

Look at the impl line: impl<T> History<T>, with no trait bound on T. Not even Clone. The history never copies a T; it only moves the values it is given from one list to another. The caller makes the copy (self.project.clone()) and hands it over. If a method of History ever called .clone() on a state, the compiler would refuse with “no method named clone found for type parameter T”, and we would have to write impl<T: Clone>. A generic type with no bounds is a strong promise: the code cannot do anything with a T except store it, move it and drop it.

The compiler makes a separate copy of the code for each T it is used with (this is monomorphization), so a History<Project> is as fast as if it had been written for Project by hand.

&'static str labels

Each step has a label, such as "move clip", for the Edit menu. Its type is &'static str: a reference to text that lives for the whole program. Every string literal written in the code has this type, because the text is stored inside the program file itself. So a label costs nothing: no String is built, nothing is allocated, and the history holds no borrow that could end too soon. The price is that a label cannot be made at run time (“rename pattern Beat”), since that text would not exist until the program builds it.

The labels are written in English, and the Edit menu translates them when it shows them, so the history does not need to know the app’s language (see Two languages).

VecDeque: a list with two cheap ends

When there are already limit steps, recording one more forgets the oldest. In a Vec, removing the first item (remove(0)) moves every other item one place down. A VecDeque (“double-ended queue”, said “vec deck”) is a ring buffer, like the queues between the threads (see Two threads): it keeps track of where its front is, so push_back and pop_front are both cheap.

The limit is not a constant in the app. History::new takes it, and undo_limit_for works it out from the project’s size:

#![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 counts the notes, clips, markers and names, the things whose number grows with the song. A small project divides 256 MiB by a few kilobytes and gets more than DEFAULT_LIMIT, so it is clamped to 100. A huge project gets fewer, but never fewer than 10, so undo does not vanish just because the song is long. Opening a file builds a fresh History with the limit for that project. History::default() means 100, which is what the little tests use.

#![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;
}
}

Every new change clears the redo list. After you undo three steps and then change something, those three steps belong to a future that no longer exists. (A few programs keep a whole tree of histories, so no branch is ever lost; most simply drop the old future, as this one does.)

The redo list is a plain Vec, because steps are only added and taken at its end.

mem::swap: undo without copying

#![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() gives an Option, and the ? operator works on Option in a function that returns an Option: if there is nothing to undo, undo returns None at once and changes nothing.

std::mem::swap exchanges the values behind two &mut references. After it, current (the app’s project) holds the older version, and the step holds the version we just left, ready for redo. Nothing is copied or allocated: a Project is mostly Vecs, and a Vec is a pointer, a length and a capacity, so swapping two projects swaps a few of those small values, whatever the size of the song. The borrow checker is what makes this simple: swap needs two &mut references, and two &mut references can never point at the same value, so swap can exchange the two values’ bytes without checking anything.

redo is the mirror image, popping from redo and pushing onto undo.

When does one step end?

Dragging a clip across the playlist moves it a little on many frames, and each move is an edit. If each one were its own step, undoing the drag would take as many presses of Ctrl+Z as there were frames. So the history keeps the newest step open, and record does nothing while it is: the step already holds the project from before the drag started, which is exactly where undo should go back to.

The app decides when a step ends, because only it knows what the mouse is doing. It calls the history at the start and at the end of every frame:

#![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()));
        }
    }
}
}

Follow a few gestures through these two functions:

  • A click on a step. egui reports a click on the frame the button is let go. That frame’s edit opens a step, and the end of the same frame closes it, since no button is down any more: one change, one step.
  • A drag. The press closes whatever was open. Every frame of the drag edits the project while the button is down, so the edits join one step. The frame where the button is let go closes it.
  • Typing a pattern name. While the text field has the keyboard focus, every letter joins the same step. Clicking elsewhere takes the focus away and closes it, so undo puts back the whole old name at once.
  • Typing a tempo. Clicking the BPM box turns it into a text field, so the same rule applies: everything typed there is one step, which ends when you leave the box.
  • Recording a take. Every note played while recording joins one step, so one undo takes the whole take away.

start_frame_for_undo matters for one case: a click that starts while a text field still has the focus. The press closes the typing step, so the click’s change does not join it.

The else if branch is about sound, not undo. While a step is open, changes reach the audio thread at most every 50 ms, carried by the next edit (see Telling the audio thread what changed). A drag held still makes no next edit, so the frame sends what is still waiting once it is due, and request_repaint_after asks egui for a frame at that moment even if nothing moves.

Closing a step is 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();
}
}

It sends whatever the audio thread is still missing, and works out the unsaved-changes star again (a note dragged away and back leaves the song as it was saved). Then the history stops taking changes into the step.

Several clicks, one step

Some changes come one click at a time but belong together. Tapping a tempo is a click per beat; nudging the master volume or the number of choruses is a click or a drag per nudge. Undo should take all of them back at once. reopen lets the app open the newest step again:

#![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
}
}

and the app calls it just before the edit:

#![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);
}
}

If the last step was another change, or something was undone since, the label does not match and a new step starts as usual.

Everything goes through edit

The pattern name field shows how strict the rule “every change goes through edit” is. egui’s text field wants a &mut String to change. Giving it the project’s own name would change the project directly, behind the history’s back: no undo step, and no message to the audio thread either. So the field edits a copy, and the copy goes through edit when it changed:

#![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);
}
}

The Project type helps here: its lists are private, and only its methods can change them (see The project). Anything that changes a project needs a &mut Project, and in the app the only place that hands one out is edit.

Keyboard shortcuts

#![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 is Ctrl on Linux and Windows and ⌘ on a Mac. egui’s consume_shortcut is forgiving about extra keys: a shortcut without Shift also matches when Shift is held. That is usually what you want (on many keyboards + needs Shift, and Ctrl++ should still work), but here it means Ctrl+Shift+Z would count as Ctrl+Z too. Checking redo first, and using else if, settles it.

|| stops at the first true, so if Ctrl+Shift+Z was pressed, Ctrl+Y is not even looked at. “Consume” means the key press is taken out of egui’s input, so nothing else in the same frame reacts to it.

These shortcuts are skipped while a text field has the keyboard. egui’s text fields have their own undo for typing, and Ctrl+Z there should undo the last letters, not the last change to the song.

The Edit menu shows the same shortcuts, written the way the system writes them (“Ctrl+Z” on Linux, “⌘Z” on a Mac), and puts the translated label in the item, “Undo move clip”.

Undo and the unsaved-changes star

The window title shows a star when the project differs from the saved file. It does not compare the two projects on every frame: an edit sets a flag, and the comparison happens when the title is next worked out. Undo and redo set the flag too, so they can make the star go away when the project is equal to the saved one again. A plain dirty: bool set on every edit would have kept the star after the undo. Saving shows the rest of this.

Opening a file, or File > New, replaces the whole project and clears the history. Undoing into a different song would be surprising, and the “Save changes?” question already protects what you leave behind.