Editor T2 W2: rubber-band selection in the GUI

Drag-to-select over the score view: a pure DragRect tracks the gesture, a
4.0pt movement threshold (under egui's 6.0pt click tolerance) keeps still
long-holds resolving as the clicks users intend — egui reclassifies them as
drags by elapsed time alone — and resolve_release dispatches rubber-band
vs click/toggle as a pure, unit-tested decision. Ctrl/Cmd-click toggles
membership; every member draws the existing highlight with the anchor
accented on top; pencil mode takes precedence and never starts a drag. The
T1a baselines are byte-identical: selection is overlay-side and never
touches the raster. Both mutations killed and coordinator re-verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 09:35:54 -04:00
parent 51f7ac9f61
commit f50f0ee52d
2 changed files with 380 additions and 36 deletions

View File

@ -153,3 +153,72 @@ one additive step (`.github/workflows/ci.yml`): an `if: failure()`
`golden-failures` artifact (`if-no-files-found: ignore`, since it produces `golden-failures` artifact (`if-no-files-found: ignore`, since it produces
nothing on a green run). This is the tranche's only CI change; nothing else nothing on a green run). This is the tranche's only CI change; nothing else
in `ci.yml` moves. in `ci.yml` moves.
## 9. Rubber-band selection (2026-07-23, T2-W2) — drag threshold and anchor accent
Dispatched under `spec/CONTRACT_EDITOR_T2_SELECTION.md` §W2, over W1's
selection-set API (`selections()`, `anchor()`, `toggle_at`, `select_within`).
`main.rs` only; no golden pixel moves — the overlay is drawn by `score_view`
after the score texture is painted, entirely outside `goldens.rs`'s headless
raster path, so none of it is exercised by a golden test.
**Sensing.** The score view's `Sense` widens from `click()` to
`click_and_drag()`. A plain click still resolves through
`Response::clicked()`, unchanged. A drag is tracked in a new pure `DragRect`
(origin + current screen `Pos2`, no `egui::Response`/`Context` dependency, so
it is unit-testable headlessly), updated across frames via
`drag_started`/`dragged`, and resolved on `drag_stopped`.
**The drag threshold, and why it exists even though egui has its own.**
`DragRect::RUBBER_BAND_THRESHOLD = 4.0` screen points: below it, a completed
drag resolves as a plain click/toggle at the release point, not a
rubber-band `select_within`. This exists on top of egui's own click-distance
tolerance (`InputOptions::max_click_dist`, 6.0 points) because a widget
sensing both click and drag reclassifies a **long, still press-and-hold**
(no meaningful movement, held past `max_click_duration`, 0.8s) as "dragging"
purely on elapsed time (`PointerState::is_decidedly_dragging`) — a gesture a
user experiences as a click would otherwise silently become a near-empty
rubber-band replace of the current selection. `DragRect::is_rubber_band`
(`distance >= threshold`) and the release dispatch
(`resolve_release(&DragRect, ctrl) -> ReleaseAction`) are pure and directly
unit-tested, including the exact boundary (`>=`, not `>`).
**World-space query stays unnormalized.** `select_within`'s `BoundingBox`
normalizes its own corners (its doc: "rect's corners are order-independent");
the release handler therefore maps `drag.origin`/`drag.current` straight
through `ViewMap::screen_to_world` with no pre-normalization of its own.
`DragRect::screen_rect`'s own min/max normalization is a separate concern
(painting a valid, non-inverted rectangle for the live overlay) and never
feeds the world-space query.
**Ctrl/Cmd-click and the below-threshold-drag release** both resolve through
one shared `EditorApp::resolve_click(world, grid, toggle)`: `toggle` calls
`toggle_at`; otherwise `click`, with the exact same "empty — pencil would
insert…" reporting the single-selection code already had. This keeps the
plain-click behavior — including its status-line wording — identical to
before this packet for both call sites.
**Anchor accent.** Every member in `session.selections()` gets the existing
2.0pt blue stroke (unchanged from the prior single-selection code, now
looped); `session.anchor()` additionally gets a second pass with a 3.0pt
orange stroke on top. A single-member selection's sole member is trivially
the anchor, so it now receives both passes (blue then orange) where it
previously received only the blue one — a deliberate, undocumented-by-any-
golden visual change (the overlay is outside the golden raster path per
above), not a behavior change: `click`'s selection-logic contract (single
member, replaces the set) is unchanged, only its paint style gained an
always-present anchor accent.
**Debug panel and help text.** Adds a `selected: N member(s)` line and
relabels the existing selection lines as the anchor's (`session.anchor()`
directly, replacing the `session.selection()` compatibility read — the two
are equal by construction, `selection()` is `anchor().copied()`). The help
text now documents drag-to-select and Ctrl/Cmd-click, and states which
toolbar/key intents act on the whole selection (`delete_selection`,
`alter_selection` — both batch over every/every-pitch member per W1's
`DECISIONS.md`) versus the anchor alone (`move_selection_staff_step`,
`add_note_to_selection`, `insert_note_after_selection`,
`set_selection_duration` — verified against each function's own
`self.selection.anchor()` read in `epiphany-editor-core/src/lib.rs`, not
assumed from the contract's own summary, which groups `alter` with the
anchor-only intents even though its implementation batches).

