From d6fb8afcdc9994be5d5168b7a7f510c388a88705 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 29 Jun 2026 10:35:35 -0400 Subject: [PATCH] editor-core: override-carrying insert_note_after_selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An insert after a pitch that carries an authored spelling override no longer refuses — it carries the spelling onto the copy. With no override the insert is still a plain InsertEvent (the copied value takes the inferred spelling); with one, it emits a transaction of InsertEvent plus a RespellPitch on the new note's fresh PitchId set to the anchor's authored spelling. The members apply in counter order, so the insert mints the pitch before the respell spells it, and the copy renders like the original. The spelling is copied verbatim, not stepped: an insert-after is a same-pitch time copy, so it keeps the same staff position. This completes the four-step transaction plan: foundation, override-aware move, override-carrying insert. The only remaining override refusal is the chord add (PitchSpellingOverridden), where picking the visual top note needs resolved-spelling-aware stacking — a separate follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS --- crates/epiphany-editor-core/src/lib.rs | 104 ++++++++++++++++--------- 1 file changed, 69 insertions(+), 35 deletions(-) diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index da69220..66b6de7 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -111,11 +111,11 @@ pub enum EditorError { }, /// A pitch involved in the edit carries an authored spelling override (user-chosen, /// imported, or propagated) that outranks the inferred spelling, and the intent - /// cannot carry it. Raised by a chord add whose target event has an override (so - /// the rendered staff order can't be read off the raw pitch positions) and by an - /// insert that copies the pitch (which would drop the override from the new note); - /// both await an override-aware (transaction-carrying) version. A staff-step move - /// does *not* raise this — it rebases the override atomically instead. + /// cannot carry it. Raised by a chord add whose target event has an override — the + /// rendered staff order can't be read off the raw pitch positions, so the "above + /// the top" pick could be wrong; resolved-spelling-aware stacking is a follow-up. + /// The staff-step move and the insert do *not* raise this — they rebase / carry the + /// override atomically instead. PitchSpellingOverridden, /// An insert-after would land on a musical position already occupied by another /// event in the same voice (the reducer would silently no-op it). The edit is @@ -653,24 +653,19 @@ impl EditorSession { /// position, copying the selected pitch and its rhythmic value. The selection is /// unchanged — the anchored notehead is still there. /// - /// Errors if nothing — or a non-pitch — is selected, the selected pitch carries an - /// authored spelling override (which the copy could not carry — - /// [`EditorError::PitchSpellingOverridden`]), the anchor event is not a metric - /// (musical) event in a metric region, or the position right after it is already - /// occupied in the voice ([`EditorError::InsertSlotOccupied`]) — inserting into a - /// packed voice needs an explicit make-room policy, a follow-up. + /// If the selected pitch carries an **authored spelling override**, the copy carries + /// it too: the insert lands **atomically** with a `RespellPitch` that gives the new + /// note the same spelling, so the copy renders like the original. + /// + /// Errors if nothing — or a non-pitch — is selected, the anchor event is not a + /// metric (musical) event in a metric region, or the position right after it is + /// already occupied in the voice ([`EditorError::InsertSlotOccupied`]) — inserting + /// into a packed voice needs an explicit make-room policy, a follow-up. pub fn insert_note_after_selection(&mut self) -> Result { let selection = self.selection.ok_or(EditorError::NoSelection)?; let TypedObjectId::Pitch(anchor) = selection.source else { return Err(EditorError::WrongSelection { expected: "pitch" }); }; - // The copy carries only the raw pitch value, not a pitch-scoped authored - // spelling, so the new note would render differently from the selected one. - // Refuse until an override-aware insert (atomic InsertEvent + RespellPitch) - // exists. - if has_authored_spelling_override(&self.score, anchor) { - return Err(EditorError::PitchSpellingOverridden); - } let (event_id, anchor_value) = self .event_and_pitch_of(anchor) .ok_or(EditorError::WrongSelection { expected: "pitch" })?; @@ -716,13 +711,14 @@ impl EditorSession { return Err(EditorError::InsertSlotOccupied); } + let new_pitch = self.mint_pitch_id(); let event = Event::Pitched(PitchedEvent { id: self.mint_event_id(), voice, position: EventPosition::Musical(next_position), duration: EventDuration::Musical(duration), pitches: vec![IdentifiedPitch { - id: self.mint_pitch_id(), + id: new_pitch, pitch: anchor_value, }], articulations: vec![], @@ -731,10 +727,29 @@ impl EditorSession { stem: StemConfiguration, grace: None, }); - self.apply(OperationKind::InsertEvent(InsertEventOp { + let insert = OperationKind::InsertEvent(InsertEventOp { staff_instance, event, - })) + }); + + // The new note copies the selected pitch's value; if that pitch has an authored + // spelling, copy it onto the new note too — atomically, so the copy renders the + // same. The respell targets the pitch the insert mints, so the insert must run + // first; the transaction's canonical order (by counter) guarantees that. + match authored_spelling(&self.score, anchor) { + None => self.apply(insert), + Some(spelling) => self.apply_transaction( + "insert note", + Some(TransactionCategory::NoteEntry), + vec![ + insert, + OperationKind::RespellPitch(RespellPitchOp { + pitch: new_pitch, + spelling, + }), + ], + ), + } } /// The staff instance hosting `voice`, but only when its region is metric — the @@ -1739,31 +1754,50 @@ mod tests { } #[test] - fn insert_after_refuses_a_pitch_with_an_authored_spelling_override() { - use epiphany_core::PitchSpelling; - use epiphany_ops::RespellPitchOp; - + fn insert_after_an_overridden_pitch_carries_the_spelling() { let mut session = open_rich(0x5EED); let anchor = last_event_pitch(&session); select_pitch(&mut session, anchor); - // Pin the anchor's spelling; a value-only copy would drop the override and - // render differently, so the insert is refused. + // Pin the anchor's spelling (C4). The copy must carry it, not drop it. session .apply(OperationKind::RespellPitch(RespellPitchOp { pitch: anchor, spelling: PitchSpelling::cmn(CmnNominal::C, 4), })) .expect("the respell applies"); - let logged = session.applied_operations().len(); + let before_ids: std::collections::BTreeSet<_> = + session.score().events.iter().map(Event::id).collect(); + let outcome = session + .insert_note_after_selection() + .expect("the override-carrying insert applies"); + assert!(outcome.graph_changed); + + // The new event's note carries the same authored spelling (C4). + let new_eid = session + .score() + .events + .iter() + .map(Event::id) + .find(|e| !before_ids.contains(e)) + .expect("a new event"); + let mut buf: Vec<&IdentifiedPitch> = Vec::new(); + session + .score() + .events + .get(new_eid) + .unwrap() + .collect_identified_pitches(&mut buf); + let new_pitch = buf[0].id; + let spelling = + authored_spelling(session.score(), new_pitch).expect("the copy carries an override"); + assert!(matches!( + spelling.nominal, + SpellingNominal::Cmn(CmnNominal::C) + )); assert_eq!( - session.insert_note_after_selection(), - Err(EditorError::PitchSpellingOverridden) - ); - assert_eq!( - session.applied_operations().len(), - logged, - "a refused insert logs no dead op" + spelling.octave, 4, + "the copy's spelling matches the original" ); }