Files
reasampler/src/core/ui/CLAUDE.md
T

151 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# src/core/ui — pure UI geometry, palette, and interaction-decision modules
## Scope
Pure, REAPER-free UI geometry, palette, and interaction-decision modules shared by
the extension's docked bank panel and the VST3 instrument's editor/embed surfaces:
layout math, hit-testing, hover/drag-gesture-precedence decisions, and the
role-based color palette. Does **not** include: the actual LICE drawing (`draw_kit`
lives in `shell/panel`; the editor's own paint code lives in `shell/instrument`),
REAPER/SWELL window or dialog mechanics, or the DAW-side Design View flag
application (`shell/view`).
## Invariants
Look-and-feel — visual design language (Phase L) (settled decisions, 2026-07-26;
L7 sub-pass, 2026-07-27):
- **DS-1 — toolkit discipline.** Draw with LICE + reused WDL/vwnd pieces directly;
external frameworks (iPlug2/JUCE/VSTGUI) are rejected. "**Caution, not a ban:**
keep hit-test **geometry** in pure CTest-covered modules — do not import vwnd's
retained-mode object model wholesale (its controls own their hit-test internally,
which would move geometry into untestable shell code and undercut the pure/shell
split)." This directory is that pure-geometry seam.
- **DS-2 — palette is role-based, not hardcoded hue**, in one constants block
(`theme`): `bg/base`, `bg/panel`, `bg/cell`, `line/hairline`, `text/primary`,
`text/dim`, `accent/primary`, `accent/secondary`, `accent/tertiary`,
`accent/hot`, `overlay/trace`, `warn`. Neutral ladder sits in REAPER's mid-grey
theme family (`bg/base``#2b2b2b`, `bg/panel``#333333`, `bg/cell`
`#3a3a3a`, `line/hairline``#4a4a4a`, `text/primary``#dcdcdc`, `text/dim`
`~#a0a0a0`+), elevation-ladder discipline (base < panel < cell by a few %,
micro-gradient + inner highlight/shadow carry elevation, not hard borders). The
accents carry categorical meaning: **primary (pastel lime) =
live/active/selected**, secondary (deep teal) + tertiary (pastel purple) =
supporting categorical distinctions (kinds, not intensity). `warn` (red/amber)
is reserved **only** for byte-deleting or clip states (prune, delete).
**The role count is not fixed at three** — a shade that works better in one spot
earns its own named role (Daniel, 2026-07-31). What does not relax: draw sites
ask by role. A literal color outside `theme`'s constants block is the violation,
not the role count.
- **WCAG-floor discipline (tight pairs to re-verify on any palette change):**
`text/dim` on `bg/panel`/`bg/cell` is the classic mid-grey-on-mid-grey floor
failure — must clear AA 4.5:1 body text. The accents as state indicators/active
fills on `bg/cell` have a shrunken contrast cushion (primary ~7.6:1, tertiary
~5.5:1, secondary only ~3.9:1 since it was darkened — all still clear 3:1, but
re-check every one on any hue nudge). Body text on an accent fill is a tight AA
4.5:1 pair (4.91:1 for `accent/secondary`). **The *binding* limiter on how dark
`accent/secondary` may go is neither of those: it is the velocity-curve trace on
a HOVER-lightened `bg/cell` at 3.03:1 against a 3:1 floor** — 1.2% headroom, the
tightest pair in the tree, and invisible to any rest-surface check. Take the most
pastel value that still clears the floor, approached from the soft side, never
re-saturated toward neon "to be safe."
- **A state surface is a DIFFERENT surface.** Hover lightens toward `accent/hot`,
pressed darkens, disabled desaturates — each changes the contrast of everything
drawn on it. Enumerate the state surfaces a role is drawn over, not just the rest
ones; the limiter above is exactly the pair that enumeration missed.
- **A mark drawn OVER an accent fill answers to two neighbours at once.** THE home
for this rule. `overlay/trace` (the envelope trace on the waveform) must clear 3:1
against both `accent/primary` and `bg/base`, which sit 9.41:1 apart — so
`sqrt(9.41)` ≈ 3.07:1 is the ceiling for *any* single value, and the role sits at
that optimum with no headroom either way. Three consequences, all load-bearing:
the role is confined to the waveform band (no value clears 3:1 on `bg/cell` and
against the lime at once); inside the 20%-alpha loop-span fill it is under floor
at 2.25:1, and against `line/hairline`'s zero line at 1.92:1 — both KNOWN,
ACCEPTED, and asserted as such, because no trace value fixes either; and any two
values that BOTH clear the ceiling sit within 1.05:1 of each other, so a state of
this mark (grabbed, hot, selected) **cannot** be carried by color and must be
carried by size, fill-vs-outline, or shape.
- **"Speed is the selling point" — a design constraint on this geometry, not
just the draw layer.** Sub-frame hover/press/drag feedback must repaint
immediately on the input message. **No decorative animation** — no
tweens/fades/pulses; the only permitted motion is a level/meter readout
following audio directly. Any glow/bloom state is a static drawn state, never
a pulse.
- **Precision/invariant implications (Phase L does not change these):** "the
pure/shell split holds" — all layout/hit-test stays in pure CTest-covered
geometry modules; the kit's *draw* half is shell, its *geometry* half is pure,
even where a WDL piece is reused. Look-and-feel work never touches capture,
placement, or bank data ownership.
- **Drag-gesture precedence is a pure decision helper, on both sides of the client
rect.** Inside: drop on a tab/other bank → move/copy; else same-bank grid →
reorder-to-slot (empty slot = place, occupied + no modifier =
insert-before-and-shift, occupied + Alt = replace). Outside: `decideDropClass`
resolves the surface under the cursor. The shell only reads live
pointer/focus/client-rect/modifier state and calls these, then maps the resolved
cue to a cursor via `SetCursor`. No cue or precedence logic belongs in the shell.
- **The drag-out law is per-move and stateless.** The class is resolved from the
current pointer on every move and again at the release point; nothing is latched
between evaluations. That is what makes every transition reversible and what
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. Leaving
REAPER entirely is the one irreversible transition, because the OS hand-off goes
modal. A first-move class lock and a drag-lifetime "cannot hand off" latch both
existed here and were removed — do not reintroduce either.
## Modules
- `rect` (`core/ui`, header-only) — the ONE concrete pixel rectangle (Q-W1): XYWH storage + `right()`/`bottom()`/`ltrb()`/`contains()`, replacing 12+ byte-identical role structs (`ButtonRect`/`FooterRect`/`CellRect`/`KitBox`/…) and the VST side's separate LTRB `Rect`; every prior role name survives as a `using` alias at its old site (e.g. `editor_geometry::Rect`).
- `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel.
- `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip.
- `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button.
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (OffReaper / TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
- `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure.
- `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers.
- `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement.
- `overflow_menu` — pure overflow-menu-button geometry/reserve/hit-test for the top-toolbar More (⋯) button.
- `mode_enable` — pure opposite-mode enablement predicate: given the active mode, computes per-button live/disabled state for the four Item/Track × Arrange/Design tag buttons.
- `tooltip` — pure tooltip placement + prefix-strip: strips the `ReaSampler:` display prefix from the registered action phrase; width clamped to the client rect.
- `card_drag` — pure drag-gesture precedence + slot hit-test: leave-client → OS drag-out; other-bank → move/copy; same-bank → reorder / Alt-over-occupied → replace.
- `card_meta` — pure card-metadata formatters: bars.beats.subdivisions and seconds.milliseconds; blank when the sample is unstamped.
- `stroke_aa` — analytic antialiased thick-stroke COVERAGE (the shell blends it): `StrokeCanvas`, a reusable mask holding distance-to-polyline coverage MAX-accumulated across segments, plus `strokePolyline` / `strokeBounds` / `appendArc` / `rasterRowOffset` (the row-major offset
math for a possibly bottom-up raster, pulled out of the shell's LICE blend so its flipped
branch is pinned by a host-free test). An arc is just a flattened polyline, so ONE path serves the knob arcs, the inner dial, the envelope polyline and both spline traces. Coverage is `clamp(halfWidth + 0.5 - distance, 0, 1)`, which makes perpendicular weight exactly `2·halfWidth` at every angle. **The guaranteed-opaque-core threshold is width ≥ 2 px, not any width above 1 px**: opacity needs `distance <= halfWidth - 0.5`, and the worst-case distance from a pixel centre to the centreline is 0.5, so a 1 px stroke (`halfWidth = 0.5`) has zero slack — its peak alpha modulates with the stroke's exact alignment to the pixel grid instead of pinning to 255 (Daniel's ruling, 2026-08-01: every stroker-drawn width on the editor is now >= 2 px for this reason — `testSubOpaqueCoreAtOnePixelWidth` in `tests/test_stroke_aa.cpp` still pins the 1 px case as a property of the stroker, independent of whether any surface ships at that width). Long segments are subdivided before rasterizing — EXACT, not an approximation (min-distance to a partition of a segment is min-distance to the whole), purely to keep each piece's bounding box tight, since one long diagonal's box has area O(len²).
## Gotchas
- The WCAG contrast-floor pairs above are real math in `theme`'s tests, not a
visual eyeball check — any new hue or role needs its own contrast-floor
assertion. Enumerate the draw sites by grep, not from memory: the enumeration
must cover state surfaces (hover/pressed/active), translucent composites, and
anything else drawn into the same rect, not just the rest surfaces.
- **A text color's floor is set by its FONT, not by its role.** WCAG's large-scale
class needs ≥24px, or ≥18.66px BOLD (`theme.h`'s thresholds); anything smaller is
body class and answers to 4.5:1. A draw site relying on the 3:1 floor must pin its
font metrics against those constants — `draw_kit.cpp`'s `Font::RegionTitle` is the
worked example. A prior revision wrote the threshold ~25% low and let 15px
semibold clear a floor it was not entitled to.
- `card_drag`'s precedence order must stay a pure decision helper mirroring
`drag_out::decideDropClass` — don't let a shell reimplement gesture precedence
ad hoc; the cursor-cue mapping in the shell must stay a thin lookup over the
pure result.
- `drag_out` is deliberately **dependency-free**, and `instrument_drop` links it
rather than the reverse. Inverting that edge would drag the instrument's state
serializer into `card_drag` and into every `drag_out` consumer.
- `rect`'s prior role names survive only as `using` aliases at their old call
sites — changing `rect.h` itself ripples across every directory that aliases
it (e.g. `editor_geometry::Rect`); check all alias sites, not just this one.
- **`stroke_aa`'s mask is deliberately NOT cleared on `reset`.** Only
`[rowLo, rowHi)` of each row holds meaningful coverage; everything else is
whatever the reused buffer last held. That is what keeps a stroke's cost
proportional to its ink rather than to its bounding box — but it means any new
reader must respect the row extents, and any new writer must grow them through
`extendRow`, which zero-fills the newly-valid cells INCLUDING the gap when a
stroke revisits a row far from where it left it (a circle touches most rows on
both sides). Reading the raw buffer outside the extents returns garbage by
design, not zero.
- **Neither `LICE_Arc` nor `LICE_ThickFLine` can draw these strokes** — the first
never reaches an opaque core, the second's width is along the minor axis so its
perpendicular weight falls off as `cos θ`. The evidence and the measurements
live in `docs/product/visual-design-language.md` §8; do not "simplify" a stroke
site back onto either primitive.