diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index a6234dd..4b385bb 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -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 { + 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 { + 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()), diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index a22c8e8..87b9f9c 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -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) {