View File

@ -27,7 +27,7 @@ use eframe::egui;
use epiphany_core::NoteValue; use epiphany_core::NoteValue;
use epiphany_editor_core::{EditOutcome, EditorError, EditorSession, GridResolution}; use epiphany_editor_core::{EditOutcome, EditorError, EditorSession, GridResolution};
use epiphany_engrave::Engraver; use epiphany_engrave::Engraver;
use epiphany_layout_ir::{BoundingBox, HitShape, Point}; use epiphany_layout_ir::{BoundingBox, HitShape, LayoutObjectId, Point};
use epiphany_ops::{OperationKind, OperationPayload}; use epiphany_ops::{OperationKind, OperationPayload};
use epiphany_render_svg::{render, RenderOptions}; use epiphany_render_svg::{render, RenderOptions};
@ -123,6 +123,106 @@ fn shape_rect(shape: &HitShape, vm: &ViewMap) -> egui::Rect {
} }
} }
/// The screen rect for the hit-test region backing `layout_object`, if it is
/// still present in the current layout — shared by the selection overlay's
/// member and anchor passes.
fn selection_rect(
session: &EditorSession,
layout_object: LayoutObjectId,
vm: &ViewMap,
) -> Option<egui::Rect> {
session
.hit_test()
.regions
.iter()
.find(|r| r.layout_object == layout_object)
.map(|region| shape_rect(&region.shape, vm))
}
/// Accumulates a rubber-band drag's two screen-space corners (the point where
/// the drag started and where the pointer currently is), pure and independent
/// of any `egui::Response`/`Context` so it can be unit-tested headlessly. The
/// *world*-space selection query ([`EditorSession::select_within`]) maps
/// `origin`/`current` directly and lets that API normalize corner order
/// itself (its own doc: "rect's corners are order-independent"); this
/// struct's own min/max normalization ([`Self::screen_rect`]) exists only so
/// the live overlay always paints a valid (non-inverted) screen rectangle,
/// regardless of which direction the drag went.
#[derive(Clone, Copy, Debug, PartialEq)]
struct DragRect {
origin: egui::Pos2,
current: egui::Pos2,
}
impl DragRect {
/// Below this screen-space distance (points), a completed drag still
/// resolves as a plain click/toggle rather than a rubber-band select.
/// Chosen smaller than egui's own default click-distance tolerance
/// (`InputOptions::max_click_dist`, 6.0 points): egui reclassifies a
/// long, still press-and-hold as "dragging" purely by elapsed time (see
/// `PointerState::is_decidedly_dragging`), even with ~0 pointer movement,
/// so a widget sensing both click and drag can receive
/// `drag_started`/`drag_stopped` for a gesture a user experiences as a
/// click. This threshold exists to catch exactly that case and resolve
/// it as a click, not a near-empty rubber-band replace.
const RUBBER_BAND_THRESHOLD: f32 = 4.0;
fn new(origin: egui::Pos2) -> Self {
DragRect {
origin,
current: origin,
}
}
fn update(&mut self, pos: egui::Pos2) {
self.current = pos;
}
/// The screen-space distance travelled from the drag's origin so far.
fn distance(&self) -> f32 {
self.origin.distance(self.current)
}
/// Whether the drag has moved far enough to count as a rubber-band
/// select rather than a click (see [`Self::RUBBER_BAND_THRESHOLD`]).
fn is_rubber_band(&self) -> bool {
self.distance() >= Self::RUBBER_BAND_THRESHOLD
}
/// The normalized on-screen rectangle spanning the drag so far — `min <=
/// max` on both axes regardless of which corner the drag started from.
/// The rubber-band overlay's paint rect.
fn screen_rect(&self) -> egui::Rect {
let min_x = self.origin.x.min(self.current.x);
let max_x = self.origin.x.max(self.current.x);
let min_y = self.origin.y.min(self.current.y);
let max_y = self.origin.y.max(self.current.y);
egui::Rect::from_min_max(egui::pos2(min_x, min_y), egui::pos2(max_x, max_y))
}
}
/// What a drag's release should do, decided purely from the accumulated
/// screen-space drag and whether Ctrl/Cmd was held at release — split out
/// from the release handler itself so the threshold decision (rubber-band
/// vs. click/toggle) has a headlessly testable seam.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReleaseAction {
/// At or above the rubber-band threshold: `select_within` over the
/// drag's rectangle.
RubberBand,
/// Below the threshold: resolves as a click (`toggle` = Ctrl/Cmd was
/// held, so toggle membership instead of replacing the selection).
Point { toggle: bool },
}
fn resolve_release(drag: &DragRect, ctrl: bool) -> ReleaseAction {
if drag.is_rubber_band() {
ReleaseAction::RubberBand
} else {
ReleaseAction::Point { toggle: ctrl }
}
}
/// A short label for the last applied op, for the debug panel. /// A short label for the last applied op, for the debug panel.
fn payload_label(payload: &OperationPayload) -> &'static str { fn payload_label(payload: &OperationPayload) -> &'static str {
match payload { match payload {
@ -201,6 +301,10 @@ struct EditorApp {
/// Pencil ("insert") mode: a click on the staff inserts a note at that pitch and /// Pencil ("insert") mode: a click on the staff inserts a note at that pitch and
/// beat (with make-room overwrite) instead of selecting. /// beat (with make-room overwrite) instead of selecting.
pencil: bool, pencil: bool,
/// The in-progress rubber-band drag, if any (`None` outside a drag, and always
/// `None` in pencil mode — pencil's click-to-insert takes precedence and ignores
/// dragging entirely).
rubber_band: Option<DragRect>,
status: String, status: String,
} }
@ -217,6 +321,7 @@ impl EditorApp {
px_per_staff_space: 12.0, px_per_staff_space: 12.0,
needs_render: true, needs_render: true,
pencil: false, pencil: false,
rubber_band: None,
status: "opened ten_measure_single_staff".to_string(), status: "opened ten_measure_single_staff".to_string(),
} }
} }
@ -344,10 +449,14 @@ impl EditorApp {
ui.heading("State"); ui.heading("State");
ui.label(format!("status: {}", self.status)); ui.label(format!("status: {}", self.status));
ui.separator(); ui.separator();
match self.session.selection() { ui.label(format!(
"selected: {} member(s)",
self.session.selections().len()
));
match self.session.anchor() {
Some(sel) => { Some(sel) => {
ui.label(format!("selection source: {:?}", sel.source)); ui.label(format!("anchor source: {:?}", sel.source));
ui.label(format!("layout id: {:?}", sel.layout_object)); ui.label(format!("anchor layout id: {:?}", sel.layout_object));
} }
None => { None => {
ui.label("selection: none"); ui.label("selection: none");
@ -369,7 +478,11 @@ impl EditorApp {
} }
ui.separator(); ui.separator();
ui.label(format!( ui.label(format!(
"Click a notehead to select. Pencil mode: {}.\n\ "Click a notehead to select (replaces). Ctrl/Cmd-click toggles a member. Drag a \
rubber-band to select every hit within it. Pencil mode: {}.\n\
Delete and Transpose act on the whole selection; Move / Add chord / Insert after / \
duration act on the anchor (the drag/toggle reference point, drawn with the \
thicker accent stroke).\n\
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", 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" } if self.pencil { "ON — click to insert" } else { "off" }
)); ));
@ -425,6 +538,39 @@ impl EditorApp {
} }
} }
/// Resolves a non-pencil click/toggle at world `point`: Ctrl/Cmd held (`toggle`)
/// adds/removes it from the set ([`EditorSession::toggle_at`]); otherwise it
/// replaces the selection ([`EditorSession::click`], today's exact behavior),
/// reporting on empty space what a pencil click *would* insert. Shared by the
/// plain-click path and the release path for a drag that stayed under the
/// rubber-band threshold, so both resolve identically.
fn resolve_click(&mut self, world: Point, grid: &GridResolution, toggle: bool) {
if toggle {
let members = self.session.toggle_at(world);
self.status = format!("toggled — {} member(s) selected", members.len());
return;
}
// 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, grid);
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(),
},
};
}
fn score_view(&mut self, ui: &mut egui::Ui) { fn score_view(&mut self, ui: &mut egui::Ui) {
let Some(texture) = self.texture.clone() else { let Some(texture) = self.texture.clone() else {
return; return;
@ -434,7 +580,7 @@ impl EditorApp {
// back to the layout exactly, with no sub-pixel stretch from the `ceil`. // back to the layout exactly, with no sub-pixel stretch from the `ceil`.
let pixmap = texture.size_vec2(); let pixmap = texture.size_vec2();
let logical = self.logical_size; let logical = self.logical_size;
let (rect, response) = ui.allocate_exact_size(logical, egui::Sense::click()); let (rect, response) = ui.allocate_exact_size(logical, egui::Sense::click_and_drag());
let uv = egui::Rect::from_min_max( let uv = egui::Rect::from_min_max(
egui::pos2(0.0, 0.0), egui::pos2(0.0, 0.0),
egui::pos2(logical.x / pixmap.x, logical.y / pixmap.y), egui::pos2(logical.x / pixmap.x, logical.y / pixmap.y),
@ -442,9 +588,76 @@ impl EditorApp {
ui.painter() ui.painter()
.image(texture.id(), rect, uv, egui::Color32::WHITE); .image(texture.id(), rect, uv, egui::Color32::WHITE);
let vm = ViewMap::new(self.view_box, rect);
// Rubber-band drag tracking. Pencil mode ignores dragging entirely — it takes
// precedence, and its click-to-insert behavior below is unchanged — so no drag
// is ever started while it is on.
if !self.pencil {
if response.drag_started() {
if let Some(pos) = response.interact_pointer_pos() {
self.rubber_band = Some(DragRect::new(pos));
}
} else if response.dragged() {
if let Some(pos) = response.interact_pointer_pos() {
match self.rubber_band.as_mut() {
Some(drag) => drag.update(pos),
// `dragged` without a prior `drag_started` shouldn't happen, but
// start tracking now rather than lose the gesture.
None => self.rubber_band = Some(DragRect::new(pos)),
}
}
}
if response.drag_stopped() {
if let Some(mut drag) = self.rubber_band.take() {
// `dragged()` is already false on the release frame (egui clears it
// before `drag_stopped` fires), but `interact_pointer_pos` still
// reports the release position — fold it in so the rect's second
// corner is the exact release point, not the prior frame's.
if let Some(pos) = response.interact_pointer_pos() {
drag.update(pos);
}
let ctrl = ui.input(|i| i.modifiers.command);
match resolve_release(&drag, ctrl) {
ReleaseAction::RubberBand => {
// `select_within`'s `BoundingBox` normalizes corner order
// itself, so the two raw corners are passed through as-is.
let a = vm.screen_to_world(drag.origin);
let b = vm.screen_to_world(drag.current);
let members = self
.session
.select_within(BoundingBox::new(a.x.0, a.y.0, b.x.0, b.y.0));
self.status = format!("selected {} member(s)", members.len());
}
ReleaseAction::Point { toggle } => {
let world = vm.screen_to_world(drag.current);
let grid = self
.session
.default_grid_at(world)
.unwrap_or_else(GridResolution::quarter);
self.resolve_click(world, &grid, toggle);
}
}
}
}
}
// Live rubber-band overlay while dragging.
if let Some(drag) = &self.rubber_band {
ui.painter().rect(
drag.screen_rect(),
0.0,
egui::Color32::from_rgba_unmultiplied(0, 120, 215, 40),
egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgba_unmultiplied(0, 120, 215, 180),
),
);
}
if response.clicked() { if response.clicked() {
if let Some(pos) = response.interact_pointer_pos() { if let Some(pos) = response.interact_pointer_pos() {
let world = ViewMap::new(self.view_box, rect).screen_to_world(pos); let world = vm.screen_to_world(pos);
// The grid the pencil snaps to: the meter's beat under the cursor, // The grid the pencil snaps to: the meter's beat under the cursor,
// defaulting to a quarter off-staff. // defaulting to a quarter off-staff.
let grid = self let grid = self
@ -461,40 +674,19 @@ impl EditorApp {
ui.ctx().request_repaint(); ui.ctx().request_repaint();
return; return;
} else { } else {
// Select the topmost hit; on empty staff, report what a pencil let ctrl = ui.input(|i| i.modifiers.command);
// click *would* insert (the pitch and grid-snapped beat). self.resolve_click(world, &grid, ctrl);
let pitch = self.session.staff_pitch_at(world);
let position = self.session.position_at(world, &grid);
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(),
},
};
} }
} }
} }
if let Some(sel) = self.session.selection() { // Selection overlay: every member gets the existing highlight; the anchor
if let Some(region) = self // additionally gets a thicker, differently-colored accent stroke on top (for a
.session // single selection the sole member is trivially the anchor, so it gets both).
.hit_test() for sel in self.session.selections() {
.regions if let Some(rc) = selection_rect(&self.session, sel.layout_object, &vm) {
.iter()
.find(|r| r.layout_object == sel.layout_object)
{
let vm = ViewMap::new(self.view_box, rect);
ui.painter().rect_stroke( ui.painter().rect_stroke(
shape_rect(&region.shape, &vm), rc,
0.0, 0.0,
// Suffixed: `float_literal_f32_fallback` (Rust 1.97) rejects // Suffixed: `float_literal_f32_fallback` (Rust 1.97) rejects
// inferring `f32` here, and it becomes a hard error later. // inferring `f32` here, and it becomes a hard error later.
@ -502,6 +694,15 @@ impl EditorApp {
); );
} }
} }
if let Some(anchor) = self.session.anchor() {
if let Some(rc) = selection_rect(&self.session, anchor.layout_object, &vm) {
ui.painter().rect_stroke(
rc,
0.0,
egui::Stroke::new(3.0_f32, egui::Color32::from_rgb(255, 140, 0)),
);
}
}
} }
} }
@ -613,4 +814,78 @@ mod tests {
let bottom_left = vm.screen_to_world(egui::pos2(rect.min.x, rect.max.y)); let bottom_left = vm.screen_to_world(egui::pos2(rect.min.x, rect.max.y));
assert!((bottom_left.y.0 - (-5.0)).abs() < 1e-3); assert!((bottom_left.y.0 - (-5.0)).abs() < 1e-3);
} }
#[test]
fn drag_rect_screen_rect_normalizes_any_corner_order() {
// Dragging bottom-right -> top-left must paint the same rect as top-left ->
// bottom-right: `screen_rect` normalizes min<=max on both axes regardless of
// which corner the drag started from. A corner-math mutation (e.g. swapping
// which side takes `.min` vs `.max`) makes one of these directions produce an
// inverted (or simply wrong) rect, which this equality catches.
let mut forward = DragRect::new(egui::pos2(10.0, 20.0));
forward.update(egui::pos2(50.0, 80.0));
let mut backward = DragRect::new(egui::pos2(50.0, 80.0));
backward.update(egui::pos2(10.0, 20.0));
let expected = egui::Rect::from_min_max(egui::pos2(10.0, 20.0), egui::pos2(50.0, 80.0));
assert_eq!(forward.screen_rect(), expected);
assert_eq!(backward.screen_rect(), expected);
// Every other corner order too, so a mutation on just one axis's min/max still
// gets caught.
let mut mixed = DragRect::new(egui::pos2(50.0, 20.0));
mixed.update(egui::pos2(10.0, 80.0));
assert_eq!(mixed.screen_rect(), expected);
}
#[test]
fn drag_rect_distance_is_euclidean() {
let mut drag = DragRect::new(egui::pos2(0.0, 0.0));
drag.update(egui::pos2(3.0, 4.0));
assert!((drag.distance() - 5.0).abs() < 1e-5);
}
#[test]
fn drag_rect_threshold_distinguishes_click_from_rubber_band() {
// Below the threshold: still a click.
let mut small = DragRect::new(egui::pos2(0.0, 0.0));
small.update(egui::pos2(1.0, 1.0));
assert!(small.distance() < DragRect::RUBBER_BAND_THRESHOLD);
assert!(!small.is_rubber_band());
// At the threshold exactly: counts as a rubber-band (>=).
let mut at_threshold = DragRect::new(egui::pos2(0.0, 0.0));
at_threshold.update(egui::pos2(DragRect::RUBBER_BAND_THRESHOLD, 0.0));
assert!(at_threshold.is_rubber_band());
// Well above: a rubber-band.
let mut large = DragRect::new(egui::pos2(0.0, 0.0));
large.update(egui::pos2(100.0, 0.0));
assert!(large.is_rubber_band());
}
#[test]
fn resolve_release_below_threshold_is_a_point_carrying_the_modifier() {
let mut drag = DragRect::new(egui::pos2(0.0, 0.0));
drag.update(egui::pos2(1.0, 1.0));
assert_eq!(
resolve_release(&drag, false),
ReleaseAction::Point { toggle: false }
);
assert_eq!(
resolve_release(&drag, true),
ReleaseAction::Point { toggle: true }
);
}
#[test]
fn resolve_release_at_or_above_threshold_is_rubber_band_regardless_of_modifier() {
// This is mutation w2b's headlessly-testable seam: a release handler that
// dispatched `Point`/`click` here instead of `RubberBand`/`select_within` would
// fail this assertion outright.
let mut drag = DragRect::new(egui::pos2(0.0, 0.0));
drag.update(egui::pos2(100.0, 0.0));
assert_eq!(resolve_release(&drag, false), ReleaseAction::RubberBand);
assert_eq!(resolve_release(&drag, true), ReleaseAction::RubberBand);
}
} }