From ce37de8834fb5b68cb57dff380be7eaeb195d35f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 30 Jun 2026 12:35:39 -0400 Subject: [PATCH] =?UTF-8?q?editor-core:=20an=20insert=5Fnote=5Fat=20click?= =?UTF-8?q?=E2=86=92insert=20with=20make-room=20overwrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pencil: a click-to-insert that composes the two inverses — staff_pitch_at (the natural pitch at the height) and position_at (the grid-snapped onset) — into a fresh CMN note (duration = grid step) in the staff's primary voice, and makes room under the overwrite policy as one atomic transaction. Make-room over the overlapping notes/rests of the voice: a fully-covered event is deleted; one the new note partially overlaps is trimmed (a phase-3 ModifyEvent placement change); one the new note lands inside is split — the original is trimmed to its head and its tail re-inserted. A split tail is a clone of the original event's whole shape (a rest's visibility, a note's articulations/dynamics/stem/grace) with fresh event and pitch ids, and any authored spelling is carried onto the fresh pitches via RespellPitch (an inferred spelling re-derives) — the same atomic copy insert_note_after_selection does. The several ids minted in one intent advance local checked counters, since the session high-water mark does not move until commit. Refuses cleanly rather than corrupt: an overlapped tuplet member raises OverlapsTuplet (ratio compensation is out of scope); an off-staff or non-metric click, or a non-note/rest overlap, raises NoInsertTarget. Tests (against valid_score's tuplet-free metric region; the rich fixture's only metric region is a triplet): empty-space insert, full-cover overwrite, the split structure, the split tail's shape fidelity and authored-spelling carry, the tuplet refusal, and the non-metric refusal. A click_for_position helper inverts position_at so a test targets an exact onset. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS --- crates/epiphany-editor-core/src/lib.rs | 707 ++++++++++++++++++++++++- 1 file changed, 697 insertions(+), 10 deletions(-) diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index a0f4d0b..264adc0 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -43,12 +43,12 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt; use epiphany_core::{ - AcousticRealization, Clef, CmnNominal, Event, EventDuration, EventId, EventPosition, - IdentifiedPitch, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, - PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId, RegionTimeModel, - ReplicaId, Score, SpellingDirective, SpellingNominal, SpellingScope, SpellingSourceKind, - StaffId, StaffInstance, StaffInstanceId, StemConfiguration, TransactionId, TypedObjectId, - VoiceId, WallClockTime, + AcousticPitch, AcousticRealization, Clef, CmnNominal, Event, EventDuration, EventId, + EventPosition, IdentifiedPitch, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, + PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId, + RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal, + SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration, + TransactionId, TuningReference, TypedObjectId, VoiceId, WallClockTime, }; use epiphany_layout_ir::{ active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render, @@ -57,9 +57,10 @@ use epiphany_layout_ir::{ }; use epiphany_ops::{ AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp, - HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, ModifyIdentifiedPitchOp, - OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, - RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeOp, TupletCompensation, + HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp, + ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationPayload, OperationSet, + OperationStamp, RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeOp, + TupletCompensation, }; /// The current selection: the score-graph object to act on, plus the stable layout @@ -170,6 +171,15 @@ pub enum EditorError { /// event in the same voice (the reducer would silently no-op it). The edit is /// refused; inserting into a packed voice needs an explicit make-room policy. InsertSlotOccupied, + /// A click-to-insert resolved to no insert target: the point is off any staff, the + /// region is non-metric or has too few rendered events to place a position, or the + /// staff has no voice / no diatonic clef. Nothing is inserted. + NoInsertTarget, + /// A click-to-insert's make-room would have to trim or delete a **tuplet member**, + /// whose duration is governed by the tuplet's ratio. Compensating a tuplet on a + /// pencil overwrite is a later refinement, so the insert is refused rather than + /// leaving the tuplet inconsistent. + OverlapsTuplet, } impl fmt::Display for EditorError { @@ -198,6 +208,12 @@ impl fmt::Display for EditorError { EditorError::DeclareTransactionNotAllowed => { f.write_str("a transaction cannot be declared through an edit; the session manages transactions") } + EditorError::NoInsertTarget => { + f.write_str("the click resolved to no insert target (off-staff, non-metric, or no voice)") + } + EditorError::OverlapsTuplet => { + f.write_str("the insert would have to trim or delete a tuplet member") + } } } } @@ -983,6 +999,188 @@ impl EditorSession { } } + /// Inserts a note at a world `point` on the beat grid — the **click-to-insert** + /// ("pencil"): the pitch is the natural pitch at the cursor's height, the position + /// is the grid-snapped onset, and the written duration is the grid `step`. The note + /// goes into the staff's primary voice and **makes room** under an overwrite policy + /// — an existing note/rest the new note fully covers is deleted, one it partially + /// overlaps is trimmed, and one it lands inside is split (head trimmed, tail + /// re-inserted) — all as one transaction, so it applies atomically or not at all. + /// + /// Errors with [`EditorError::NoInsertTarget`] when the click resolves to no metric + /// staff/position (or the overlap includes a non-note/rest event there is no + /// make-room rule for), or [`EditorError::OverlapsTuplet`] when make-room would have + /// to disturb a tuplet member (its duration is ratio-governed). + pub fn insert_note_at( + &mut self, + point: Point, + grid: &GridResolution, + ) -> Result { + let pitch = self + .staff_pitch_at(point) + .ok_or(EditorError::NoInsertTarget)?; + let placed = self + .position_at(point, grid) + .ok_or(EditorError::NoInsertTarget)?; + // The staff instance's region (must match the resolved position's) and its + // primary voice — the new note's home. + let (region, voice) = self + .score + .staff_instances() + .find(|(_, si)| si.id == pitch.staff_instance) + .and_then(|(region, si)| { + let voice = si + .voices + .iter() + .find(|v| v.is_primary) + .or_else(|| si.voices.first())?; + Some((region, voice.id)) + }) + .ok_or(EditorError::NoInsertTarget)?; + if region != placed.region { + return Err(EditorError::NoInsertTarget); + } + + // The new note's half-open span. + let start = placed.position; + let duration = grid.step.clone(); + let end = start.clone() + duration.clone(); + + // Make-room: classify every note/rest in the voice that overlaps the span. + let mut trims: Vec = Vec::new(); + let mut deletes: Vec = Vec::new(); + let mut tails: Vec<(Event, MusicalPosition, MusicalDuration)> = Vec::new(); + for event in self.score.events.iter() { + if event.voice() != voice { + continue; + } + let (EventPosition::Musical(ep), EventDuration::Musical(ed)) = + (event.position(), event.duration()) + else { + continue; + }; + let event_end = ep.clone() + ed.clone(); + if !(ep < &end && start < event_end) { + continue; // disjoint from [start, end) + } + if self.event_in_tuplet(event.id()) { + return Err(EditorError::OverlapsTuplet); + } + if !matches!(event, Event::Pitched(_) | Event::Rest(_)) { + return Err(EditorError::NoInsertTarget); // no make-room rule for this kind + } + match (ep < &start, event_end > end) { + // Fully covered → delete. + (false, false) => deletes.push(event.id()), + // The note lands inside it → split: trim to the head, re-insert the tail. + (true, true) => { + trims.push(replace_span(event, ep.clone(), span_between(ep, &start))); + tails.push((event.clone(), end.clone(), span_between(&end, &event_end))); + } + // Overlaps the head (event ends inside the note) → trim the tail off. + (true, false) => { + trims.push(replace_span(event, ep.clone(), span_between(ep, &start))); + } + // Overlaps the tail (event starts inside the note) → trim the head off. + (false, true) => { + trims.push(replace_span( + event, + end.clone(), + span_between(&end, &event_end), + )); + } + } + } + + // Mint ids for the split tails and the new note with local counters, advancing + // them (checked, like the session minters) so the several mints in this one + // intent do not collide — the session high-water mark does not move until commit. + let replica = self.replica; + let mut next_event = self.mint_event_id().counter(); + let mut next_pitch = self.mint_pitch_id().counter(); + let mut mint_event = || { + let id = EventId::new(replica, next_event); + next_event = next_event + .checked_add(1) + .expect("event id counter overflowed u64"); + id + }; + let mut mint_pitch = || { + let id = PitchId::new(replica, next_pitch); + next_pitch = next_pitch + .checked_add(1) + .expect("pitch id counter overflowed u64"); + id + }; + + // Order: free the span first (trims, deletes), then the re-inserted tails (each + // carrying its spellings), then the new note. Members apply in this order. + let mut ops: Vec = Vec::new(); + for event in trims { + ops.push(OperationKind::ModifyEvent(ModifyEventOp { event })); + } + for event in deletes { + ops.push(OperationKind::DeleteEvent(DeleteEventOp { + event, + tuplet_compensation: TupletCompensation::NotInTuplet, + })); + } + for (original, position, duration) in tails { + // The tail is the original event's later portion: clone its whole shape (a + // rest's visibility, a note's articulations/dynamics/stem/grace) with fresh + // ids, then carry any *authored* spelling onto the fresh pitches (an inferred + // one re-derives) — the same atomic copy `insert_note_after_selection` does. + let mut pitches: Vec<&IdentifiedPitch> = Vec::new(); + original.collect_identified_pitches(&mut pitches); + let originals: Vec = pitches.iter().map(|ip| ip.id).collect(); + let fresh: Vec = originals.iter().map(|_| mint_pitch()).collect(); + let tail = respan_with_fresh_ids(&original, mint_event(), position, duration, &fresh); + ops.push(OperationKind::InsertEvent(InsertEventOp { + staff_instance: pitch.staff_instance, + event: tail, + })); + for (old, new) in originals.iter().zip(&fresh) { + if let Some(spelling) = authored_spelling(&self.score, *old) { + ops.push(OperationKind::RespellPitch(RespellPitchOp { + pitch: *new, + spelling, + })); + } + } + } + let new_note = note_event( + mint_event(), + voice, + start, + duration, + vec![IdentifiedPitch { + id: mint_pitch(), + pitch: cmn_pitch(pitch.nominal, pitch.octave), + }], + ); + ops.push(OperationKind::InsertEvent(InsertEventOp { + staff_instance: pitch.staff_instance, + event: new_note, + })); + + // A bare insert needs no transaction; a make-room insert is atomic. + if ops.len() == 1 { + self.apply(ops.into_iter().next().expect("one op")) + } else { + self.apply_transaction("insert note", Some(TransactionCategory::NoteEntry), ops) + } + } + + /// Whether `event` is a member of any tuplet (its duration is ratio-governed, so + /// make-room cannot trim or delete it without tuplet compensation). + fn event_in_tuplet(&self, event: EventId) -> bool { + self.score + .cross_cutting + .tuplets + .iter() + .any(|tuplet| tuplet.members.contains(&event)) + } + /// The staff instance hosting `voice`, but only when its region is metric — the /// time model `InsertEvent` requires (the reducer rejects any other). `None` if /// the voice is absent from the voice tree or its region is non-metric. @@ -1219,6 +1417,121 @@ fn note_above(top: &Pitch) -> Option { Some(above) } +/// A natural CMN pitch at `nominal`/`octave`, sounding at its written position +/// ([`AcousticRealization::Implicit`]) — the value a click-to-insert mints for the +/// height under the cursor (a caller respells if an accidental is wanted). +fn cmn_pitch(nominal: CmnNominal, octave: i8) -> Pitch { + Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("cmn-12"), + position: PitchSpacePosition::Cmn { + nominal, + alteration: 0, + octave, + }, + }, + acoustic: AcousticPitch { + tuning: TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + } +} + +/// The musical duration spanning `from..to` (`to - from`), exact over rational time. +fn span_between(from: &MusicalPosition, to: &MusicalPosition) -> MusicalDuration { + MusicalDuration(to.0.sub(&from.0)) +} + +/// `event` re-placed at a new metric `position`/`duration` (a make-room trim), keeping +/// everything else. Only notes and rests carry a make-room rule; other kinds return +/// unchanged (the caller refuses such an overlap before reaching here). +fn replace_span(event: &Event, position: MusicalPosition, duration: MusicalDuration) -> Event { + let mut moved = event.clone(); + match &mut moved { + Event::Pitched(pe) => { + pe.position = EventPosition::Musical(position); + pe.duration = EventDuration::Musical(duration); + } + Event::Rest(rest) => { + rest.position = EventPosition::Musical(position); + rest.duration = EventDuration::Musical(duration); + } + _ => {} + } + moved +} + +/// `event`'s later portion as a fresh event for a split tail: a clone with a fresh +/// event `id`, its pitches re-identified by `fresh_pitch_ids` (same values, in order), +/// re-placed at `position`/`duration`. Cloning keeps everything a continuation should +/// keep — a rest's `visible`/`vertical_position`, a note's articulations, dynamics, +/// ornaments, stem, and grace. Authored spellings are carried separately (they key on +/// pitch id, so the caller adds `RespellPitch`es for the fresh ids). +fn respan_with_fresh_ids( + event: &Event, + id: EventId, + position: MusicalPosition, + duration: MusicalDuration, + fresh_pitch_ids: &[PitchId], +) -> Event { + let mut tail = event.clone(); + let position = EventPosition::Musical(position); + let duration = EventDuration::Musical(duration); + match &mut tail { + Event::Pitched(pe) => { + pe.id = id; + pe.position = position; + pe.duration = duration; + for (ip, fresh) in pe.pitches.iter_mut().zip(fresh_pitch_ids) { + ip.id = *fresh; + } + } + Event::Rest(rest) => { + rest.id = id; + rest.position = position; + rest.duration = duration; + } + _ => {} + } + tail +} + +/// A fresh metric event in `voice`: a [`PitchedEvent`] when `pitches` is non-empty, +/// else a [`Rest`](epiphany_core::Rest) — used for the new note and any split tail. +fn note_event( + id: EventId, + voice: VoiceId, + position: MusicalPosition, + duration: MusicalDuration, + pitches: Vec, +) -> Event { + let position = EventPosition::Musical(position); + let duration = EventDuration::Musical(duration); + if pitches.is_empty() { + Event::Rest(epiphany_core::Rest { + id, + voice, + position, + duration, + vertical_position: None, + visible: true, + }) + } else { + Event::Pitched(PitchedEvent { + id, + voice, + position, + duration, + pitches, + articulations: Vec::new(), + dynamic: None, + ornaments: Vec::new(), + stem: StemConfiguration, + grace: None, + }) + } +} + /// The pitch ids a session envelope brought into being — so the minter never reuses /// one, including ids since deleted (a `DeleteIdentifiedPitch` leaves no trace in the /// materialized score, so the log is the authoritative record). Both insert ops mint @@ -1380,7 +1693,7 @@ fn staff_start_clefs(logical: &LogicalLayoutIR) -> StartClefs { #[cfg(test)] mod tests { use super::*; - use epiphany_core::generators::valid_score_rich; + use epiphany_core::generators::{valid_score, valid_score_rich}; use epiphany_layout_ir::{ ConstrainedLayoutIR, HitShape, InvalidationSet, SolveReport, SolveStatus, SolverState, SolverTier, SolverVersion, StubSolver, @@ -1390,6 +1703,13 @@ mod tests { EditorSession::open(valid_score_rich(seed), Box::new(StubSolver)).expect("rich renders") } + /// A session on the plain fixture, whose single metric region is a tuplet-free run + /// of quarter notes — the clean target for the make-room tests (the rich fixture's + /// only metric region is a triplet). + fn open_plain(seed: u64) -> EditorSession { + EditorSession::open(valid_score(seed), Box::new(StubSolver)).expect("plain renders") + } + /// Clicks the centre of the first notehead (a pitch-backed glyph) and returns the /// resulting selection. fn click_a_notehead(session: &mut EditorSession) -> Selection { @@ -1850,6 +2170,373 @@ mod tests { } } + fn grid(numerator: i64, denominator: i64) -> GridResolution { + GridResolution { + step: MusicalDuration(RationalTime::new(numerator, denominator).unwrap()), + } + } + + /// A metric region (its time model is metric) none of whose events are tuplet + /// members — the clean target for the make-room tests. + fn a_clean_metric_region(session: &EditorSession) -> RegionId { + session + .score() + .canvas + .regions + .iter() + .filter(|r| matches!(r.time_model, RegionTimeModel::Metric(_))) + .map(|r| r.id) + .find(|rid| { + session + .score() + .voices() + .filter(|(r, _, _)| r == rid) + .flat_map(|(_, _, v)| v.events.clone()) + .all(|eid| { + !session + .score() + .cross_cutting + .tuplets + .iter() + .any(|t| t.members.contains(&eid)) + }) + }) + .expect("a tuplet-free metric region") + } + + fn primary_voice(session: &EditorSession, region: RegionId) -> VoiceId { + let (_, si) = session + .score() + .staff_instances() + .find(|(r, _)| *r == region) + .expect("the region has a staff instance"); + si.voices + .iter() + .find(|v| v.is_primary) + .or_else(|| si.voices.first()) + .expect("the staff has a voice") + .id + } + + fn voice_events( + session: &EditorSession, + voice: VoiceId, + ) -> Vec<(EventId, MusicalPosition, MusicalDuration)> { + let mut out = Vec::new(); + for ev in session.score().events.iter() { + if ev.voice() != voice { + continue; + } + if let (EventPosition::Musical(p), EventDuration::Musical(d)) = + (ev.position(), ev.duration()) + { + out.push((ev.id(), p.clone(), d.clone())); + } + } + out.sort_by(|a, b| a.1.cmp(&b.1)); + out + } + + /// A click whose horizontal inverse snaps to `position` in `region` — derived from + /// the region's rendered anchors, so it is the inverse of `position_at` (the stub + /// solver spaces a metric region's onsets linearly, so a global fit suffices). + fn click_for_position( + session: &EditorSession, + region: RegionId, + position: &MusicalPosition, + y: f32, + ) -> Point { + let anchors = session.position_anchors(region); + let (p0, x0) = (anchors[0].0 .0.to_f64(), anchors[0].1 as f64); + let last = anchors.last().unwrap(); + let (p1, x1) = (last.0 .0.to_f64(), last.1 as f64); + let t = (position.0.to_f64() - p0) / (p1 - p0); + Point::new((x0 + t * (x1 - x0)) as f32, y) + } + + #[test] + fn insert_note_at_fills_empty_space() { + let mut session = open_plain(0x5EED); + let region = a_clean_metric_region(&session); + let voice = primary_voice(&session, region); + let before = voice_events(&session, voice); + let (_, _, origin_y) = region_staff_line(&session, region); + + // The onset just after the last note — empty space, so a bare insert. + let (_, last_pos, last_dur) = before.last().unwrap().clone(); + let target = MusicalPosition(last_pos.0.add(&last_dur.0)); + let at = click_for_position(&session, region, &target, origin_y + 1.0); + let outcome = session + .insert_note_at(at, &grid(1, 4)) + .expect("insert into empty space"); + assert!(outcome.graph_changed); + + let after = voice_events(&session, voice); + assert_eq!( + after.len(), + before.len() + 1, + "one note added, none removed" + ); + assert!( + after + .iter() + .any(|(_, p, d)| *p == target && d.0 == RationalTime::new(1, 4).unwrap()), + "the new note sits at the snapped onset with the grid duration" + ); + } + + #[test] + fn insert_note_at_overwrites_a_covered_note() { + let mut session = open_plain(0x5EED); + let region = a_clean_metric_region(&session); + let voice = primary_voice(&session, region); + let before = voice_events(&session, voice); + let (_, _, origin_y) = region_staff_line(&session, region); + + // Click the last note with grid = its duration: the new note fully covers it. + let (old_id, pos, dur) = before.last().unwrap().clone(); + let at = click_for_position(&session, region, &pos, origin_y + 1.0); + session + .insert_note_at(at, &GridResolution { step: dur }) + .expect("overwrite the covered note"); + + let after = voice_events(&session, voice); + assert_eq!(after.len(), before.len(), "delete + insert keeps the count"); + let covering = after + .iter() + .find(|(_, p, _)| *p == pos) + .expect("a note at the covered onset"); + assert_ne!(covering.0, old_id, "the covered note was replaced"); + assert!( + !after.iter().any(|(id, _, _)| *id == old_id), + "the covered note's event is gone" + ); + } + + #[test] + fn insert_note_at_splits_an_enclosing_note() { + let mut session = open_plain(0x5EED); + let region = a_clean_metric_region(&session); + let voice = primary_voice(&session, region); + let before = voice_events(&session, voice); + let (_, _, origin_y) = region_staff_line(&session, region); + + // A grid a quarter of the first note's length, clicked one cell in, lands the + // new note strictly inside the note (its second of four cells) — a split. + let (first_id, first_pos, first_dur) = before.first().unwrap().clone(); + let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap()); + let step = GridResolution { + step: MusicalDuration(cell.clone()), + }; + let target = MusicalPosition(first_pos.0.add(&cell)); + let at = click_for_position(&session, region, &target, origin_y + 1.0); + session + .insert_note_at(at, &step) + .expect("split the enclosing note"); + + let after = voice_events(&session, voice); + assert_eq!( + after.len(), + before.len() + 2, + "the split adds the new note and a tail" + ); + let head = after + .iter() + .find(|(id, _, _)| *id == first_id) + .expect("the original note survives as the head"); + assert_eq!(head.1, first_pos, "the head keeps the original onset"); + assert_eq!(head.2 .0, cell, "the head is trimmed to the click"); + assert!( + after + .iter() + .any(|(id, p, d)| *id != first_id && *p == target && d.0 == cell), + "the new note sits at the click" + ); + let tail_start = MusicalPosition(target.0.add(&cell)); + assert!( + after.iter().any(|(_, p, _)| *p == tail_start), + "a tail picks up where the new note ends" + ); + } + + #[test] + fn insert_note_at_split_tail_keeps_the_event_shape() { + // Give the first metric note an articulation, then split it: the tail must keep + // the note's shape (it is a clone with fresh ids), not be rebuilt as a default + // note that drops articulations/dynamics/stem/grace. + let mut score = valid_score(0x5EED); + let region = score + .canvas + .regions + .iter() + .find(|r| matches!(r.time_model, RegionTimeModel::Metric(_))) + .unwrap() + .id; + let voice = score + .voices() + .find(|(r, _, _)| *r == region) + .map(|(_, _, v)| v.id) + .unwrap(); + let first_id = { + let mut evs: Vec<(EventId, MusicalPosition)> = score + .events + .iter() + .filter(|e| e.voice() == voice) + .filter_map(|e| match e.position() { + EventPosition::Musical(p) => Some((e.id(), p.clone())), + _ => None, + }) + .collect(); + evs.sort_by(|a, b| a.1.cmp(&b.1)); + evs[0].0 + }; + if let Some(Event::Pitched(pe)) = score.events.get_mut(first_id) { + pe.articulations.push(epiphany_core::ArticulationMark); + } + + let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders"); + let (_, _, origin_y) = region_staff_line(&session, region); + let (_, first_pos, first_dur) = voice_events(&session, voice) + .into_iter() + .find(|(id, _, _)| *id == first_id) + .unwrap(); + let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap()); + let target = MusicalPosition(first_pos.0.add(&cell)); + let at = click_for_position(&session, region, &target, origin_y + 1.0); + session + .insert_note_at( + at, + &GridResolution { + step: MusicalDuration(cell.clone()), + }, + ) + .expect("split the articulated note"); + + let tail_pos = MusicalPosition(target.0.add(&cell)); + let tail = session + .score() + .events + .iter() + .find(|e| { + e.voice() == voice + && matches!(e.position(), EventPosition::Musical(p) if *p == tail_pos) + }) + .expect("a tail event at the original note's remainder"); + match tail { + Event::Pitched(pe) => assert!( + !pe.articulations.is_empty(), + "the split tail kept the note's articulation" + ), + _ => panic!("the tail of a pitched note is pitched"), + } + assert_ne!( + tail.id(), + first_id, + "the tail is a fresh event, not the original" + ); + } + + #[test] + fn insert_note_at_split_tail_carries_an_authored_spelling() { + let mut session = open_plain(0x5EED); + let region = a_clean_metric_region(&session); + let voice = primary_voice(&session, region); + let before = voice_events(&session, voice); + let (_, _, origin_y) = region_staff_line(&session, region); + let (first_id, first_pos, first_dur) = before.first().unwrap().clone(); + + // Pin the first note's pitch with an explicit user spelling — an authored + // override the split tail must carry onto its fresh pitch id. + let source_pitch = { + let ev = session.score().events.get(first_id).unwrap(); + let mut buf: Vec<&IdentifiedPitch> = Vec::new(); + ev.collect_identified_pitches(&mut buf); + buf.first().expect("the first note has a pitch").id + }; + let pinned = PitchSpelling::cmn(CmnNominal::D, 4); + session + .apply(OperationKind::RespellPitch(RespellPitchOp { + pitch: source_pitch, + spelling: pinned.clone(), + })) + .expect("the respell applies"); + assert_eq!( + authored_spelling(session.score(), source_pitch), + Some(pinned.clone()) + ); + + // Split the pinned note (grid a quarter of its length, clicked one cell in). + let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap()); + let target = MusicalPosition(first_pos.0.add(&cell)); + let at = click_for_position(&session, region, &target, origin_y + 1.0); + session + .insert_note_at( + at, + &GridResolution { + step: MusicalDuration(cell.clone()), + }, + ) + .expect("split the pinned note"); + + // The tail is a fresh event whose pitch carries the authored spelling. + let tail_pos = MusicalPosition(target.0.add(&cell)); + let tail = session + .score() + .events + .iter() + .find(|e| { + e.voice() == voice + && matches!(e.position(), EventPosition::Musical(p) if *p == tail_pos) + }) + .expect("a tail event"); + let mut buf: Vec<&IdentifiedPitch> = Vec::new(); + tail.collect_identified_pitches(&mut buf); + let tail_pitch = buf.first().expect("the tail has a pitch").id; + assert_ne!(tail_pitch, source_pitch, "the tail's pitch id is fresh"); + assert_eq!( + authored_spelling(session.score(), tail_pitch), + Some(pinned), + "the authored spelling carried onto the tail" + ); + } + + #[test] + fn insert_note_at_refuses_to_disturb_a_tuplet() { + let mut session = open_rich(0x5EED); + // The rich fixture's first metric region is an eighth-note triplet; covering a + // member would need tuplet compensation, so the insert is refused. + let region = a_region_with(&session, true); + assert!( + session + .score() + .cross_cutting + .tuplets + .iter() + .any(|t| !t.members.is_empty()), + "the fixture region is a tuplet" + ); + let voice = primary_voice(&session, region); + let (_, pos, dur) = voice_events(&session, voice).into_iter().next().unwrap(); + let (_, _, origin_y) = region_staff_line(&session, region); + let at = click_for_position(&session, region, &pos, origin_y + 1.0); + assert_eq!( + session.insert_note_at(at, &GridResolution { step: dur }), + Err(EditorError::OverlapsTuplet) + ); + } + + #[test] + fn insert_note_at_on_a_non_metric_region_is_no_target() { + let mut session = open_rich(0x5EED); + // The proportional region has no musical onset to insert at. + let region = a_region_with(&session, false); + let at = point_on_region_staff(&session, region); + assert_eq!( + session.insert_note_at(at, &grid(1, 4)), + Err(EditorError::NoInsertTarget) + ); + } + #[test] fn the_full_editing_loop_runs_through_the_session() { let mut session = open_rich(0x5EED);