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:
parent
56950f8d7c
commit
62117d3426
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(©.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<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(©.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<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(©.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<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(©.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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue