Commit Graph

4 Commits

Author SHA1 Message Date
Levi Neuwirth 42208c166d 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
2026-06-27 19:03:05 -04:00
Levi Neuwirth 2b4340feea editor-core: an applied-operation log with causal context
Expose the envelopes a session has applied — applied_operations() (oldest
first) and last_applied() — the append-only record undo, history, and sync
build on. Each intent feeds it automatically through apply().

Minted envelopes now carry a real causal context: the session's edits form one
replica's contiguous, zero-based history, so the op at counter n covers the
range [0, n-1]. Two sequential edits to the same target therefore reduce as
intentional overwrites (the later covering the earlier), not as concurrent
StructuralFieldCollision conflicts — both when the session re-reduces its own
log and when a peer replays it.

To keep a covering context satisfiable, apply() reduces the whole accumulated
log onto the pristine open-time score rather than the new op alone onto the
running materialization: the predecessors are present in the set, so the
missing-predecessor rule does not hold the new op pending, and the session's
render is now exactly the canonical reduction of the op log it emits. The
counter is derived from applied.len() (a failed apply consumes no id), and
with_identity is enforced as pre-edit-only so the contiguous history stays
hole-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
2026-06-27 18:36:50 -04:00
Levi Neuwirth eca5e37ec9 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>
2026-06-27 17:45:32 -04:00
Levi Neuwirth a28d888c8e epiphany-editor-core: a headless editor API + a conformance UI-seam gate
Packages the proven editing-loop vertical slice as the API a GUI calls -- no UI,
no rendering backend of its own (it produces a RenderIR). EditorSession owns:

  - selection state (Selection { source, layout_object }): click(point) selects the
    topmost hit, select(id) restores a selection, selection()/clear_selection();
  - render/hit-test query: render() and hit_test() for the GUI to draw and resolve
    clicks/drags;
  - operation minting -- the ergonomics gap the harness exposed, closed before UI
    depends on it: the caller passes an OperationKind to apply() (or an intent like
    transpose_selection(+1)) and the session assembles the OperationEnvelope (id,
    author, stamp, causal context). A GUI never hand-rolls envelope bookkeeping;
  - apply/re-render -- ATOMIC: a minted op the reducer rejects (e.g. a reserved
    replica identity) returns Err(RejectedOperation), not a silent no-op, and a
    diagnostic-only layout returns Err(NotRenderable); on any error nothing mutates,
    operation counter included (the candidate id is committed only on success);
  - selection preservation: the selection is re-resolved against the new layout,
    kept when its layout object survives and cleared when it is gone.

The session is solver-agnostic (Box<dyn ConstraintSolver>), so a GUI plugs in the
Engraver, the stub, or any conformant solver. EditorError implements Display/Error.
epiphany-ops now re-exports AcceptOutcome (accept()'s return type, previously
unreachable) so a caller can inspect a rejection.

Also wires the edit-loop harness into the conformance suite as the [7c] UI-seam
gate: over both fixtures (ten_measure_single_staff and valid_score_rich) every seed
must drive a click->sharpen->re-render cycle whose selection survives the relayout
-- the contract a GUI's correctness rests on.

Full gate green: build, fmt, clippy, 606 tests, conformance scale 1 (incl. [7c]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:47:34 -04:00