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,
|
PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId,
|
||||||
RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal,
|
RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal,
|
||||||
SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration,
|
SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration,
|
||||||
TransactionId, TuningReference, TypedObjectId, VoiceId, WallClockTime,
|
TimeSignature, TimeSignatureDisplay, TransactionId, TuningReference, TypedObjectId, VoiceId,
|
||||||
|
WallClockTime,
|
||||||
};
|
};
|
||||||
use epiphany_layout_ir::{
|
use epiphany_layout_ir::{
|
||||||
active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render,
|
active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render,
|
||||||
|
|
@ -492,6 +493,43 @@ impl EditorSession {
|
||||||
Some(GridPosition { region, position })
|
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).
|
/// Whether `region` uses metric time (positions are musical onsets).
|
||||||
fn region_is_metric(&self, region: RegionId) -> bool {
|
fn region_is_metric(&self, region: RegionId) -> bool {
|
||||||
self.score
|
self.score
|
||||||
|
|
@ -1740,6 +1778,21 @@ fn span_between(from: &MusicalPosition, to: &MusicalPosition) -> MusicalDuration
|
||||||
MusicalDuration(to.0.sub(&from.0))
|
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
|
/// 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
|
/// 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
|
/// 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 {
|
fn grid(numerator: i64, denominator: i64) -> GridResolution {
|
||||||
GridResolution {
|
GridResolution {
|
||||||
step: MusicalDuration(RationalTime::new(numerator, denominator).unwrap()),
|
step: MusicalDuration(RationalTime::new(numerator, denominator).unwrap()),
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
//! ([`EditorSession::undo`] / [`EditorSession::redo`]; Ctrl/Cmd+Z undo,
|
//! ([`EditorSession::undo`] / [`EditorSession::redo`]; Ctrl/Cmd+Z undo,
|
||||||
//! Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redo), a
|
//! Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redo), a
|
||||||
//! **pencil mode** that turns a click into a click-to-insert
|
//! **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
|
//! 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
|
//! panel shows the selection and the last applied op. The GUI is the thing meant to
|
||||||
//! surface the next real core gaps.
|
//! surface the next real core gaps.
|
||||||
|
|
@ -412,22 +413,26 @@ impl EditorApp {
|
||||||
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 = 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 {
|
if self.pencil {
|
||||||
// Insert a note at the clicked pitch/beat on a quarter-note grid,
|
// Insert a note at the clicked pitch/beat, making room over whatever
|
||||||
// making room over whatever it overlaps. This runs *after* this
|
// it overlaps. This runs *after* this frame's rerender slot, so
|
||||||
// frame's rerender slot, so request a repaint to draw the edit, and
|
// request a repaint to draw the edit, and return before the selection
|
||||||
// return before the selection overlay below paints the new hit map
|
// overlay below paints the new hit map over the old (not-yet-
|
||||||
// over the old (not-yet-rerendered) texture.
|
// rerendered) texture.
|
||||||
self.run("insert note", |s| {
|
self.run("insert note", |s| s.insert_note_at(world, &grid));
|
||||||
s.insert_note_at(world, &GridResolution::quarter())
|
|
||||||
});
|
|
||||||
ui.ctx().request_repaint();
|
ui.ctx().request_repaint();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Select the topmost hit; on empty staff, report what a pencil
|
// Select the topmost hit; on empty staff, report what a pencil
|
||||||
// click *would* insert (the pitch and grid-snapped beat).
|
// click *would* insert (the pitch and grid-snapped beat).
|
||||||
let pitch = self.session.staff_pitch_at(world);
|
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) {
|
self.status = match self.session.click(world) {
|
||||||
Some(_) => "selected".to_string(),
|
Some(_) => "selected".to_string(),
|
||||||
None => match (pitch, position) {
|
None => match (pitch, position) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue