editor-core: a set_selection_duration intent with make-room overwrite
The duration-palette gesture: resize the selected note/rest. Shrinking frees the space after it; lengthening makes room over the events it grows into (trim, delete, or split), atomically with the resize — the same overwrite policy as insert. Built on phase 3's metric placement materialization. Extracts the make-room logic shared with insert_note_at: make_room classifies a voice's events overlapping a span (excluding the event being inserted/resized) into deletes/trims/split-tails; make_room_ops turns that into ops (cloning a split tail's shape with fresh ids, carrying authored spellings); a Minter struct replaces the inline id closures. insert_note_at now calls these (its 7 tests confirm behaviour is preserved). Refuses cleanly: InvalidDuration (non-positive), WrongSelection (nothing apt / non-metric / not a note or rest — replace_span only resizes Pitched/Rest), OverlapsTuplet (a tuplet member, whose duration is ratio-governed), and DecomposedEvent — a resize/trim/split would change the duration of an event with a persistent decomposition attachment, leaving its notated components no longer summing to it (invariant 15); a full-cover delete is allowed, since the tombstoned target's decomposition is no longer checked. make_room also skips non-positive existing spans, matching the reducer's overlap rule. Tests: shrink, lengthen-into-empty, lengthen-deletes-a-covered-note, lengthen-trims-a-partial-overlap, refuse non-positive/tuplet, refuse a decomposed event, refuse a non-note/rest event, requires a selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
parent
7387eeafc5
commit
0f298508af
|
|
@ -171,8 +171,18 @@ pub enum EditorError {
|
|||
/// 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.
|
||||
/// leaving the tuplet inconsistent. Also raised by a duration edit of a tuplet
|
||||
/// member itself.
|
||||
OverlapsTuplet,
|
||||
/// A duration edit was given a non-positive duration, which is not a valid written
|
||||
/// note value. Nothing changes.
|
||||
InvalidDuration,
|
||||
/// A duration change (a resize, or a make-room trim/split) would alter an event that
|
||||
/// carries a persistent **decomposition attachment** — its notated components would
|
||||
/// no longer sum to the event's duration (invariant 15). Editing decompositions is a
|
||||
/// later refinement, so the edit is refused rather than left inconsistent. (A delete
|
||||
/// is fine; the tombstoned target's decomposition is no longer checked.)
|
||||
DecomposedEvent,
|
||||
}
|
||||
|
||||
impl fmt::Display for EditorError {
|
||||
|
|
@ -202,7 +212,11 @@ impl fmt::Display for EditorError {
|
|||
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")
|
||||
f.write_str("the edit would have to trim or delete a tuplet member")
|
||||
}
|
||||
EditorError::InvalidDuration => f.write_str("a non-positive duration is not a note value"),
|
||||
EditorError::DecomposedEvent => {
|
||||
f.write_str("the edit would change the duration of an event with a decomposition")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1031,120 +1045,21 @@ impl EditorSession {
|
|||
return Err(EditorError::NoInsertTarget);
|
||||
}
|
||||
|
||||
// The new note's half-open span.
|
||||
// The new note's half-open span; make room over whatever it overlaps.
|
||||
let start = placed.position;
|
||||
let duration = grid.step.clone();
|
||||
let end = start.clone() + duration.clone();
|
||||
let room = self.make_room(voice, &start, &end, None)?;
|
||||
|
||||
// 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 mut minter = self.minter();
|
||||
let mut ops = self.make_room_ops(room, pitch.staff_instance, &mut minter);
|
||||
let new_note = note_event(
|
||||
mint_event(),
|
||||
minter.event(),
|
||||
voice,
|
||||
start,
|
||||
duration,
|
||||
vec![IdentifiedPitch {
|
||||
id: mint_pitch(),
|
||||
id: minter.pitch(),
|
||||
pitch: cmn_pitch(pitch.nominal, pitch.octave),
|
||||
}],
|
||||
);
|
||||
|
|
@ -1161,6 +1076,223 @@ impl EditorSession {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sets the selected event's written **duration** (a notation duration-palette
|
||||
/// gesture; the selection may be a notehead or a rest/stem). Shrinking just frees
|
||||
/// the space after the event; **lengthening makes room** under the overwrite policy
|
||||
/// — the events it grows over are trimmed, deleted, or split, atomically with the
|
||||
/// resize. Errors: [`InvalidDuration`](EditorError::InvalidDuration) for a
|
||||
/// non-positive duration, [`OverlapsTuplet`](EditorError::OverlapsTuplet) when the
|
||||
/// event (or one it grows over) is a tuplet member, and
|
||||
/// [`WrongSelection`](EditorError::WrongSelection) when nothing apt is selected or
|
||||
/// the event is not metric.
|
||||
pub fn set_selection_duration(
|
||||
&mut self,
|
||||
duration: MusicalDuration,
|
||||
) -> Result<EditOutcome, EditorError> {
|
||||
if !duration.is_positive() {
|
||||
return Err(EditorError::InvalidDuration);
|
||||
}
|
||||
let selection = self.selection.ok_or(EditorError::NoSelection)?;
|
||||
let event_id = match selection.source {
|
||||
TypedObjectId::Pitch(pitch) => self
|
||||
.event_and_pitch_of(pitch)
|
||||
.map(|(event, _)| event)
|
||||
.ok_or(EditorError::WrongSelection {
|
||||
expected: "pitch or event",
|
||||
})?,
|
||||
TypedObjectId::Event(event) => event,
|
||||
_ => {
|
||||
return Err(EditorError::WrongSelection {
|
||||
expected: "pitch or event",
|
||||
})
|
||||
}
|
||||
};
|
||||
// The event's voice and metric onset. Only a note or rest is resizable here (a
|
||||
// non-metric event has no note value; only `Pitched`/`Rest` carry a make-room
|
||||
// rule). A tuplet member's duration is ratio-governed, and a decomposed event's
|
||||
// notated components would no longer sum to a changed duration, so refuse both.
|
||||
let (voice, position) = {
|
||||
let event = self
|
||||
.score
|
||||
.events
|
||||
.get(event_id)
|
||||
.ok_or(EditorError::WrongSelection {
|
||||
expected: "pitch or event",
|
||||
})?;
|
||||
if !matches!(event, Event::Pitched(_) | Event::Rest(_)) {
|
||||
return Err(EditorError::WrongSelection {
|
||||
expected: "note or rest",
|
||||
});
|
||||
}
|
||||
let (EventPosition::Musical(position), EventDuration::Musical(_)) =
|
||||
(event.position().clone(), event.duration())
|
||||
else {
|
||||
return Err(EditorError::WrongSelection {
|
||||
expected: "metric event",
|
||||
});
|
||||
};
|
||||
(event.voice(), position)
|
||||
};
|
||||
if self.event_in_tuplet(event_id) {
|
||||
return Err(EditorError::OverlapsTuplet);
|
||||
}
|
||||
if self.event_has_decomposition(event_id) {
|
||||
return Err(EditorError::DecomposedEvent);
|
||||
}
|
||||
// Require a metric region (its time model) — and resolve the staff instance for
|
||||
// any re-inserted split tails.
|
||||
let staff_instance =
|
||||
self.metric_staff_instance_of_voice(voice)
|
||||
.ok_or(EditorError::WrongSelection {
|
||||
expected: "metric event",
|
||||
})?;
|
||||
|
||||
// Lengthening claims [position, position + duration); make room over the other
|
||||
// events there (the resized event itself is excluded). Shrinking frees space, so
|
||||
// make-room finds nothing. The resize applies last, after the span is cleared.
|
||||
let end = position.clone() + duration.clone();
|
||||
let resized = replace_span(
|
||||
self.score.events.get(event_id).unwrap(),
|
||||
position.clone(),
|
||||
duration,
|
||||
);
|
||||
let room = self.make_room(voice, &position, &end, Some(event_id))?;
|
||||
|
||||
let mut minter = self.minter();
|
||||
let mut ops = self.make_room_ops(room, staff_instance, &mut minter);
|
||||
ops.push(OperationKind::ModifyEvent(ModifyEventOp { event: resized }));
|
||||
if ops.len() == 1 {
|
||||
self.apply(ops.into_iter().next().expect("one op"))
|
||||
} else {
|
||||
self.apply_transaction("set duration", Some(TransactionCategory::NoteEntry), ops)
|
||||
}
|
||||
}
|
||||
|
||||
/// The events make-room must change to clear `[start, end)` in `voice` (other than
|
||||
/// `exclude`, the event being inserted/resized): whole-event deletes, in-place
|
||||
/// trims, and splits. Errors on a tuplet member or a non-note/rest overlap there is
|
||||
/// no make-room rule for.
|
||||
fn make_room(
|
||||
&self,
|
||||
voice: VoiceId,
|
||||
start: &MusicalPosition,
|
||||
end: &MusicalPosition,
|
||||
exclude: Option<EventId>,
|
||||
) -> Result<MakeRoom, EditorError> {
|
||||
let mut room = MakeRoom::default();
|
||||
for event in self.score.events.iter() {
|
||||
if event.voice() != voice || Some(event.id()) == exclude {
|
||||
continue;
|
||||
}
|
||||
let (EventPosition::Musical(ep), EventDuration::Musical(ed)) =
|
||||
(event.position(), event.duration())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// A non-positive existing span never occupies the range (matching the
|
||||
// reducer's overlap rule), so it is not in the way.
|
||||
if !ed.is_positive() {
|
||||
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
|
||||
}
|
||||
// A trim or split changes the event's duration; refuse if a persistent
|
||||
// decomposition would no longer sum to it (a full-cover delete is fine — the
|
||||
// tombstoned target's decomposition is no longer checked).
|
||||
let fully_covered = ep >= start && &event_end <= end;
|
||||
if !fully_covered && self.event_has_decomposition(event.id()) {
|
||||
return Err(EditorError::DecomposedEvent);
|
||||
}
|
||||
match (ep < start, &event_end > end) {
|
||||
// Fully covered → delete.
|
||||
(false, false) => room.deletes.push(event.id()),
|
||||
// The span lands inside it → split: trim to the head, re-insert the tail.
|
||||
(true, true) => {
|
||||
room.trims
|
||||
.push(replace_span(event, ep.clone(), span_between(ep, start)));
|
||||
room.tails
|
||||
.push((event.clone(), end.clone(), span_between(end, &event_end)));
|
||||
}
|
||||
// Overlaps the head (event ends inside the span) → trim the tail off.
|
||||
(true, false) => {
|
||||
room.trims
|
||||
.push(replace_span(event, ep.clone(), span_between(ep, start)))
|
||||
}
|
||||
// Overlaps the tail (event starts inside the span) → trim the head off.
|
||||
(false, true) => room.trims.push(replace_span(
|
||||
event,
|
||||
end.clone(),
|
||||
span_between(end, &event_end),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Ok(room)
|
||||
}
|
||||
|
||||
/// Turns a [`MakeRoom`] into operations: trims (`ModifyEvent`), deletes
|
||||
/// (`DeleteEvent`), and split tails (`InsertEvent`, cloning the original event's
|
||||
/// shape with fresh ids, carrying any authored spelling via `RespellPitch`). Order
|
||||
/// frees the span before the caller appends its own op (the new note or the resize).
|
||||
fn make_room_ops(
|
||||
&self,
|
||||
room: MakeRoom,
|
||||
staff_instance: StaffInstanceId,
|
||||
minter: &mut Minter,
|
||||
) -> Vec<OperationKind> {
|
||||
let mut ops: Vec<OperationKind> = Vec::new();
|
||||
for event in room.trims {
|
||||
ops.push(OperationKind::ModifyEvent(ModifyEventOp { event }));
|
||||
}
|
||||
for event in room.deletes {
|
||||
ops.push(OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event,
|
||||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||
}));
|
||||
}
|
||||
for (original, position, duration) in room.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(|_| minter.pitch()).collect();
|
||||
let tail = respan_with_fresh_ids(&original, minter.event(), position, duration, &fresh);
|
||||
ops.push(OperationKind::InsertEvent(InsertEventOp {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
/// A fresh-id [`Minter`] seeded from the session high-water marks (which do not move
|
||||
/// until commit), so several mints within one intent never collide.
|
||||
fn minter(&self) -> Minter {
|
||||
Minter {
|
||||
replica: self.replica,
|
||||
next_event: self.mint_event_id().counter(),
|
||||
next_pitch: self.mint_pitch_id().counter(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -1171,6 +1303,16 @@ impl EditorSession {
|
|||
.any(|tuplet| tuplet.members.contains(&event))
|
||||
}
|
||||
|
||||
/// Whether `event` carries a persistent decomposition attachment — its notated
|
||||
/// components, which a duration change would no longer sum to (invariant 15), so the
|
||||
/// editor cannot resize/trim it until decomposition-edit ops exist.
|
||||
fn event_has_decomposition(&self, event: EventId) -> bool {
|
||||
self.score
|
||||
.decomposition_attachments
|
||||
.iter()
|
||||
.any(|attachment| attachment.target == 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.
|
||||
|
|
@ -1457,6 +1599,47 @@ fn span_between(from: &MusicalPosition, to: &MusicalPosition) -> MusicalDuration
|
|||
MusicalDuration(to.0.sub(&from.0))
|
||||
}
|
||||
|
||||
/// The events make-room must change to clear a span: whole-event deletes, in-place
|
||||
/// trims (a `ModifyEvent` value), and splits (the original event plus the tail's onset
|
||||
/// and duration, for re-inserting the tail with fresh ids). Built by
|
||||
/// [`EditorSession::make_room`], turned into ops by [`EditorSession::make_room_ops`].
|
||||
#[derive(Default)]
|
||||
struct MakeRoom {
|
||||
trims: Vec<Event>,
|
||||
deletes: Vec<EventId>,
|
||||
tails: Vec<(Event, MusicalPosition, MusicalDuration)>,
|
||||
}
|
||||
|
||||
/// Mints fresh event/pitch ids within one intent, advancing local counters (checked,
|
||||
/// like the session minters) so the several mints in one transaction never collide —
|
||||
/// the session high-water mark does not move until commit. Seeded by
|
||||
/// [`EditorSession::minter`].
|
||||
struct Minter {
|
||||
replica: ReplicaId,
|
||||
next_event: u64,
|
||||
next_pitch: u64,
|
||||
}
|
||||
|
||||
impl Minter {
|
||||
fn event(&mut self) -> EventId {
|
||||
let id = EventId::new(self.replica, self.next_event);
|
||||
self.next_event = self
|
||||
.next_event
|
||||
.checked_add(1)
|
||||
.expect("event id counter overflowed u64");
|
||||
id
|
||||
}
|
||||
|
||||
fn pitch(&mut self) -> PitchId {
|
||||
let id = PitchId::new(self.replica, self.next_pitch);
|
||||
self.next_pitch = self
|
||||
.next_pitch
|
||||
.checked_add(1)
|
||||
.expect("pitch id counter overflowed u64");
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// `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).
|
||||
|
|
@ -2547,6 +2730,286 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
fn dur(numerator: i64, denominator: i64) -> MusicalDuration {
|
||||
MusicalDuration(RationalTime::new(numerator, denominator).unwrap())
|
||||
}
|
||||
|
||||
/// Selects the first pitch (notehead) of `event` and returns its id.
|
||||
fn select_first_pitch_of(session: &mut EditorSession, event: EventId) -> PitchId {
|
||||
let mut buf: Vec<&IdentifiedPitch> = Vec::new();
|
||||
session
|
||||
.score()
|
||||
.events
|
||||
.get(event)
|
||||
.expect("event present")
|
||||
.collect_identified_pitches(&mut buf);
|
||||
let pid = buf.first().expect("a pitch on the event").id;
|
||||
select_pitch(session, pid);
|
||||
pid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selection_duration_shrinks_a_note() {
|
||||
let mut session = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&session);
|
||||
let voice = primary_voice(&session, region);
|
||||
let events = voice_events(&session, voice);
|
||||
let (target, pos, _) = events[0].clone();
|
||||
select_first_pitch_of(&mut session, target);
|
||||
|
||||
session
|
||||
.set_selection_duration(dur(1, 8))
|
||||
.expect("shrink applies");
|
||||
let after = voice_events(&session, voice);
|
||||
let resized = after
|
||||
.iter()
|
||||
.find(|(id, _, _)| *id == target)
|
||||
.expect("the note survives");
|
||||
assert_eq!(resized.2, dur(1, 8), "the note is shorter");
|
||||
assert_eq!(resized.1, pos, "its onset is unchanged");
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
events.len(),
|
||||
"no other events change (the gap is ok)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selection_duration_lengthens_into_empty_space() {
|
||||
let mut session = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&session);
|
||||
let voice = primary_voice(&session, region);
|
||||
let events = voice_events(&session, voice);
|
||||
let (last, _, _) = events.last().unwrap().clone();
|
||||
select_first_pitch_of(&mut session, last);
|
||||
|
||||
session
|
||||
.set_selection_duration(dur(1, 2))
|
||||
.expect("lengthen into empty space applies");
|
||||
let after = voice_events(&session, voice);
|
||||
assert_eq!(
|
||||
after.iter().find(|(id, _, _)| *id == last).unwrap().2,
|
||||
dur(1, 2)
|
||||
);
|
||||
assert_eq!(after.len(), events.len(), "no other events change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selection_duration_lengthens_and_deletes_a_covered_note() {
|
||||
let mut session = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&session);
|
||||
let voice = primary_voice(&session, region);
|
||||
let events = voice_events(&session, voice);
|
||||
// First note [0, 1/4); next [1/4, 1/2). Lengthen the first to 1/2 → fully covers
|
||||
// the next, which is deleted.
|
||||
let (first, _, _) = events[0].clone();
|
||||
let (second, _, _) = events[1].clone();
|
||||
select_first_pitch_of(&mut session, first);
|
||||
|
||||
session
|
||||
.set_selection_duration(dur(1, 2))
|
||||
.expect("lengthen with overwrite applies");
|
||||
let after = voice_events(&session, voice);
|
||||
assert_eq!(
|
||||
after.iter().find(|(id, _, _)| *id == first).unwrap().2,
|
||||
dur(1, 2),
|
||||
"lengthened to 1/2"
|
||||
);
|
||||
assert!(
|
||||
!after.iter().any(|(id, _, _)| *id == second),
|
||||
"the covered next note was deleted"
|
||||
);
|
||||
assert_eq!(after.len(), events.len() - 1, "one event removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selection_duration_lengthen_trims_a_partial_overlap() {
|
||||
let mut session = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&session);
|
||||
let voice = primary_voice(&session, region);
|
||||
let events = voice_events(&session, voice);
|
||||
let (first, _, _) = events[0].clone();
|
||||
let (second, _, _) = events[1].clone();
|
||||
select_first_pitch_of(&mut session, first);
|
||||
|
||||
// Lengthen [0, 1/4) to 3/8 → [0, 3/8) overlaps [1/4, 1/2)'s head → trim it.
|
||||
session
|
||||
.set_selection_duration(dur(3, 8))
|
||||
.expect("partial-overlap lengthen applies");
|
||||
let after = voice_events(&session, voice);
|
||||
assert_eq!(
|
||||
after.iter().find(|(id, _, _)| *id == first).unwrap().2,
|
||||
dur(3, 8)
|
||||
);
|
||||
let trimmed = after
|
||||
.iter()
|
||||
.find(|(id, _, _)| *id == second)
|
||||
.expect("the next note survives, trimmed");
|
||||
assert_eq!(
|
||||
trimmed.1,
|
||||
MusicalPosition(RationalTime::new(3, 8).unwrap()),
|
||||
"trimmed to start where the first now ends"
|
||||
);
|
||||
assert_eq!(trimmed.2, dur(1, 8), "with the remaining 1/8");
|
||||
assert_eq!(after.len(), events.len(), "trimmed, not deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selection_duration_refuses_non_positive_and_tuplet_members() {
|
||||
// Non-positive duration: refused before anything else.
|
||||
let mut plain = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&plain);
|
||||
let voice = primary_voice(&plain, region);
|
||||
let (note, _, _) = voice_events(&plain, voice)[0].clone();
|
||||
select_first_pitch_of(&mut plain, note);
|
||||
assert_eq!(
|
||||
plain.set_selection_duration(MusicalDuration(RationalTime::zero())),
|
||||
Err(EditorError::InvalidDuration)
|
||||
);
|
||||
|
||||
// A tuplet member's duration is ratio-governed → refused.
|
||||
let mut rich = open_rich(0x5EED);
|
||||
let triplet = a_region_with(&rich, true);
|
||||
let tvoice = primary_voice(&rich, triplet);
|
||||
let (member, _, member_dur) = voice_events(&rich, tvoice)[0].clone();
|
||||
select_first_pitch_of(&mut rich, member);
|
||||
assert_eq!(
|
||||
rich.set_selection_duration(member_dur),
|
||||
Err(EditorError::OverlapsTuplet)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selection_duration_refuses_a_decomposed_event() {
|
||||
use epiphany_core::{
|
||||
DecompositionAttachment, DecompositionSource, NotatedComponent, NoteValue,
|
||||
};
|
||||
|
||||
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 = {
|
||||
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
|
||||
};
|
||||
// A valid quarter-note decomposition of the first (quarter) note: changing the
|
||||
// note's duration would leave it no longer summing, so the resize is refused.
|
||||
score
|
||||
.decomposition_attachments
|
||||
.push(DecompositionAttachment {
|
||||
target: first,
|
||||
components: vec![NotatedComponent {
|
||||
base_value: NoteValue::Quarter,
|
||||
dots: 0,
|
||||
tuplet: None,
|
||||
tied_to_next: false,
|
||||
}],
|
||||
source: DecompositionSource::Inferred,
|
||||
});
|
||||
|
||||
let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
|
||||
select_first_pitch_of(&mut session, first);
|
||||
assert_eq!(
|
||||
session.set_selection_duration(dur(1, 8)),
|
||||
Err(EditorError::DecomposedEvent)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selection_duration_refuses_a_non_note_event() {
|
||||
use epiphany_core::{StaffPosition, UnpitchedEvent, UnpitchedMemberId};
|
||||
|
||||
// Turn the first metric note into an unpitched event (same id/voice/span): it
|
||||
// renders as a structural anchor and can be selected, but has no note value to
|
||||
// resize, so the duration edit is refused before anything is minted.
|
||||
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 = {
|
||||
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
|
||||
};
|
||||
{
|
||||
let ev = score.events.get(first).unwrap();
|
||||
let (position, duration) = (ev.position().clone(), ev.duration().clone());
|
||||
*score.events.get_mut(first).unwrap() = Event::Unpitched(UnpitchedEvent {
|
||||
id: first,
|
||||
voice,
|
||||
position,
|
||||
duration,
|
||||
staff_position: StaffPosition(0),
|
||||
instrument_member: UnpitchedMemberId(0),
|
||||
articulations: Vec::new(),
|
||||
dynamic: None,
|
||||
stem: StemConfiguration,
|
||||
grace: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut session = EditorSession::open(score, Box::new(StubSolver)).expect("renders");
|
||||
let lo = session
|
||||
.hit_test()
|
||||
.regions
|
||||
.iter()
|
||||
.find(|r| r.source == TypedObjectId::Event(first))
|
||||
.map(|r| r.layout_object)
|
||||
.expect("the unpitched event has a hit region");
|
||||
session.select(lo).expect("selects the unpitched event");
|
||||
assert_eq!(
|
||||
session.set_selection_duration(dur(1, 4)),
|
||||
Err(EditorError::WrongSelection {
|
||||
expected: "note or rest"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selection_duration_requires_a_selection() {
|
||||
let mut session = open_plain(0x5EED);
|
||||
assert_eq!(
|
||||
session.set_selection_duration(dur(1, 4)),
|
||||
Err(EditorError::NoSelection)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_editing_loop_runs_through_the_session() {
|
||||
let mut session = open_rich(0x5EED);
|
||||
|
|
|
|||
Loading…
Reference in New Issue