From ba84df67fbd8120003fb08d3854dfcb569715bd9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 30 Jun 2026 13:17:32 -0400 Subject: [PATCH] editor-gui: a pencil mode that clicks notes onto the staff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the click-to-insert into the GUI: a pencil ("insert") mode, toggled by a toolbar button or the P key, turns a staff click into insert_note_at on a default quarter-note grid (make-room overwrite), instead of selecting. A non-pencil click still selects, and its empty-staff status now reports what a pencil click would insert (pitch + grid-snapped beat). The pencil edit runs in score_view, after the frame's rerender slot, so it requests a repaint and returns before the selection overlay would paint the new hit map over the not-yet-rerendered texture. Non-pencil selection keeps the immediate overlay path (it does not mutate the rendered score). This completes the beat-grid click-to-insert feature (vertical inverse → horizontal inverse → trim/move reducer semantics → insert_note_at → this GUI wiring). The GUI's rendering/interaction is still not visually verifiable in this environment; only the ViewMap coordinate logic is unit-tested. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS --- crates/epiphany-editor-gui/src/main.rs | 82 +++++++++++++++++--------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index ffe2e41..3145434 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -7,10 +7,11 @@ //! `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 duration editing, no -//! empty-space insertion, and no undo UI — the debug panel just shows the selection -//! and the last applied op, enough to confirm the op log is usable. The GUI is the -//! thing meant to surface the next real core gaps. +//! Intentionally narrow (per the GUI vertical-slice plan): no duration editing and 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 ([`EditorSession::insert_note_at`]) with make-room overwrite, on a +//! quarter-note grid. 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 @@ -159,6 +160,9 @@ struct EditorApp { logical_size: egui::Vec2, px_per_staff_space: f32, needs_render: bool, + /// Pencil ("insert") mode: a click on the staff inserts a note at that pitch and + /// beat (with make-room overwrite) instead of selecting. + pencil: bool, status: String, } @@ -174,6 +178,7 @@ impl EditorApp { logical_size: egui::vec2(1.0, 1.0), px_per_staff_space: 12.0, needs_render: true, + pencil: false, status: "opened ten_measure_single_staff".to_string(), } } @@ -242,6 +247,8 @@ impl EditorApp { self.run("insert after", |s| s.insert_note_after_selection()); } ui.separator(); + ui.toggle_value(&mut self.pencil, "✏ Pencil (insert)"); + ui.separator(); if ui .add(egui::Slider::new(&mut self.px_per_staff_space, 6.0..=28.0).text("zoom")) .changed() @@ -279,10 +286,11 @@ impl EditorApp { } } ui.separator(); - ui.label( - "Click a notehead to select.\n\ - Keys: Del delete · ↑/↓ staff-step move · +/− transpose · A add chord · I insert after", - ); + 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", + if self.pencil { "ON — click to insert" } else { "off" } + )); } fn handle_keys(&mut self, ctx: &egui::Context) { @@ -294,7 +302,12 @@ impl EditorApp { flat: i.key_pressed(egui::Key::Minus), add: i.key_pressed(egui::Key::A), insert: i.key_pressed(egui::Key::I), + pencil: i.key_pressed(egui::Key::P), }); + if k.pencil { + self.pencil = !self.pencil; + self.status = format!("pencil mode {}", if self.pencil { "on" } else { "off" }); + } if k.delete { self.run("delete", |s| s.delete_selection()); } @@ -338,26 +351,38 @@ impl EditorApp { if response.clicked() { if let Some(pos) = response.interact_pointer_pos() { let world = ViewMap::new(self.view_box, rect).screen_to_world(pos); - // What a click-to-insert would resolve to on empty staff: the pitch - // (vertical half) and the grid-snapped position (horizontal half), on - // a default quarter-note grid. - let pitch = self.session.staff_pitch_at(world); - let position = self.session.position_at(world, &GridResolution::quarter()); - self.status = match self.session.click(world) { - Some(_) => "selected".to_string(), - None => match (pitch, position) { - (Some(p), Some(gp)) => format!( - "empty — would insert {:?}{} at beat {:.3}", - p.nominal, - p.octave, - gp.position.0.to_f64() - ), - (Some(p), None) => { - format!("empty — would insert {:?}{}", p.nominal, p.octave) - } - _ => "cleared selection".to_string(), - }, - }; + if self.pencil { + // Insert a note at the clicked pitch/beat on a quarter-note grid, + // making room over whatever it overlaps. This runs *after* this + // frame's rerender slot, so request a repaint to draw the edit, and + // return before the selection overlay below paints the new hit map + // over the old (not-yet-rerendered) texture. + self.run("insert note", |s| { + s.insert_note_at(world, &GridResolution::quarter()) + }); + ui.ctx().request_repaint(); + return; + } else { + // Select the topmost hit; on empty staff, report what a pencil + // click *would* insert (the pitch and grid-snapped beat). + let pitch = self.session.staff_pitch_at(world); + let position = self.session.position_at(world, &GridResolution::quarter()); + self.status = match self.session.click(world) { + Some(_) => "selected".to_string(), + None => match (pitch, position) { + (Some(p), Some(gp)) => format!( + "empty — pencil would insert {:?}{} at beat {:.3}", + p.nominal, + p.octave, + gp.position.0.to_f64() + ), + (Some(p), None) => { + format!("empty — pencil would insert {:?}{}", p.nominal, p.octave) + } + _ => "cleared selection".to_string(), + }, + }; + } } } @@ -389,6 +414,7 @@ struct Keys { flat: bool, add: bool, insert: bool, + pencil: bool, } impl eframe::App for EditorApp {