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:
parent
51f7ac9f61
commit
f50f0ee52d
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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(®ion.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(®ion.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue