From 62117d342651987ebd354d306cab9f2ee8e3b7e8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 10:27:08 -0400 Subject: [PATCH] Editor T2 W4b: slur/tie replay on paste, and the clipboard reaches the GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste now re-mints contained slurs and ties as fresh CreateCrossCutting ops — fragment ordinals mapped to freshly-minted destination EventIds, ids from new mint_slur_id/mint_tie_id under the same three-source high-water discipline, inserts ordered before cross-cuttings because the reducer preconditions live anchors — all inside the single paste transaction, with the emitted-stream test tightened to the exact member count and its kill re-proven against the per-op-commit mutation. Tie replay is unconditional: pitch_pairing None is the model's own ascending-PitchId default, not a gap (investigated, not assumed). The GUI gains Ctrl/Cmd+C / Ctrl/Cmd+V and toolbar Copy/Paste over egui 0.29's actual clipboard surfaces (copied_text out, Event::Paste in — verified against vendored source), pasting over the selection anchor. Goldens byte-identical; all mutations killed and coordinator re-verified. Co-Authored-By: Claude Fable 5 --- crates/epiphany-editor-core/DECISIONS.md | 112 ++++ crates/epiphany-editor-core/src/fragment.rs | 9 +- crates/epiphany-editor-core/src/lib.rs | 700 +++++++++++++++++++- crates/epiphany-editor-gui/DECISIONS.md | 109 +++ crates/epiphany-editor-gui/src/main.rs | 193 +++++- 5 files changed, 1096 insertions(+), 27 deletions(-) diff --git a/crates/epiphany-editor-core/DECISIONS.md b/crates/epiphany-editor-core/DECISIONS.md index 0b4b910..8f585ec 100644 --- a/crates/epiphany-editor-core/DECISIONS.md +++ b/crates/epiphany-editor-core/DECISIONS.md @@ -292,3 +292,115 @@ 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. + +## Slur/tie replay on paste (2026-07-24, T2-W4b) — closing the W4a follow-up + +Dispatched as the W4b packet's Half 1, ratified by the user 2026-07-23. W4a +(above, point 4) captured a fully-contained slur/tie into the fragment +*format* but never re-minted it on paste, filed as a named follow-up rather +than papered over. This closes it: `paste_document` now re-mints every +fragment slur/tie, once both endpoints resolve to freshly-minted destination +events, as a fresh `CreateCrossCutting` op — inside the same single atomic +paste transaction as the inserts/respells (Ruling E), never a second unit. + +**Id mapping.** A fragment's `EventRef` names an event by its +fragment-local position, never a source id (Ruling E: no object ids) — so +paste needs its own map from that position to the *freshly minted* +destination `EventId`. Implemented as `event_map: Vec>` +(`event_map[voice_ordinal][event_ordinal]`), built alongside the existing +per-voice insert loop and pushed once per lane (an empty `Vec` for an +empty/skipped lane) so indices stay aligned with the fragment's own voice +ordinals — a plain array index, not a fallible lookup, because +`fragment::decode` already rejected any dangling `EventRef` before this +method ever sees the document (the module's own documented invariant: "an +`EventRef` decode validated resolves — nothing downstream needs to +re-check"). A `BTreeMap` was the first design +considered; `EventRef` derives no `Ord`, and adding one purely for a +paste-local, non-format concern was judged not worth extending a +purpose-built, already-reviewed type's derive set. Fresh `SlurId`/`TieId` +values come from two new session minters (`mint_slur_id`/`mint_tie_id`, +mirroring `mint_event_id`/`mint_pitch_id`'s three-source high-water-mark +discipline: `base`, current `score`, and this session's **authored** +history) plus matching `Minter::slur`/`Minter::tie` fields, so several +cross-cutting mints within one paste never collide and a since-deleted or +since-undone slur/tie's id is never reused. Unlike a pitch, a deleted +cross-cutting structure has no separate score-level tombstone list to chain +(`DeleteCrossCutting` → `graph_delete_cross_cutting` removes it from +`score.cross_cutting.{slurs,ties}` outright, `reduce.rs:3539`), so the +`authored` source is what actually carries a deleted/undone slur/tie's id +forward — the same reasoning `mint_pitch_id`'s doc already gives for +pitches, just with no `base`/`score` tombstone list to add. + +**Ordering: inserts before cross-cuttings, same transaction.** The +reducer's own `create_cross_cutting` precondition (`reduce.rs:3249`) +requires every anchor event already `Live` in its per-envelope object +registry — checked against reduction's own evolving state as the +transaction's envelopes apply in order, not against `self.score` (which +does not yet contain the paste at advisory-check time). `paste_document` +therefore builds every lane's `InsertEvent`s first, then every +`CreateCrossCutting` after, in one `ops` list — the existing +`apply`/`apply_transaction` dispatch (N=1 unwrapped, else one +`DeclareTransaction` + members) needed no change at all; a freshly-minted +event and the slur/tie naming it simply ride the same transaction as any +other multi-op paste. The pre-mint advisory gate +(`epiphany_ops::validate::advisory_violations`) is unaffected: its +`CreateCrossCutting(Slur)` check resolves both endpoints' regions against +`self.score` (the pre-transaction state), so a freshly-minted event +resolves to `None` on both sides and the boundary check passes vacuously — +documented as intentional in that module ("conservative for the rare member +that only violates against another member's intermediate effect"). + +**Tie pairing: `None`, and this is NOT a structural block.** A replayed +tie always carries `pitch_pairing: None` — the fragment format never +carries the source's explicit pairing at all (W4a's `FragmentTie` has no +such field; Ruling E: no object ids, and pairing keys on source `PitchId`s). +The packet's brief asked to fail closed (skip tie replay, report it) *if* +the model requires a pairing the fragment cannot express. Investigated and +ruled out: `Tie::pitch_pairing: Option<...>` with `None` means "pair all +pitches by enharmonic matching in ascending `PitchId` order" — a fully +representable, spec-legitimate value (Chapter 5 §"Ties"; +`epiphany_core::invariants::check_tie_pairing`'s `None` arm implements +exactly this rule as a *checkable* invariant, not a requirement the reducer +enforces at mint time). `create_cross_cutting`'s only preconditions are (a) +the structure id is not already live/tombstoned and (b) its anchor events +are live — nothing inspects `pitch_pairing` at all +(`materialize_graph_cross_cutting` pushes the `Tie` value verbatim). The +pairing-consistency invariant check lives in `epiphany_core::invariants`, a +graph-wide checker this session's `apply`/`apply_transaction` never runs +(the crate's own tests call `epiphany_core::check_invariants` explicitly, +separately, when they want it — `lib.rs`'s `commit` does not). So: full tie +replay is implemented, unconditionally, with `pitch_pairing: None`; no +`dropped`-style outcome field was needed because there is no fail-closed +case to report on this path. `PasteOutcome` gained `slurs_inserted` / +`ties_inserted` counts instead (both always equal to the fragment's own +`slurs.len()`/`ties.len()` given a well-formed paste — a straightforward +"how many landed" report, not a fallibility channel). + +**`CopyOutcome` gained `events_copied: usize` too** (same packet, needed by +W4b's Half 2 GUI status line: "N event(s) copied"). The fragment +grammar/decoder (`fragment.rs`) are crate-private by design (Ruling E's +format is application-internal, not a public surface), so a caller outside +this crate has no way to learn how many events a copy actually captured +without this field — `copy_selection` now sums `document.voices[*].events.len()` +once, right before encoding, and returns it alongside the fragment text. + +**Tests.** `paste_replays_a_contained_slur_as_a_fresh_cross_cutting_structure` +(r1) and `paste_replays_a_contained_tie_with_the_default_pairing` (r2) each +build a source fixture with the cross-cutting structure captured directly on +the raw `Score` (mirroring `copy_selection_drops_a_boundary_cut_slur_and_reports_it`'s +technique), copy, paste far away in the *same* session, and assert the +destination carries a **second**, fresh-id structure spanning exactly the +two pasted events — r2's source tie carries an explicit `pitch_pairing` on +purpose, so the assertion that the replayed tie's pairing is `None` proves +the drop is real, not merely untested. `paste_atomicity_rolls_back_mid_transaction_with_a_slur_aboard` +(r3) reuses the existing two-lane refusal fixture shape +(`two_voice_second_voice_has_a_nested_tuplet` as the destination) with a new +source fixture whose lane 0 carries two notes and a slur; the whole paste — +cross-cutting op included — still rolls back byte-identically when lane 1's +make-room refuses. `paste_emits_one_transaction_descriptor_plus_members` (the +coordinator-added transaction-shape test) now pins the **exact** member +count with a slur aboard (descriptor + 2 inserts + 1 respell + 1 +`CreateCrossCutting` = 5), re-proven live against the same "per-op loop +instead of the transaction dispatch" mutation the test's own doc comment +already described for the pre-slur version — the mutation still kills it +(0 descriptors instead of 1), confirmed and reverted. diff --git a/crates/epiphany-editor-core/src/fragment.rs b/crates/epiphany-editor-core/src/fragment.rs index 9f6881a..2de482b 100644 --- a/crates/epiphany-editor-core/src/fragment.rs +++ b/crates/epiphany-editor-core/src/fragment.rs @@ -433,10 +433,11 @@ impl TextValue for FragmentSlur { /// **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. +/// was judged not worth the added grammar for a rare feature. Paste (W4b, +/// `crate::EditorSession::paste_document`) does replay a captured tie as a +/// fresh `CreateCrossCutting`, always with `pitch_pairing: None` — the +/// reducer's default enharmonic pairing (not a structural limitation: the +/// reducer accepts `None` unconditionally, see `DECISIONS.md`). #[derive(Clone, PartialEq, Debug)] pub(crate) struct FragmentTie { pub(crate) start: EventRef, diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index 2c8abe5..4060584 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -61,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, SlurId, SpellingDirective, SpellingNominal, - SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration, - TieId, TimeSignature, TimeSignatureDisplay, TransactionId, TranspositionInterval, - TuningReference, TupletId, TypedObjectId, VoiceId, WallClockTime, + RegionTimeModel, ReplicaId, ScalePosition, Score, Slur, SlurId, SpellingDirective, + SpellingNominal, SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, + StemConfiguration, Tie, 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, @@ -73,11 +73,12 @@ use epiphany_layout_ir::{ SolverConfig, TimePoint, }; use epiphany_ops::{ - advisory_violations, AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, - DeleteIdentifiedPitchOp, HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, - ModifyEventOp, ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationKindTag, - OperationPayload, OperationSet, OperationStamp, RespellPitchOp, TransactionCategory, - TransactionDescriptor, TransposeIntervalOp, TupletCompensation, + advisory_violations, AcceptOutcome, AuthorId, CausalContext, CreateCrossCuttingOp, + CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, HybridLogicalClock, InsertEventOp, + InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationEnvelope, + OperationKind, OperationKindTag, OperationPayload, OperationSet, OperationStamp, + RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeIntervalOp, + TupletCompensation, }; /// The current selection: the score-graph object to act on, plus the stable layout @@ -315,6 +316,12 @@ pub struct CopyOutcome { /// Every slur/tie closure v1 dropped because only one endpoint was /// inside the selection — the "report dropped" channel. pub dropped: Vec, + /// How many events made it into the fragment (summed over every voice + /// lane) — a GUI-facing count (T2 W4b) so a caller can report "N + /// event(s) copied" without decoding the fragment text itself (the + /// fragment grammar/decoder are crate-private; this is the supported + /// way to learn what a copy actually captured). + pub events_copied: usize, } /// What a [`EditorSession::paste_at`] / [`EditorSession::paste_over_selection`] @@ -327,6 +334,22 @@ pub struct PasteOutcome { /// How many fragment events were inserted (one /// [`OperationKind::InsertEvent`] each). pub events_inserted: usize, + /// How many fragment slurs were re-minted as fresh + /// [`OperationKind::CreateCrossCutting`] operations (closing the W4a + /// follow-up, `DECISIONS.md` 2026-07-24): every fragment slur, once both + /// its endpoints resolve to freshly-minted destination events, is + /// replayed as a **brand-new** slur — a fresh [`SlurId`], never the + /// source's. + pub slurs_inserted: usize, + /// How many fragment ties were re-minted the same way. A replayed tie's + /// `pitch_pairing` is always `None`: the fragment format never carries + /// the source's explicit pairing (Ruling E: no object ids — pairing keys + /// on source `PitchId`s), so a replayed tie always falls back to the + /// reducer's implicit enharmonic-pairing-by-ascending-`PitchId` rule + /// (Chapter 5 §"Ties"; `DECISIONS.md` documents this is not a + /// structural blocker — the reducer accepts `pitch_pairing: None` + /// unconditionally). + pub ties_inserted: usize, } /// An editing error. None of these mutate the session. @@ -2253,9 +2276,11 @@ impl EditorSession { slurs, ties, }; + let events_copied = document.voices.iter().map(|v| v.events.len()).sum(); Ok(CopyOutcome { fragment: fragment::encode(&document), dropped, + events_copied, }) } @@ -2401,9 +2426,36 @@ impl EditorSession { /// (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. + /// event, then re-mints every fragment slur/tie whose endpoints resolve + /// to freshly-minted destination events as a fresh `CreateCrossCutting` + /// (closing the W4a follow-up, `DECISIONS.md` 2026-07-24) — 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, + /// cross-cuttings included. + /// + /// **Id mapping.** A fragment's [`fragment::EventRef`] names an event by + /// its position in the fragment's own per-voice lanes, never a source + /// id (Ruling E). `event_map[voice_ordinal][event_ordinal]` records the + /// fresh [`EventId`] minted for that position as the insert loop below + /// mints it, so the slur/tie pass afterward resolves every endpoint by a + /// plain index — never a source id, and never the source's own + /// `SlurId`/`TieId`, which this session never even decodes (the + /// fragment format carries none). Every reference [`fragment::decode`] + /// accepted is guaranteed to resolve here (it already rejected any + /// dangling one), so indexing is a direct array access, not a fallible + /// lookup. + /// + /// **Tie pairing.** A replayed tie's `pitch_pairing` is always `None` — + /// the fragment format never carries the source's explicit pairing + /// (Ruling E: no object ids; `fragment::FragmentTie` has no + /// `pitch_pairing` field at all). This is not a structural block: the + /// reducer accepts `pitch_pairing: None` unconditionally (it means + /// "pair enharmonically by ascending `PitchId`", Chapter 5 §"Ties") and + /// enforces nothing about it at `CreateCrossCutting` apply time — the + /// pairing-consistency check lives in `epiphany_core::invariants`, an + /// optional graph-wide check this session never runs as part of + /// `apply`/`apply_transaction`. See `DECISIONS.md` for the full + /// investigation. fn paste_document( &mut self, fragment: &str, @@ -2422,8 +2474,14 @@ impl EditorSession { let mut minter = self.minter(); let mut ops: Vec = Vec::new(); let mut events_inserted = 0usize; + // event_map[voice_ordinal][event_ordinal] -> the fresh EventId minted + // for that fragment-local position. Pushed once per lane (an empty + // Vec for a skipped/empty lane) so indices stay aligned with the + // fragment's own voice ordinals for the slur/tie pass below. + let mut event_map: Vec> = Vec::with_capacity(document.voices.len()); for (&voice, lane) in voice_ids.iter().zip(&document.voices) { if lane.events.is_empty() { + event_map.push(Vec::new()); continue; } let lane_end = lane @@ -2434,9 +2492,11 @@ impl EditorSession { .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)); + let mut lane_map = Vec::with_capacity(lane.events.len()); for fe in &lane.events { let position = target_start.clone() + fe.onset.clone(); let event_id = minter.event(); + lane_map.push(event_id); let (event, respells) = fragment_event_to_op_event(event_id, voice, position, fe, &mut minter); ops.push(OperationKind::InsertEvent(InsertEventOp { @@ -2446,7 +2506,53 @@ impl EditorSession { events_inserted += 1; ops.extend(respells); } + event_map.push(lane_map); } + + // Every InsertEvent above precedes every CreateCrossCutting below in + // the op list — required, not incidental: the reducer's own + // `create_cross_cutting` precondition demands both endpoint events + // already be Live in its per-envelope object registry + // (`reduce.rs`'s `anchor_object_refs` check), so a slur/tie naming a + // freshly-minted event only reduces cleanly when its insert has + // already been processed earlier in the same transaction. + let resolve = + |r: fragment::EventRef| -> EventId { event_map[r.voice as usize][r.event as usize] }; + + let mut slurs_inserted = 0usize; + for slur in &document.slurs { + let slur_id = minter.slur(); + ops.push(OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingValue::Slur(Slur { + id: slur_id, + start_event: resolve(slur.start), + end_event: resolve(slur.end), + kind: slur.kind, + curvature_override: slur.curvature_override.clone(), + style: slur.style.clone(), + }), + })); + slurs_inserted += 1; + } + let mut ties_inserted = 0usize; + for tie in &document.ties { + let tie_id = minter.tie(); + ops.push(OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingValue::Tie(Tie { + id: tie_id, + start_event: resolve(tie.start), + end_event: resolve(tie.end), + // The fragment never carries the source's explicit + // pairing (see this method's doc); the reducer's + // implicit enharmonic pairing applies. + pitch_pairing: None, + class: tie.class.clone(), + style: tie.style.clone(), + }), + })); + ties_inserted += 1; + } + if ops.is_empty() { return Err(EditorError::EmptyFragment); } @@ -2459,6 +2565,8 @@ impl EditorSession { Ok(PasteOutcome { outcome, events_inserted, + slurs_inserted, + ties_inserted, }) } @@ -2763,6 +2871,8 @@ impl EditorSession { replica: self.replica, next_event: self.mint_event_id().counter(), next_pitch: self.mint_pitch_id().counter(), + next_slur: self.mint_slur_id().counter(), + next_tie: self.mint_tie_id().counter(), } } @@ -2892,6 +3002,54 @@ impl EditorSession { PitchId::new(self.replica, next) } + /// Mints a fresh [`SlurId`] in the session's replica namespace, on the same + /// high-water-mark discipline as [`Self::mint_pitch_id`] — the pristine `base`, the + /// current score, and this session's **authored** history (so a since-deleted or + /// since-undone slur's id is never reused). Unlike a pitch, a deleted slur leaves + /// no separate tombstone bookkeeping to chain (`DeleteCrossCutting` only flips the + /// reducer's transient `objects` map, which this session does not retain), so the + /// authored-history source is what actually carries a deleted/undone slur's id + /// forward here. + fn mint_slur_id(&self) -> SlurId { + let ids = self + .base + .cross_cutting + .slurs + .iter() + .map(|s| s.id) + .chain(self.score.cross_cutting.slurs.iter().map(|s| s.id)) + .chain(self.authored.iter().flat_map(inserted_slur_ids)); + let next = ids + .filter(|s| s.replica() == self.replica) + .map(|s| s.counter()) + .max() + .map_or(0, |c| { + c.checked_add(1).expect("slur id counter overflowed u64") + }); + SlurId::new(self.replica, next) + } + + /// Mints a fresh [`TieId`], the tie analogue of [`Self::mint_slur_id`] (same + /// discipline, same rationale). + fn mint_tie_id(&self) -> TieId { + let ids = self + .base + .cross_cutting + .ties + .iter() + .map(|t| t.id) + .chain(self.score.cross_cutting.ties.iter().map(|t| t.id)) + .chain(self.authored.iter().flat_map(inserted_tie_ids)); + let next = ids + .filter(|t| t.replica() == self.replica) + .map(|t| t.counter()) + .max() + .map_or(0, |c| { + c.checked_add(1).expect("tie id counter overflowed u64") + }); + TieId::new(self.replica, next) + } + /// Re-resolves the whole selection set against the current layout — see /// [`SelectionSet::reresolve`] for the member-survival and anchor-fallback /// rules. Returns whether the anchor's own member survived unchanged. @@ -3125,6 +3283,8 @@ struct Minter { replica: ReplicaId, next_event: u64, next_pitch: u64, + next_slur: u64, + next_tie: u64, } impl Minter { @@ -3145,6 +3305,24 @@ impl Minter { .expect("pitch id counter overflowed u64"); id } + + fn slur(&mut self) -> SlurId { + let id = SlurId::new(self.replica, self.next_slur); + self.next_slur = self + .next_slur + .checked_add(1) + .expect("slur id counter overflowed u64"); + id + } + + fn tie(&mut self) -> TieId { + let id = TieId::new(self.replica, self.next_tie); + self.next_tie = self + .next_tie + .checked_add(1) + .expect("tie id counter overflowed u64"); + id + } } /// `event` re-placed at a new metric `position`/`duration` (a make-room trim), keeping @@ -3334,6 +3512,31 @@ fn inserted_event_ids(env: &OperationEnvelope) -> Vec { } } +/// The slur ids a session envelope brought into being — the slur-id analogue of +/// [`inserted_pitch_ids`], so [`EditorSession::mint_slur_id`] never reuses a +/// since-deleted or since-undone one. +fn inserted_slur_ids(env: &OperationEnvelope) -> Vec { + match &env.payload { + OperationPayload::Primitive(OperationKind::CreateCrossCutting(op)) => match &op.structure { + CrossCuttingValue::Slur(slur) => vec![slur.id], + _ => Vec::new(), + }, + _ => Vec::new(), + } +} + +/// The tie ids a session envelope brought into being — the tie analogue of +/// [`inserted_slur_ids`]. +fn inserted_tie_ids(env: &OperationEnvelope) -> Vec { + match &env.payload { + OperationPayload::Primitive(OperationKind::CreateCrossCutting(op)) => match &op.structure { + CrossCuttingValue::Tie(tie) => vec![tie.id], + _ => Vec::new(), + }, + _ => Vec::new(), + } +} + /// The transaction id a session envelope declares, if it is a `DeclareTransaction`. fn declared_transaction_id(env: &OperationEnvelope) -> Option { match &env.payload { @@ -7726,9 +7929,16 @@ mod tests { /// The id of `eid`'s first pitch. fn first_pitch_of(session: &EditorSession, eid: EventId) -> PitchId { + first_pitch_id_in_score(session.score(), eid) + } + + /// The id of `eid`'s first pitch, read directly off a `Score` — the + /// pre-`EditorSession::open` sibling of [`first_pitch_of`], for fixtures + /// that need a source `PitchId` before a session exists (e.g. to build + /// an explicit tie `pitch_pairing` on the raw score). + fn first_pitch_id_in_score(score: &Score, eid: EventId) -> PitchId { let mut buf: Vec<&IdentifiedPitch> = Vec::new(); - session - .score() + score .events .get(eid) .expect("live event") @@ -7791,6 +8001,10 @@ mod tests { fragment_event_count, 2, "exactly the two targeted notes made it into the fragment" ); + assert_eq!( + copy.events_copied, 2, + "events_copied agrees with the fragment's own event count" + ); // Paste far past the fixture's own content (its four notes span // [0, 1)) — a clearly distinct destination. @@ -7859,6 +8073,204 @@ mod tests { ); } + /// r1 (T2 W4b, closing the W4a slur/tie-replay follow-up): copying a + /// range containing a **whole** slur, then pasting far away, mints a + /// brand-new slur at the destination — a fresh [`SlurId`], disjoint from + /// the source's, spanning **exactly** the two freshly-pasted events (not + /// the source's own event ids). + /// + /// Mutation r1 (skip the slur re-mint loop in `paste_document`): the + /// destination gains no new slur at all (`slurs_inserted` stays 0 and no + /// second slur appears in `cross_cutting.slurs`) — this test dies on the + /// `slurs_inserted`/slur-presence assertions below. Confirmed live, then + /// reverted. + #[test] + fn paste_replays_a_contained_slur_as_a_fresh_cross_cutting_structure() { + 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 source_slur_id: SlurId = score.identity.mint(); + score.cross_cutting.slurs.push(Slur { + id: source_slur_id, + start_event: events[0], + end_event: events[1], + kind: SlurKind::Phrase, + curvature_override: None, + style: SpanStyle::default(), + }); + + let mut session = EditorSession::open(score, 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_ids: BTreeSet = before.iter().map(|(id, _, _)| *id).collect(); + + 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(), "the slur is fully inside the copy"); + let decoded = fragment::decode(©.fragment).expect("well-formed"); + assert_eq!(decoded.slurs.len(), 1, "the slur made it into the fragment"); + + 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 outcome = session + .paste_at(at, &grid(1, 4), ©.fragment) + .expect("the paste applies"); + assert_eq!(outcome.events_inserted, 2); + assert_eq!(outcome.slurs_inserted, 1, "one slur re-minted"); + assert_eq!(outcome.ties_inserted, 0); + + 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"); + + assert_eq!( + session.score().cross_cutting.slurs.len(), + 2, + "the source slur is untouched and a new one appeared" + ); + let new_slur = session + .score() + .cross_cutting + .slurs + .iter() + .find(|s| s.id != source_slur_id) + .expect("a new slur exists"); + assert_ne!( + new_slur.id, source_slur_id, + "the pasted slur's id is fresh, disjoint from the source's" + ); + assert_eq!( + new_slur.start_event, pasted[0].0, + "the new slur starts at the first pasted event" + ); + assert_eq!( + new_slur.end_event, pasted[1].0, + "the new slur ends at the second pasted event" + ); + assert_eq!( + new_slur.kind, + SlurKind::Phrase, + "the slur kind carries over" + ); + assert_eq!(new_slur.curvature_override, None); + assert_eq!(new_slur.style, SpanStyle::default()); + } + + /// r2 (T2 W4b): the tie equivalent of r1. A tie fully inside the copied + /// range replays as a fresh [`TieId`] at the destination, spanning + /// exactly the two pasted events, with `pitch_pairing: None` — the + /// fragment never carries the source's explicit pairing (Ruling E: no + /// object ids), and this is not a structural block: the reducer accepts + /// `pitch_pairing: None` unconditionally (see `DECISIONS.md`). The + /// source tie's own explicit pairing is deliberately set here so the + /// test also proves the drop is real, not merely untested. + /// + /// Mutation r2 (skip the tie re-mint loop in `paste_document`): dies on + /// the `ties_inserted`/tie-presence assertions below. Confirmed live, + /// then reverted. + #[test] + fn paste_replays_a_contained_tie_with_the_default_pairing() { + use epiphany_core::{RegionContent, SpanStyle, Tie, TieClass, TieId}; + + 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 source_pitch0 = first_pitch_id_in_score(&score, events[0]); + let source_pitch1 = first_pitch_id_in_score(&score, events[1]); + let source_tie_id: TieId = score.identity.mint(); + score.cross_cutting.ties.push(Tie { + id: source_tie_id, + start_event: events[0], + end_event: events[1], + // An explicit source pairing — proves the fragment really drops + // it (Ruling E), not merely that this test never exercised one. + pitch_pairing: Some(vec![(source_pitch0, source_pitch1)]), + class: TieClass::Standard, + style: SpanStyle::default(), + }); + + let mut session = EditorSession::open(score, 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_ids: BTreeSet = before.iter().map(|(id, _, _)| *id).collect(); + + 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(), "the tie is fully inside the copy"); + let decoded = fragment::decode(©.fragment).expect("well-formed"); + assert_eq!(decoded.ties.len(), 1, "the tie made it into the fragment"); + + 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 outcome = session + .paste_at(at, &grid(1, 4), ©.fragment) + .expect("the paste applies"); + assert_eq!(outcome.events_inserted, 2); + assert_eq!(outcome.slurs_inserted, 0); + assert_eq!(outcome.ties_inserted, 1, "one tie re-minted"); + + 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"); + + assert_eq!( + session.score().cross_cutting.ties.len(), + 2, + "the source tie is untouched and a new one appeared" + ); + let new_tie = session + .score() + .cross_cutting + .ties + .iter() + .find(|t| t.id != source_tie_id) + .expect("a new tie exists"); + assert_ne!( + new_tie.id, source_tie_id, + "the pasted tie's id is fresh, disjoint from the source's" + ); + assert_eq!(new_tie.start_event, pasted[0].0); + assert_eq!(new_tie.end_event, pasted[1].0); + assert_eq!( + new_tie.pitch_pairing, None, + "the source's explicit pairing is not carried; the default applies" + ); + assert_eq!( + new_tie.class, + TieClass::Standard, + "the tie class carries over" + ); + assert_eq!(new_tie.style, SpanStyle::default()); + } + /// 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. @@ -8040,6 +8452,139 @@ mod tests { score } + /// r3's source fixture (T2 W4b): a one-staff, one-region score whose + /// staff instance has two voices — voice 0 carries **two** quarter notes + /// (onset 0 and 1/4) joined by a slur, voice 1 carries one quarter note + /// at onset 0. Copying all three events yields a two-lane fragment whose + /// lane 0 (two events + the slur) is exactly [`two_voice_one_note_each_score`]'s + /// lane 0 with a slur added — the r3 atomicity test's proof that a + /// cross-cutting structure riding along a multi-lane paste does not + /// change the all-or-nothing guarantee. + fn two_voice_first_voice_has_two_notes_with_a_slur_second_voice_one_note_score() -> Score { + use epiphany_core::{ + Canvas, EventArena, IdentityContext, Instrument, InstrumentId, MetricTimeModel, Region, + RegionContent, Slur, SlurKind, SpanStyle, Staff, StaffBasedContent, StaffExtent, + StaffLineConfiguration, TimeAnchor, TimeExtent, Voice, + }; + let replica = ReplicaId(0x7777); + 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); + + // Voice 0: two notes, slurred. + let voice0_id: VoiceId = idc.mint(); + let mut voice0 = Voice::user(voice0_id); + let mut voice0_events = Vec::new(); + for index in 0..2i64 { + let eid: EventId = idc.mint(); + let pid: PitchId = idc.mint(); + arena + .insert(Event::Pitched(PitchedEvent { + id: eid, + voice: voice0_id, + position: EventPosition::Musical(MusicalPosition( + RationalTime::new(index, 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"); + voice0.events.push(eid); + voice0_events.push(eid); + } + instance.voices.push(voice0); + + // Voice 1: one note. + let voice1_id: VoiceId = idc.mint(); + let mut voice1 = Voice::user(voice1_id); + 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(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"); + voice1.events.push(eid); + 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 slur_id: SlurId = score.identity.mint(); + score.cross_cutting.slurs.push(Slur { + id: slur_id, + start_event: voice0_events[0], + end_event: voice0_events[1], + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::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 @@ -8223,8 +8768,81 @@ mod tests { ); } - /// Coordinator-added (T2 W4a review): the commit's transactional **shape** - /// is itself canonical surface. The atomicity test above pins + /// r3 (T2 W4b): the atomicity guarantee still holds with a cross-cutting + /// structure aboard the paste. Reuses the two-lane refusal scenario + /// above (`two_voice_second_voice_has_a_nested_tuplet` as the + /// destination), now with a slur spanning lane 0's two source events — + /// the whole paste, the slur's `CreateCrossCutting` included, still + /// rolls back byte-identically when lane 1's make-room refuses. + /// + /// Mutation r3 (the same "commit per lane inside the loop" mutation as + /// f3 above, reapplied now that lane 0 also carries a slur): lane 0's + /// two inserts land before lane 1's refusal is discovered, so + /// `canonical_bytes` changes even though the call still returns `Err` — + /// this test dies on the `canonical_bytes` equality. Confirmed live, + /// then reverted (see the packet report). + #[test] + fn paste_atomicity_rolls_back_mid_transaction_with_a_slur_aboard() { + let mut source = EditorSession::open( + two_voice_first_voice_has_two_notes_with_a_slur_second_voice_one_note_score(), + Box::new(StubSolver), + ) + .expect("renders"); + let events: Vec = source.score().events.iter().map(|e| e.id()).collect(); + assert_eq!(events.len(), 3, "two notes in voice 0, one in voice 1"); + 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"); + assert!(copy.dropped.is_empty(), "the slur is fully inside the copy"); + let decoded = fragment::decode(©.fragment).expect("well-formed"); + assert_eq!(decoded.voices.len(), 2, "two lanes"); + assert_eq!( + decoded.voices[0].events.len(), + 2, + "lane 0 carries both slurred notes" + ); + assert_eq!(decoded.slurs.len(), 1, "the slur made it into the fragment"); + + 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); + // Same click strategy as the test above: voice 1's first triplet + // member's onset — `paste_document` maps lane 0 -> the instance's + // voice 0, lane 1 -> voice 1, positionally. + 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: the slur-bearing lane 0 must not land just because lane 1 refused" + ); + } + + /// Coordinator-added (T2 W4a review); extended T2 W4b (2026-07-24) to pin + /// a slur aboard the paste too. 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 @@ -8235,11 +8853,41 @@ mod tests { /// 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. + /// **exactly** every member — two inserts, the carried respell, and (W4b) + /// the re-minted slur's `CreateCrossCutting` — appended by a single + /// multi-op paste. + /// + /// Re-proven kill (W4b, per the packet's own instruction): temporarily + /// replacing the `if ops.len() == 1 { self.apply(...) } else { + /// self.apply_transaction(...) }` dispatch in `paste_document` with a + /// per-op `self.apply` loop still kills this test — `descriptors` drops + /// to 0 (a per-op loop never mints a `DeclareTransaction` at all), tripping + /// the `descriptors == 1` assertion. Confirmed live, then reverted (not + /// left in the tree — see the packet report). #[test] fn paste_emits_one_transaction_descriptor_plus_members() { - let mut session = - EditorSession::open(small_metric_score(4), Box::new(StubSolver)).expect("renders"); + 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() + }; + // A slur fully inside the copied/pasted range (events 0 and 1) — it + // must ride along as one more transaction member. + let slur_id: SlurId = score.identity.mint(); + score.cross_cutting.slurs.push(Slur { + id: slur_id, + start_event: events[0], + end_event: events[1], + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::default(), + }); + + let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders"); let region = a_clean_metric_region(&session); let voice = primary_voice(&session, region); let before = voice_events(&session, voice); @@ -8255,6 +8903,9 @@ mod tests { 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(), "the slur is fully inside the copy"); + let decoded = fragment::decode(©.fragment).expect("well-formed"); + assert_eq!(decoded.slurs.len(), 1, "the slur made it into the fragment"); let far = MusicalPosition(RationalTime::new(50, 4).unwrap()); let (_, _, origin_y) = region_staff_line(&session, region); @@ -8265,6 +8916,8 @@ mod tests { .paste_at(at, &grid(1, 4), ©.fragment) .expect("the paste applies"); assert_eq!(outcome.events_inserted, 2); + assert_eq!(outcome.slurs_inserted, 1); + assert_eq!(outcome.ties_inserted, 0); let new = &session.applied_operations()[applied_before..]; let descriptors = new @@ -8280,9 +8933,12 @@ mod tests { descriptors, 1, "a multi-op paste emits exactly one transaction descriptor" ); - assert!( - new.len() >= 4, - "descriptor + two inserts + the carried respell, got {}", + // Exactly: descriptor + two inserts + the carried respell + the + // re-minted slur's CreateCrossCutting. + assert_eq!( + new.len(), + 5, + "descriptor + two inserts + the carried respell + one CreateCrossCutting, got {}", new.len() ); } diff --git a/crates/epiphany-editor-gui/DECISIONS.md b/crates/epiphany-editor-gui/DECISIONS.md index 4f8acf3..d8da294 100644 --- a/crates/epiphany-editor-gui/DECISIONS.md +++ b/crates/epiphany-editor-gui/DECISIONS.md @@ -222,3 +222,112 @@ toolbar/key intents act on the whole selection (`delete_selection`, `self.selection.anchor()` read in `epiphany-editor-core/src/lib.rs`, not assumed from the contract's own summary, which groups `alter` with the anchor-only intents even though its implementation batches). + +## Clipboard wiring (2026-07-24, T2-W4b) + +Dispatched as the W4b packet's Half 2, over `epiphany-editor-core`'s +`copy_selection`/`paste_over_selection`/`CopyOutcome`/`PasteOutcome` +(fragment.rs's clipboard fragment projection, Ruling E). `main.rs` only; no +golden pixel moves for the same structural reason W2's overlay didn't +(decision 9): `goldens.rs` builds its own session and calls +`render`/`rasterize_pixmap` directly, never through `EditorApp`, so nothing +this packet touches (toolbar, keys, status line, a new `last_paste_text` +field) is reachable from a golden test. Verified, not assumed: the three +baseline PNGs (`ten_measure_open.png` 53638B, `ten_measure_insert.png` +54590B, `ten_measure_slurs_castoff.png` 57891B) are byte-identical +(`md5sum` before/after) and untouched by `git status` throughout this +packet. + +**The clipboard mechanism egui 0.29 actually offers — verified against the +vendored source (`~/.cargo/registry/…/egui-0.29.1`, +`~/.cargo/registry/…/eframe-0.29.1`), not assumed from memory of a newer +egui:** + +* **Write (copy): `egui::Context::output_mut`/`egui::Ui::output_mut`, + setting `PlatformOutput::copied_text`.** This is the *only* clipboard-write + surface in 0.29 (`egui/src/data/output.rs:107`, `egui/src/context.rs:1423`) + — the backend (`eframe`'s `egui-winit` integration) reads it after the + frame and pushes it to the OS clipboard. `do_copy` sets + `ctx.output_mut(|o| o.copied_text = outcome.fragment.clone())` on a + successful `copy_selection`. +* **Read (paste): there is no synchronous "read the OS clipboard now" call + anywhere in `eframe::Frame`'s or `egui::Context`'s public API** — checked + directly (`grep -rn "pub fn" eframe-0.29.1/src/epi.rs`; the only clipboard + reference in `eframe`'s own source is `egui_winit.clipboard_text()` + inside the native integration's *internal* event-translation code, + `native/glow_integration.rs:681`, never exposed to app code). The **only** + way an app learns paste content is by consuming + **`egui::Event::Paste(String)`** from the input event queue + (`egui/src/data/input.rs:388`) — the backend detects an OS paste gesture + (Ctrl/Cmd+V, or an OS-level paste menu action), reads the clipboard + itself, and injects the text as one event, once, for that frame only. + `EditorApp::handle_clipboard_events` reads `ctx.input(|i| &i.events)` + every frame via the new pure helper `paste_event_text(&[egui::Event]) -> + Option<&str>` and, on a hit, both **acts immediately** (pastes over the + current selection) and **caches the text** (`last_paste_text`) for the + toolbar "Paste" button — which has no other way to act between paste + gestures, since a button click generates no `Event::Paste` of its own and + there is nothing else to read it from. + +**Why Copy is a plain keyboard edge but Paste cannot be.** `handle_keys`'s +existing pattern is a boolean edge-read (`i.modifiers.command && +i.key_pressed(egui::Key::...)`), used for every other shortcut in this file. +Ctrl/Cmd+C fits it exactly: `do_copy` needs nothing from egui *except* that +the chord fired — the text it writes out comes from `copy_selection()`, not +from any event payload — so `Keys` gained one more field (`copy`) the same +way. Ctrl/Cmd+V structurally cannot fit that pattern: a bare `key_pressed` +edge tells you *that* a paste gesture happened, never *what* was pasted, and +only `Event::Paste`'s own `String` payload carries that. So paste has no +`Keys` field at all — `handle_clipboard_events` (called once per frame from +`update`, alongside `handle_keys`) is the whole mechanism, and it is what +makes Ctrl/Cmd+V "just work": egui's own native integration already turns +that chord into the event, this app only has to consume it. + +**No-selection paste policy.** `paste_over_selection` itself returns +`Err(EditorError::NoSelection)` when nothing is selected — Display: "no +selection", accurate but not actionable in a clipboard context (a user +who never selected anything doesn't know that's what "no selection" means +here). `do_paste` pre-checks `session.anchor().is_none()` and reports +**"select a destination first"** instead, without ever calling +`paste_over_selection` — a GUI-level policy decision, not a change to the +core session's error semantics. Every *other* outcome — a successful paste +(reports events/slur/tie counts from `PasteOutcome`) or any other +`EditorError` — surfaces `{err}` verbatim, per the packet's brief ("the +fragment error Display strings are user-grade"); `FragmentError`'s and +`EditorError`'s `Display` impls (`epiphany-editor-core/src/fragment.rs`, +`lib.rs`) are already written for a human reader, so this crate adds no +translation layer over them. + +**Toolbar buttons.** "Copy" is always clickable (unguarded), matching this +toolbar's existing convention for every other selection-dependent action +(Delete, Transpose, Move, …) — none of them are `add_enabled`-gated on +selection state; a `NoSelection`/`WrongSelection` error is left to surface +normally through the status line. "Paste" **is** gated +(`add_enabled(self.last_paste_text.is_some(), …)`), because unlike a +domain error, "nothing has ever arrived to paste" is a structural +precondition with no session-side error to report at all — the same class +of gate `can_undo`/`can_redo` already use for Undo/Redo. + +**Pencil mode is untouched.** Clipboard actions (buttons, keys, and +`handle_clipboard_events`) run unconditionally regardless of `self.pencil` +— no new interaction with the pencil click-to-insert path, no new branch in +`score_view`. Pencil's own behavior, and every existing test/golden that +exercises it, is unchanged. + +**Testing scope, stated honestly.** `paste_event_text` is pure (no +`egui::Context`/`InputState`/native-backend dependency) and gets four real +unit tests, each proven live by a mutation (dropping `.rev()` — breaks +"last `Paste` in the frame wins" — and matching the wrong `Event` variant +— both confirmed to fail the expected tests, then reverted). Everything +else this packet added — `do_copy`/`do_paste`/`do_paste_from_cache`/ +`handle_clipboard_events`'s own dispatch, the toolbar buttons, the +`Keys::copy` edge, the help text — is **egui-side dispatch, reviewed but +not unit-tested**: there is no headless way in this crate to synthesize an +`egui::Context` frame, drive `ctx.input`/`output_mut`, or simulate an OS +clipboard/paste gesture (the same limitation the rest of `main.rs` +already lives with — `goldens.rs` exists precisely because rendering has +no headless story either, and `resolve_release`/`DragRect` were pulled out +pure for the same reason W2 needed to unit-test *something* about +release-time dispatch). `cargo test -p epiphany-editor-gui` green (20/20, +including all four golden tests) is this packet's regression gate for that +untested surface, per the brief. diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index 9e1a279..0efb6e1 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -223,6 +223,20 @@ fn resolve_release(drag: &DragRect, ctrl: bool) -> ReleaseAction { } } +/// The text of the most recent `egui::Event::Paste` in `events`, if any (T2 +/// W4b) — pure and independent of `egui::Context`/`InputState`, so it is +/// headlessly unit-testable; `EditorApp::handle_clipboard_events` is the only +/// egui-side caller. If more than one `Paste` event lands in the same frame +/// (unusual — one OS paste gesture is one event — but the queue is a plain +/// `Vec`), the *last* one wins, mirroring `PlatformOutput::copied_text`'s own +/// "most recent wins" discipline for the analogous output side. +fn paste_event_text(events: &[egui::Event]) -> Option<&str> { + events.iter().rev().find_map(|e| match e { + egui::Event::Paste(text) => Some(text.as_str()), + _ => None, + }) +} + /// A short label for the last applied op, for the debug panel. fn payload_label(payload: &OperationPayload) -> &'static str { match payload { @@ -305,6 +319,14 @@ struct EditorApp { /// `None` in pencil mode — pencil's click-to-insert takes precedence and ignores /// dragging entirely). rubber_band: Option, + /// The most recent clipboard text egui delivered via `egui::Event::Paste` + /// (T2 W4b). egui 0.29 exposes no synchronous "read the OS clipboard now" + /// call (see `DECISIONS.md`) — the only way this app ever learns paste + /// text is by consuming that event as it arrives (on an OS paste gesture, + /// e.g. Ctrl/Cmd+V). Cached here so the toolbar "Paste" button has + /// something to act on between paste gestures, not just at the instant + /// one occurs. + last_paste_text: Option, status: String, } @@ -322,6 +344,7 @@ impl EditorApp { needs_render: true, pencil: false, rubber_band: None, + last_paste_text: None, status: "opened ten_measure_single_staff".to_string(), } } @@ -357,6 +380,96 @@ impl EditorApp { self.needs_render = true; } + /// Copies the current selection (`EditorSession::copy_selection`) and puts + /// the fragment text on the OS clipboard via egui's own output channel + /// (`ctx.output_mut(|o| o.copied_text = ...)`, the only clipboard-write + /// path egui 0.29 exposes — see `DECISIONS.md`). The status line reports + /// how many events copied, plus anything `CopyOutcome::dropped` names (a + /// boundary-cut slur/tie); an error surfaces verbatim (`EditorError`'s + /// `Display` is user-grade). + fn do_copy(&mut self, ctx: &egui::Context) { + self.status = match self.session.copy_selection() { + Ok(outcome) => { + ctx.output_mut(|o| o.copied_text = outcome.fragment.clone()); + if outcome.dropped.is_empty() { + format!("copy: {} event(s) copied", outcome.events_copied) + } else { + let dropped: Vec = + outcome.dropped.iter().map(|d| format!("{d:?}")).collect(); + format!( + "copy: {} event(s) copied; dropped {}", + outcome.events_copied, + dropped.join(", ") + ) + } + } + Err(err) => format!("copy: {err}"), + }; + } + + /// Pastes `text` (clipboard fragment content) over the current selection + /// (`EditorSession::paste_over_selection`) — clipboard content has no + /// on-screen point to place it at (unlike pencil's click-to-insert), so + /// the destination is always the current selection, never `paste_at`. + /// Reports "select a destination first" — friendlier than + /// `paste_over_selection`'s own bare `EditorError::NoSelection` + /// ("no selection") — when nothing is selected; every other outcome + /// (success, or any other `EditorError`) surfaces verbatim. + fn do_paste(&mut self, text: &str) { + if self.session.anchor().is_none() { + self.status = "paste: select a destination first".to_string(); + return; + } + self.status = match self.session.paste_over_selection(text) { + Ok(outcome) => { + let mut parts = vec![format!("{} event(s)", outcome.events_inserted)]; + if outcome.slurs_inserted > 0 { + parts.push(format!("{} slur(s)", outcome.slurs_inserted)); + } + if outcome.ties_inserted > 0 { + parts.push(format!("{} tie(s)", outcome.ties_inserted)); + } + format!("paste: {} pasted", parts.join(", ")) + } + Err(err) => format!("paste: {err}"), + }; + self.needs_render = true; + } + + /// The toolbar "Paste" button's action: egui 0.29 offers no synchronous + /// "read the OS clipboard now" call (`DECISIONS.md`), so a button click + /// cannot pull fresh clipboard text on demand — it reuses whatever + /// `egui::Event::Paste` most recently delivered + /// (`Self::handle_clipboard_events`/`last_paste_text`). Reports plainly + /// when nothing has arrived yet. + fn do_paste_from_cache(&mut self) { + match self.last_paste_text.clone() { + Some(text) => self.do_paste(&text), + None => { + self.status = + "paste: no clipboard text yet — press Ctrl/Cmd+V once, or copy something \ + first" + .to_string(); + } + } + } + + /// Consumes this frame's `egui::Event::Paste`, if any (the only mechanism + /// egui 0.29 offers for reading OS paste content — see `DECISIONS.md`): + /// caches it for the toolbar "Paste" button and immediately pastes it + /// over the current selection, so Ctrl/Cmd+V "just works" without a + /// separate keyboard shortcut of its own (a plain `key_pressed(V) && + /// command` check, `handle_keys`'s usual pattern, cannot substitute here + /// — it would tell us *that* a paste gesture happened, never *what* was + /// pasted). + fn handle_clipboard_events(&mut self, ctx: &egui::Context) { + let text = ctx.input(|i| paste_event_text(&i.events).map(str::to_string)); + if let Some(text) = text { + self.last_paste_text = Some(text.clone()); + self.do_paste(&text); + } + } + /// Re-renders the session's resolved layout to a texture. The view box, logical /// size, and texture are updated together (only on a successful rasterization), so /// the displayed pixels never disagree with the click plane; on failure the score @@ -417,6 +530,16 @@ impl EditorApp { self.run("insert after", |s| s.insert_note_after_selection()); } ui.separator(); + if ui.button("Copy").clicked() { + self.do_copy(ui.ctx()); + } + if ui + .add_enabled(self.last_paste_text.is_some(), egui::Button::new("Paste")) + .clicked() + { + self.do_paste_from_cache(); + } + ui.separator(); // Duration palette: set the selected note/rest's written value (make-room // overwrite when lengthening). ui.label("Dur:"); @@ -483,7 +606,14 @@ impl EditorApp { Delete and Transpose act on the whole selection; Move / Add chord / Insert after / \ duration act on the anchor (the drag/toggle reference point, drawn with the \ thicker accent stroke).\n\ - Keys: Del delete · ↑/↓ staff-step move · +/− transpose · A add chord · I insert after · P pencil · Ctrl/Cmd+Z undo · Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redo", + Copy/Paste: copies the selection to the fragment clipboard, pastes it over the \ + current selection (a destination must be selected first — Paste with none selected \ + reports that instead of pasting). The Paste button acts on the last clipboard text \ + this app has seen (egui reads it only on an actual paste gesture); Ctrl/Cmd+V pastes \ + immediately using that same text as it arrives.\n\ + Keys: Del delete · ↑/↓ staff-step move · +/− transpose · A add chord · I insert after · \ + P pencil · Ctrl/Cmd+C copy · Ctrl/Cmd+V paste · Ctrl/Cmd+Z undo · Ctrl/Cmd+Shift+Z or \ + Ctrl/Cmd+Y redo", if self.pencil { "ON — click to insert" } else { "off" } )); } @@ -504,6 +634,13 @@ impl EditorApp { add: i.key_pressed(egui::Key::A), insert: i.key_pressed(egui::Key::I), pencil: i.key_pressed(egui::Key::P), + // Copy is a plain boolean edge, same as every other shortcut here — + // `do_copy` needs nothing egui delivered *to* it, only the current + // selection, so it fits this file's usual `key_pressed` pattern. + // Paste does NOT get a matching edge here: reading the clipboard's + // actual text needs `egui::Event::Paste`'s payload, which a bare + // key-press check cannot provide (see `handle_clipboard_events`). + copy: i.modifiers.command && i.key_pressed(egui::Key::C), }); if k.undo { self.run_history("undo", |s| s.undo()); @@ -536,6 +673,9 @@ impl EditorApp { if k.insert { self.run("insert after", |s| s.insert_note_after_selection()); } + if k.copy { + self.do_copy(ctx); + } } /// Resolves a non-pencil click/toggle at world `point`: Ctrl/Cmd held (`toggle`) @@ -718,11 +858,13 @@ struct Keys { add: bool, insert: bool, pencil: bool, + copy: bool, } impl eframe::App for EditorApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.handle_keys(ctx); + self.handle_clipboard_events(ctx); egui::TopBottomPanel::top("toolbar").show(ctx, |ui| self.toolbar(ui)); egui::SidePanel::right("debug") @@ -888,4 +1030,53 @@ mod tests { assert_eq!(resolve_release(&drag, false), ReleaseAction::RubberBand); assert_eq!(resolve_release(&drag, true), ReleaseAction::RubberBand); } + + // T2 W4b: `paste_event_text`, the pure extraction helper over a frame's + // `egui::Event`s that `handle_clipboard_events` calls. This is the one + // piece of clipboard-wiring logic with no `egui::Context`/native-backend + // dependency, so it gets real unit tests (the surrounding egui-side + // dispatch — reading `ctx.input`, calling `output_mut`, wiring the + // toolbar buttons — is review-only, per the packet's own gate; there is + // no headless way to synthesize an `egui::Context` frame or an OS + // clipboard gesture here). + + #[test] + fn paste_event_text_finds_the_paste_events_own_text() { + let events = vec![ + egui::Event::Copy, + egui::Event::Text("x".to_string()), + egui::Event::Paste("hello fragment".to_string()), + ]; + assert_eq!(paste_event_text(&events), Some("hello fragment")); + } + + #[test] + fn paste_event_text_returns_none_without_a_paste_event() { + let events = vec![ + egui::Event::Copy, + egui::Event::Cut, + egui::Event::Text("x".to_string()), + ]; + assert_eq!(paste_event_text(&events), None); + } + + #[test] + fn paste_event_text_on_an_empty_frame_is_none() { + assert_eq!(paste_event_text(&[]), None); + } + + #[test] + fn paste_event_text_prefers_the_last_paste_event_in_the_frame() { + // Two Paste events in one frame is not a real egui scenario (one OS + // paste gesture is one event), but the queue is a plain `Vec`, and + // "most recent wins" is the documented contract — a mutation that + // took the *first* match instead of the last would pass every other + // test here (they carry at most one `Paste`) but fail this one. + let events = vec![ + egui::Event::Paste("first".to_string()), + egui::Event::Copy, + egui::Event::Paste("second".to_string()), + ]; + assert_eq!(paste_event_text(&events), Some("second")); + } }