13 KiB
13 KiB
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 outsidetheme's constants block is the violation, not the role count. - WCAG-floor discipline (tight pairs to re-verify on any palette change):
text/dimonbg/panel/bg/cellis the classic mid-grey-on-mid-grey floor failure — must clear AA 4.5:1 body text. The accents as state indicators/active fills onbg/cellhave 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 foraccent/secondary). The binding limiter on how darkaccent/secondarymay go is neither of those: it is the velocity-curve trace on a HOVER-lightenedbg/cellat 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 bothaccent/primaryandbg/base, which sit 9.41:1 apart — sosqrt(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 onbg/celland against the lime at once); inside the 20%-alpha loop-span fill it is under floor at 2.25:1, and againstline/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:
decideDropClassresolves 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 viaSetCursor. 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 LTRBRect; every prior role name survives as ausingalias 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 thebank_panelfooter Prune button.drag_out— the pure drag-out gesture law plus path-list assembly. Owns theReaperSurfacevocabulary (OffReaper / TrackPanel / FxSurface / FxEmbed / Arrange / Other —core/wire/instrument_dropclassifies REAPER's info token INTO it),decideDropClass(surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), andcueForDropClass. NoDropClassmeans "nothing happens": a surface with no defined outcome for the payload resolves toRefuse, 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 ofaccent/primary/accent/tertiary, so a categorical accent move CAN still reorder the ramp —testSpectralRampLuminanceIsMonotonicis 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 theReaSampler: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, plusstrokePolyline/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 isclamp(halfWidth + 0.5 - distance, 0, 1), which makes perpendicular weight exactly2·halfWidthat every angle. The guaranteed-opaque-core threshold is width ≥ 2 px, not any width above 1 px: opacity needsdistance <= 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 —testSubOpaqueCoreAtOnePixelWidthintests/test_stroke_aa.cppstill 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'sFont::RegionTitleis 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 mirroringdrag_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_outis deliberately dependency-free, andinstrument_droplinks it rather than the reverse. Inverting that edge would drag the instrument's state serializer intocard_dragand into everydrag_outconsumer.rect's prior role names survive only asusingaliases at their old call sites — changingrect.hitself 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 onreset. 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 throughextendRow, 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_ArcnorLICE_ThickFLinecan 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 ascos θ. The evidence and the measurements live indocs/product/visual-design-language.md§8; do not "simplify" a stroke site back onto either primitive.