editor-core: a diatonic move_selection_staff_step intent
Move the selected pitch by N diatonic staff positions (+1 up, -1 down): a nominal move that carries the octave at the B-C boundary and preserves the accidental — the "diatonic move" a vertical drag performs. It emits ModifyIdentifiedPitch, which keeps the note's id, so the selection survives the relayout. The staff step is the same data path the per-edit causal context was built for: two sequential moves to one pitch reduce as intentional overwrites, not a StructuralFieldCollision (proven by replaying the op log). An authored spelling override (user-chosen, imported, or propagated) resolves ahead of the inferred spelling and pins the rendered staff position, so a value-only move would change the sound without moving the notehead. The intent refuses that case (PitchSpellingOverridden) rather than mislead — the predicate mirrors resolve_spelling's precedence ranking. A respelling-aware move that atomically rebases the override is a follow-up (it needs a transaction-aware apply). The diatonic index is computed in i64 so an extreme step count cannot overflow before the octave range-check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
parent
2b4340feea
commit
42208c166d
|
|
@ -35,15 +35,18 @@
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use epiphany_core::{OperationId, ReplicaId, Score, TypedObjectId, WallClockTime};
|
use epiphany_core::{
|
||||||
|
CmnNominal, IdentifiedPitch, OperationId, Pitch, PitchId, PitchSpacePosition, ReplicaId, Score,
|
||||||
|
SpellingDirective, SpellingScope, SpellingSourceKind, TypedObjectId, WallClockTime,
|
||||||
|
};
|
||||||
use epiphany_layout_ir::{
|
use epiphany_layout_ir::{
|
||||||
to_constrained, to_logical, to_render, ConstraintSolver, HitTestMap, LayoutObjectId, Point,
|
to_constrained, to_logical, to_render, ConstraintSolver, HitTestMap, LayoutObjectId, Point,
|
||||||
RenderIR, SolverConfig,
|
RenderIR, SolverConfig,
|
||||||
};
|
};
|
||||||
use epiphany_ops::{
|
use epiphany_ops::{
|
||||||
AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp,
|
AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp,
|
||||||
HybridLogicalClock, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
|
HybridLogicalClock, ModifyIdentifiedPitchOp, OperationEnvelope, OperationKind,
|
||||||
OperationStamp, TransposeOp, TupletCompensation,
|
OperationPayload, OperationSet, OperationStamp, TransposeOp, TupletCompensation,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The current selection: the score-graph object to act on, plus the stable layout
|
/// The current selection: the score-graph object to act on, plus the stable layout
|
||||||
|
|
@ -84,6 +87,12 @@ pub enum EditorError {
|
||||||
/// The kind of object the intent expected.
|
/// The kind of object the intent expected.
|
||||||
expected: &'static str,
|
expected: &'static str,
|
||||||
},
|
},
|
||||||
|
/// The selected pitch carries an authored spelling override (user-chosen,
|
||||||
|
/// imported, or propagated) that outranks the inferred spelling and pins its
|
||||||
|
/// rendered staff position. A staff-step move that changed only the pitch value
|
||||||
|
/// would move the sound but not the notehead, so it is refused. A respelling-aware
|
||||||
|
/// move (atomically rebasing the override) is a follow-up.
|
||||||
|
PitchSpellingOverridden,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for EditorError {
|
impl fmt::Display for EditorError {
|
||||||
|
|
@ -99,6 +108,9 @@ impl fmt::Display for EditorError {
|
||||||
EditorError::WrongSelection { expected } => {
|
EditorError::WrongSelection { expected } => {
|
||||||
write!(f, "the selection is not a {expected}")
|
write!(f, "the selection is not a {expected}")
|
||||||
}
|
}
|
||||||
|
EditorError::PitchSpellingOverridden => f.write_str(
|
||||||
|
"the selected pitch has an authored spelling override that pins its staff position",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -353,6 +365,59 @@ impl EditorSession {
|
||||||
self.apply(kind)
|
self.apply(kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves the selected pitch by `steps` diatonic **staff steps** (`+1` is up one
|
||||||
|
/// staff position, `-1` down): a nominal move that wraps the octave at the B↔C
|
||||||
|
/// boundary and preserves the accidental — the "diatonic move" a vertical drag
|
||||||
|
/// performs. It modifies the pitch in place ([`OperationKind::ModifyIdentifiedPitch`]),
|
||||||
|
/// so the note keeps its identity and the selection survives the relayout.
|
||||||
|
///
|
||||||
|
/// Errors if nothing — or a non-pitch — is selected, if the selected pitch has no
|
||||||
|
/// CMN staff position to step (an N-tone or grammar-defined position), or if the
|
||||||
|
/// pitch carries an authored spelling override that would pin its rendered position
|
||||||
|
/// ([`EditorError::PitchSpellingOverridden`]).
|
||||||
|
pub fn move_selection_staff_step(&mut self, steps: i32) -> Result<EditOutcome, EditorError> {
|
||||||
|
let selection = self.selection.ok_or(EditorError::NoSelection)?;
|
||||||
|
let TypedObjectId::Pitch(pitch) = selection.source else {
|
||||||
|
return Err(EditorError::WrongSelection { expected: "pitch" });
|
||||||
|
};
|
||||||
|
// An authored spelling override resolves ahead of the inferred spelling, so
|
||||||
|
// it pins the rendered staff position: modifying the pitch value alone would
|
||||||
|
// change the sound but leave the notehead where it was. Refuse rather than
|
||||||
|
// mislead — moving the override too needs an atomic respell (a follow-up).
|
||||||
|
if has_authored_spelling_override(&self.score, pitch) {
|
||||||
|
return Err(EditorError::PitchSpellingOverridden);
|
||||||
|
}
|
||||||
|
// The move is relative to the pitch's current value, so read it from the
|
||||||
|
// graph and step its staff position.
|
||||||
|
let moved = self
|
||||||
|
.current_pitch(pitch)
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|current| staff_step(current, steps))
|
||||||
|
.ok_or(EditorError::WrongSelection {
|
||||||
|
expected: "CMN pitch",
|
||||||
|
})?;
|
||||||
|
self.apply(OperationKind::ModifyIdentifiedPitch(
|
||||||
|
ModifyIdentifiedPitchOp {
|
||||||
|
pitch,
|
||||||
|
value: moved,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current value of the identified `pitch` in the score graph, if it is
|
||||||
|
/// present (a live embedded pitch).
|
||||||
|
fn current_pitch(&self, pitch: PitchId) -> Option<Pitch> {
|
||||||
|
let mut buf: Vec<&IdentifiedPitch> = Vec::new();
|
||||||
|
for event in self.score.events.iter() {
|
||||||
|
buf.clear();
|
||||||
|
event.collect_identified_pitches(&mut buf);
|
||||||
|
if let Some(ip) = buf.iter().find(|ip| ip.id == pitch) {
|
||||||
|
return Some(ip.pitch.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-resolves the current selection against the current layout: keeps it
|
/// Re-resolves the current selection against the current layout: keeps it
|
||||||
/// (refreshing its source) when its layout object survives, clears it otherwise.
|
/// (refreshing its source) when its layout object survives, clears it otherwise.
|
||||||
/// Returns whether it survived.
|
/// Returns whether it survived.
|
||||||
|
|
@ -381,6 +446,65 @@ impl EditorSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pitch one or more diatonic **staff steps** from `pitch`: the CMN nominal is
|
||||||
|
/// moved by `steps`, carrying the octave at the B↔C boundary, with the alteration and
|
||||||
|
/// acoustic realization preserved (a pure staff-position move). Returns `None` for a
|
||||||
|
/// non-CMN position — which has no staff-step notion — or an octave that overflows
|
||||||
|
/// `i8`.
|
||||||
|
fn staff_step(pitch: &Pitch, steps: i32) -> Option<Pitch> {
|
||||||
|
let PitchSpacePosition::Cmn {
|
||||||
|
nominal,
|
||||||
|
alteration,
|
||||||
|
octave,
|
||||||
|
} = pitch.scale_position.position
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
// Seven nominals to an octave: index diatonically, move, then decompose so a
|
||||||
|
// move past B (or below C) carries the octave. Computed in i64 so an extreme
|
||||||
|
// `steps` cannot overflow the intermediate before the octave range-check below.
|
||||||
|
let diatonic = octave as i64 * 7 + nominal as i64 + steps as i64;
|
||||||
|
let new_octave = i8::try_from(diatonic.div_euclid(7)).ok()?;
|
||||||
|
let new_nominal = nominal_from_index(diatonic.rem_euclid(7));
|
||||||
|
let mut moved = pitch.clone();
|
||||||
|
moved.scale_position.position = PitchSpacePosition::Cmn {
|
||||||
|
nominal: new_nominal,
|
||||||
|
alteration,
|
||||||
|
octave: new_octave,
|
||||||
|
};
|
||||||
|
Some(moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CMN nominal at diatonic index `i` (`0..=6` → `C..=B`); callers pass
|
||||||
|
/// `rem_euclid(7)`, so other values are unreachable and fold to `B`.
|
||||||
|
fn nominal_from_index(i: i64) -> CmnNominal {
|
||||||
|
match i {
|
||||||
|
0 => CmnNominal::C,
|
||||||
|
1 => CmnNominal::D,
|
||||||
|
2 => CmnNominal::E,
|
||||||
|
3 => CmnNominal::F,
|
||||||
|
4 => CmnNominal::G,
|
||||||
|
5 => CmnNominal::A,
|
||||||
|
_ => CmnNominal::B,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the score carries an authored explicit spelling override for `pitch`
|
||||||
|
/// that the resolver would rank ahead of the inferred spelling — pinning the
|
||||||
|
/// rendered staff position regardless of the pitch value. Mirrors `epiphany_core`'s
|
||||||
|
/// `resolve_spelling`: engraved layer, pitch-scoped, explicit, and a source that
|
||||||
|
/// outranks `Inferred` under the score's precedence (the default ranks `UserChosen`,
|
||||||
|
/// `Imported`, and `Propagated` all ahead of `Inferred`, so any of them pins).
|
||||||
|
fn has_authored_spelling_override(score: &Score, pitch: PitchId) -> bool {
|
||||||
|
let inferred_rank = score.spelling_precedence.rank(SpellingSourceKind::Inferred);
|
||||||
|
score.spelling_attachments.iter().any(|att| {
|
||||||
|
att.layer.is_none()
|
||||||
|
&& matches!(&att.scope, SpellingScope::Pitch(p) if *p == pitch)
|
||||||
|
&& matches!(att.directive, SpellingDirective::Explicit(_))
|
||||||
|
&& score.spelling_precedence.rank(att.source.kind()) < inferred_rank
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders a score with `solver` to its `RenderIR` + hit-test map, or `None` if the
|
/// Renders a score with `solver` to its `RenderIR` + hit-test map, or `None` if the
|
||||||
/// solver's report is diagnostic-only (not renderable).
|
/// solver's report is diagnostic-only (not renderable).
|
||||||
fn render_score(score: &Score, solver: &dyn ConstraintSolver) -> Option<(RenderIR, HitTestMap)> {
|
fn render_score(score: &Score, solver: &dyn ConstraintSolver) -> Option<(RenderIR, HitTestMap)> {
|
||||||
|
|
@ -571,6 +695,188 @@ mod tests {
|
||||||
assert_eq!(session.delete_selection(), Err(EditorError::NoSelection));
|
assert_eq!(session.delete_selection(), Err(EditorError::NoSelection));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_selection_staff_step_moves_the_note_and_keeps_the_selection() {
|
||||||
|
let mut session = open_rich(0x5EED);
|
||||||
|
let before_render = session.render().clone();
|
||||||
|
let selection = click_a_notehead(&mut session);
|
||||||
|
let TypedObjectId::Pitch(pid) = selection.source else {
|
||||||
|
panic!("a notehead selects a pitch");
|
||||||
|
};
|
||||||
|
let before = session
|
||||||
|
.current_pitch(pid)
|
||||||
|
.expect("the selected pitch is live");
|
||||||
|
|
||||||
|
let outcome = session
|
||||||
|
.move_selection_staff_step(1)
|
||||||
|
.expect("the move applies");
|
||||||
|
assert!(outcome.graph_changed, "the move changed the pitch");
|
||||||
|
assert!(outcome.selection_preserved, "the note keeps its identity");
|
||||||
|
assert_eq!(
|
||||||
|
session.selection().map(|s| s.layout_object),
|
||||||
|
Some(selection.layout_object),
|
||||||
|
"the selection still anchors the same notehead"
|
||||||
|
);
|
||||||
|
let after = session.current_pitch(pid).expect("the pitch is still live");
|
||||||
|
assert_eq!(
|
||||||
|
after,
|
||||||
|
staff_step(&before, 1).unwrap(),
|
||||||
|
"the pitch moved exactly one staff step up"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
&before_render,
|
||||||
|
session.render(),
|
||||||
|
"the note moved on the staff"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_selection_staff_step_requires_a_pitch_selection() {
|
||||||
|
let mut session = open_rich(0x5EED);
|
||||||
|
assert_eq!(
|
||||||
|
session.move_selection_staff_step(1),
|
||||||
|
Err(EditorError::NoSelection)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staff_step_carries_the_octave_and_keeps_the_accidental() {
|
||||||
|
// A real fixture pitch is a convenient template (it spares hand-building the
|
||||||
|
// acoustic identity); only its staff position is what the step touches.
|
||||||
|
let mut session = open_rich(0x5EED);
|
||||||
|
let selection = click_a_notehead(&mut session);
|
||||||
|
let TypedObjectId::Pitch(pid) = selection.source else {
|
||||||
|
panic!("a notehead selects a pitch");
|
||||||
|
};
|
||||||
|
let mut p = session
|
||||||
|
.current_pitch(pid)
|
||||||
|
.expect("the selected pitch is live");
|
||||||
|
|
||||||
|
// C#4 up one staff step → D#4: the letter advances, the sharp is preserved.
|
||||||
|
p.scale_position.position = PitchSpacePosition::Cmn {
|
||||||
|
nominal: CmnNominal::C,
|
||||||
|
alteration: 1,
|
||||||
|
octave: 4,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
staff_step(&p, 1).unwrap().scale_position.position,
|
||||||
|
PitchSpacePosition::Cmn {
|
||||||
|
nominal: CmnNominal::D,
|
||||||
|
alteration: 1,
|
||||||
|
octave: 4
|
||||||
|
}
|
||||||
|
));
|
||||||
|
// B4 up → C5: the octave carries up at the boundary.
|
||||||
|
p.scale_position.position = PitchSpacePosition::Cmn {
|
||||||
|
nominal: CmnNominal::B,
|
||||||
|
alteration: 0,
|
||||||
|
octave: 4,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
staff_step(&p, 1).unwrap().scale_position.position,
|
||||||
|
PitchSpacePosition::Cmn {
|
||||||
|
nominal: CmnNominal::C,
|
||||||
|
octave: 5,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
// C4 down → B3: and down.
|
||||||
|
p.scale_position.position = PitchSpacePosition::Cmn {
|
||||||
|
nominal: CmnNominal::C,
|
||||||
|
alteration: 0,
|
||||||
|
octave: 4,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
staff_step(&p, -1).unwrap().scale_position.position,
|
||||||
|
PitchSpacePosition::Cmn {
|
||||||
|
nominal: CmnNominal::B,
|
||||||
|
octave: 3,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_refuses_a_pitch_with_an_authored_spelling_override() {
|
||||||
|
use epiphany_core::PitchSpelling;
|
||||||
|
use epiphany_ops::RespellPitchOp;
|
||||||
|
|
||||||
|
let mut session = open_rich(0x5EED);
|
||||||
|
let selection = click_a_notehead(&mut session);
|
||||||
|
let TypedObjectId::Pitch(pid) = selection.source else {
|
||||||
|
panic!("a notehead selects a pitch");
|
||||||
|
};
|
||||||
|
// Pin the pitch with an explicit user spelling (what an authored respell leaves).
|
||||||
|
session
|
||||||
|
.apply(OperationKind::RespellPitch(RespellPitchOp {
|
||||||
|
pitch: pid,
|
||||||
|
spelling: PitchSpelling::cmn(CmnNominal::C, 4),
|
||||||
|
}))
|
||||||
|
.expect("the respell applies");
|
||||||
|
|
||||||
|
// The override pins the rendered staff position, so a value-only move is
|
||||||
|
// refused rather than silently changing the sound without moving the notehead.
|
||||||
|
assert_eq!(
|
||||||
|
session.move_selection_staff_step(1),
|
||||||
|
Err(EditorError::PitchSpellingOverridden)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_imported_or_propagated_override_pins_a_move_too() {
|
||||||
|
// The resolver ranks any explicit override that outranks Inferred — not just
|
||||||
|
// user-chosen ones — so the refusal predicate must mirror the precedence, not
|
||||||
|
// a single source. A propagated override (default rank above Inferred) pins.
|
||||||
|
use epiphany_core::{
|
||||||
|
PitchSpelling, SpellingAttachment, SpellingDirective, SpellingScope, SpellingSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut score = valid_score_rich(0x5EED);
|
||||||
|
let pid = *score.live_pitch_ids().iter().next().expect("a live pitch");
|
||||||
|
// Baseline: an unspelled live pitch is not pinned.
|
||||||
|
assert!(!has_authored_spelling_override(&score, pid));
|
||||||
|
|
||||||
|
score.spelling_attachments.push(SpellingAttachment {
|
||||||
|
scope: SpellingScope::Pitch(pid),
|
||||||
|
directive: SpellingDirective::Explicit(PitchSpelling::cmn(CmnNominal::C, 4)),
|
||||||
|
source: SpellingSource::Propagated { from: pid },
|
||||||
|
priority: 0,
|
||||||
|
layer: None,
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
has_authored_spelling_override(&score, pid),
|
||||||
|
"a propagated explicit override outranks Inferred and pins the notehead"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequential_staff_step_moves_replay_without_structural_conflict() {
|
||||||
|
// Two moves on one notehead are sequential local edits to the same target,
|
||||||
|
// each a distinct-value ModifyIdentifiedPitch (the note climbs, e.g. C→D→E).
|
||||||
|
// The second envelope covers the first, so replaying the emitted op log
|
||||||
|
// reduces them as intentional overwrites — no StructuralFieldCollision and
|
||||||
|
// nothing held pending. This is the modify path the per-edit causal context
|
||||||
|
// exists for (a transpose would not exercise the concurrent() check).
|
||||||
|
let mut session = open_rich(0x5EED);
|
||||||
|
click_a_notehead(&mut session);
|
||||||
|
session.move_selection_staff_step(1).expect("first move");
|
||||||
|
session.move_selection_staff_step(1).expect("second move");
|
||||||
|
|
||||||
|
let log = session.applied_operations();
|
||||||
|
assert_eq!(log.len(), 2);
|
||||||
|
|
||||||
|
let base = valid_score_rich(0x5EED);
|
||||||
|
let mut set = OperationSet::new();
|
||||||
|
for env in log {
|
||||||
|
set.accept(env.clone());
|
||||||
|
}
|
||||||
|
let materialized = set.reduce_onto(&base);
|
||||||
|
assert!(
|
||||||
|
materialized.state.is_clean(),
|
||||||
|
"sequential same-target moves must replay without a conflict or pending op"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn applied_operations_log_records_each_successful_edit_in_order() {
|
fn applied_operations_log_records_each_successful_edit_in_order() {
|
||||||
let mut session = open_rich(0x5EED);
|
let mut session = open_rich(0x5EED);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue