From eca5e37ec9432a872976d1d7041ac803219325d9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 17:45:32 -0400 Subject: [PATCH] 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 --- crates/epiphany-editor-core/src/lib.rs | 55 +++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index 4ddcba6..23794eb 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -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 { + 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;