editor-gui: a pencil mode that clicks notes onto the staff

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
Levi Neuwirth 2026-06-30 13:17:32 -04:00
parent ce37de8834
commit ba84df67fb
1 changed files with 54 additions and 28 deletions

View File

@ -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 {