diff --git a/crates/epiphany-editor-core/DECISIONS.md b/crates/epiphany-editor-core/DECISIONS.md index dc26958..0b4b910 100644 --- a/crates/epiphany-editor-core/DECISIONS.md +++ b/crates/epiphany-editor-core/DECISIONS.md @@ -130,3 +130,165 @@ one untransposable pitch (`AcousticRealization::AbsoluteHz`, pinned) leaves *every* selected pitch — including the otherwise-transposable one — unmoved (`graph_changed == false`), proving the reducer's own atomicity, not this method's plumbing, is what's carrying the guarantee. + +## The clipboard fragment projection (2026-07-23, T2-W4a) + +Dispatched under `spec/PLAN_EDITOR_APP.md` §Ruling E (granted 2026-07-23) and +`spec/CONTRACT_EDITOR_T2_SELECTION.md` §W4. New module `src/fragment.rs` (the +versioned s-expression format, private/`pub(crate)` types) plus three new +`EditorSession` intents in `lib.rs`: `copy_selection`, `paste_at`, +`paste_over_selection`. Blast radius: `epiphany-editor-core` only — the GUI +clipboard wiring is packet W4b. + +**Grammar: reuse the Text Projection's leaf productions directly, not a +re-derived lookalike.** `Pitch`, `PitchSpelling`, `MusicalDuration`, +`SlurKind`, `TieClass`, `SpanStyle`, `CurvatureOverride`, +`ArticulationMark`/`DynamicMark`/`OrnamentMark`/`StemConfiguration`/`GraceKind`/`StaffPosition` +already implement `epiphany_core::textvalue::TextValue` — the same +`Sexp`/`TextValue`/`read_sexp` machinery `epiphany-textproj`/`epiphany-ops`'s +`textproj_leaf` build on, generated by `epiphany-core`'s `struct_codec!` / +`cstyle_enum_codec!` macros alongside their binary codec. The fragment format +calls `.project()`/`::parse()` on these directly rather than reinventing +their textual shape, and needs **no new dependency on `epiphany-textproj`**: +`epiphany_core::textvalue` is already public, and `epiphany-editor-core` +already depends on `epiphany-core`. What is bespoke is only the +document-shaped container Ruling E says the fragment must NOT borrow from +TP's document grammar: `(epiphany-fragment (0 1 0) VOICES SLURS TIES)`, with +`EventRef { voice: u32, event: u32 }` (a position in the fragment's own +per-voice lanes) as the sole way anything inside a fragment refers to +anything else — never a source `EventId`/`PitchId`/`SlurId`/`TieId`. Worked +example, captured live from `copy_paste_round_trip_preserves_values_with_fresh_ids` +(a two-note voice; the first note carries an authored spelling override, the +second does not — no cross-cutting structures span this range): +``` +(epiphany-fragment (0 1 0) ((voice ((event (ratio 0 1) (ratio 1 4) (pitched ((pitch-entry (pitch (scale-position "cmn-12" (cmn g 0 4)) (acoustic-pitch inherit implicit)) (some (pitch-spelling (cmn d) () 4 (spelling-render-hints false false false false))))) () () () stem-configuration ())) (event (ratio 1 4) (ratio 1 4) (pitched ((pitch-entry (pitch (scale-position "cmn-12" (cmn g 0 4)) (acoustic-pitch inherit implicit)) ())) () () () stem-configuration ()))))) () ()) +``` + +**API, as landed (diverges from the packet's indicative shape in two +places, both flagged there and re-flagged here):** +* `copy_selection(&self) -> Result` — `&self`, not + `&mut self` (it never mutates); `CopyOutcome { fragment: String, dropped: + Vec }`. The indicative shape had `copy_selection() -> + Result` with `dropped` living on `PasteOutcome`; the + required boundary-slur/tie test needs the dropped report at the point the + closure *decision* is made, which is copy time, against the source + selection — a fragment carries no memory of what it isn't, so paste could + never reconstruct this list. `PasteOutcome { outcome: EditOutcome, + events_inserted: usize }` therefore carries no `dropped`. +* `paste_at(&mut self, point: Point, grid: &GridResolution, fragment: &str) + -> Result`, `paste_over_selection(&mut self, + fragment: &str) -> Result` — match the + indicative shape. +* `fragment::{FragmentError, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_EVENTS, + MAX_FRAGMENT_NESTING_DEPTH}` are re-exported at the crate root; + `EditorError::InvalidFragment(fragment::FragmentError)` folds decode + failures in via `From`. New `EditorError` variants: + `PartialTupletSelection { tuplet }` (copy-side closure refusal), + `InsufficientVoicesForFragment { needed, available }` (paste-side lane + policy, below), `EmptyFragment` (a decoded fragment naming no events). + +**Closure v1, as implemented.** Tuplet refusal and the slur/tie +copy-or-drop-and-report decision are both evaluated in `copy_selection` +against the live score (`fragment.rs` never sees a tuplet, a `Score`, or a +selection — it only knows its own already-built `FragmentDocument`). A slur +whose `start_event`/`end_event` both resolve to fragment-local `EventRef`s +is carried into the fragment (`kind`/`curvature_override`/`style` copied +verbatim, since none are identities); exactly one resolving is a boundary +cut, reported via `DroppedItem::Slur`/`Tie`; neither resolving means the +slur/tie has nothing to do with this copy and is silently absent (not a +"drop" — it was never in scope). A partially-covered tuplet's members +short-circuit the whole copy before any fragment content is built. + +**Points Ruling E left underdetermined, decided here (flagged per the +brief):** +1. **Event-kind scope.** Ruling E names no event-kind boundary for + "copies". `copy_selection` scopes to what `make_room` already treats as + copyable — a live, metric `Pitched`/`Rest` event — mirroring the crate's + one other place this exact line gets drawn, rather than inventing a + second boundary. Non-metric events and every other `Event` variant + (`Unpitched`/`Indeterminate`/`Trajectory`/`Graphic`/`Cue`) are out of v1. +2. **A selection member that is not a copyable pitch/event is *skipped*, not + refused — a deliberate departure from `delete_selection`'s hard-refusal + precedent, discovered while writing the required `select_within`-driven + tests.** A rubber-band `select_within` over a real staff routinely also + selects incidental geometry it visually crosses — staff lines, a note's + own stem — verified directly (a tight rect around two on-staff notes + still selected the staff line their noteheads sit on, plus both notes' + stems, five members total for two intended notes). A hard refusal + (`delete_selection`'s own rule: anything that is not a pitch/event errors + the whole call) would make geometric copy nearly unusable in practice. + `copy_selection` instead mirrors `alter_selection`'s established + "silently ignored" precedent (this file, above): a member that is not a + pitch/event, or a pitch/event that does not resolve to a live copyable + note/rest, is skipped; the whole copy still refuses + (`WrongSelection`) only if *nothing* usable remains. A note's own stem + (an `Event`-sourced region distinct from its `Pitch`-sourced notehead) + being incidentally selected alongside its notehead is harmless either + way — both resolve to the same `EventId` and collapse in the + `BTreeSet`. +3. **Multi-lane paste placement is resolved positionally, refusing rather + than guessing when short.** Ruling E specifies the destination staff/voice + for both placement forms but not a policy for a fragment naming more than + one voice lane. This session maps fragment lane `i` onto the destination + staff instance's `i`-th voice (`StaffInstance::voices[i]`) for both + `paste_at` (rooted at the clicked staff instance) and + `paste_over_selection` (rooted at the anchor's own staff instance); + `InsufficientVoicesForFragment` refuses cleanly when the destination has + fewer voices than the fragment has lanes, rather than collapsing lanes + onto one voice or dropping the extras silently. +4. **Slur/tie closure captures into the fragment *format* fully; paste does + not yet replay a captured one.** A fully-contained slur/tie is carried in + the fragment text (satisfying "copies" as a wire-format guarantee — the + data is not lost, and a future paste enhancement needs no format bump to + consume it), but Ruling E's own placement bullet names exactly + `InsertEvent` + `RespellPitch` as what paste mints, and no required test + exercises re-minting a pasted slur/tie (`CreateCrossCutting`, which would + need its own `SlurId`/`TieId` high-water-mark minter alongside + `mint_event_id`/`mint_pitch_id`). Filed here as a named follow-up, not a + silent gap: a fragment carrying a captured slur/tie today decodes and + pastes its notes correctly; the slur/tie itself is inert cargo until a + later packet wires the mint. +5. **A tie's explicit `pitch_pairing` is not carried; captured ties fall + back to `None`** (default enharmonic pairing on any future replay). + `pitch_pairing: Option>` keys on source + `PitchId`s, which the fragment deliberately never carries (Ruling E: no + object ids); remapping specific pairings through per-pitch ordinals was + judged not worth the added grammar for a rare feature with no test + coverage requirement. `TieClass`/`SpanStyle` are still carried in full. + +**Untrusted-input caps**, each a named public constant with its own +value-asserting rejection test in `fragment.rs`: `MAX_FRAGMENT_BYTES = 1 << +20` (byte length, checked first — cheapest rejection); `MAX_FRAGMENT_EVENTS += 4096` (checked after structural parse, since counting needs typed +voices); `MAX_FRAGMENT_NESTING_DEPTH = 64` (`(`-nesting, checked by one +linear, non-recursive scan over the raw text *before* `read_sexp`'s +recursive-descent reader ever sees adversarial input — the scan is +string-literal-aware, so a legitimate catalog-id string's own characters +can never spuriously trip it). An unrecognized major is rejected +immediately after the version triple is read and *before* `Vec::parse` +or its siblings ever attempt to interpret the body — "never partially +parsed" verified directly (a major-1 fragment whose body would not even +parse under today's grammar still reports `UnsupportedVersion`, not a parse +error, proving the gate runs first). + +**Paste atomicity.** `paste_document` builds the *entire* op list — every +lane's make-room clears plus every fragment event's `InsertEvent` (+ +`RespellPitch` for a carried spelling override, insert-before-its-own-respell, +the same discipline `make_room_ops`'s split-tail loop already uses) — before +calling `apply`/`apply_transaction` even once; a refusal discovered while +building it (`make_room` overlapping a nested tuplet) therefore can never +leave a partial mutation, regardless of how the final application is shaped. +Because of that, the packet's own suggested atomicity scenario ("make-room +hits a refusal") cannot distinguish a transaction-committing implementation +from one that applies ops individually — both return `Err` before anything +would be applied either way (verified by construction, not asserted away: +`paste_refuses_cleanly_on_a_nested_tuplet_and_changes_nothing` keeps that +scenario as a real, useful test of a different property). The mutation that +*does* separate the two designs needs a refusal that a **second lane's** +make-room hits *after* a first lane's ops have already been built — +`paste_atomicity_rolls_back_mid_transaction_on_a_second_lanes_refusal` pastes +a two-lane fragment onto a destination whose voice 0 is clear and voice 1 +carries a nested tuplet; mutating the commit to run per-lane inside the +loop (instead of once, over every lane's accumulated ops) lands voice 0's +insert before voice 1's refusal is discovered, changing `canonical_bytes` +even though the call still returns `Err` — killed, confirmed live. diff --git a/crates/epiphany-editor-core/src/fragment.rs b/crates/epiphany-editor-core/src/fragment.rs new file mode 100644 index 0000000..9f6881a --- /dev/null +++ b/crates/epiphany-editor-core/src/fragment.rs @@ -0,0 +1,788 @@ +//! The clipboard fragment projection (Ruling E, `spec/PLAN_EDITOR_APP.md` +//! §Ruling E, GRANTED 2026-07-23): a versioned, values-only s-expression +//! format for [`crate::EditorSession::copy_selection`] / +//! [`crate::EditorSession::paste_at`] / [`crate::EditorSession::paste_over_selection`]. +//! +//! # Grammar +//! +//! ```text +//! fragment ::= "(epiphany-fragment" version voices slurs ties ")" +//! version ::= "(" u32 " " u32 " " u32 ")" ; (major minor patch) +//! voices ::= "(" voice* ")" +//! voice ::= "(voice (" event* "))" +//! event ::= "(event" onset duration content ")" +//! onset ::= ; relative to the fragment origin +//! duration ::= +//! content ::= "rest" fields | "pitched" fields +//! pitches ::= "(" pitch-entry* ")" +//! pitch-entry ::= "(pitch-entry" spelling-override ")" +//! spelling-override ::= "()" | "(some" ")" +//! slurs ::= "(" slur* ")" +//! slur ::= "(slur" event-ref event-ref curvature-override ")" +//! ties ::= "(" tie* ")" +//! tie ::= "(tie" event-ref event-ref ")" +//! event-ref ::= "(event-ref" u32{voice} u32{event} ")" +//! ``` +//! +//! ``, ``, ``, ``, ``, +//! ``, `` are the Text Projection's own +//! ratified leaf/value productions — [`epiphany_core::textvalue::TextValue`] +//! impls this module reuses verbatim (`struct_codec!`/`cstyle_enum_codec!` in +//! `epiphany-core`), never re-derived. What is bespoke here is everything +//! *document*-shaped the Text Projection does not have an opinion on: no +//! document id, no envelopes, no causal contexts, and — the load-bearing +//! difference from every id in the rest of the codebase — **no object ids**. +//! An [`EventRef`] names an event by its position in the fragment's own +//! per-voice lanes (`voice` ordinal, `event` ordinal within that lane), never +//! a source `EventId`/`PitchId`/`SlurId`/`TieId`. Paste mints fresh ids from +//! the session's own minters; nothing in this module ever sees a source id. +//! +//! # Closure v1 (Ruling E, fail closed) +//! +//! This module only *represents* closure's outcome — a fragment either +//! carries a slur/tie (both its endpoints resolved to in-fragment +//! [`EventRef`]s) or it does not exist in the fragment at all. The decision +//! of *which* — and the "report dropped" bookkeeping for what closure +//! discarded — is [`crate::EditorSession::copy_selection`]'s job, against the +//! live selection and score; this module has no access to either. A +//! partially-selected tuplet's refusal is likewise a copy-time decision (this +//! module never sees a tuplet). +//! +//! # Untrusted input +//! +//! Fragments arrive from the OS clipboard. [`decode`] enforces three named +//! caps — [`MAX_FRAGMENT_BYTES`], [`MAX_FRAGMENT_EVENTS`], +//! [`MAX_FRAGMENT_NESTING_DEPTH`] — each checked, in that order, *before* the +//! more expensive check after it (byte length is a slice op; nesting depth is +//! one linear scan that never recurses, so it bounds stack depth *before* +//! [`read_sexp`]'s recursive-descent reader ever runs on attacker input; the +//! event count is checked only after a full structural parse, since it needs +//! typed voices to count). An unrecognized major version is rejected +//! immediately after the version triple is read and *before* any attempt to +//! interpret the body as today's voices/slurs/ties grammar — Ruling E: "never +//! partially parsed". + +use std::fmt; + +use epiphany_core::textvalue::{read_sexp, Sexp, TextError, TextValue}; +use epiphany_core::{ + ArticulationMark, CurvatureOverride, DynamicMark, GraceKind, MusicalDuration, OrnamentMark, + Pitch, PitchSpelling, SlurKind, SpanStyle, StaffPosition, StemConfiguration, TieClass, +}; + +/// The one fragment major version this build encodes and accepts. Ruling E: +/// starting `(0 1 0)`. +pub const FRAGMENT_VERSION: (u32, u32, u32) = (0, 1, 0); + +/// Hard cap on a fragment's encoded byte length. Checked first, on the raw +/// text, before any parsing — the cheapest possible rejection of an +/// unreasonably large clipboard payload. +pub const MAX_FRAGMENT_BYTES: usize = 1 << 20; // 1 MiB + +/// Hard cap on the total number of events a fragment may carry, summed over +/// every voice lane. Checked after structural parsing (counting typed events +/// needs the typed voices). +pub const MAX_FRAGMENT_EVENTS: usize = 4096; + +/// Hard cap on `(`-nesting depth, checked by one linear, non-recursive scan +/// over the raw text *before* [`read_sexp`] — whose reader recurses one stack +/// frame per open paren — ever sees the input. Comfortably above this +/// grammar's legitimate worst case (a fully-populated pitched event with a +/// spelling override nests on the order of a dozen levels); its job is +/// bounding stack depth against adversarial input, not modeling this +/// grammar's real shape precisely. +pub const MAX_FRAGMENT_NESTING_DEPTH: usize = 64; + +/// Why a clipboard fragment could not be decoded. Folded into +/// [`crate::EditorError::InvalidFragment`]. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum FragmentError { + /// The encoded text is over [`MAX_FRAGMENT_BYTES`]. + TooManyBytes { + /// The byte cap. + limit: usize, + /// The text's actual length. + found: usize, + }, + /// The text's `(`-nesting exceeds [`MAX_FRAGMENT_NESTING_DEPTH`]. + NestingTooDeep { + /// The depth cap. + limit: usize, + }, + /// The header named a major version this build does not read (only + /// `FRAGMENT_VERSION`'s major is accepted). Rejected before any attempt + /// to parse the body under today's grammar. + UnsupportedVersion { + /// The unrecognized major version. + major: u32, + }, + /// The text is not a well-formed s-expression, or not a well-formed + /// fragment of a recognized major version. + Malformed(TextError), + /// The fragment's total event count (summed over every voice) is over + /// [`MAX_FRAGMENT_EVENTS`]. + TooManyEvents { + /// The event-count cap. + limit: usize, + /// The fragment's actual total event count. + found: usize, + }, + /// A slur or tie names an `EventRef` outside the fragment's own voices — + /// corrupt or adversarial input; a well-formed fragment produced by + /// `encode` never emits one. + DanglingEventRef, +} + +impl fmt::Display for FragmentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FragmentError::TooManyBytes { limit, found } => { + write!(f, "fragment is {found} bytes, over the {limit}-byte cap") + } + FragmentError::NestingTooDeep { limit } => { + write!(f, "fragment nesting exceeds the {limit}-level cap") + } + FragmentError::UnsupportedVersion { major } => write!( + f, + "fragment major version {major} is not supported (this build reads major {})", + FRAGMENT_VERSION.0 + ), + FragmentError::Malformed(err) => write!(f, "malformed fragment: {err}"), + FragmentError::TooManyEvents { limit, found } => { + write!(f, "fragment has {found} events, over the {limit}-event cap") + } + FragmentError::DanglingEventRef => { + write!(f, "a slur or tie references an event outside the fragment") + } + } + } +} + +impl std::error::Error for FragmentError {} + +impl From for FragmentError { + fn from(err: TextError) -> Self { + FragmentError::Malformed(err) + } +} + +/// The lexical class of `s`, for error messages — mirrors `Sexp`'s own +/// private classifier (duplicated here the same way `epiphany-ops`' +/// `textproj_leaf`/`textvalue_event` each keep their own copy; `Sexp::class` +/// is private to its defining module). +fn class_of(s: &Sexp) -> &'static str { + match s { + Sexp::List(_) => "list", + Sexp::Symbol(_) => "symbol", + Sexp::Int(_) => "integer", + Sexp::Bytes(_) => "byte string", + Sexp::Str(_) => "string", + } +} + +/// A reference to one event **within this fragment**, by its position in a +/// voice lane — never a source `EventId`/`PitchId` (Ruling E: "no object +/// ids"). `voice`/`event` are 0-based indices into [`FragmentDocument::voices`] +/// and that voice's `events`. [`decode`] validates every reference actually +/// resolves before returning a [`FragmentDocument`] — nothing downstream +/// needs to re-check. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub(crate) struct EventRef { + pub(crate) voice: u32, + pub(crate) event: u32, +} + +impl TextValue for EventRef { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::sym("event-ref"), + self.voice.project(), + self.event.project(), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct("event-ref", 2)?; + Ok(EventRef { + voice: u32::parse(&fields[0])?, + event: u32::parse(&fields[1])?, + }) + } +} + +/// One note in a chord: its pitch **value** plus an authored spelling +/// override, if the source pitch carried one (an *inferred* spelling never +/// copies — Ruling E: derived state re-derives). +#[derive(Clone, PartialEq, Debug)] +pub(crate) struct FragmentPitch { + pub(crate) pitch: Pitch, + pub(crate) spelling_override: Option, +} + +impl TextValue for FragmentPitch { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::sym("pitch-entry"), + self.pitch.project(), + self.spelling_override.project(), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct("pitch-entry", 2)?; + Ok(FragmentPitch { + pitch: Pitch::parse(&fields[0])?, + spelling_override: Option::::parse(&fields[1])?, + }) + } +} + +/// An event's content: a rest, or a chord of one or more pitches with its +/// per-event attachments (Ruling E: "notes/rests with their per-event +/// attachments copy"). Scoped to what [`crate::EditorSession::make_room`] +/// itself already treats as copyable — a note or a rest — mirroring the +/// crate's established make-room boundary rather than inventing a new one; +/// Ruling E names no event-kind scope explicitly (flagged in the W4a +/// report). +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum FragmentEventContent { + Rest { + vertical_position: Option, + visible: bool, + }, + Pitched { + pitches: Vec, + articulations: Vec, + dynamic: Option, + ornaments: Vec, + stem: StemConfiguration, + grace: Option, + }, +} + +impl TextValue for FragmentEventContent { + fn project(&self) -> Sexp { + match self { + FragmentEventContent::Rest { + vertical_position, + visible, + } => Sexp::List(vec![ + Sexp::sym("rest"), + vertical_position.project(), + visible.project(), + ]), + FragmentEventContent::Pitched { + pitches, + articulations, + dynamic, + ornaments, + stem, + grace, + } => Sexp::List(vec![ + Sexp::sym("pitched"), + pitches.project(), + articulations.project(), + dynamic.project(), + ornaments.project(), + stem.project(), + grace.project(), + ]), + } + } + fn parse(s: &Sexp) -> Result { + let items = s.as_list().ok_or(TextError::Expected { + expected: "FragmentEventContent", + found: class_of(s), + })?; + let head = items + .first() + .and_then(Sexp::as_symbol) + .ok_or(TextError::Syntax( + "a fragment event content is a list headed by its kind", + ))?; + match head { + "rest" => { + let fields = s.expect_struct("rest", 2)?; + Ok(FragmentEventContent::Rest { + vertical_position: Option::::parse(&fields[0])?, + visible: bool::parse(&fields[1])?, + }) + } + "pitched" => { + let fields = s.expect_struct("pitched", 6)?; + let pitches = Vec::::parse(&fields[0])?; + if pitches.is_empty() { + return Err(TextError::NotCanonical( + "a pitched fragment event must have at least one pitch", + )); + } + Ok(FragmentEventContent::Pitched { + pitches, + articulations: Vec::::parse(&fields[1])?, + dynamic: Option::::parse(&fields[2])?, + ornaments: Vec::::parse(&fields[3])?, + stem: StemConfiguration::parse(&fields[4])?, + grace: Option::::parse(&fields[5])?, + }) + } + found => Err(TextError::UnknownConstructor { + type_name: "FragmentEventContent", + found: found.to_owned(), + }), + } + } +} + +/// One event: a rational onset **relative to the fragment origin**, its +/// written duration, and its content (Ruling E). +#[derive(Clone, PartialEq, Debug)] +pub(crate) struct FragmentEvent { + pub(crate) onset: MusicalDuration, + pub(crate) duration: MusicalDuration, + pub(crate) content: FragmentEventContent, +} + +impl TextValue for FragmentEvent { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::sym("event"), + self.onset.project(), + self.duration.project(), + self.content.project(), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct("event", 3)?; + let onset = MusicalDuration::parse(&fields[0])?; + if onset.0.is_negative() { + return Err(TextError::NotCanonical( + "a fragment event's onset must be non-negative", + )); + } + let duration = MusicalDuration::parse(&fields[1])?; + if !duration.is_positive() { + return Err(TextError::NotCanonical( + "a fragment event's duration must be positive", + )); + } + let content = FragmentEventContent::parse(&fields[2])?; + Ok(FragmentEvent { + onset, + duration, + content, + }) + } +} + +/// One **ordinal-keyed** voice lane (Ruling E: "in per-voice lanes keyed by +/// ordinal (not `VoiceId`)") — its ordinal is this voice's index in +/// [`FragmentDocument::voices`], not a field of this type. +#[derive(Clone, PartialEq, Debug, Default)] +pub(crate) struct FragmentVoice { + pub(crate) events: Vec, +} + +impl TextValue for FragmentVoice { + fn project(&self) -> Sexp { + Sexp::List(vec![Sexp::sym("voice"), self.events.project()]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct("voice", 1)?; + Ok(FragmentVoice { + events: Vec::::parse(&fields[0])?, + }) + } +} + +/// A slur fully inside the copied range — closure v1 carried it (both +/// endpoints resolved to in-fragment events); a boundary-cut slur never +/// reaches this type (it is dropped and reported at copy time instead, see +/// the module doc). +#[derive(Clone, PartialEq, Debug)] +pub(crate) struct FragmentSlur { + pub(crate) start: EventRef, + pub(crate) end: EventRef, + pub(crate) kind: SlurKind, + pub(crate) curvature_override: Option, + pub(crate) style: SpanStyle, +} + +impl TextValue for FragmentSlur { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::sym("slur"), + self.start.project(), + self.end.project(), + self.kind.project(), + self.curvature_override.project(), + self.style.project(), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct("slur", 5)?; + Ok(FragmentSlur { + start: EventRef::parse(&fields[0])?, + end: EventRef::parse(&fields[1])?, + kind: SlurKind::parse(&fields[2])?, + curvature_override: Option::::parse(&fields[3])?, + style: SpanStyle::parse(&fields[4])?, + }) + } +} + +/// A tie fully inside the copied range (see [`FragmentSlur`]'s doc — the +/// same closure rule). The source tie's explicit pitch pairing, if any, is +/// **not** carried (a decision this packet made explicitly, see +/// `DECISIONS.md`): pairing keys on source `PitchId`s, which the fragment +/// deliberately never carries, and remapping it through per-pitch ordinals +/// was judged not worth the added grammar for a v1 whose paste does not yet +/// re-mint cross-cutting structures at all (below). A pasted tie, when +/// pasting is extended to replay one, falls back to `None` — the default +/// enharmonic pairing. +#[derive(Clone, PartialEq, Debug)] +pub(crate) struct FragmentTie { + pub(crate) start: EventRef, + pub(crate) end: EventRef, + pub(crate) class: TieClass, + pub(crate) style: SpanStyle, +} + +impl TextValue for FragmentTie { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::sym("tie"), + self.start.project(), + self.end.project(), + self.class.project(), + self.style.project(), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct("tie", 4)?; + Ok(FragmentTie { + start: EventRef::parse(&fields[0])?, + end: EventRef::parse(&fields[1])?, + class: TieClass::parse(&fields[2])?, + style: SpanStyle::parse(&fields[3])?, + }) + } +} + +/// A parsed, validated fragment body (everything after the version header). +/// **Values, never identities** (Ruling E): no document id, no envelopes, no +/// causal contexts, no object ids anywhere in this type or its fields. +#[derive(Clone, PartialEq, Debug, Default)] +pub(crate) struct FragmentDocument { + pub(crate) voices: Vec, + pub(crate) slurs: Vec, + pub(crate) ties: Vec, +} + +fn project_version((major, minor, patch): (u32, u32, u32)) -> Sexp { + Sexp::List(vec![Sexp::int(major), Sexp::int(minor), Sexp::int(patch)]) +} + +fn parse_version(s: &Sexp) -> Result<(u32, u32, u32), FragmentError> { + let items = s.as_list().ok_or(TextError::Expected { + expected: "version triple", + found: class_of(s), + })?; + let [major, minor, patch] = items else { + return Err(TextError::Arity { + type_name: "version", + expected: 3, + found: items.len(), + } + .into()); + }; + Ok((u32::parse(major)?, u32::parse(minor)?, u32::parse(patch)?)) +} + +/// Renders `document` as fragment text: `(epiphany-fragment (0 1 0) …)`. +/// Never fails — it is built from a live, already-valid session selection, +/// not untrusted input (the caps in [`decode`] are a **read**-side +/// discipline; [`crate::EditorSession::copy_selection`] does not need them on +/// the way out). +pub(crate) fn encode(document: &FragmentDocument) -> String { + Sexp::List(vec![ + Sexp::sym("epiphany-fragment"), + project_version(FRAGMENT_VERSION), + document.voices.project(), + document.slurs.project(), + document.ties.project(), + ]) + .render() +} + +/// Decodes and validates fragment text — untrusted input (Ruling E). See the +/// module doc for the cap ordering and the "never partially parsed" version +/// discipline. +pub(crate) fn decode(text: &str) -> Result { + if text.len() > MAX_FRAGMENT_BYTES { + return Err(FragmentError::TooManyBytes { + limit: MAX_FRAGMENT_BYTES, + found: text.len(), + }); + } + check_nesting_depth(text)?; + + let sexp = read_sexp(text)?; + let items = sexp.as_list().ok_or(TextError::Expected { + expected: "epiphany-fragment", + found: class_of(&sexp), + })?; + let Some((head, rest)) = items.split_first() else { + return Err(TextError::Syntax("an empty fragment").into()); + }; + if head.as_symbol() != Some("epiphany-fragment") { + return Err(TextError::Syntax("a fragment begins `(epiphany-fragment …)`").into()); + } + let [version, voices, slurs, ties] = rest else { + return Err(TextError::Arity { + type_name: "epiphany-fragment", + expected: 4, + found: rest.len(), + } + .into()); + }; + + // The version gate: checked, and dispositioned, before any attempt to + // interpret the body under today's grammar — an unrecognized major is + // rejected cleanly, never partially parsed (Ruling E). + let (major, _minor, _patch) = parse_version(version)?; + if major != FRAGMENT_VERSION.0 { + return Err(FragmentError::UnsupportedVersion { major }); + } + + let voices = Vec::::parse(voices)?; + let slurs = Vec::::parse(slurs)?; + let ties = Vec::::parse(ties)?; + + let total_events: usize = voices.iter().map(|v| v.events.len()).sum(); + if total_events > MAX_FRAGMENT_EVENTS { + return Err(FragmentError::TooManyEvents { + limit: MAX_FRAGMENT_EVENTS, + found: total_events, + }); + } + + let ref_resolves = |r: &EventRef| { + voices + .get(r.voice as usize) + .is_some_and(|v| (r.event as usize) < v.events.len()) + }; + let dangling = slurs + .iter() + .any(|s| !ref_resolves(&s.start) || !ref_resolves(&s.end)) + || ties + .iter() + .any(|t| !ref_resolves(&t.start) || !ref_resolves(&t.end)); + if dangling { + return Err(FragmentError::DanglingEventRef); + } + + Ok(FragmentDocument { + voices, + slurs, + ties, + }) +} + +/// A linear, non-recursive scan bounding `(`-nesting depth *before* +/// [`read_sexp`]'s recursive-descent reader runs — see [`MAX_FRAGMENT_NESTING_DEPTH`]. +/// Parens inside a quoted string are not counted (a legitimate catalog-id +/// string, the only string-shaped leaf this grammar's leaf productions ever +/// emit, must not spuriously trip the cap); byte strings (`#x…`) never +/// contain parens by grammar, so they need no special handling. +fn check_nesting_depth(text: &str) -> Result<(), FragmentError> { + let mut depth: usize = 0; + let mut in_string = false; + let mut escaped = false; + for &b in text.as_bytes() { + if in_string { + if escaped { + escaped = false; + } else if b == b'\\' { + escaped = true; + } else if b == b'"' { + in_string = false; + } + continue; + } + match b { + b'"' => in_string = true, + b'(' => { + depth += 1; + if depth > MAX_FRAGMENT_NESTING_DEPTH { + return Err(FragmentError::NestingTooDeep { + limit: MAX_FRAGMENT_NESTING_DEPTH, + }); + } + } + b')' => depth = depth.saturating_sub(1), + _ => {} + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use epiphany_core::{AcousticPitch, AcousticRealization, TuningReference}; + use epiphany_core::{ + CmnNominal, PitchSpaceId, PitchSpacePosition, RationalTime, ScalePosition, + }; + + fn a_pitch() -> Pitch { + Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("cmn-12"), + position: PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave: 4, + }, + }, + acoustic: AcousticPitch { + tuning: TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + } + } + + fn a_duration(n: i64, d: i64) -> MusicalDuration { + MusicalDuration(RationalTime::new(n, d).unwrap()) + } + + fn rest_event(onset: i64) -> FragmentEvent { + FragmentEvent { + onset: a_duration(onset, 4), + duration: a_duration(1, 4), + content: FragmentEventContent::Rest { + vertical_position: None, + visible: true, + }, + } + } + + fn pitched_event(onset: i64) -> FragmentEvent { + FragmentEvent { + onset: a_duration(onset, 4), + duration: a_duration(1, 4), + content: FragmentEventContent::Pitched { + pitches: vec![FragmentPitch { + pitch: a_pitch(), + spelling_override: Some(PitchSpelling::cmn(CmnNominal::D, 4)), + }], + articulations: vec![], + dynamic: None, + ornaments: vec![], + stem: StemConfiguration, + grace: None, + }, + } + } + + #[test] + fn a_small_document_round_trips_through_text() { + let document = FragmentDocument { + voices: vec![FragmentVoice { + events: vec![pitched_event(0), rest_event(1)], + }], + slurs: vec![FragmentSlur { + start: EventRef { voice: 0, event: 0 }, + end: EventRef { voice: 0, event: 1 }, + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::default(), + }], + ties: vec![FragmentTie { + start: EventRef { voice: 0, event: 0 }, + end: EventRef { voice: 0, event: 1 }, + class: TieClass::Standard, + style: SpanStyle::default(), + }], + }; + let text = encode(&document); + assert!(text.starts_with("(epiphany-fragment (0 1 0) ")); + let decoded = decode(&text).expect("a well-formed fragment decodes"); + assert_eq!(decoded, document); + } + + #[test] + fn decode_rejects_a_fragment_over_the_byte_cap() { + let text = "a".repeat(MAX_FRAGMENT_BYTES + 1); + assert_eq!( + decode(&text), + Err(FragmentError::TooManyBytes { + limit: MAX_FRAGMENT_BYTES, + found: text.len(), + }) + ); + } + + #[test] + fn decode_rejects_a_fragment_over_the_event_cap() { + let document = FragmentDocument { + voices: vec![FragmentVoice { + events: (0..=MAX_FRAGMENT_EVENTS as i64).map(rest_event).collect(), + }], + slurs: vec![], + ties: vec![], + }; + let text = encode(&document); + let found = MAX_FRAGMENT_EVENTS + 1; + assert_eq!( + decode(&text), + Err(FragmentError::TooManyEvents { + limit: MAX_FRAGMENT_EVENTS, + found, + }) + ); + } + + #[test] + fn decode_rejects_nesting_over_the_depth_cap_before_parsing() { + let text = format!( + "{}{}", + "(".repeat(MAX_FRAGMENT_NESTING_DEPTH + 1), + ")".repeat(MAX_FRAGMENT_NESTING_DEPTH + 1) + ); + assert_eq!( + decode(&text), + Err(FragmentError::NestingTooDeep { + limit: MAX_FRAGMENT_NESTING_DEPTH, + }) + ); + } + + #[test] + fn decode_rejects_an_unrecognized_major_cleanly() { + // A major-1 header over a body that would not even parse under + // today's grammar (three atoms, not three lists) — proving the + // version gate rejects before any attempt to interpret the body. + let text = "(epiphany-fragment (1 0 0) x y z)"; + assert_eq!( + decode(text), + Err(FragmentError::UnsupportedVersion { major: 1 }) + ); + } + + #[test] + fn decode_rejects_a_dangling_event_ref() { + let document = FragmentDocument { + voices: vec![FragmentVoice { + events: vec![rest_event(0)], + }], + slurs: vec![FragmentSlur { + start: EventRef { voice: 0, event: 0 }, + end: EventRef { voice: 0, event: 5 }, // out of range + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::default(), + }], + ties: vec![], + }; + let text = encode(&document); + assert_eq!(decode(&text), Err(FragmentError::DanglingEventRef)); + } +} diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index de63e44..2c8abe5 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -45,8 +45,12 @@ //! produces a [`RenderIR`]; turning that into pixels is the renderer's job. mod barriers; +mod fragment; pub use barriers::ActiveExtension; +pub use fragment::{ + FragmentError, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_EVENTS, MAX_FRAGMENT_NESTING_DEPTH, +}; use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -57,10 +61,10 @@ use epiphany_core::{ 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, + RegionTimeModel, ReplicaId, ScalePosition, Score, SlurId, SpellingDirective, SpellingNominal, SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration, - TimeSignature, TimeSignatureDisplay, TransactionId, TranspositionInterval, TuningReference, - TupletId, TypedObjectId, VoiceId, WallClockTime, + TieId, TimeSignature, TimeSignatureDisplay, TransactionId, TranspositionInterval, + TuningReference, TupletId, TypedObjectId, VoiceId, WallClockTime, }; use epiphany_layout_ir::{ active_clef_or, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, @@ -288,6 +292,43 @@ pub struct EditOutcome { pub selection_preserved: bool, } +/// A cross-cutting structure [`EditorSession::copy_selection`] could not +/// carry into the fragment because only one of its two endpoints was inside +/// the selection (Ruling E closure v1: "fail closed, report dropped"). The +/// item itself — a slur or a tie fully inside the selection instead copies +/// into the fragment text; it is never named here. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum DroppedItem { + /// A slur cut by the selection boundary, named by its source id (for + /// diagnostics only — the id never appears in the fragment text). + Slur(SlurId), + /// A tie cut by the selection boundary. + Tie(TieId), +} + +/// What [`EditorSession::copy_selection`] produced. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CopyOutcome { + /// The fragment text (`(epiphany-fragment (0 1 0) …)`, Ruling E) — pass + /// this to [`EditorSession::paste_at`] / [`EditorSession::paste_over_selection`]. + pub fragment: String, + /// Every slur/tie closure v1 dropped because only one endpoint was + /// inside the selection — the "report dropped" channel. + pub dropped: Vec, +} + +/// What a [`EditorSession::paste_at`] / [`EditorSession::paste_over_selection`] +/// inserted. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct PasteOutcome { + /// The underlying `apply`/`apply_transaction` outcome the paste's single + /// atomic transaction produced. + pub outcome: EditOutcome, + /// How many fragment events were inserted (one + /// [`OperationKind::InsertEvent`] each). + pub events_inserted: usize, +} + /// An editing error. None of these mutate the session. #[derive(Clone, PartialEq, Eq, Debug)] pub enum EditorError { @@ -367,6 +408,39 @@ pub enum EditorError { /// The prohibited operation class. operation: OperationKindTag, }, + // --- Fragment (copy/paste) errors: Ruling E, `spec/PLAN_EDITOR_APP.md`. --- + /// [`EditorSession::copy_selection`] refuses: the selection covers some, + /// but not all, of a live tuplet's members. Closure v1's own refusal + /// discipline (the reducer's own atomicity, applied at clipboard scale) + /// — a tuplet is atomic, so a fragment cannot represent part of one. + /// Nothing is minted; the selection is unchanged. + PartialTupletSelection { + /// The tuplet only partially covered by the selection. + tuplet: TupletId, + }, + /// A pasted fragment's clipboard text was malformed, oversized, or an + /// unsupported version (Ruling E: "Fragments arrive from the OS + /// clipboard; they are input, not trusted state"). Nothing changes. + InvalidFragment(fragment::FragmentError), + /// A fragment names more voice **lanes** than the paste destination's + /// staff instance has voices to receive them. Ruling E names the + /// destination staff/voice but not a multi-lane mapping policy; this + /// session resolves it positionally — fragment lane `i` pastes into the + /// destination staff instance's `i`-th voice — and refuses rather than + /// guessing when the destination falls short (flagged in the W4a + /// report as a resolved ambiguity). + InsufficientVoicesForFragment { + /// How many voice lanes the fragment names. + needed: usize, + /// How many voices the destination staff instance actually has. + available: usize, + }, + /// A pasted fragment decoded cleanly but named no events to insert + /// (every voice lane was empty). Nothing changes. A fragment produced by + /// [`EditorSession::copy_selection`] never has this shape (a copy always + /// requires a non-empty selection); this guards a hand-crafted or + /// corrupt fragment. + EmptyFragment, } impl fmt::Display for EditorError { @@ -420,12 +494,32 @@ impl fmt::Display for EditorError { (tombstoning that extension's data)" ) } + EditorError::PartialTupletSelection { tuplet } => write!( + f, + "the selection covers only some members of tuplet {tuplet:?}; a tuplet is \ + atomic and cannot be partially copied" + ), + EditorError::InvalidFragment(err) => write!(f, "invalid clipboard fragment: {err}"), + EditorError::InsufficientVoicesForFragment { needed, available } => write!( + f, + "the fragment names {needed} voice lane(s) but the destination staff instance \ + has only {available}" + ), + EditorError::EmptyFragment => { + f.write_str("the fragment names no events to paste") + } } } } impl std::error::Error for EditorError {} +impl From for EditorError { + fn from(err: fragment::FragmentError) -> Self { + EditorError::InvalidFragment(err) + } +} + /// A headless editor session over a score. A GUI opens one, queries its render and /// hit-test map to draw and to resolve clicks, and drives edits through it. pub struct EditorSession { @@ -1993,6 +2087,381 @@ impl EditorSession { } } + /// Copies the current selection to the clipboard fragment projection + /// (Ruling E, `spec/PLAN_EDITOR_APP.md` §Ruling E): per-event **values** + /// (pitches, durations, an authored spelling override if the source + /// pitch carries one, relative onsets), in per-voice lanes keyed by + /// ordinal — never source ids. Paste mints fresh ones. + /// + /// **Closure v1** (fail closed, report dropped): a note/rest copies with + /// its per-event attachments; a slur or tie copies iff **both** + /// endpoints are in the selection, else it is omitted from the fragment + /// and named in [`CopyOutcome::dropped`]; a selection covering some but + /// not all of a live tuplet's members refuses the whole copy + /// ([`EditorError::PartialTupletSelection`]) — a tuplet is atomic, so a + /// fragment cannot represent part of one. + /// + /// Errors with [`EditorError::NoSelection`] if nothing is selected, or + /// [`EditorError::WrongSelection`] if a member is not a pitch/event, or + /// resolves to an event this session cannot yet copy — a non-metric + /// event, or a kind other than a note/rest (mirrors `Self::make_room`'s + /// own note/rest-only, metric-only scope; Ruling E names no event-kind + /// scope explicitly). + pub fn copy_selection(&self) -> Result { + if self.selection.members.is_empty() { + return Err(EditorError::NoSelection); + } + // A rubber-band `select_within` routinely also catches incidental + // geometry alongside the intended notes — staff lines, a barline, a + // directly-selected slur or tuplet-as-object — since those render + // real hit regions too (proven directly by this packet's own + // geometry-driven tests). Ruling E names no policy for a mixed + // selection; copy's own resolution, mirroring `alter_selection`'s + // established "silently ignored" precedent (`DECISIONS.md`) rather + // than `delete_selection`'s hard refusal: a member that is not a + // pitch/event (or a pitch/event that does not resolve to a live, + // metric note or rest) is skipped, not refused — copy is "copy the + // notes in this range", not "refuse unless the range is surgically + // exact". The whole copy still refuses if *nothing* usable remains. + let mut event_ids: BTreeSet = BTreeSet::new(); + for member in &self.selection.members { + let event_id = match member.source { + TypedObjectId::Pitch(pitch) => { + self.event_and_pitch_of(pitch).map(|(event, _)| event) + } + TypedObjectId::Event(event) => Some(event), + _ => None, + }; + if let Some(event_id) = event_id { + event_ids.insert(event_id); + } + } + if event_ids.is_empty() { + return Err(EditorError::WrongSelection { + expected: "a pitch or event", + }); + } + + // Closure v1: a partially-selected tuplet refuses the whole copy — + // the reducer's own atomicity discipline, at clipboard scale. + let mut covered_tuplets: BTreeSet = BTreeSet::new(); + for &event_id in &event_ids { + covered_tuplets.extend(self.tuplets_containing(event_id)); + } + for tuplet in covered_tuplets { + let members = self.tuplet_members(tuplet); + if !members.iter().all(|m| event_ids.contains(m)) { + return Err(EditorError::PartialTupletSelection { tuplet }); + } + } + + // Only a metric note/rest is copyable in v1 (mirrors `make_room`'s + // own scope; Ruling E names no event-kind scope explicitly). This + // pass records every usable member's voice and onset (skipping, per + // the policy above, anything that resolves but is not a live metric + // note/rest), so the origin (the earliest onset) can be found before + // any fragment event is built. + let mut located: Vec<(EventId, VoiceId, MusicalPosition)> = + Vec::with_capacity(event_ids.len()); + for &event_id in &event_ids { + let Some(event) = self.score.events.get(event_id) else { + continue; + }; + if !matches!(event, Event::Pitched(_) | Event::Rest(_)) { + continue; + } + let EventPosition::Musical(at) = event.position().clone() else { + continue; + }; + located.push((event_id, event.voice(), at)); + } + if located.is_empty() { + return Err(EditorError::WrongSelection { + expected: "a metric note or rest", + }); + } + let origin = located + .iter() + .map(|(_, _, at)| at.clone()) + .min() + .expect("located is non-empty, checked above"); + + let mut by_voice: BTreeMap> = BTreeMap::new(); + for (event_id, voice, at) in located { + by_voice.entry(voice).or_default().push((event_id, at)); + } + + // Fragment-local refs, keyed by source EventId — built once so the + // slur/tie pass below can resolve both endpoints without knowing + // anything about voices or ordering itself. + let mut refs: BTreeMap = BTreeMap::new(); + let mut voices = Vec::with_capacity(by_voice.len()); + for (voice_ordinal, (_voice, mut events)) in by_voice.into_iter().enumerate() { + events.sort_by(|a, b| a.1.cmp(&b.1)); + for (event_ordinal, (event_id, _)) in events.iter().enumerate() { + refs.insert( + *event_id, + fragment::EventRef { + voice: voice_ordinal as u32, + event: event_ordinal as u32, + }, + ); + } + let fragment_events = events + .iter() + .map(|(event_id, _)| self.fragment_event(*event_id, &origin)) + .collect(); + voices.push(fragment::FragmentVoice { + events: fragment_events, + }); + } + + // Closure v1: both endpoints in the selection carries the slur/tie + // into the fragment; exactly one is a boundary cut — dropped and + // reported; neither is irrelevant to this copy. + let mut slurs = Vec::new(); + let mut ties = Vec::new(); + let mut dropped = Vec::new(); + for slur in &self.score.cross_cutting.slurs { + match (refs.get(&slur.start_event), refs.get(&slur.end_event)) { + (Some(&start), Some(&end)) => slurs.push(fragment::FragmentSlur { + start, + end, + kind: slur.kind, + curvature_override: slur.curvature_override.clone(), + style: slur.style.clone(), + }), + (Some(_), None) | (None, Some(_)) => dropped.push(DroppedItem::Slur(slur.id)), + (None, None) => {} + } + } + for tie in &self.score.cross_cutting.ties { + match (refs.get(&tie.start_event), refs.get(&tie.end_event)) { + (Some(&start), Some(&end)) => ties.push(fragment::FragmentTie { + start, + end, + class: tie.class.clone(), + style: tie.style.clone(), + }), + (Some(_), None) | (None, Some(_)) => dropped.push(DroppedItem::Tie(tie.id)), + (None, None) => {} + } + } + + let document = fragment::FragmentDocument { + voices, + slurs, + ties, + }; + Ok(CopyOutcome { + fragment: fragment::encode(&document), + dropped, + }) + } + + /// The value-only [`fragment::FragmentEvent`] for the live `event_id`, + /// its onset expressed relative to `origin` — [`Self::copy_selection`]'s + /// per-event projection. `event_id` must already be known Pitched/Rest + /// and metric (checked by the caller). + fn fragment_event( + &self, + event_id: EventId, + origin: &MusicalPosition, + ) -> fragment::FragmentEvent { + let event = self + .score + .events + .get(event_id) + .expect("event_id came from the current score (copy_selection)"); + let EventPosition::Musical(at) = event.position().clone() else { + unreachable!("checked Musical in copy_selection") + }; + let EventDuration::Musical(duration) = event.duration().clone() else { + unreachable!("checked Musical in copy_selection") + }; + let onset = span_between(origin, &at); + let content = match event { + Event::Rest(rest) => fragment::FragmentEventContent::Rest { + vertical_position: rest.vertical_position, + visible: rest.visible, + }, + Event::Pitched(pe) => fragment::FragmentEventContent::Pitched { + pitches: pe + .pitches + .iter() + .map(|ip| fragment::FragmentPitch { + pitch: ip.pitch.clone(), + spelling_override: authored_spelling(&self.score, ip.id), + }) + .collect(), + articulations: pe.articulations.clone(), + dynamic: pe.dynamic.clone(), + ornaments: pe.ornaments.clone(), + stem: pe.stem.clone(), + grace: pe.grace.clone(), + }, + _ => unreachable!("checked Pitched|Rest in copy_selection"), + }; + fragment::FragmentEvent { + onset, + duration, + content, + } + } + + /// Pastes `fragment` at a world `point` on `grid` — pencil-style, via + /// [`Self::position_at`] (the target musical position) and + /// [`Self::staff_pitch_at`] (the target staff instance; only its + /// `staff_instance` is used — the fragment's own pitches replace + /// whatever height was clicked). Ruling E: "`paste_at(point, &grid)` + /// (pencil-style via `position_at`) … make-room overwrite …, atomic + /// transaction". + /// + /// The fragment's voice **lanes** map positionally onto the clicked + /// staff instance's voices (lane 0 → its first voice, and so on); see + /// [`EditorError::InsufficientVoicesForFragment`] for what happens when + /// the destination has fewer. + pub fn paste_at( + &mut self, + point: Point, + grid: &GridResolution, + fragment: &str, + ) -> Result { + let pitch = self + .staff_pitch_at(point) + .ok_or(EditorError::NoInsertTarget)?; + let placed = self + .position_at(point, grid) + .ok_or(EditorError::NoInsertTarget)?; + let (region, si) = self + .score + .staff_instances() + .find(|(_, si)| si.id == pitch.staff_instance) + .ok_or(EditorError::NoInsertTarget)?; + if region != placed.region { + return Err(EditorError::NoInsertTarget); + } + let voice_ids: Vec = si.voices.iter().map(|v| v.id).collect(); + self.paste_document(fragment, pitch.staff_instance, &voice_ids, placed.position) + } + + /// Pastes `fragment` at the **anchor** member's own onset, in its own + /// voice (Ruling E). The lane→voice mapping is [`Self::paste_at`]'s same + /// positional rule, rooted at the anchor's staff instance. + /// + /// Errors with [`EditorError::NoSelection`] if nothing is selected, or + /// [`EditorError::WrongSelection`] if the anchor is not a pitch/event + /// resolving to a live metric note or rest. + pub fn paste_over_selection(&mut self, fragment: &str) -> Result { + let selection = self + .selection + .anchor() + .copied() + .ok_or(EditorError::NoSelection)?; + let event_id = match selection.source { + TypedObjectId::Pitch(pitch) => self.event_and_pitch_of(pitch).map(|(e, _)| e), + TypedObjectId::Event(event) => Some(event), + _ => None, + } + .ok_or(EditorError::WrongSelection { + expected: "pitch or event", + })?; + let (voice, at) = { + let event = self + .score + .events + .get(event_id) + .ok_or(EditorError::WrongSelection { + expected: "pitch or event", + })?; + let EventPosition::Musical(at) = event.position().clone() else { + return Err(EditorError::WrongSelection { + expected: "metric event", + }); + }; + (event.voice(), at) + }; + let staff_instance = + self.metric_staff_instance_of_voice(voice) + .ok_or(EditorError::WrongSelection { + expected: "metric event", + })?; + let si = self + .score + .staff_instances() + .find(|(_, si)| si.id == staff_instance) + .map(|(_, si)| si) + .expect("metric_staff_instance_of_voice resolved a real staff instance"); + let voice_ids: Vec = si.voices.iter().map(|v| v.id).collect(); + self.paste_document(fragment, staff_instance, &voice_ids, at) + } + + /// The shared tail of [`Self::paste_at`] / [`Self::paste_over_selection`]: + /// decodes `fragment`, maps each voice lane onto `voice_ids` positionally + /// (lane `i` → `voice_ids[i]`), clears each lane's own span under + /// [`Self::make_room`]'s overwrite policy, and mints `InsertEvent` (+ + /// `RespellPitch` for a carried authored spelling) for every fragment + /// event — all as **one** atomic transaction (Ruling E), so a refusal + /// anywhere (e.g. a lane's make-room hits a nested tuplet) rolls back + /// the whole paste. + fn paste_document( + &mut self, + fragment: &str, + staff_instance: StaffInstanceId, + voice_ids: &[VoiceId], + target_start: MusicalPosition, + ) -> Result { + let document = fragment::decode(fragment)?; + if document.voices.len() > voice_ids.len() { + return Err(EditorError::InsufficientVoicesForFragment { + needed: document.voices.len(), + available: voice_ids.len(), + }); + } + + let mut minter = self.minter(); + let mut ops: Vec = Vec::new(); + let mut events_inserted = 0usize; + for (&voice, lane) in voice_ids.iter().zip(&document.voices) { + if lane.events.is_empty() { + continue; + } + let lane_end = lane + .events + .iter() + .map(|e| target_start.clone() + e.onset.clone() + e.duration.clone()) + .max() + .expect("lane.events is non-empty, checked above"); + let room = self.make_room(voice, &target_start, &lane_end, None)?; + ops.extend(self.make_room_ops(room, staff_instance, &mut minter)); + for fe in &lane.events { + let position = target_start.clone() + fe.onset.clone(); + let event_id = minter.event(); + let (event, respells) = + fragment_event_to_op_event(event_id, voice, position, fe, &mut minter); + ops.push(OperationKind::InsertEvent(InsertEventOp { + staff_instance, + event, + })); + events_inserted += 1; + ops.extend(respells); + } + } + if ops.is_empty() { + return Err(EditorError::EmptyFragment); + } + + let outcome = if ops.len() == 1 { + self.apply(ops.into_iter().next().expect("len == 1"))? + } else { + self.apply_transaction("paste", Some(TransactionCategory::NoteEntry), ops)? + }; + Ok(PasteOutcome { + outcome, + events_inserted, + }) + } + /// Sets the selected event's written **duration** (a notation duration-palette /// gesture; the selection may be a notehead or a rest/stem). Shrinking just frees /// the space after the event; **lengthening makes room** under the overwrite policy @@ -2768,6 +3237,81 @@ fn note_event( } } +/// Builds the [`Event`] a pasted fragment event mints, plus the +/// `RespellPitch` op for every pitch that carried an authored spelling +/// override — the insert precedes its own respells in the returned `Vec`, so +/// appending them in order to a transaction's op list gives every respell +/// the pitch it targets (the same discipline `EditorSession::make_room_ops`'s +/// split-tail loop uses). A free function, not a method: it reads nothing +/// from a session, only `fe` and the fresh ids `minter` mints. +fn fragment_event_to_op_event( + id: EventId, + voice: VoiceId, + position: MusicalPosition, + fe: &fragment::FragmentEvent, + minter: &mut Minter, +) -> (Event, Vec) { + let position = EventPosition::Musical(position); + let duration = EventDuration::Musical(fe.duration.clone()); + match &fe.content { + fragment::FragmentEventContent::Rest { + vertical_position, + visible, + } => ( + Event::Rest(epiphany_core::Rest { + id, + voice, + position, + duration, + vertical_position: *vertical_position, + visible: *visible, + }), + Vec::new(), + ), + fragment::FragmentEventContent::Pitched { + pitches, + articulations, + dynamic, + ornaments, + stem, + grace, + } => { + let mut respells = Vec::with_capacity(pitches.len()); + let identified: Vec = pitches + .iter() + .map(|fp| { + let pid = minter.pitch(); + if let Some(spelling) = &fp.spelling_override { + respells.push(OperationKind::RespellPitch(RespellPitchOp { + pitch: pid, + spelling: spelling.clone(), + })); + } + IdentifiedPitch { + id: pid, + pitch: fp.pitch.clone(), + } + }) + .collect(); + ( + Event::Pitched(PitchedEvent { + id, + voice, + position, + duration, + pitches: identified, + articulations: articulations.clone(), + dynamic: dynamic.clone(), + ornaments: ornaments.clone(), + stem: stem.clone(), + grace: grace.clone(), + }), + respells, + ) + } + } +} + /// 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 @@ -7053,4 +7597,748 @@ mod tests { "the ordinary pitch must NOT move just because a sibling target refused" ); } + + // --- Fragment (copy/paste) tests: Ruling E, `spec/PLAN_EDITOR_APP.md` §W4a. --- + + /// A tiny hand-built plain score: one staff/instrument, one metric region + /// on one staff instance, one voice with exactly `count` quarter-note + /// events at onsets `0/4, 1/4, …`. `valid_score`'s own event count is + /// seed-random (2..=4, `generators.rs`); fragment tests need a guaranteed + /// count to select a strict sub-range from, so this mirrors `valid_score`'s + /// shape with a fixed one instead. + fn small_metric_score(count: i64) -> Score { + use epiphany_core::{ + Canvas, EventArena, IdentityContext, Instrument, InstrumentId, MetricTimeModel, Region, + RegionContent, Staff, StaffBasedContent, StaffExtent, StaffLineConfiguration, + TimeAnchor, TimeExtent, Voice, + }; + let replica = ReplicaId(0x4321); + let mut idc = IdentityContext::new(replica); + let staff_id: StaffId = idc.mint(); + let instrument: InstrumentId = idc.mint(); + let region_id: RegionId = idc.mint(); + let instance_id: StaffInstanceId = idc.mint(); + let voice_id: VoiceId = idc.mint(); + + let mut arena = EventArena::new(); + let mut voice = Voice::user(voice_id); + for index in 0..count { + let eid: EventId = idc.mint(); + let pid: PitchId = idc.mint(); + arena + .insert(Event::Pitched(PitchedEvent { + id: eid, + voice: voice_id, + position: EventPosition::Musical(MusicalPosition( + RationalTime::new(index, 4).unwrap(), + )), + duration: EventDuration::Musical(MusicalDuration( + RationalTime::new(1, 4).unwrap(), + )), + pitches: vec![IdentifiedPitch { + id: pid, + // On the staff (treble clef's second line), so it never + // needs a ledger line — geometry tests below select over + // rendered rects and must not catch a neighbor's ledger. + pitch: cmn_pitch(CmnNominal::G, 4), + }], + articulations: vec![], + dynamic: None, + ornaments: vec![], + stem: StemConfiguration, + grace: None, + })) + .expect("fresh event id"); + voice.events.push(eid); + } + let mut instance = StaffInstance::new(instance_id, staff_id); + instance.voices.push(voice); + + let region = Region { + id: region_id, + time_model: RegionTimeModel::Metric(MetricTimeModel::default()), + content: RegionContent::StaffBased(StaffBasedContent { + staff_instances: vec![instance], + ..Default::default() + }), + time_extent: TimeExtent { + start: TimeAnchor::WallClock { + time: WallClockTime(0), + }, + end: TimeAnchor::WallClock { + time: WallClockTime(1_000_000), + }, + }, + staff_extent: StaffExtent { + staves: vec![staff_id], + }, + local_tempo_map: None, + permits_spanning_slurs: false, + }; + + let mut score = Score::empty(idc.clone()); + score.identity = idc; + score.staves = vec![Staff { + id: staff_id, + name: String::from("staff"), + abbreviation: None, + instrument, + default_staff_lines: StaffLineConfiguration::default(), + group: None, + default_clef: Clef::treble(), + }]; + score.instruments = vec![Instrument::new(instrument, String::from("instrument"))]; + score.events = arena; + score.canvas = Canvas { + regions: vec![region], + ..Default::default() + }; + score + } + + /// The bounding box covering every rendered glyph sourced from any of + /// `pitches` — the geometry `select_within` is exercised against (same + /// technique as `select_within_collapses_a_ledgered_notes_ledger_lines_to_one_member`). + fn rect_covering_pitches(session: &EditorSession, pitches: &[PitchId]) -> BoundingBox { + session + .hit_test() + .regions + .iter() + .filter(|r| matches!(r.source, TypedObjectId::Pitch(p) if pitches.contains(&p))) + .map(|r| r.shape.aabb()) + .fold( + BoundingBox::new( + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ), + |acc, b| { + BoundingBox::new( + acc.left.0.min(b.left.0), + acc.bottom.0.min(b.bottom.0), + acc.right.0.max(b.right.0), + acc.top.0.max(b.top.0), + ) + }, + ) + } + + /// The id of `eid`'s first pitch. + fn first_pitch_of(session: &EditorSession, eid: EventId) -> PitchId { + let mut buf: Vec<&IdentifiedPitch> = Vec::new(); + session + .score() + .events + .get(eid) + .expect("live event") + .collect_identified_pitches(&mut buf); + buf.first().expect("a pitch").id + } + + /// Round-trip: `select_within` over rendered geometry selects two + /// adjacent notes (one carrying an authored spelling override), copy, + /// paste at a position far past the fixture's own content. The pasted + /// events' VALUES — pitches (including the carried spelling override via + /// a fresh `RespellPitch`), durations, and their onset *relative to each + /// other* — equal the source's, and their ids are fresh. + /// + /// Mutation f1 (drop the `RespellPitch` carry, either the capture in + /// `EditorSession::fragment_event` or the mint in + /// `fragment_event_to_op_event`): the spelling-fidelity assertion below + /// dies. + #[test] + fn copy_paste_round_trip_preserves_values_with_fresh_ids() { + let mut session = + EditorSession::open(small_metric_score(4), Box::new(StubSolver)).expect("renders"); + let region = a_clean_metric_region(&session); + let voice = primary_voice(&session, region); + let before = voice_events(&session, voice); + assert_eq!(before.len(), 4); + + // Pin the first note's spelling with an explicit user override — copy + // must carry it via a fresh `RespellPitch`, not the source pitch id. + let source_pitch = first_pitch_of(&session, before[0].0); + let override_spelling = PitchSpelling::cmn(CmnNominal::D, 4); + session + .apply(OperationKind::RespellPitch(RespellPitchOp { + pitch: source_pitch, + spelling: override_spelling.clone(), + })) + .expect("the respell applies"); + + // Select the first two notes over their rendered geometry. The rect + // is built tightly around those two notes' own glyphs, but a real + // rubber-band `select_within` over a staff also sweeps in incidental + // geometry (the staff lines it visually crosses, each note's own + // stem) — `copy_selection` skips anything that is not a pitch/event + // resolving to a copyable note (see its doc comment); only the two + // targeted notes end up in the fragment, asserted below via the + // fragment's own event count rather than the raw selection's. + let pitch0 = first_pitch_of(&session, before[0].0); + let pitch1 = first_pitch_of(&session, before[1].0); + let rect = rect_covering_pitches(&session, &[pitch0, pitch1]); + session.select_within(rect); + + let copy = session.copy_selection().expect("the selection copies"); + assert!( + copy.dropped.is_empty(), + "nothing spans the selection boundary in this fixture" + ); + let decoded = fragment::decode(©.fragment).expect("a well-formed fragment"); + let fragment_event_count: usize = decoded.voices.iter().map(|v| v.events.len()).sum(); + assert_eq!( + fragment_event_count, 2, + "exactly the two targeted notes made it into the fragment" + ); + + // Paste far past the fixture's own content (its four notes span + // [0, 1)) — a clearly distinct destination. + let far = MusicalPosition(RationalTime::new(50, 4).unwrap()); + let (_, _, origin_y) = region_staff_line(&session, region); + let at = click_for_position(&session, region, &far, origin_y + 1.0); + let source_ids: BTreeSet = before.iter().map(|(id, _, _)| *id).collect(); + + let outcome = session + .paste_at(at, &grid(1, 4), ©.fragment) + .expect("the paste applies"); + assert_eq!(outcome.events_inserted, 2); + + let mut pasted: Vec<(EventId, MusicalPosition, MusicalDuration)> = + voice_events(&session, voice) + .into_iter() + .filter(|(id, _, _)| !source_ids.contains(id)) + .collect(); + pasted.sort_by(|a, b| a.1.cmp(&b.1)); + assert_eq!(pasted.len(), 2, "two fresh events appeared"); + for (id, _, _) in &pasted { + assert!( + !source_ids.contains(id), + "pasted ids are fresh, disjoint from the source" + ); + } + + // Onsets: the first pasted note lands exactly at the target, and the + // gap between the two pasted notes equals the source gap. + assert_eq!( + pasted[0].1, far, + "the first pasted note lands at the target" + ); + let src_gap = before[1].1 .0.sub(&before[0].1 .0); + let dst_gap = pasted[1].1 .0.sub(&pasted[0].1 .0); + assert_eq!(src_gap, dst_gap, "the relative onset is preserved"); + + // Durations preserved. + assert_eq!(pasted[0].2, before[0].2); + assert_eq!(pasted[1].2, before[1].2); + + // Pitch values preserved (raw nominal/octave), and the authored + // spelling override on the first note carried onto its fresh pitch. + let dst_pitch0 = first_pitch_of(&session, pasted[0].0); + let dst_pitch1 = first_pitch_of(&session, pasted[1].0); + assert_ne!(dst_pitch0, source_pitch, "the pasted pitch id is fresh"); + assert_eq!( + session.current_pitch(dst_pitch0), + session.current_pitch(pitch0), + "the first pitch's value is preserved" + ); + assert_eq!( + session.current_pitch(dst_pitch1), + session.current_pitch(pitch1), + "the second pitch's value is preserved" + ); + assert_eq!( + authored_spelling(session.score(), dst_pitch0), + Some(override_spelling), + "the authored spelling override carried via a fresh RespellPitch" + ); + assert_eq!( + authored_spelling(session.score(), dst_pitch1), + None, + "the second note never had an override — none is invented" + ); + } + + /// Partial tuplet refuses: a selection covering two of the rich fixture's + /// three triplet members (never all three) makes `copy_selection` return + /// the closure-v1 refusal, not a fragment. + /// + /// Mutation f2 (remove the tuplet-coverage check in `copy_selection`): + /// this test dies (the copy would succeed instead of refusing). + #[test] + fn copy_selection_refuses_a_partially_selected_tuplet() { + let session = open_rich(0x5EED); + let tuplet = session.score().cross_cutting.tuplets[0].clone(); + assert_eq!( + tuplet.members.len(), + 3, + "the rich fixture's triplet has three members" + ); + let pitches = region_pitches(&session, 0); // region A, the triplet + assert_eq!(pitches.len(), 3); + + let mut session = session; + select_sources( + &mut session, + &[ + TypedObjectId::Pitch(pitches[0]), + TypedObjectId::Pitch(pitches[1]), + ], + ); + assert_eq!( + session.copy_selection(), + Err(EditorError::PartialTupletSelection { tuplet: tuplet.id }) + ); + } + + /// Paste refusal: pasting a one-note fragment onto a destination whose + /// make-room hits a nested tuplet (the same fixture shape as + /// `insert_note_at_over_a_nested_tuplet_is_refused`) errs, and the + /// destination's `canonical_bytes` are byte-identical to before the call + /// — `paste_document` builds its whole op list *before* ever calling + /// `apply`/`apply_transaction`, so a refusal discovered while building + /// it (as here) can never leave a partial mutation behind, regardless of + /// how the final application is shaped. `paste_atomicity_rolls_back_ + /// mid_transaction_on_a_second_lanes_refusal` (below) is the test that + /// isolates the transaction-vs-individual-application question this + /// scenario cannot (mutation f3 lives there). + #[test] + fn paste_refuses_cleanly_on_a_nested_tuplet_and_changes_nothing() { + // A one-note fragment, copied from a plain, tuplet-free fixture. + let mut source = + EditorSession::open(small_metric_score(1), Box::new(StubSolver)).expect("renders"); + let region = a_clean_metric_region(&source); + let voice = primary_voice(&source, region); + let events = voice_events(&source, voice); + let pid = first_pitch_of(&source, events[0].0); + let rect = rect_covering_pitches(&source, &[pid]); + source.select_within(rect); + let copy = source.copy_selection().expect("copies"); + + // A nested tuplet on the destination — the flat-cascade make-room + // rule refuses rather than misstate its ratio arithmetic (mirrors + // `insert_note_at_over_a_nested_tuplet_is_refused`). + use epiphany_core::generators::valid_score_rich; + use epiphany_core::{Tuplet, TupletRatio}; + let mut score = valid_score_rich(0x5EED); + let triplet_id = score.cross_cutting.tuplets[0].id; + let replica = score.identity.replica_id; + score.cross_cutting.tuplets.push(Tuplet { + id: TupletId::new(replica, 7_000_002), + ratio: TupletRatio::new(3, 2).unwrap(), + members: vec![], + parent: Some(triplet_id), + required_total: MusicalDuration(RationalTime::new(1, 8).unwrap()), + }); + let mut dest = EditorSession::open(score, Box::new(StubSolver)).expect("renders"); + let region = a_region_with(&dest, true); + let voice = primary_voice(&dest, region); + let (_, pos, dur) = voice_events(&dest, voice).into_iter().next().unwrap(); + let (_, _, origin_y) = region_staff_line(&dest, region); + let at = click_for_position(&dest, region, &pos, origin_y + 1.0); + let before = dest.score().canonical_bytes(); + + let err = dest + .paste_at(at, &GridResolution { step: dur }, ©.fragment) + .expect_err("overlapping the nested tuplet refuses"); + assert_eq!(err, EditorError::OverlapsTuplet); + assert_eq!( + dest.score().canonical_bytes(), + before, + "atomic: nothing changed" + ); + } + + /// A one-staff, one-region score whose single staff instance has exactly + /// two voices, each with one quarter-note event at onset 0 — the source + /// for a two-lane fragment (`copy_selection` groups by voice, so + /// selecting one note from each voice yields fragment lane 0 and lane 1). + fn two_voice_one_note_each_score() -> Score { + use epiphany_core::{ + Canvas, EventArena, IdentityContext, Instrument, InstrumentId, MetricTimeModel, Region, + RegionContent, Staff, StaffBasedContent, StaffExtent, StaffLineConfiguration, + TimeAnchor, TimeExtent, Voice, + }; + let replica = ReplicaId(0x8888); + let mut idc = IdentityContext::new(replica); + let staff_id: StaffId = idc.mint(); + let instrument: InstrumentId = idc.mint(); + let region_id: RegionId = idc.mint(); + let instance_id: StaffInstanceId = idc.mint(); + + let mut arena = EventArena::new(); + let mut instance = StaffInstance::new(instance_id, staff_id); + for _ in 0..2 { + let voice_id: VoiceId = idc.mint(); + let mut voice = Voice::user(voice_id); + let eid: EventId = idc.mint(); + let pid: PitchId = idc.mint(); + arena + .insert(Event::Pitched(PitchedEvent { + id: eid, + voice: voice_id, + position: EventPosition::Musical(MusicalPosition( + RationalTime::new(0, 4).unwrap(), + )), + duration: EventDuration::Musical(MusicalDuration( + RationalTime::new(1, 4).unwrap(), + )), + pitches: vec![IdentifiedPitch { + id: pid, + pitch: cmn_pitch(CmnNominal::G, 4), + }], + articulations: vec![], + dynamic: None, + ornaments: vec![], + stem: StemConfiguration, + grace: None, + })) + .expect("fresh event id"); + voice.events.push(eid); + instance.voices.push(voice); + } + + let region = Region { + id: region_id, + time_model: RegionTimeModel::Metric(MetricTimeModel::default()), + content: RegionContent::StaffBased(StaffBasedContent { + staff_instances: vec![instance], + ..Default::default() + }), + time_extent: TimeExtent { + start: TimeAnchor::WallClock { + time: WallClockTime(0), + }, + end: TimeAnchor::WallClock { + time: WallClockTime(1_000_000), + }, + }, + staff_extent: StaffExtent { + staves: vec![staff_id], + }, + local_tempo_map: None, + permits_spanning_slurs: false, + }; + + let mut score = Score::empty(idc.clone()); + score.identity = idc; + score.staves = vec![Staff { + id: staff_id, + name: String::from("staff"), + abbreviation: None, + instrument, + default_staff_lines: StaffLineConfiguration::default(), + group: None, + default_clef: Clef::treble(), + }]; + score.instruments = vec![Instrument::new(instrument, String::from("instrument"))]; + score.events = arena; + score.canvas = Canvas { + regions: vec![region], + ..Default::default() + }; + score + } + + /// A destination fixture for the two-lane paste-atomicity test: one + /// staff instance with two voices — voice 0 is empty (a paste there + /// makes room trivially, over nothing), voice 1 hosts a three-member + /// flat triplet with a nested child tuplet pushed onto it (the same + /// shape `insert_note_at_over_a_nested_tuplet_is_refused` uses), whose + /// flat-cascade make-room rule refuses rather than misstate a nested + /// tuplet's ratio arithmetic. + fn two_voice_second_voice_has_a_nested_tuplet() -> Score { + use epiphany_core::{ + Canvas, EventArena, IdentityContext, Instrument, InstrumentId, MetricTimeModel, Region, + RegionContent, Staff, StaffBasedContent, StaffExtent, StaffLineConfiguration, + TimeAnchor, TimeExtent, Tuplet, TupletRatio, Voice, + }; + let replica = ReplicaId(0x9999); + let mut idc = IdentityContext::new(replica); + let staff_id: StaffId = idc.mint(); + let instrument: InstrumentId = idc.mint(); + let region_id: RegionId = idc.mint(); + let instance_id: StaffInstanceId = idc.mint(); + + let voice0_id: VoiceId = idc.mint(); + let voice0 = Voice::user(voice0_id); + + let voice1_id: VoiceId = idc.mint(); + let mut voice1 = Voice::user(voice1_id); + let mut arena = EventArena::new(); + let mut triplet_members = Vec::new(); + for k in 0..3i64 { + let eid: EventId = idc.mint(); + let pid: PitchId = idc.mint(); + arena + .insert(Event::Pitched(PitchedEvent { + id: eid, + voice: voice1_id, + position: EventPosition::Musical(MusicalPosition( + RationalTime::new(k, 12).unwrap(), + )), + duration: EventDuration::Musical(MusicalDuration( + RationalTime::new(1, 12).unwrap(), + )), + pitches: vec![IdentifiedPitch { + id: pid, + pitch: cmn_pitch(CmnNominal::G, 4), + }], + articulations: vec![], + dynamic: None, + ornaments: vec![], + stem: StemConfiguration, + grace: None, + })) + .expect("fresh event id"); + voice1.events.push(eid); + triplet_members.push(eid); + } + + let mut instance = StaffInstance::new(instance_id, staff_id); + instance.voices.push(voice0); + instance.voices.push(voice1); + + let region = Region { + id: region_id, + time_model: RegionTimeModel::Metric(MetricTimeModel::default()), + content: RegionContent::StaffBased(StaffBasedContent { + staff_instances: vec![instance], + ..Default::default() + }), + time_extent: TimeExtent { + start: TimeAnchor::WallClock { + time: WallClockTime(0), + }, + end: TimeAnchor::WallClock { + time: WallClockTime(1_000_000), + }, + }, + staff_extent: StaffExtent { + staves: vec![staff_id], + }, + local_tempo_map: None, + permits_spanning_slurs: false, + }; + + let mut score = Score::empty(idc.clone()); + score.identity = idc; + score.staves = vec![Staff { + id: staff_id, + name: String::from("staff"), + abbreviation: None, + instrument, + default_staff_lines: StaffLineConfiguration::default(), + group: None, + default_clef: Clef::treble(), + }]; + score.instruments = vec![Instrument::new(instrument, String::from("instrument"))]; + score.events = arena; + score.canvas = Canvas { + regions: vec![region], + ..Default::default() + }; + + let triplet_id: TupletId = score.identity.mint(); + score.cross_cutting.tuplets.push(Tuplet { + id: triplet_id, + ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"), + members: triplet_members, + parent: None, + required_total: MusicalDuration(RationalTime::new(1, 4).unwrap()), + }); + // A nested child: the flat cascade cannot safely restate its ratio. + score.cross_cutting.tuplets.push(Tuplet { + id: TupletId::new(replica, 9_000_002), + ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"), + members: vec![], + parent: Some(triplet_id), + required_total: MusicalDuration(RationalTime::new(1, 8).unwrap()), + }); + score + } + + /// The mutation f3 test proper: a two-lane fragment (one note per voice) + /// pastes onto a destination whose lane 0 (voice 0) is clear — its + /// make-room and insert would succeed in complete isolation — and whose + /// lane 1 (voice 1) overlaps a nested tuplet, refusing. `paste_document` + /// builds *every* lane's ops before submitting *any* of them, so the + /// whole paste errs and the destination is untouched. + /// + /// Mutation f3 (commit each lane's ops immediately after building them — + /// e.g. via its own `self.apply_transaction(...)` call inside the lane + /// loop — instead of accumulating every lane into one list and + /// submitting it as a single transaction at the end): lane 0 (processed + /// first, since `voice_ids`/`document.voices` iterate in lane order) has + /// already committed its insert by the time lane 1's make-room refuses, + /// so `canonical_bytes` changes even though the overall call still + /// returns `Err` — this test dies on the `canonical_bytes` equality. + #[test] + fn paste_atomicity_rolls_back_mid_transaction_on_a_second_lanes_refusal() { + let mut source = EditorSession::open(two_voice_one_note_each_score(), Box::new(StubSolver)) + .expect("renders"); + let events: Vec = source.score().events.iter().map(|e| e.id()).collect(); + assert_eq!(events.len(), 2, "one note per voice"); + let pitches: Vec = events + .iter() + .map(|&eid| first_pitch_of(&source, eid)) + .collect(); + let rect = rect_covering_pitches(&source, &pitches); + source.select_within(rect); + let copy = source.copy_selection().expect("copies"); + let decoded = fragment::decode(©.fragment).expect("well-formed"); + assert_eq!(decoded.voices.len(), 2, "two lanes, one event each"); + + let mut dest = EditorSession::open( + two_voice_second_voice_has_a_nested_tuplet(), + Box::new(StubSolver), + ) + .expect("renders"); + let region = a_region_with(&dest, true); + // Click voice 1's first triplet member's onset — `paste_at` resolves + // the *staff instance* under the click (both voices share one), and + // `paste_document` maps lane 0 -> the instance's voice 0, lane 1 -> + // voice 1, positionally, regardless of which voice was clicked. + let voice1 = dest + .score() + .staff_instances() + .find(|(r, _)| *r == region) + .expect("a staff instance") + .1 + .voices[1] + .id; + let (_, pos, dur) = voice_events(&dest, voice1).into_iter().next().unwrap(); + let (_, _, origin_y) = region_staff_line(&dest, region); + let at = click_for_position(&dest, region, &pos, origin_y + 1.0); + let before = dest.score().canonical_bytes(); + + let err = dest + .paste_at(at, &GridResolution { step: dur }, ©.fragment) + .expect_err("lane 1 overlaps the nested tuplet"); + assert_eq!(err, EditorError::OverlapsTuplet); + assert_eq!( + dest.score().canonical_bytes(), + before, + "atomic: lane 0's insert must not land just because lane 1 refused" + ); + } + + /// Coordinator-added (T2 W4a review): the commit's transactional **shape** + /// is itself canonical surface. The atomicity test above pins + /// build-everything-before-committing-anything (its refusal fires at + /// make-room time, inside `paste_document`'s build), but nothing there + /// reaches the commit itself — a mutation that keeps the build intact and + /// commits the finished op list **per op** survives it, because make-room + /// and the reducer agree by design and no mid-commit rejection is + /// reachable on that input. The difference that IS observable: a + /// transaction's `DeclareTransaction` descriptor grants its members + /// descriptor-precedence in concurrent reduction, so a paste emitting bare + /// ops would merge differently at a peer even though it applies + /// identically here. This pins the emitted stream: one descriptor plus + /// every member, appended by a single multi-op paste. + #[test] + fn paste_emits_one_transaction_descriptor_plus_members() { + let mut session = + EditorSession::open(small_metric_score(4), Box::new(StubSolver)).expect("renders"); + let region = a_clean_metric_region(&session); + let voice = primary_voice(&session, region); + let before = voice_events(&session, voice); + let source_pitch = first_pitch_of(&session, before[0].0); + session + .apply(OperationKind::RespellPitch(RespellPitchOp { + pitch: source_pitch, + spelling: PitchSpelling::cmn(CmnNominal::D, 4), + })) + .expect("the respell applies"); + let pitch0 = first_pitch_of(&session, before[0].0); + let pitch1 = first_pitch_of(&session, before[1].0); + let rect = rect_covering_pitches(&session, &[pitch0, pitch1]); + session.select_within(rect); + let copy = session.copy_selection().expect("the selection copies"); + + let far = MusicalPosition(RationalTime::new(50, 4).unwrap()); + let (_, _, origin_y) = region_staff_line(&session, region); + let at = click_for_position(&session, region, &far, origin_y + 1.0); + + let applied_before = session.applied_operations().len(); + let outcome = session + .paste_at(at, &grid(1, 4), ©.fragment) + .expect("the paste applies"); + assert_eq!(outcome.events_inserted, 2); + + let new = &session.applied_operations()[applied_before..]; + let descriptors = new + .iter() + .filter(|e| { + matches!( + &e.payload, + OperationPayload::Primitive(OperationKind::DeclareTransaction(_)) + ) + }) + .count(); + assert_eq!( + descriptors, 1, + "a multi-op paste emits exactly one transaction descriptor" + ); + assert!( + new.len() >= 4, + "descriptor + two inserts + the carried respell, got {}", + new.len() + ); + } + + /// Boundary slur: a slur spans event 0..event 2 of a four-note voice; + /// selecting only events 0 and 1 cuts it at the selection boundary. The + /// resulting fragment omits the slur, and `copy_selection`'s outcome + /// names it in `dropped`. + /// + /// Mutation f6 (suppress the dropped-report — e.g. drop the `dropped.push` + /// arm and just fall through to `(None, None) => {}`): this test dies. + #[test] + fn copy_selection_drops_a_boundary_cut_slur_and_reports_it() { + use epiphany_core::{RegionContent, Slur, SlurId, SlurKind, SpanStyle}; + + let mut score = small_metric_score(4); + let events: Vec = { + let RegionContent::StaffBased(content) = &score.canvas.regions[0].content else { + panic!("region 0 is staff-based"); + }; + content.staff_instances[0].voices[0].events.clone() + }; + let slur_id: SlurId = score.identity.mint(); + score.cross_cutting.slurs.push(Slur { + id: slur_id, + start_event: events[0], + end_event: events[2], + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::default(), + }); + let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders"); + + let pitch0 = first_pitch_of(&session, events[0]); + let pitch1 = first_pitch_of(&session, events[1]); + let rect = rect_covering_pitches(&session, &[pitch0, pitch1]); + session.select_within(rect); + + let copy = session + .copy_selection() + .expect("the notes themselves still copy fine"); + assert_eq!( + copy.dropped, + vec![DroppedItem::Slur(slur_id)], + "the boundary-cut slur is reported dropped" + ); + + let decoded = fragment::decode(©.fragment).expect("a well-formed fragment"); + assert!( + decoded.slurs.is_empty(), + "the cut slur must not appear in the fragment text itself" + ); + let fragment_event_count: usize = decoded.voices.iter().map(|v| v.events.len()).sum(); + assert_eq!( + fragment_event_count, 2, + "exactly the two targeted (non-slur-endpoint-3) notes made it into the fragment" + ); + } }