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:
parent
a28d888c8e
commit
eca5e37ec9
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue