editor-core: a delete_selection() intent

EditorSession::delete_selection() deletes the selected object through the right
operation: a selected pitch (a notehead) -> DeleteIdentifiedPitch (the reducer
degrades a last-pitch event to a rest of the same duration, so the rhythm
survives; a chord note simply drops), a selected event (a rest, a stem) ->
DeleteEvent. The deleted object's layout id no longer exists, so the selection is
cleared. Errors (NoSelection / WrongSelection) leave the session untouched, via the
atomic apply().

This exercises a different reducer path from transpose (tombstone-with-rest-degrade
vs. in-place pitch mutation) and stays small. Tests: deleting a note changes the
graph and the render and drops the selection; the intent requires a selection.

(Duration editing is intentionally not added here: a ModifyEvent duration change is
not materialized into the graph -- graph_replace_event defers position/duration
edits pending the voice non-overlap semantics -- which is an epiphany-ops increment,
not editor-core code.)

Full gate green: build, fmt, clippy, 608 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 17:45:32 -04:00
parent a28d888c8e
commit eca5e37ec9
1 changed files with 53 additions and 2 deletions

View File

@ -36,8 +36,9 @@ use epiphany_layout_ir::{
RenderIR, SolverConfig,
};
use epiphany_ops::{
AcceptOutcome, AuthorId, CausalContext, HybridLogicalClock, OperationEnvelope, OperationKind,
OperationPayload, OperationSet, OperationStamp, TransposeOp,
AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp,
HybridLogicalClock, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
OperationStamp, TransposeOp, TupletCompensation,
};
/// The current selection: the score-graph object to act on, plus the stable layout
@ -262,6 +263,30 @@ impl EditorSession {
}))
}
/// Deletes the selected object. A selected **pitch** (a notehead) is tombstoned
/// — the note's last pitch degrades its event to a rest of the same duration, so
/// the rhythm survives; a selected **event** (a rest, a stem) is deleted whole.
/// Errors if the selection is neither. The selection does not survive (its layout
/// object is gone), so it is cleared.
pub fn delete_selection(&mut self) -> Result<EditOutcome, EditorError> {
let selection = self.selection.ok_or(EditorError::NoSelection)?;
let kind = match selection.source {
TypedObjectId::Pitch(pitch) => {
OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp { pitch })
}
TypedObjectId::Event(event) => OperationKind::DeleteEvent(DeleteEventOp {
event,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
_ => {
return Err(EditorError::WrongSelection {
expected: "pitch or event",
})
}
};
self.apply(kind)
}
/// Re-resolves the current selection against the current layout: keeps it
/// (refreshing its source) when its layout object survives, clears it otherwise.
/// Returns whether it survived.
@ -454,6 +479,32 @@ mod tests {
assert_eq!(opened.err(), Some(EditorError::NotRenderable));
}
#[test]
fn delete_selection_removes_the_note_and_drops_the_selection() {
let mut session = open_rich(0x5EED);
let before = session.render().clone();
let selection = click_a_notehead(&mut session);
let outcome = session.delete_selection().expect("the delete applies");
assert!(outcome.graph_changed, "the note was tombstoned");
// The selected pitch is gone (its event degraded to a rest or lost a chord
// note), so its layout object no longer exists and the selection is cleared.
assert!(!outcome.selection_preserved);
assert_eq!(session.selection(), None);
assert!(!session
.hit_test()
.regions
.iter()
.any(|r| r.layout_object == selection.layout_object));
assert_ne!(&before, session.render(), "the delete changed the render");
}
#[test]
fn delete_selection_requires_a_selection() {
let mut session = open_rich(0x5EED);
assert_eq!(session.delete_selection(), Err(EditorError::NoSelection));
}
#[test]
fn a_rejected_operation_is_an_error_not_a_silent_no_op() {
use epiphany_core::ReplicaId;