editor-gui: undo/redo buttons and shortcuts

Wires the session's undo/redo into the GUI: ↶ Undo / ↷ Redo toolbar buttons,
add_enabled-gated by can_undo()/can_redo() so they grey out when there is
nothing to do, and Ctrl/Cmd+Z (undo) / Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y (redo)
shortcuts read with modifier checks (first in handle_keys, so a modified Z is
not also taken as a plain key). A run_history helper handles the
Option-returning steps, reporting "nothing to undo/redo" when the stack is
empty. These run in the toolbar/key path, before the frame's rerender slot, so
there is no stale-texture concern. Module doc, help text, and the crate
description are updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
Levi Neuwirth 2026-06-30 17:21:02 -04:00
parent 285d7bfd95
commit 1496c81302
2 changed files with 51 additions and 8 deletions

View File

@ -4,7 +4,7 @@ version = "0.0.0"
edition.workspace = true
authors.workspace = true
repository.workspace = true
description = "A thin native (egui) GUI shell over epiphany-editor-core: renders the score with epiphany-render-svg (rasterized by resvg), click to select, a pencil mode that clicks notes onto the staff, and a toolbar/keys for the note-editing intents and a duration palette. A vertical slice proving the editor seam — no undo UI yet."
description = "A thin native (egui) GUI shell over epiphany-editor-core: renders the score with epiphany-render-svg (rasterized by resvg), click to select, a pencil mode that clicks notes onto the staff, undo/redo, and a toolbar/keys for the note-editing intents and a duration palette. A vertical slice proving the editor seam."
# A demo binary, not a library other crates build on, so it carries the heavy GUI
# dependency tree (eframe/winit/wgpu, resvg/usvg/tiny-skia) the rest of the

View File

@ -7,13 +7,15 @@
//! `resvg` into an `egui` texture, resolves clicks back to world coordinates to
//! select, and drives the note-editing intents from a toolbar and keyboard.
//!
//! Intentionally narrow (per the GUI vertical-slice plan): no undo UI — the debug
//! panel just shows the selection and the last applied op, enough to confirm the op log
//! is usable. A **pencil mode** turns a click into a click-to-insert
//! A thin but real editing surface over the headless session: click to select, a
//! toolbar and keys for the note-editing intents, **undo/redo**
//! ([`EditorSession::undo`] / [`EditorSession::redo`]; Ctrl/Cmd+Z undo,
//! Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redo), a
//! **pencil mode** that turns a click into a click-to-insert
//! ([`EditorSession::insert_note_at`]) with make-room overwrite on a quarter-note grid,
//! and a **duration palette** resizes the selection
//! ([`EditorSession::set_selection_duration`]). The GUI is the thing meant to surface
//! the next real core gaps.
//! and a **duration palette** ([`EditorSession::set_selection_duration`]). The debug
//! panel shows the selection and the last applied op. The GUI is the thing meant to
//! surface the next real core gaps.
//!
//! It is a demo binary; there is no headless way to assert its rendering here, so the
//! one piece of nontrivial logic — the screen↔world coordinate map a click depends on
@ -203,6 +205,20 @@ impl EditorApp {
self.needs_render = true;
}
/// Runs an undo/redo step (which returns `None` when there is nothing to do),
/// recording the outcome in the status line and scheduling a re-render.
fn run_history(
&mut self,
name: &str,
step: impl FnOnce(&mut EditorSession) -> Option<EditOutcome>,
) {
self.status = match step(&mut self.session) {
Some(outcome) => format!("{name}: ok (changed={})", outcome.graph_changed),
None => format!("nothing to {name}"),
};
self.needs_render = true;
}
/// Re-renders the session's resolved layout to a texture. The view box, logical
/// size, and texture are updated together (only on a successful rasterization), so
/// the displayed pixels never disagree with the click plane; on failure the score
@ -228,6 +244,19 @@ impl EditorApp {
fn toolbar(&mut self, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
if ui
.add_enabled(self.session.can_undo(), egui::Button::new("↶ Undo"))
.clicked()
{
self.run_history("undo", |s| s.undo());
}
if ui
.add_enabled(self.session.can_redo(), egui::Button::new("↷ Redo"))
.clicked()
{
self.run_history("redo", |s| s.redo());
}
ui.separator();
if ui.button("Delete").clicked() {
self.run("delete", |s| s.delete_selection());
}
@ -308,13 +337,19 @@ impl EditorApp {
ui.separator();
ui.label(format!(
"Click a notehead to select. Pencil mode: {}.\n\
Keys: Del delete · / staff-step move · +/ transpose · A add chord · I insert after · P pencil",
Keys: Del delete · / staff-step move · +/ transpose · A add chord · I insert after · P pencil · Ctrl/Cmd+Z undo · Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redo",
if self.pencil { "ON — click to insert" } else { "off" }
));
}
fn handle_keys(&mut self, ctx: &egui::Context) {
let k = ctx.input(|i| Keys {
// Ctrl/Cmd-Z undo, Ctrl/Cmd-Shift-Z (or Ctrl/Cmd-Y) redo, read first so a
// modified Z is not also taken as a plain key.
undo: i.modifiers.command && !i.modifiers.shift && i.key_pressed(egui::Key::Z),
redo: i.modifiers.command
&& ((i.modifiers.shift && i.key_pressed(egui::Key::Z))
|| i.key_pressed(egui::Key::Y)),
delete: i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace),
move_up: i.key_pressed(egui::Key::ArrowUp),
move_down: i.key_pressed(egui::Key::ArrowDown),
@ -324,6 +359,12 @@ impl EditorApp {
insert: i.key_pressed(egui::Key::I),
pencil: i.key_pressed(egui::Key::P),
});
if k.undo {
self.run_history("undo", |s| s.undo());
}
if k.redo {
self.run_history("redo", |s| s.redo());
}
if k.pencil {
self.pencil = !self.pencil;
self.status = format!("pencil mode {}", if self.pencil { "on" } else { "off" });
@ -427,6 +468,8 @@ impl EditorApp {
/// Keyboard edges read once per frame (so a held key fires one edit).
struct Keys {
undo: bool,
redo: bool,
delete: bool,
move_up: bool,
move_down: bool,