28 KiB
epiphany-editor-gui — Decisions
This crate's first DECISIONS.md. Records the calls made standing up the T1a
visual golden harness (spec/CONTRACT_EDITOR_T1A_GOLDENS.md; plan
spec/PLAN_EDITOR_APP.md §Ruling C, granted as amended 2026-07-23) — pixel-level
golden baselines of the resvg-rasterized score, as ordinary #[test]s in this
crate, comparing decoded pixels rather than encoded PNG files. The harness lives
in src/goldens.rs (#[cfg(test)]-only; never ships in the built binary) and
goldens/*.png.
1. The comparison contract: decoded pixels, never encoded PNG bytes
A golden test decodes the committed baseline PNG and compares dimensions
first, then raw RGBA bytes exactly — never the encoded file bytes. Plan
§Ruling C (granted as amended 2026-07-23) is explicit about why: comparing
encoded bytes would also lock the PNG encoder's own behavior (compression
level, filter choice, chunk layout, …), so the goldens would churn on an
encoder change even when every rendered pixel is identical — exactly the
defect the amendment exists to prevent. goldens.rs's
reencoding_with_different_settings_still_passes test makes this guarantee
executable rather than merely asserted: it encodes one pixmap twice with
deliberately different png encoder settings (filter type, compression
level) via the png crate directly (decision 7), confirms the two encoded
byte strings differ, and confirms the comparator accepts either as a baseline
for the same pixels.
On a mismatch the comparator writes failure artifacts to
target/golden-failures/<name>/ and names them in the panic message: a
dimension mismatch writes actual.png + expected.png (no diff.png — a
per-pixel map is not meaningful across differing dimensions, and the
comparator fails there before any pixel is compared); a pixel mismatch
writes all three, diff.png being a per-pixel highlight of exactly the
differing pixels. Decision 8 covers how that record survives past the
ephemeral CI runner.
2. The bless mechanism and its policy
EPIPHANY_BLESS_GOLDENS=1 makes assert_golden write/overwrite the baseline
unconditionally instead of comparing (creating goldens/ if needed). This is
a reviewed decision, stated at the function's definition: never a
mechanism for turning a red test green, only for accepting a new or
deliberately-changed raster after a human has looked at it. The three initial
baselines (ten_measure_open.png, ten_measure_insert.png,
ten_measure_slurs_castoff.png) are the first visual record of this editor's
output in the project's history — an unreviewed baseline is an unverified
claim wearing a checkmark (plan §Ruling C, user deep-dive point 2) — and were
visually reviewed and approved by the user on 2026-07-23 before being
committed. Any future re-bless is the same kind of event: a diff is a finding,
reviewed before it is accepted, never a fix applied to make a failing test
pass.
3. G3 reuses G1's baseline and deliberately bypasses the bless path
There is no ten_measure_undo.png. G3 (post-undo) asserts the raster equals
G1's own baseline file byte-for-byte in decoded RGBA — undo must return
the pixels, not just the model — by calling assert_golden_at directly
against baseline_path("ten_measure_open"), never assert_golden. Routing G3
through assert_golden would let EPIPHANY_BLESS_GOLDENS=1 overwrite
ten_measure_open.png: an initial bless run performed before undo is known to
be correct would silently bless a broken undo's post-undo pixels as the new
"as opened" baseline, after which every future run would compare undo against
its own bug instead of against G1. Bypassing assert_golden removes that
failure mode entirely — this comparison always compares, and can never bless
itself green. (G3 replays G2's scripted insert on its own fresh session
rather than continuing G2's, so the two tests stay independent of each
other's mutations.)
4. The casting-off fixture, and what its system-count assertion actually guards
G4 uses ten_measure_with_slurs(0) (fixtures.rs:777) specifically for its
three slurs, one of which is forced across a system break — the cross-system
slur-split path (casting.rs:2262) — the layout path real documents take,
not exercised by any single-system fixture.
An empirical finding changed the contract's original framing: at
px_per_staff_space: 12.0, ten_measure_single_staff(0) itself already
casts off into two systems (ten measures of quarter notes don't fit one
line at this scale) — casting-off is not unique to the slurred fixture. All
three baselines (G1/G2/G3's shared raster and G4's) are therefore
multi-system layouts. Consequently G4's system_count > 1 assertion does not
guard "this is the slurred fixture, not the plain one" — a mutation
substituting ten_measure_single_staff(0) for the slurs fixture leaves the
system count at 2 either way, so that assertion still passes under the
mutation. What it guards is the more durable claim it was written for:
casting-off itself has not stopped triggering at this geometry — if it ever
did, this would fail as a named system-count value error rather than a
mystery pixel diff. The mutation instead dies on the golden pixel comparison
(dimensions differ: G4's baseline is taller, carrying the slur curves and the
wider slurred content), which is where the fixture-identity guarantee
actually lives.
5. Known engraving gaps, locked knowingly
The goldens lock the current, real output of the Minimal-tier engraver — bugs and rough edges included, by design (plan §Ruling C: "a golden locks whatever it sees"). Two are worth naming explicitly, both blessed by the user with this record in hand on 2026-07-23:
- No clef restatement on second and subsequent systems. A new system does not redraw the governing clef at its start, unlike conventional engraving practice.
- The second system's spacing is noticeably denser than the first's. The casting-off balance between systems is not yet even.
Both are engraving-track items (epiphany-engrave's casting/spacing passes;
plan §3.7), not epiphany-editor-gui work — this crate only observes the
rendered score, it does not engrave it. When engraving improves, the fix will
change these three PNGs' pixels, and the golden harness will surface that as
a reviewable diff to bless deliberately. That is the harness doing its job,
not a defect of it.
6. The baselines pin the raster stack
Determinism basis: GlyphMode::PathOutline uses no fonts (inlined Bravura
outline paths), and resvg/tiny-skia are pure Rust with deterministic
rasterization; CI and dev are both Linux. Standing consequence: the
baselines pin the raster stack, so any Cargo.lock movement of
resvg, tiny-skia, or png is a golden-review event — the diff must be
inspected and deliberately re-blessed — never a silent re-bless folded into
an unrelated dependency bump. If cross-platform rasterization drift is ever
observed, the fallback is a bounded per-pixel tolerance, recorded as that
decision when it happens, not pre-engineered here.
7. The png dev-dependency
tiny_skia::Pixmap::encode_png/decode_png (used throughout the comparator
and the bless path) expose no encoder configuration — every call from a given
pixmap produces byte-identical output. Proving decision 1 executable (that
two differently-encoded PNGs of the same pixels both compare equal) needs
an encoder with configurable filter/compression settings, which only the
underlying png crate exposes directly. png is therefore a dev-only
dependency, used exclusively inside goldens.rs's own test module — never in
the comparator or bless code paths, which stay on
resvg::tiny_skia::Pixmap::{encode_png,decode_png} exclusively, and never at
runtime.
Declared as a caret requirement (png = "0.17.16"), not an exact = pin (a
W1-review amendment): tiny-skia 0.11.4 already resolves png 0.17.16 in
Cargo.lock, so the caret requirement is a dev-only edge onto that same
dependency-tree node today — and stays unified with it after any future
tiny-skia bump that moves its own png requirement forward, rather than
forking a second png version into the tree (which an exact pin would force
the day tiny-skia moves).
8. CI failure artifacts
A comparator panic's assertion message names actual.png/expected.png/
diff.png paths under target/golden-failures/<name>/ — useful for local
reproduction, but those paths name the CI runner's own ephemeral filesystem,
gone the moment the job ends. The reviewable record is the editor-gui job's
one additive step (.github/workflows/ci.yml): an if: failure()
actions/upload-artifact@v4 step uploading target/golden-failures/ as the
golden-failures artifact (if-no-files-found: ignore, since it produces
nothing on a green run). This is the tranche's only CI change; nothing else
in ci.yml moves.
9. Rubber-band selection (2026-07-23, T2-W2) — drag threshold and anchor accent
Dispatched under spec/CONTRACT_EDITOR_T2_SELECTION.md §W2, over W1's
selection-set API (selections(), anchor(), toggle_at, select_within).
main.rs only; no golden pixel moves — the overlay is drawn by score_view
after the score texture is painted, entirely outside goldens.rs's headless
raster path, so none of it is exercised by a golden test.
Sensing. The score view's Sense widens from click() to
click_and_drag(). A plain click still resolves through
Response::clicked(), unchanged. A drag is tracked in a new pure DragRect
(origin + current screen Pos2, no egui::Response/Context dependency, so
it is unit-testable headlessly), updated across frames via
drag_started/dragged, and resolved on drag_stopped.
The drag threshold, and why it exists even though egui has its own.
DragRect::RUBBER_BAND_THRESHOLD = 4.0 screen points: below it, a completed
drag resolves as a plain click/toggle at the release point, not a
rubber-band select_within. This exists on top of egui's own click-distance
tolerance (InputOptions::max_click_dist, 6.0 points) because a widget
sensing both click and drag reclassifies a long, still press-and-hold
(no meaningful movement, held past max_click_duration, 0.8s) as "dragging"
purely on elapsed time (PointerState::is_decidedly_dragging) — a gesture a
user experiences as a click would otherwise silently become a near-empty
rubber-band replace of the current selection. DragRect::is_rubber_band
(distance >= threshold) and the release dispatch
(resolve_release(&DragRect, ctrl) -> ReleaseAction) are pure and directly
unit-tested, including the exact boundary (>=, not >).
World-space query stays unnormalized. select_within's BoundingBox
normalizes its own corners (its doc: "rect's corners are order-independent");
the release handler therefore maps drag.origin/drag.current straight
through ViewMap::screen_to_world with no pre-normalization of its own.
DragRect::screen_rect's own min/max normalization is a separate concern
(painting a valid, non-inverted rectangle for the live overlay) and never
feeds the world-space query.
Ctrl/Cmd-click and the below-threshold-drag release both resolve through
one shared EditorApp::resolve_click(world, grid, toggle): toggle calls
toggle_at; otherwise click, with the exact same "empty — pencil would
insert…" reporting the single-selection code already had. This keeps the
plain-click behavior — including its status-line wording — identical to
before this packet for both call sites.
Anchor accent. Every member in session.selections() gets the existing
2.0pt blue stroke (unchanged from the prior single-selection code, now
looped); session.anchor() additionally gets a second pass with a 3.0pt
orange stroke on top. A single-member selection's sole member is trivially
the anchor, so it now receives both passes (blue then orange) where it
previously received only the blue one — a deliberate, undocumented-by-any-
golden visual change (the overlay is outside the golden raster path per
above), not a behavior change: click's selection-logic contract (single
member, replaces the set) is unchanged, only its paint style gained an
always-present anchor accent.
Debug panel and help text. Adds a selected: N member(s) line and
relabels the existing selection lines as the anchor's (session.anchor()
directly, replacing the session.selection() compatibility read — the two
are equal by construction, selection() is anchor().copied()). The help
text now documents drag-to-select and Ctrl/Cmd-click, and states which
toolbar/key intents act on the whole selection (delete_selection,
alter_selection — both batch over every/every-pitch member per W1's
DECISIONS.md) versus the anchor alone (move_selection_staff_step,
add_note_to_selection, insert_note_after_selection,
set_selection_duration — verified against each function's own
self.selection.anchor() read in epiphany-editor-core/src/lib.rs, not
assumed from the contract's own summary, which groups alter with the
anchor-only intents even though its implementation batches).
Clipboard wiring (2026-07-24, T2-W4b)
Dispatched as the W4b packet's Half 2, over epiphany-editor-core's
copy_selection/paste_over_selection/CopyOutcome/PasteOutcome
(fragment.rs's clipboard fragment projection, Ruling E). main.rs only; no
golden pixel moves for the same structural reason W2's overlay didn't
(decision 9): goldens.rs builds its own session and calls
render/rasterize_pixmap directly, never through EditorApp, so nothing
this packet touches (toolbar, keys, status line, a new last_paste_text
field) is reachable from a golden test. Verified, not assumed: the three
baseline PNGs (ten_measure_open.png 53638B, ten_measure_insert.png
54590B, ten_measure_slurs_castoff.png 57891B) are byte-identical
(md5sum before/after) and untouched by git status throughout this
packet.
The clipboard mechanism egui 0.29 actually offers — verified against the
vendored source (~/.cargo/registry/…/egui-0.29.1,
~/.cargo/registry/…/eframe-0.29.1), not assumed from memory of a newer
egui:
- Write (copy):
egui::Context::output_mut/egui::Ui::output_mut, settingPlatformOutput::copied_text. This is the only clipboard-write surface in 0.29 (egui/src/data/output.rs:107,egui/src/context.rs:1423) — the backend (eframe'segui-winitintegration) reads it after the frame and pushes it to the OS clipboard.do_copysetsctx.output_mut(|o| o.copied_text = outcome.fragment.clone())on a successfulcopy_selection. - Read (paste): there is no synchronous "read the OS clipboard now" call
anywhere in
eframe::Frame's oregui::Context's public API — checked directly (grep -rn "pub fn" eframe-0.29.1/src/epi.rs; the only clipboard reference ineframe's own source isegui_winit.clipboard_text()inside the native integration's internal event-translation code,native/glow_integration.rs:681, never exposed to app code). The only way an app learns paste content is by consumingegui::Event::Paste(String)from the input event queue (egui/src/data/input.rs:388) — the backend detects an OS paste gesture (Ctrl/Cmd+V, or an OS-level paste menu action), reads the clipboard itself, and injects the text as one event, once, for that frame only.EditorApp::handle_clipboard_eventsreadsctx.input(|i| &i.events)every frame via the new pure helperpaste_event_text(&[egui::Event]) -> Option<&str>and, on a hit, both acts immediately (pastes over the current selection) and caches the text (last_paste_text) for the toolbar "Paste" button — which has no other way to act between paste gestures, since a button click generates noEvent::Pasteof its own and there is nothing else to read it from.
Why Copy is a plain keyboard edge but Paste cannot be. handle_keys's
existing pattern is a boolean edge-read (i.modifiers.command && i.key_pressed(egui::Key::...)), used for every other shortcut in this file.
Ctrl/Cmd+C fits it exactly: do_copy needs nothing from egui except that
the chord fired — the text it writes out comes from copy_selection(), not
from any event payload — so Keys gained one more field (copy) the same
way. Ctrl/Cmd+V structurally cannot fit that pattern: a bare key_pressed
edge tells you that a paste gesture happened, never what was pasted, and
only Event::Paste's own String payload carries that. So paste has no
Keys field at all — handle_clipboard_events (called once per frame from
update, alongside handle_keys) is the whole mechanism, and it is what
makes Ctrl/Cmd+V "just work": egui's own native integration already turns
that chord into the event, this app only has to consume it.
No-selection paste policy. paste_over_selection itself returns
Err(EditorError::NoSelection) when nothing is selected — Display: "no
selection", accurate but not actionable in a clipboard context (a user
who never selected anything doesn't know that's what "no selection" means
here). do_paste pre-checks session.anchor().is_none() and reports
"select a destination first" instead, without ever calling
paste_over_selection — a GUI-level policy decision, not a change to the
core session's error semantics. Every other outcome — a successful paste
(reports events/slur/tie counts from PasteOutcome) or any other
EditorError — surfaces {err} verbatim, per the packet's brief ("the
fragment error Display strings are user-grade"); FragmentError's and
EditorError's Display impls (epiphany-editor-core/src/fragment.rs,
lib.rs) are already written for a human reader, so this crate adds no
translation layer over them.
Toolbar buttons. "Copy" is always clickable (unguarded), matching this
toolbar's existing convention for every other selection-dependent action
(Delete, Transpose, Move, …) — none of them are add_enabled-gated on
selection state; a NoSelection/WrongSelection error is left to surface
normally through the status line. "Paste" is gated
(add_enabled(self.last_paste_text.is_some(), …)), because unlike a
domain error, "nothing has ever arrived to paste" is a structural
precondition with no session-side error to report at all — the same class
of gate can_undo/can_redo already use for Undo/Redo.
Pencil mode is untouched. Clipboard actions (buttons, keys, and
handle_clipboard_events) run unconditionally regardless of self.pencil
— no new interaction with the pencil click-to-insert path, no new branch in
score_view. Pencil's own behavior, and every existing test/golden that
exercises it, is unchanged.
Testing scope, stated honestly. paste_event_text is pure (no
egui::Context/InputState/native-backend dependency) and gets four real
unit tests, each proven live by a mutation (dropping .rev() — breaks
"last Paste in the frame wins" — and matching the wrong Event variant
— both confirmed to fail the expected tests, then reverted). Everything
else this packet added — do_copy/do_paste/do_paste_from_cache/
handle_clipboard_events's own dispatch, the toolbar buttons, the
Keys::copy edge, the help text — is egui-side dispatch, reviewed but
not unit-tested: there is no headless way in this crate to synthesize an
egui::Context frame, drive ctx.input/output_mut, or simulate an OS
clipboard/paste gesture (the same limitation the rest of main.rs
already lives with — goldens.rs exists precisely because rendering has
no headless story either, and resolve_release/DragRect were pulled out
pure for the same reason W2 needed to unit-test something about
release-time dispatch). cargo test -p epiphany-editor-gui green (20/20,
including all four golden tests) is this packet's regression gate for that
untested surface, per the brief.
Note-entry mode + G5 (2026-07-24, T3-W2)
Dispatched under spec/CONTRACT_EDITOR_T3_CARET.md §W2, over W1's caret seam
(EditorSession::{caret, set_caret_at, set_entry_duration, advance, retreat, enter_nominal, enter_pitch, enter_rest, x_at_position}, Caret). main.rs
(mode, keys, palette gating, click dispatch, caret overlay, help/debug text)
and goldens.rs (the new G5 test) only — no editor-core change was
needed, confirmed by grep-checking the packet's own suggestion of a "tiny
pub accessor": every piece the caret overlay needs (score().voices(),
score().staff_instances(), resolved().strokes, x_at_position()) was
already public.
Mode exclusivity. pencil: bool and the new entry_mode: bool are
mutually exclusive: turning either on always clears the other. Implemented
once, as a pure toggle_exclusive(this, other) -> (bool, bool) (mirrors
resolve_release's role from T2-W2 — pulled out so the invariant itself is
unit-tested, not eyeballed at each of its four call sites: the P/N keys and
the two toolbar toggle_values). toggle_exclusive's contract is
intentionally asymmetric: turning this on forces other off; turning
this off leaves other exactly as it was (never assumes the
invariant already holds coming in, only guarantees it holds going out).
Letter-key interpretation is mode-gated, pulled out as a second pure
function resolve_letter(entry_mode, nominal) -> LetterAction (Enter,
AddChordNote, or None) — the packet's own named candidate for "the
obvious testable pure fn". In entry mode every letter A–G enters that
natural; outside it, only A keeps its pre-existing "add chord note to the
anchor" meaning (B–G stay unbound outside entry mode, as they always were).
handle_keys reads whichever of the seven letter keys fired this frame (at
most one in practice — one key event — but the lookup takes the first match
either way) into Keys::letter: Option<CmnNominal>, then dispatches through
resolve_letter.
Key map, verified against the code rather than assumed from the
contract's own summary (the contract said "←/→ ... they currently move
selection pitch", which does not match handle_keys as it stood — ↑/↓ move
pitch, ←/→ were unbound; the coordinator's dispatch caught this and it is
recorded here as the corrected, verified fact):
- N — toggles note-entry mode (mutually exclusive with pencil).
- A–G, entry mode on —
enter_nominal. A, entry mode off — the existing "add chord note" (unchanged). B–G, entry mode off — no binding (unchanged: they never had one). - R —
enter_rest, entry mode only. Verified free:handle_keysnever readegui::Key::Rbefore this packet. - ←/→ —
retreat/advance, entry mode only. Both new bindings (neither was bound before); outside entry mode they remain inert, same as before this packet. - ↑/↓ — unchanged in every mode:
move_selection_staff_step. - Duration palette (toolbar buttons
1/1/2/1/4/1/8/1/16) — while entry mode is on,set_entry_duration; otherwise unchanged (set_selection_duration). Gated per-click onself.entry_mode, not a separate widget.
Click dispatch. score_view's three-way branch is now pencil →
insert_note_at (unchanged) → else entry_mode → set_caret_at (new) →
else the existing select/toggle path. Pencil's branch keeps its early
return after request_repaint() (needed because insert_note_at mutates
the score, staling the hit-test map the overlay code below would otherwise
paint against this same frame). set_caret_at needs no such guard — it
mutates no score/layout state at all, so the overlay code safely runs in
the same frame using the just-updated caret. Rubber-band drag tracking
(DragRect) is now gated on !self.pencil && !self.entry_mode (widened
from !self.pencil alone) — an extension beyond the contract's literal
text (which only names "a click in entry mode"), decided for the same
reason pencil already excludes dragging: a click in either mode does
something other than select, so a drag gesture in either mode has no
selection to build either. Flagged as this packet's own call, not
contract-mandated.
The caret overlay's region/staff derivation (caret_segment, next to
selection_rect): caret.voice → (region, staff_instance) via
session.score().voices() (matches epiphany-editor-core's own internal
use of Score::voices()) → the staff's global StaffId via
session.score().staff_instances() → that staff's own rendered line
strokes, filtered from session.resolved().strokes by
TypedObjectId::Staff(staff_id) (not just any staff — a multi-staff score
needs the caret drawn on its own staff, not the topmost one) — min/max y
across those strokes is the "full staff height" span, no hardcoded staff-
space constant needed (unlike duplicating editor-core's private
STAFF_SPAN, this reads the actual rendered geometry). x comes
straight from x_at_position(region, None, &caret.position). Drawn as a
2.0pt line in a distinct green (rgb(0, 170, 90)), separate from the
selection's blue and the anchor's orange.
G5 — the caret-entry golden. ten_measure_single_staff(0), the same
fixture and the same scripted_insert_target click point G2/G3 already
use (the contract's own ask: "the same extrapolation territory G2 used") —
reused verbatim rather than deriving a second target function. Drives
set_caret_at then four enter_nominal(C/D/E/F) calls at the caret's
default (quarter, from the 4/4 meter) entry duration. Value assertions
before pixels, per the contract: the grid step, the caret's post-
set_caret_at position (independently re-derived, not just asserted equal
to G2's own — though it does land on the same whole-note-10 slot G2 proves,
since it is the identical target on the identical fixture) and entry
duration, then each of the four entries' resulting caret position as an
exact MusicalPosition/RationalTime before any raster is taken. Baseline:
goldens/ten_measure_caret_entry.png, 57379 bytes — generated via
EPIPHANY_BLESS_GOLDENS=1, not judged correct here: per plan §Ruling
C's user deep-dive point, a new baseline is visually reviewed and approved
by the user before being committed. G1–G4's three baselines
(ten_measure_open.png 53638B, ten_measure_insert.png 54590B,
ten_measure_slurs_castoff.png 57891B) are confirmed byte-identical
(git status reports them untouched throughout this packet).
Mutations, each substituted, observed failing, then reversed (never
git checkout): (1) resolve_letter's mode gate (if entry_mode →
if !entry_mode) — kills both of its own tests (letters interpreted
backwards in and out of entry mode). (2) toggle_exclusive's clear branch
(other always passed through unchanged) — kills
toggle_exclusive_turning_on_clears_the_other ((true, true) vs expected
(true, false)). (3) G5: skipping the D entry (substituting a hand-built
EditOutcome{graph_changed: true, ..} in place of ever calling
enter_nominal) — the position assertion fires, not the pixel one:
the test panics at session.caret().map(|c| c.position) == Some(after)
(Some(MusicalPosition(41/4)) vs the expected Some(MusicalPosition(21/2)))
long before reaching render_pixmap/assert_golden at all — confirming
the contract's own ordering claim ("value assertions before pixels") is
real, not just documented. (4) G5's determinism double: perturbing the
second render_pixmap call's px_per_staff_space (12.0 → 13.0) — kills
the assert_eq!(svg1, svg2, ...) check (a large SVG-string diff), proving
the double is a real, load-bearing check and not vestigial boilerplate
copied from G1–G4.
Testing scope, stated honestly. resolve_letter and toggle_exclusive
are pure and unit-tested directly, same discipline as resolve_release/
DragRect. Everything else this packet added — the toolbar's two
toggle_value calls and their .changed() mutual-exclusion glue, the
Keys struct's new fields and their ctx.input reads, do_set_caret_at/
do_caret_step/set_entry_duration's own bodies, score_view's click/drag
branching, the caret overlay's paint call, the debug panel and help text —
is egui-side dispatch, reviewed but not unit-tested, for the same
structural reason T2-W2/W4b's equivalent surfaces are not: there is no
headless way in this crate to synthesize an egui::Context frame or drive
ctx.input/painter calls. cargo test -p epiphany-editor-gui green
(25/25, including all five golden tests) is this packet's regression gate
for that untested surface.