Editor T2 W4b: slur/tie replay on paste, and the clipboard reaches the GUI

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 10:27:08 -04:00
parent 56950f8d7c
commit 62117d3426
5 changed files with 1096 additions and 27 deletions

View File

@ -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<Vec<EventId>>`
(`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<fragment::EventRef, EventId>` 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.

View File

@ -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,

View File

@ -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<DroppedItem>,
/// 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<OperationKind> = 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<EventId>> = 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<EventId> {
}
}
/// 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<SlurId> {
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<TieId> {
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<TransactionId> {
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<EventId> = {
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<EventId> = 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(&copy.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), &copy.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<EventId> = {
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<EventId> = 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(&copy.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), &copy.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<EventId> = 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<PitchId> = 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(&copy.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 }, &copy.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<EventId> = {
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(&copy.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), &copy.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()
);
}

View File

@ -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.

View File

@ -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<DragRect>,
/// 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<String>,
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<String> =
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"));
}
}