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 epiphany_core::{
|
||||
AcousticRealization, Clef, CmnNominal, Event, EventDuration, EventId, EventPosition,
|
||||
IdentifiedPitch, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
|
||||
PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId, RegionTimeModel,
|
||||
ReplicaId, Score, SpellingDirective, SpellingNominal, SpellingScope, SpellingSourceKind,
|
||||
StaffId, StaffInstance, StaffInstanceId, StemConfiguration, TransactionId, TypedObjectId,
|
||||
VoiceId, WallClockTime,
|
||||
AcousticPitch, AcousticRealization, Clef, CmnNominal, Event, EventDuration, EventId,
|
||||
EventPosition, IdentifiedPitch, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
|
||||
PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId,
|
||||
RegionTimeModel, ReplicaId, ScalePosition, Score, SpellingDirective, SpellingNominal,
|
||||
SpellingScope, SpellingSourceKind, StaffId, StaffInstance, StaffInstanceId, StemConfiguration,
|
||||
TransactionId, TuningReference, TypedObjectId, VoiceId, WallClockTime,
|
||||
};
|
||||
use epiphany_layout_ir::{
|
||||
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::{
|
||||
AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp,
|
||||
HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, ModifyIdentifiedPitchOp,
|
||||
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
|
||||
RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeOp, TupletCompensation,
|
||||
HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp,
|
||||
ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
|
||||
OperationStamp, RespellPitchOp, TransactionCategory, TransactionDescriptor, TransposeOp,
|
||||
TupletCompensation,
|
||||
};
|
||||
|
||||
/// 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
|
||||
/// refused; inserting into a packed voice needs an explicit make-room policy.
|
||||
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 {
|
||||
|
|
@ -198,6 +208,12 @@ impl fmt::Display for EditorError {
|
|||
EditorError::DeclareTransactionNotAllowed => {
|
||||
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
|
||||
/// 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.
|
||||
|
|
@ -1219,6 +1417,121 @@ fn note_above(top: &Pitch) -> Option<Pitch> {
|
|||
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
|
||||
/// 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
|
||||
|
|
@ -1380,7 +1693,7 @@ fn staff_start_clefs(logical: &LogicalLayoutIR) -> StartClefs {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::generators::valid_score_rich;
|
||||
use epiphany_core::generators::{valid_score, valid_score_rich};
|
||||
use epiphany_layout_ir::{
|
||||
ConstrainedLayoutIR, HitShape, InvalidationSet, SolveReport, SolveStatus, SolverState,
|
||||
SolverTier, SolverVersion, StubSolver,
|
||||
|
|
@ -1390,6 +1703,13 @@ mod tests {
|
|||
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
|
||||
/// resulting 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]
|
||||
fn the_full_editing_loop_runs_through_the_session() {
|
||||
let mut session = open_rich(0x5EED);
|
||||
|
|
|
|||
Loading…
Reference in New Issue