editor-core: a meter-derived default grid for click-to-insert
default_grid_at(point) gives the GridResolution the pencil should snap to, derived from the meter of the region under the cursor instead of a fixed quarter. region_default_grid resolves the governing time signature the way the prepass's resolve_measure_units does (a region measure's, else the score's first), and time_signature_beat reads its beat — 1/denominator from the display (4/4 -> quarter, 6/8 -> eighth, 2/2 -> half) — defaulting to a quarter when the meter has no single denominator or none is declared. None only when the click resolves to no staff. This is additive: position_at / insert_note_at still take an explicit GridResolution, so their signatures and tests are untouched. The GUI's pencil click (and the empty-staff preview) now pick the grid via default_grid_at(world).unwrap_or_else(GridResolution::quarter). Tests: the beat is 1/denominator (4/4, 2/2; mixed-denominator -> none); a declared 6/8 yields an eighth-note grid; a meter-less score defaults to a quarter and a non-finite click yields no grid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
parent
1496c81302
commit
7f8d1a1f95
|
|
@ -55,7 +55,8 @@ use epiphany_core::{
|
|||
PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId,
|
||||
RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal,
|
||||
SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration,
|
||||
TransactionId, TuningReference, TypedObjectId, VoiceId, WallClockTime,
|
||||
TimeSignature, TimeSignatureDisplay, TransactionId, TuningReference, TypedObjectId, VoiceId,
|
||||
WallClockTime,
|
||||
};
|
||||
use epiphany_layout_ir::{
|
||||
active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render,
|
||||
|
|
@ -492,6 +493,43 @@ impl EditorSession {
|
|||
Some(GridPosition { region, position })
|
||||
}
|
||||
|
||||
/// A sensible [`GridResolution`] for a click at `point`, derived from the meter of
|
||||
/// the region under it (its governing time signature's beat — `1/denominator`),
|
||||
/// defaulting to a quarter when there is no determinable meter. A GUI uses this so
|
||||
/// the pencil snaps to the score's beat instead of a fixed value. `None` only when
|
||||
/// the point resolves to no staff (a non-finite point, or nothing rendered).
|
||||
pub fn default_grid_at(&self, point: Point) -> Option<GridResolution> {
|
||||
let (region, _, _) = self.nearest_manifestation(point)?;
|
||||
Some(self.region_default_grid(region))
|
||||
}
|
||||
|
||||
/// The meter-derived default [`GridResolution`] for `region`: the beat (`1/
|
||||
/// denominator`) of its governing time signature — the first one a region measure
|
||||
/// references, else the score's first — and a quarter note when none is determinable
|
||||
/// (or the meter has no single denominator). Mirrors the prepass's single-governing-
|
||||
/// meter-per-region resolution (`resolve_measure_units`).
|
||||
fn region_default_grid(&self, region: RegionId) -> GridResolution {
|
||||
let governing = self
|
||||
.score
|
||||
.canvas
|
||||
.regions
|
||||
.iter()
|
||||
.find(|r| r.id == region)
|
||||
.and_then(|graph_region| {
|
||||
graph_region
|
||||
.staff_instances()
|
||||
.iter()
|
||||
.flat_map(|si| si.measures.iter())
|
||||
.filter_map(|measure| measure.time_signature.as_ref())
|
||||
.find_map(|tsid| self.score.time_signatures.iter().find(|ts| &ts.id == tsid))
|
||||
.or_else(|| self.score.time_signatures.first())
|
||||
});
|
||||
match governing.and_then(time_signature_beat) {
|
||||
Some(step) => GridResolution { step },
|
||||
None => GridResolution::quarter(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `region` uses metric time (positions are musical onsets).
|
||||
fn region_is_metric(&self, region: RegionId) -> bool {
|
||||
self.score
|
||||
|
|
@ -1740,6 +1778,21 @@ fn span_between(from: &MusicalPosition, to: &MusicalPosition) -> MusicalDuration
|
|||
MusicalDuration(to.0.sub(&from.0))
|
||||
}
|
||||
|
||||
/// A time signature's beat unit — the note value it counts in, `1/denominator` (4/4 → a
|
||||
/// quarter, 6/8 → an eighth). `None` for a meter with no single denominator
|
||||
/// (mixed-denominator, none, or a symbolic display), which the caller defaults.
|
||||
fn time_signature_beat(ts: &TimeSignature) -> Option<MusicalDuration> {
|
||||
let denominator: i64 = match &ts.display {
|
||||
TimeSignatureDisplay::Standard { denominator, .. }
|
||||
| TimeSignatureDisplay::Compound { denominator, .. } => denominator.get() as i64,
|
||||
TimeSignatureDisplay::Irrational { denominator, .. } => denominator.get() as i64,
|
||||
TimeSignatureDisplay::MixedDenominators { .. }
|
||||
| TimeSignatureDisplay::None
|
||||
| TimeSignatureDisplay::Symbolic(_) => return None,
|
||||
};
|
||||
RationalTime::new(1, denominator).map(MusicalDuration)
|
||||
}
|
||||
|
||||
/// The events make-room must change to clear a span: whole-event deletes, in-place
|
||||
/// trims (a `ModifyEvent` value), and splits (the original event plus the tail's onset
|
||||
/// and duration, for re-inserting the tail with fresh ids). Built by
|
||||
|
|
@ -2523,6 +2576,90 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A time signature with `display`, a measure of `measure`, and one beat group
|
||||
/// spanning it (so the beat-sum invariant holds).
|
||||
fn time_sig(
|
||||
display: epiphany_core::TimeSignatureDisplay,
|
||||
measure: MusicalDuration,
|
||||
) -> TimeSignature {
|
||||
use epiphany_core::{BeatGroup, TimeSignatureId};
|
||||
TimeSignature::new(
|
||||
TimeSignatureId::new(ReplicaId(9), 0),
|
||||
display,
|
||||
measure.clone(),
|
||||
vec![BeatGroup {
|
||||
duration: measure,
|
||||
subdivision: None,
|
||||
accent: 0,
|
||||
}],
|
||||
)
|
||||
.expect("a single-beat-group time signature is well-formed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_signature_beat_is_one_over_the_denominator() {
|
||||
use epiphany_core::{PowerOfTwo, TimeSignatureDisplay};
|
||||
let whole = MusicalDuration(RationalTime::new(1, 1).unwrap());
|
||||
// 4/4 → quarter; 2/2 → half.
|
||||
let four_four = time_sig(
|
||||
TimeSignatureDisplay::Standard {
|
||||
numerator: 4,
|
||||
denominator: PowerOfTwo::new(4).unwrap(),
|
||||
},
|
||||
whole.clone(),
|
||||
);
|
||||
assert_eq!(time_signature_beat(&four_four), Some(dur(1, 4)));
|
||||
let cut = time_sig(
|
||||
TimeSignatureDisplay::Standard {
|
||||
numerator: 2,
|
||||
denominator: PowerOfTwo::new(2).unwrap(),
|
||||
},
|
||||
whole.clone(),
|
||||
);
|
||||
assert_eq!(time_signature_beat(&cut), Some(dur(1, 2)));
|
||||
// A meter with no single denominator has no derivable beat.
|
||||
let mixed = time_sig(
|
||||
TimeSignatureDisplay::MixedDenominators { components: vec![] },
|
||||
whole,
|
||||
);
|
||||
assert_eq!(time_signature_beat(&mixed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_grid_at_uses_the_governing_meter() {
|
||||
use epiphany_core::{PowerOfTwo, TimeSignatureDisplay};
|
||||
// Declare a 6/8 (its beat is an eighth). With no measure referencing one, the
|
||||
// score's first time signature governs — the same fallback `resolve_measure_units`
|
||||
// uses.
|
||||
let mut score = valid_score(0x5EED);
|
||||
score.time_signatures.push(time_sig(
|
||||
TimeSignatureDisplay::Standard {
|
||||
numerator: 6,
|
||||
denominator: PowerOfTwo::new(8).unwrap(),
|
||||
},
|
||||
dur(6, 8),
|
||||
));
|
||||
let session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
|
||||
let region = a_clean_metric_region(&session);
|
||||
let at = point_on_region_staff(&session, region);
|
||||
assert_eq!(
|
||||
session.default_grid_at(at),
|
||||
Some(GridResolution { step: dur(1, 8) }),
|
||||
"a 6/8 meter gives an eighth-note grid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_grid_at_defaults_to_a_quarter_without_a_meter() {
|
||||
// The plain fixture declares no time signatures, so the default is a quarter.
|
||||
let session = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&session);
|
||||
let at = point_on_region_staff(&session, region);
|
||||
assert_eq!(session.default_grid_at(at), Some(GridResolution::quarter()));
|
||||
// A non-finite click resolves to no staff, so there is no grid.
|
||||
assert_eq!(session.default_grid_at(Point::new(f32::NAN, 0.0)), None);
|
||||
}
|
||||
|
||||
fn grid(numerator: i64, denominator: i64) -> GridResolution {
|
||||
GridResolution {
|
||||
step: MusicalDuration(RationalTime::new(numerator, denominator).unwrap()),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
//! ([`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,
|
||||
//! ([`EditorSession::insert_note_at`]) with make-room overwrite, snapping to the
|
||||
//! meter's beat grid ([`EditorSession::default_grid_at`]),
|
||||
//! 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.
|
||||
|
|
@ -412,22 +413,26 @@ 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);
|
||||
// The grid the pencil snaps to: the meter's beat under the cursor,
|
||||
// defaulting to a quarter off-staff.
|
||||
let grid = self
|
||||
.session
|
||||
.default_grid_at(world)
|
||||
.unwrap_or_else(GridResolution::quarter);
|
||||
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())
|
||||
});
|
||||
// Insert a note at the clicked pitch/beat, 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, &grid));
|
||||
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());
|
||||
let position = self.session.position_at(world, &grid);
|
||||
self.status = match self.session.click(world) {
|
||||
Some(_) => "selected".to_string(),
|
||||
None => match (pitch, position) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue