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

14 KiB
Raw Blame History

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, plus modeSegmentEnabled — the mode segment's live/dead predicate under the playback gate AND under whether the shell resolved a routable command id for it (both bools passed IN, so this stays REAPER-free).
  • 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. Also cardNameStrip + the two strip constants — the card's name line sits across the TOP of the cell, drawn over the waveform exactly as the length read-out is over it at the bottom, and is suppressed entirely on a cell with no room for both strips plus a waveform band.
  • 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.