editor-core: an insert_note_at click→insert with make-room overwrite
The pencil: a click-to-insert that composes the two inverses — staff_pitch_at (the natural pitch at the height) and position_at (the grid-snapped onset) — into a fresh CMN note (duration = grid step) in the staff's primary voice, and makes room under the overwrite policy as one atomic transaction. Make-room over the overlapping notes/rests of the voice: a fully-covered event is deleted; one the new note partially overlaps is trimmed (a phase-3 ModifyEvent placement change); one the new note lands inside is split — the original is trimmed to its head and its tail re-inserted. A split tail is a clone of the original event's whole shape (a rest's visibility, a note's articulations/dynamics/stem/grace) with fresh event and pitch ids, and any authored spelling is carried onto the fresh pitches via RespellPitch (an inferred spelling re-derives) — the same atomic copy insert_note_after_selection does. The several ids minted in one intent advance local checked counters, since the session high-water mark does not move until commit. Refuses cleanly rather than corrupt: an overlapped tuplet member raises OverlapsTuplet (ratio compensation is out of scope); an off-staff or non-metric click, or a non-note/rest overlap, raises NoInsertTarget. Tests (against valid_score's tuplet-free metric region; the rich fixture's only metric region is a triplet): empty-space insert, full-cover overwrite, the split structure, the split tail's shape fidelity and authored-spelling carry, the tuplet refusal, and the non-metric refusal. A click_for_position helper inverts position_at so a test targets an exact onset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
parent
b3403c1d6a
commit
ce37de8834
|
|
@ -43,12 +43,12 @@ use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use epiphany_core::{
|
use epiphany_core::{
|
||||||
AcousticRealization, Clef, CmnNominal, Event, EventDuration, EventId, EventPosition,
|
AcousticPitch, AcousticRealization, Clef, CmnNominal, Event, EventDuration, EventId,
|
||||||
IdentifiedPitch, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
|
EventPosition, IdentifiedPitch, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
|
||||||
PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId, RegionTimeModel,
|
PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId,
|
||||||
ReplicaId, Score, SpellingDirective, SpellingNominal, SpellingScope, SpellingSourceKind,
|
RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal,
|
||||||
StaffId, StaffInstance, StaffInstanceId, StemConfiguration, TransactionId, TypedObjectId,
|
SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration,
|
||||||
VoiceId, WallClockTime,
|
TransactionId, TuningReference, TypedObjectId, VoiceId, WallClockTime,
|
||||||
};
|
};
|
||||||
use epiphany_layout_ir::{
|
use epiphany_layout_ir::{
|
||||||
active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render,
|
active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render,
|
||||||
|
|
@ -57,9 +57,10 @@ use epiphany_layout_ir::{
|
||||||
};
|
};
|
||||||
use epiphany_ops::{
|
use epiphany_ops::{
|
||||||
AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp,
|
AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp,
|
||||||
HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, ModifyIdentifiedPitchOp,
|
HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp,
|
||||||
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
|
ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
|
||||||
RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeOp, TupletCompensation,
|
OperationStamp, RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeOp,
|
||||||
|
TupletCompensation,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The current selection: the score-graph object to act on, plus the stable layout
|
/// The current selection: the score-graph object to act on, plus the stable layout
|
||||||
|
|
@ -170,6 +171,15 @@ pub enum EditorError {
|
||||||
/// event in the same voice (the reducer would silently no-op it). The edit is
|
/// event in the same voice (the reducer would silently no-op it). The edit is
|
||||||
/// refused; inserting into a packed voice needs an explicit make-room policy.
|
/// refused; inserting into a packed voice needs an explicit make-room policy.
|
||||||
InsertSlotOccupied,
|
InsertSlotOccupied,
|
||||||
|
/// A click-to-insert resolved to no insert target: the point is off any staff, the
|
||||||
|
/// region is non-metric or has too few rendered events to place a position, or the
|
||||||
|
/// staff has no voice / no diatonic clef. Nothing is inserted.
|
||||||
|
NoInsertTarget,
|
||||||
|
/// A click-to-insert's make-room would have to trim or delete a **tuplet member**,
|
||||||
|
/// whose duration is governed by the tuplet's ratio. Compensating a tuplet on a
|
||||||
|
/// pencil overwrite is a later refinement, so the insert is refused rather than
|
||||||
|
/// leaving the tuplet inconsistent.
|
||||||
|
OverlapsTuplet,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for EditorError {
|
impl fmt::Display for EditorError {
|
||||||
|
|
@ -198,6 +208,12 @@ impl fmt::Display for EditorError {
|
||||||
EditorError::DeclareTransactionNotAllowed => {
|
EditorError::DeclareTransactionNotAllowed => {
|
||||||
f.write_str("a transaction cannot be declared through an edit; the session manages transactions")
|
f.write_str("a transaction cannot be declared through an edit; the session manages transactions")
|
||||||
}
|
}
|
||||||
|
EditorError::NoInsertTarget => {
|
||||||
|
f.write_str("the click resolved to no insert target (off-staff, non-metric, or no voice)")
|
||||||
|
}
|
||||||
|
EditorError::OverlapsTuplet => {
|
||||||
|
f.write_str("the insert would have to trim or delete a tuplet member")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -983,6 +999,188 @@ impl EditorSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inserts a note at a world `point` on the beat grid — the **click-to-insert**
|
||||||
|
/// ("pencil"): the pitch is the natural pitch at the cursor's height, the position
|
||||||
|
/// is the grid-snapped onset, and the written duration is the grid `step`. The note
|
||||||
|
/// goes into the staff's primary voice and **makes room** under an overwrite policy
|
||||||
|
/// — an existing note/rest the new note fully covers is deleted, one it partially
|
||||||
|
/// overlaps is trimmed, and one it lands inside is split (head trimmed, tail
|
||||||
|
/// re-inserted) — all as one transaction, so it applies atomically or not at all.
|
||||||
|
///
|
||||||
|
/// Errors with [`EditorError::NoInsertTarget`] when the click resolves to no metric
|
||||||
|
/// staff/position (or the overlap includes a non-note/rest event there is no
|
||||||
|
/// make-room rule for), or [`EditorError::OverlapsTuplet`] when make-room would have
|
||||||
|
/// to disturb a tuplet member (its duration is ratio-governed).
|
||||||
|
pub fn insert_note_at(
|
||||||
|
&mut self,
|
||||||
|
point: Point,
|
||||||
|
grid: &GridResolution,
|
||||||
|
) -> Result<EditOutcome, EditorError> {
|
||||||
|
let pitch = self
|
||||||
|
.staff_pitch_at(point)
|
||||||
|
.ok_or(EditorError::NoInsertTarget)?;
|
||||||
|
let placed = self
|
||||||
|
.position_at(point, grid)
|
||||||
|
.ok_or(EditorError::NoInsertTarget)?;
|
||||||
|
// The staff instance's region (must match the resolved position's) and its
|
||||||
|
// primary voice — the new note's home.
|
||||||
|
let (region, voice) = self
|
||||||
|
.score
|
||||||
|
.staff_instances()
|
||||||
|
.find(|(_, si)| si.id == pitch.staff_instance)
|
||||||
|
.and_then(|(region, si)| {
|
||||||
|
let voice = si
|
||||||
|
.voices
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.is_primary)
|
||||||
|
.or_else(|| si.voices.first())?;
|
||||||
|
Some((region, voice.id))
|
||||||
|
})
|
||||||
|
.ok_or(EditorError::NoInsertTarget)?;
|
||||||
|
if region != placed.region {
|
||||||
|
return Err(EditorError::NoInsertTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new note's half-open span.
|
||||||
|
let start = placed.position;
|
||||||
|
let duration = grid.step.clone();
|
||||||
|
let end = start.clone() + duration.clone();
|
||||||
|
|
||||||
|
// Make-room: classify every note/rest in the voice that overlaps the span.
|
||||||
|
let mut trims: Vec<Event> = Vec::new();
|
||||||
|
let mut deletes: Vec<EventId> = Vec::new();
|
||||||
|
let mut tails: Vec<(Event, MusicalPosition, MusicalDuration)> = Vec::new();
|
||||||
|
for event in self.score.events.iter() {
|
||||||
|
if event.voice() != voice {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (EventPosition::Musical(ep), EventDuration::Musical(ed)) =
|
||||||
|
(event.position(), event.duration())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let event_end = ep.clone() + ed.clone();
|
||||||
|
if !(ep < &end && start < event_end) {
|
||||||
|
continue; // disjoint from [start, end)
|
||||||
|
}
|
||||||
|
if self.event_in_tuplet(event.id()) {
|
||||||
|
return Err(EditorError::OverlapsTuplet);
|
||||||
|
}
|
||||||
|
if !matches!(event, Event::Pitched(_) | Event::Rest(_)) {
|
||||||
|
return Err(EditorError::NoInsertTarget); // no make-room rule for this kind
|
||||||
|
}
|
||||||
|
match (ep < &start, event_end > end) {
|
||||||
|
// Fully covered → delete.
|
||||||
|
(false, false) => deletes.push(event.id()),
|
||||||
|
// The note lands inside it → split: trim to the head, re-insert the tail.
|
||||||
|
(true, true) => {
|
||||||
|
trims.push(replace_span(event, ep.clone(), span_between(ep, &start)));
|
||||||
|
tails.push((event.clone(), end.clone(), span_between(&end, &event_end)));
|
||||||
|
}
|
||||||
|
// Overlaps the head (event ends inside the note) → trim the tail off.
|
||||||
|
(true, false) => {
|
||||||
|
trims.push(replace_span(event, ep.clone(), span_between(ep, &start)));
|
||||||
|
}
|
||||||
|
// Overlaps the tail (event starts inside the note) → trim the head off.
|
||||||
|
(false, true) => {
|
||||||
|
trims.push(replace_span(
|
||||||
|
event,
|
||||||
|
end.clone(),
|
||||||
|
span_between(&end, &event_end),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint ids for the split tails and the new note with local counters, advancing
|
||||||
|
// them (checked, like the session minters) so the several mints in this one
|
||||||
|
// intent do not collide — the session high-water mark does not move until commit.
|
||||||
|
let replica = self.replica;
|
||||||
|
let mut next_event = self.mint_event_id().counter();
|
||||||
|
let mut next_pitch = self.mint_pitch_id().counter();
|
||||||
|
let mut mint_event = || {
|
||||||
|
let id = EventId::new(replica, next_event);
|
||||||
|
next_event = next_event
|
||||||
|
.checked_add(1)
|
||||||
|
.expect("event id counter overflowed u64");
|
||||||
|
id
|
||||||
|
};
|
||||||
|
let mut mint_pitch = || {
|
||||||
|
let id = PitchId::new(replica, next_pitch);
|
||||||
|
next_pitch = next_pitch
|
||||||
|
.checked_add(1)
|
||||||
|
.expect("pitch id counter overflowed u64");
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order: free the span first (trims, deletes), then the re-inserted tails (each
|
||||||
|
// carrying its spellings), then the new note. Members apply in this order.
|
||||||
|
let mut ops: Vec<OperationKind> = Vec::new();
|
||||||
|
for event in trims {
|
||||||
|
ops.push(OperationKind::ModifyEvent(ModifyEventOp { event }));
|
||||||
|
}
|
||||||
|
for event in deletes {
|
||||||
|
ops.push(OperationKind::DeleteEvent(DeleteEventOp {
|
||||||
|
event,
|
||||||
|
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (original, position, duration) in tails {
|
||||||
|
// The tail is the original event's later portion: clone its whole shape (a
|
||||||
|
// rest's visibility, a note's articulations/dynamics/stem/grace) with fresh
|
||||||
|
// ids, then carry any *authored* spelling onto the fresh pitches (an inferred
|
||||||
|
// one re-derives) — the same atomic copy `insert_note_after_selection` does.
|
||||||
|
let mut pitches: Vec<&IdentifiedPitch> = Vec::new();
|
||||||
|
original.collect_identified_pitches(&mut pitches);
|
||||||
|
let originals: Vec<PitchId> = pitches.iter().map(|ip| ip.id).collect();
|
||||||
|
let fresh: Vec<PitchId> = originals.iter().map(|_| mint_pitch()).collect();
|
||||||
|
let tail = respan_with_fresh_ids(&original, mint_event(), position, duration, &fresh);
|
||||||
|
ops.push(OperationKind::InsertEvent(InsertEventOp {
|
||||||
|
staff_instance: pitch.staff_instance,
|
||||||
|
event: tail,
|
||||||
|
}));
|
||||||
|
for (old, new) in originals.iter().zip(&fresh) {
|
||||||
|
if let Some(spelling) = authored_spelling(&self.score, *old) {
|
||||||
|
ops.push(OperationKind::RespellPitch(RespellPitchOp {
|
||||||
|
pitch: *new,
|
||||||
|
spelling,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let new_note = note_event(
|
||||||
|
mint_event(),
|
||||||
|
voice,
|
||||||
|
start,
|
||||||
|
duration,
|
||||||
|
vec![IdentifiedPitch {
|
||||||
|
id: mint_pitch(),
|
||||||
|
pitch: cmn_pitch(pitch.nominal, pitch.octave),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
ops.push(OperationKind::InsertEvent(InsertEventOp {
|
||||||
|
staff_instance: pitch.staff_instance,
|
||||||
|
event: new_note,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// A bare insert needs no transaction; a make-room insert is atomic.
|
||||||
|
if ops.len() == 1 {
|
||||||
|
self.apply(ops.into_iter().next().expect("one op"))
|
||||||
|
} else {
|
||||||
|
self.apply_transaction("insert note", Some(TransactionCategory::NoteEntry), ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `event` is a member of any tuplet (its duration is ratio-governed, so
|
||||||
|
/// make-room cannot trim or delete it without tuplet compensation).
|
||||||
|
fn event_in_tuplet(&self, event: EventId) -> bool {
|
||||||
|
self.score
|
||||||
|
.cross_cutting
|
||||||
|
.tuplets
|
||||||
|
.iter()
|
||||||
|
.any(|tuplet| tuplet.members.contains(&event))
|
||||||
|
}
|
||||||
|
|
||||||
/// The staff instance hosting `voice`, but only when its region is metric — the
|
/// The staff instance hosting `voice`, but only when its region is metric — the
|
||||||
/// time model `InsertEvent` requires (the reducer rejects any other). `None` if
|
/// time model `InsertEvent` requires (the reducer rejects any other). `None` if
|
||||||
/// the voice is absent from the voice tree or its region is non-metric.
|
/// the voice is absent from the voice tree or its region is non-metric.
|
||||||
|
|
@ -1219,6 +1417,121 @@ fn note_above(top: &Pitch) -> Option<Pitch> {
|
||||||
Some(above)
|
Some(above)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A natural CMN pitch at `nominal`/`octave`, sounding at its written position
|
||||||
|
/// ([`AcousticRealization::Implicit`]) — the value a click-to-insert mints for the
|
||||||
|
/// height under the cursor (a caller respells if an accidental is wanted).
|
||||||
|
fn cmn_pitch(nominal: CmnNominal, octave: i8) -> Pitch {
|
||||||
|
Pitch {
|
||||||
|
scale_position: ScalePosition {
|
||||||
|
space: PitchSpaceId::new("cmn-12"),
|
||||||
|
position: PitchSpacePosition::Cmn {
|
||||||
|
nominal,
|
||||||
|
alteration: 0,
|
||||||
|
octave,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
acoustic: AcousticPitch {
|
||||||
|
tuning: TuningReference::Inherit,
|
||||||
|
realization: AcousticRealization::Implicit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The musical duration spanning `from..to` (`to - from`), exact over rational time.
|
||||||
|
fn span_between(from: &MusicalPosition, to: &MusicalPosition) -> MusicalDuration {
|
||||||
|
MusicalDuration(to.0.sub(&from.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `event` re-placed at a new metric `position`/`duration` (a make-room trim), keeping
|
||||||
|
/// everything else. Only notes and rests carry a make-room rule; other kinds return
|
||||||
|
/// unchanged (the caller refuses such an overlap before reaching here).
|
||||||
|
fn replace_span(event: &Event, position: MusicalPosition, duration: MusicalDuration) -> Event {
|
||||||
|
let mut moved = event.clone();
|
||||||
|
match &mut moved {
|
||||||
|
Event::Pitched(pe) => {
|
||||||
|
pe.position = EventPosition::Musical(position);
|
||||||
|
pe.duration = EventDuration::Musical(duration);
|
||||||
|
}
|
||||||
|
Event::Rest(rest) => {
|
||||||
|
rest.position = EventPosition::Musical(position);
|
||||||
|
rest.duration = EventDuration::Musical(duration);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
moved
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `event`'s later portion as a fresh event for a split tail: a clone with a fresh
|
||||||
|
/// event `id`, its pitches re-identified by `fresh_pitch_ids` (same values, in order),
|
||||||
|
/// re-placed at `position`/`duration`. Cloning keeps everything a continuation should
|
||||||
|
/// keep — a rest's `visible`/`vertical_position`, a note's articulations, dynamics,
|
||||||
|
/// ornaments, stem, and grace. Authored spellings are carried separately (they key on
|
||||||
|
/// pitch id, so the caller adds `RespellPitch`es for the fresh ids).
|
||||||
|
fn respan_with_fresh_ids(
|
||||||
|
event: &Event,
|
||||||
|
id: EventId,
|
||||||
|
position: MusicalPosition,
|
||||||
|
duration: MusicalDuration,
|
||||||
|
fresh_pitch_ids: &[PitchId],
|
||||||
|
) -> Event {
|
||||||
|
let mut tail = event.clone();
|
||||||
|
let position = EventPosition::Musical(position);
|
||||||
|
let duration = EventDuration::Musical(duration);
|
||||||
|
match &mut tail {
|
||||||
|
Event::Pitched(pe) => {
|
||||||
|
pe.id = id;
|
||||||
|
pe.position = position;
|
||||||
|
pe.duration = duration;
|
||||||
|
for (ip, fresh) in pe.pitches.iter_mut().zip(fresh_pitch_ids) {
|
||||||
|
ip.id = *fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Rest(rest) => {
|
||||||
|
rest.id = id;
|
||||||
|
rest.position = position;
|
||||||
|
rest.duration = duration;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
tail
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh metric event in `voice`: a [`PitchedEvent`] when `pitches` is non-empty,
|
||||||
|
/// else a [`Rest`](epiphany_core::Rest) — used for the new note and any split tail.
|
||||||
|
fn note_event(
|
||||||
|
id: EventId,
|
||||||
|
voice: VoiceId,
|
||||||
|
position: MusicalPosition,
|
||||||
|
duration: MusicalDuration,
|
||||||
|
pitches: Vec<IdentifiedPitch>,
|
||||||
|
) -> Event {
|
||||||
|
let position = EventPosition::Musical(position);
|
||||||
|
let duration = EventDuration::Musical(duration);
|
||||||
|
if pitches.is_empty() {
|
||||||
|
Event::Rest(epiphany_core::Rest {
|
||||||
|
id,
|
||||||
|
voice,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
vertical_position: None,
|
||||||
|
visible: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Event::Pitched(PitchedEvent {
|
||||||
|
id,
|
||||||
|
voice,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
pitches,
|
||||||
|
articulations: Vec::new(),
|
||||||
|
dynamic: None,
|
||||||
|
ornaments: Vec::new(),
|
||||||
|
stem: StemConfiguration,
|
||||||
|
grace: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The pitch ids a session envelope brought into being — so the minter never reuses
|
/// The pitch ids a session envelope brought into being — so the minter never reuses
|
||||||
/// one, including ids since deleted (a `DeleteIdentifiedPitch` leaves no trace in the
|
/// one, including ids since deleted (a `DeleteIdentifiedPitch` leaves no trace in the
|
||||||
/// materialized score, so the log is the authoritative record). Both insert ops mint
|
/// materialized score, so the log is the authoritative record). Both insert ops mint
|
||||||
|
|
@ -1380,7 +1693,7 @@ fn staff_start_clefs(logical: &LogicalLayoutIR) -> StartClefs {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use epiphany_core::generators::valid_score_rich;
|
use epiphany_core::generators::{valid_score, valid_score_rich};
|
||||||
use epiphany_layout_ir::{
|
use epiphany_layout_ir::{
|
||||||
ConstrainedLayoutIR, HitShape, InvalidationSet, SolveReport, SolveStatus, SolverState,
|
ConstrainedLayoutIR, HitShape, InvalidationSet, SolveReport, SolveStatus, SolverState,
|
||||||
SolverTier, SolverVersion, StubSolver,
|
SolverTier, SolverVersion, StubSolver,
|
||||||
|
|
@ -1390,6 +1703,13 @@ mod tests {
|
||||||
EditorSession::open(valid_score_rich(seed), Box::new(StubSolver)).expect("rich renders")
|
EditorSession::open(valid_score_rich(seed), Box::new(StubSolver)).expect("rich renders")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session on the plain fixture, whose single metric region is a tuplet-free run
|
||||||
|
/// of quarter notes — the clean target for the make-room tests (the rich fixture's
|
||||||
|
/// only metric region is a triplet).
|
||||||
|
fn open_plain(seed: u64) -> EditorSession {
|
||||||
|
EditorSession::open(valid_score(seed), Box::new(StubSolver)).expect("plain renders")
|
||||||
|
}
|
||||||
|
|
||||||
/// Clicks the centre of the first notehead (a pitch-backed glyph) and returns the
|
/// Clicks the centre of the first notehead (a pitch-backed glyph) and returns the
|
||||||
/// resulting selection.
|
/// resulting selection.
|
||||||
fn click_a_notehead(session: &mut EditorSession) -> Selection {
|
fn click_a_notehead(session: &mut EditorSession) -> Selection {
|
||||||
|
|
@ -1850,6 +2170,373 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn grid(numerator: i64, denominator: i64) -> GridResolution {
|
||||||
|
GridResolution {
|
||||||
|
step: MusicalDuration(RationalTime::new(numerator, denominator).unwrap()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A metric region (its time model is metric) none of whose events are tuplet
|
||||||
|
/// members — the clean target for the make-room tests.
|
||||||
|
fn a_clean_metric_region(session: &EditorSession) -> RegionId {
|
||||||
|
session
|
||||||
|
.score()
|
||||||
|
.canvas
|
||||||
|
.regions
|
||||||
|
.iter()
|
||||||
|
.filter(|r| matches!(r.time_model, RegionTimeModel::Metric(_)))
|
||||||
|
.map(|r| r.id)
|
||||||
|
.find(|rid| {
|
||||||
|
session
|
||||||
|
.score()
|
||||||
|
.voices()
|
||||||
|
.filter(|(r, _, _)| r == rid)
|
||||||
|
.flat_map(|(_, _, v)| v.events.clone())
|
||||||
|
.all(|eid| {
|
||||||
|
!session
|
||||||
|
.score()
|
||||||
|
.cross_cutting
|
||||||
|
.tuplets
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.members.contains(&eid))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.expect("a tuplet-free metric region")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primary_voice(session: &EditorSession, region: RegionId) -> VoiceId {
|
||||||
|
let (_, si) = session
|
||||||
|
.score()
|
||||||
|
.staff_instances()
|
||||||
|
.find(|(r, _)| *r == region)
|
||||||
|
.expect("the region has a staff instance");
|
||||||
|
si.voices
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.is_primary)
|
||||||
|
.or_else(|| si.voices.first())
|
||||||
|
.expect("the staff has a voice")
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn voice_events(
|
||||||
|
session: &EditorSession,
|
||||||
|
voice: VoiceId,
|
||||||
|
) -> Vec<(EventId, MusicalPosition, MusicalDuration)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for ev in session.score().events.iter() {
|
||||||
|
if ev.voice() != voice {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let (EventPosition::Musical(p), EventDuration::Musical(d)) =
|
||||||
|
(ev.position(), ev.duration())
|
||||||
|
{
|
||||||
|
out.push((ev.id(), p.clone(), d.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.1.cmp(&b.1));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A click whose horizontal inverse snaps to `position` in `region` — derived from
|
||||||
|
/// the region's rendered anchors, so it is the inverse of `position_at` (the stub
|
||||||
|
/// solver spaces a metric region's onsets linearly, so a global fit suffices).
|
||||||
|
fn click_for_position(
|
||||||
|
session: &EditorSession,
|
||||||
|
region: RegionId,
|
||||||
|
position: &MusicalPosition,
|
||||||
|
y: f32,
|
||||||
|
) -> Point {
|
||||||
|
let anchors = session.position_anchors(region);
|
||||||
|
let (p0, x0) = (anchors[0].0 .0.to_f64(), anchors[0].1 as f64);
|
||||||
|
let last = anchors.last().unwrap();
|
||||||
|
let (p1, x1) = (last.0 .0.to_f64(), last.1 as f64);
|
||||||
|
let t = (position.0.to_f64() - p0) / (p1 - p0);
|
||||||
|
Point::new((x0 + t * (x1 - x0)) as f32, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_note_at_fills_empty_space() {
|
||||||
|
let mut session = open_plain(0x5EED);
|
||||||
|
let region = a_clean_metric_region(&session);
|
||||||
|
let voice = primary_voice(&session, region);
|
||||||
|
let before = voice_events(&session, voice);
|
||||||
|
let (_, _, origin_y) = region_staff_line(&session, region);
|
||||||
|
|
||||||
|
// The onset just after the last note — empty space, so a bare insert.
|
||||||
|
let (_, last_pos, last_dur) = before.last().unwrap().clone();
|
||||||
|
let target = MusicalPosition(last_pos.0.add(&last_dur.0));
|
||||||
|
let at = click_for_position(&session, region, &target, origin_y + 1.0);
|
||||||
|
let outcome = session
|
||||||
|
.insert_note_at(at, &grid(1, 4))
|
||||||
|
.expect("insert into empty space");
|
||||||
|
assert!(outcome.graph_changed);
|
||||||
|
|
||||||
|
let after = voice_events(&session, voice);
|
||||||
|
assert_eq!(
|
||||||
|
after.len(),
|
||||||
|
before.len() + 1,
|
||||||
|
"one note added, none removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
after
|
||||||
|
.iter()
|
||||||
|
.any(|(_, p, d)| *p == target && d.0 == RationalTime::new(1, 4).unwrap()),
|
||||||
|
"the new note sits at the snapped onset with the grid duration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_note_at_overwrites_a_covered_note() {
|
||||||
|
let mut session = open_plain(0x5EED);
|
||||||
|
let region = a_clean_metric_region(&session);
|
||||||
|
let voice = primary_voice(&session, region);
|
||||||
|
let before = voice_events(&session, voice);
|
||||||
|
let (_, _, origin_y) = region_staff_line(&session, region);
|
||||||
|
|
||||||
|
// Click the last note with grid = its duration: the new note fully covers it.
|
||||||
|
let (old_id, pos, dur) = before.last().unwrap().clone();
|
||||||
|
let at = click_for_position(&session, region, &pos, origin_y + 1.0);
|
||||||
|
session
|
||||||
|
.insert_note_at(at, &GridResolution { step: dur })
|
||||||
|
.expect("overwrite the covered note");
|
||||||
|
|
||||||
|
let after = voice_events(&session, voice);
|
||||||
|
assert_eq!(after.len(), before.len(), "delete + insert keeps the count");
|
||||||
|
let covering = after
|
||||||
|
.iter()
|
||||||
|
.find(|(_, p, _)| *p == pos)
|
||||||
|
.expect("a note at the covered onset");
|
||||||
|
assert_ne!(covering.0, old_id, "the covered note was replaced");
|
||||||
|
assert!(
|
||||||
|
!after.iter().any(|(id, _, _)| *id == old_id),
|
||||||
|
"the covered note's event is gone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_note_at_splits_an_enclosing_note() {
|
||||||
|
let mut session = open_plain(0x5EED);
|
||||||
|
let region = a_clean_metric_region(&session);
|
||||||
|
let voice = primary_voice(&session, region);
|
||||||
|
let before = voice_events(&session, voice);
|
||||||
|
let (_, _, origin_y) = region_staff_line(&session, region);
|
||||||
|
|
||||||
|
// A grid a quarter of the first note's length, clicked one cell in, lands the
|
||||||
|
// new note strictly inside the note (its second of four cells) — a split.
|
||||||
|
let (first_id, first_pos, first_dur) = before.first().unwrap().clone();
|
||||||
|
let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap());
|
||||||
|
let step = GridResolution {
|
||||||
|
step: MusicalDuration(cell.clone()),
|
||||||
|
};
|
||||||
|
let target = MusicalPosition(first_pos.0.add(&cell));
|
||||||
|
let at = click_for_position(&session, region, &target, origin_y + 1.0);
|
||||||
|
session
|
||||||
|
.insert_note_at(at, &step)
|
||||||
|
.expect("split the enclosing note");
|
||||||
|
|
||||||
|
let after = voice_events(&session, voice);
|
||||||
|
assert_eq!(
|
||||||
|
after.len(),
|
||||||
|
before.len() + 2,
|
||||||
|
"the split adds the new note and a tail"
|
||||||
|
);
|
||||||
|
let head = after
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _, _)| *id == first_id)
|
||||||
|
.expect("the original note survives as the head");
|
||||||
|
assert_eq!(head.1, first_pos, "the head keeps the original onset");
|
||||||
|
assert_eq!(head.2 .0, cell, "the head is trimmed to the click");
|
||||||
|
assert!(
|
||||||
|
after
|
||||||
|
.iter()
|
||||||
|
.any(|(id, p, d)| *id != first_id && *p == target && d.0 == cell),
|
||||||
|
"the new note sits at the click"
|
||||||
|
);
|
||||||
|
let tail_start = MusicalPosition(target.0.add(&cell));
|
||||||
|
assert!(
|
||||||
|
after.iter().any(|(_, p, _)| *p == tail_start),
|
||||||
|
"a tail picks up where the new note ends"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_note_at_split_tail_keeps_the_event_shape() {
|
||||||
|
// Give the first metric note an articulation, then split it: the tail must keep
|
||||||
|
// the note's shape (it is a clone with fresh ids), not be rebuilt as a default
|
||||||
|
// note that drops articulations/dynamics/stem/grace.
|
||||||
|
let mut score = valid_score(0x5EED);
|
||||||
|
let region = score
|
||||||
|
.canvas
|
||||||
|
.regions
|
||||||
|
.iter()
|
||||||
|
.find(|r| matches!(r.time_model, RegionTimeModel::Metric(_)))
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let voice = score
|
||||||
|
.voices()
|
||||||
|
.find(|(r, _, _)| *r == region)
|
||||||
|
.map(|(_, _, v)| v.id)
|
||||||
|
.unwrap();
|
||||||
|
let first_id = {
|
||||||
|
let mut evs: Vec<(EventId, MusicalPosition)> = score
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.voice() == voice)
|
||||||
|
.filter_map(|e| match e.position() {
|
||||||
|
EventPosition::Musical(p) => Some((e.id(), p.clone())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
evs.sort_by(|a, b| a.1.cmp(&b.1));
|
||||||
|
evs[0].0
|
||||||
|
};
|
||||||
|
if let Some(Event::Pitched(pe)) = score.events.get_mut(first_id) {
|
||||||
|
pe.articulations.push(epiphany_core::ArticulationMark);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
|
||||||
|
let (_, _, origin_y) = region_staff_line(&session, region);
|
||||||
|
let (_, first_pos, first_dur) = voice_events(&session, voice)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(id, _, _)| *id == first_id)
|
||||||
|
.unwrap();
|
||||||
|
let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap());
|
||||||
|
let target = MusicalPosition(first_pos.0.add(&cell));
|
||||||
|
let at = click_for_position(&session, region, &target, origin_y + 1.0);
|
||||||
|
session
|
||||||
|
.insert_note_at(
|
||||||
|
at,
|
||||||
|
&GridResolution {
|
||||||
|
step: MusicalDuration(cell.clone()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("split the articulated note");
|
||||||
|
|
||||||
|
let tail_pos = MusicalPosition(target.0.add(&cell));
|
||||||
|
let tail = session
|
||||||
|
.score()
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|e| {
|
||||||
|
e.voice() == voice
|
||||||
|
&& matches!(e.position(), EventPosition::Musical(p) if *p == tail_pos)
|
||||||
|
})
|
||||||
|
.expect("a tail event at the original note's remainder");
|
||||||
|
match tail {
|
||||||
|
Event::Pitched(pe) => assert!(
|
||||||
|
!pe.articulations.is_empty(),
|
||||||
|
"the split tail kept the note's articulation"
|
||||||
|
),
|
||||||
|
_ => panic!("the tail of a pitched note is pitched"),
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
tail.id(),
|
||||||
|
first_id,
|
||||||
|
"the tail is a fresh event, not the original"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_note_at_split_tail_carries_an_authored_spelling() {
|
||||||
|
let mut session = open_plain(0x5EED);
|
||||||
|
let region = a_clean_metric_region(&session);
|
||||||
|
let voice = primary_voice(&session, region);
|
||||||
|
let before = voice_events(&session, voice);
|
||||||
|
let (_, _, origin_y) = region_staff_line(&session, region);
|
||||||
|
let (first_id, first_pos, first_dur) = before.first().unwrap().clone();
|
||||||
|
|
||||||
|
// Pin the first note's pitch with an explicit user spelling — an authored
|
||||||
|
// override the split tail must carry onto its fresh pitch id.
|
||||||
|
let source_pitch = {
|
||||||
|
let ev = session.score().events.get(first_id).unwrap();
|
||||||
|
let mut buf: Vec<&IdentifiedPitch> = Vec::new();
|
||||||
|
ev.collect_identified_pitches(&mut buf);
|
||||||
|
buf.first().expect("the first note has a pitch").id
|
||||||
|
};
|
||||||
|
let pinned = PitchSpelling::cmn(CmnNominal::D, 4);
|
||||||
|
session
|
||||||
|
.apply(OperationKind::RespellPitch(RespellPitchOp {
|
||||||
|
pitch: source_pitch,
|
||||||
|
spelling: pinned.clone(),
|
||||||
|
}))
|
||||||
|
.expect("the respell applies");
|
||||||
|
assert_eq!(
|
||||||
|
authored_spelling(session.score(), source_pitch),
|
||||||
|
Some(pinned.clone())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split the pinned note (grid a quarter of its length, clicked one cell in).
|
||||||
|
let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap());
|
||||||
|
let target = MusicalPosition(first_pos.0.add(&cell));
|
||||||
|
let at = click_for_position(&session, region, &target, origin_y + 1.0);
|
||||||
|
session
|
||||||
|
.insert_note_at(
|
||||||
|
at,
|
||||||
|
&GridResolution {
|
||||||
|
step: MusicalDuration(cell.clone()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("split the pinned note");
|
||||||
|
|
||||||
|
// The tail is a fresh event whose pitch carries the authored spelling.
|
||||||
|
let tail_pos = MusicalPosition(target.0.add(&cell));
|
||||||
|
let tail = session
|
||||||
|
.score()
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|e| {
|
||||||
|
e.voice() == voice
|
||||||
|
&& matches!(e.position(), EventPosition::Musical(p) if *p == tail_pos)
|
||||||
|
})
|
||||||
|
.expect("a tail event");
|
||||||
|
let mut buf: Vec<&IdentifiedPitch> = Vec::new();
|
||||||
|
tail.collect_identified_pitches(&mut buf);
|
||||||
|
let tail_pitch = buf.first().expect("the tail has a pitch").id;
|
||||||
|
assert_ne!(tail_pitch, source_pitch, "the tail's pitch id is fresh");
|
||||||
|
assert_eq!(
|
||||||
|
authored_spelling(session.score(), tail_pitch),
|
||||||
|
Some(pinned),
|
||||||
|
"the authored spelling carried onto the tail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_note_at_refuses_to_disturb_a_tuplet() {
|
||||||
|
let mut session = open_rich(0x5EED);
|
||||||
|
// The rich fixture's first metric region is an eighth-note triplet; covering a
|
||||||
|
// member would need tuplet compensation, so the insert is refused.
|
||||||
|
let region = a_region_with(&session, true);
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.score()
|
||||||
|
.cross_cutting
|
||||||
|
.tuplets
|
||||||
|
.iter()
|
||||||
|
.any(|t| !t.members.is_empty()),
|
||||||
|
"the fixture region is a tuplet"
|
||||||
|
);
|
||||||
|
let voice = primary_voice(&session, region);
|
||||||
|
let (_, pos, dur) = voice_events(&session, voice).into_iter().next().unwrap();
|
||||||
|
let (_, _, origin_y) = region_staff_line(&session, region);
|
||||||
|
let at = click_for_position(&session, region, &pos, origin_y + 1.0);
|
||||||
|
assert_eq!(
|
||||||
|
session.insert_note_at(at, &GridResolution { step: dur }),
|
||||||
|
Err(EditorError::OverlapsTuplet)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_note_at_on_a_non_metric_region_is_no_target() {
|
||||||
|
let mut session = open_rich(0x5EED);
|
||||||
|
// The proportional region has no musical onset to insert at.
|
||||||
|
let region = a_region_with(&session, false);
|
||||||
|
let at = point_on_region_staff(&session, region);
|
||||||
|
assert_eq!(
|
||||||
|
session.insert_note_at(at, &grid(1, 4)),
|
||||||
|
Err(EditorError::NoInsertTarget)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_full_editing_loop_runs_through_the_session() {
|
fn the_full_editing_loop_runs_through_the_session() {
|
||||||
let mut session = open_rich(0x5EED);
|
let mut session = open_rich(0x5EED);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue