The plugin's editor, in JUCE/C++ (namespace t3k::ui): every screen, drawn
and laid out natively, no browser engine. The design and the decisions
behind it are in ../docs/native-ui.md; this file
is the working guide: how to build it, how to test it, where things go.
The plugin build compiles this tree into every format; nothing extra to
install. Configuration (T3K_PUBLISHABLE_KEY, T3K_API_DOMAIN,
T3K_UPDATE_NOTICE) is read at CMake configure time from the repo-root
.env and .env.local, with the configure environment overriding both,
into the generated T3kConfig.h (NativeUi.cmake). See
.env.example.
testbed/ is a standalone JUCE app that renders PluginRoot over a
fixture-driven mock backend and mock TONE3000 session. Its own project keeps
UI iteration from rebuilding the DSP:
cmake -S plugin/ui/testbed -B build-ui -DCMAKE_BUILD_TYPE=Debug
cmake --build build-ui -j
UI=build-ui/UiTestbed_artefacts/Debug/UiTestbed.app/Contents/MacOS/UiTestbed # macOS path
$UI --scenario main-mono [--live] # interactive window on one scenario (--live: moving signal)
$UI --capture out [--ref beforeDir] [filter…] # 2x PNG per scenario (+ diff table against an earlier run); fails on unnamed Tab stops
$UI --compare before.png after.png [diff.png] # one pair
$UI --selftest # unit tests: pure logic + the focus policy in a real window
$UI --bench [--seconds 10] [--json out.json] [phase…] # CPU / memory under load (--list for the phases)--bench runs the UI under a realistic load, one phase at a time. Each phase
gets its own window, and MockSignal feeds the mock backend a deterministic
guitar-like signal: note attacks and decays, rests, an occasional clipping
hit, a harmonic spectrum and a drifting pitch. That signal drives the
meters, tile glows, clip LEDs, the EQ curve's live spectrum and the tuner.
The interaction phases send input through the window's peer, the way the OS
delivers it: dragging an EQ band dot, dragging a knob, trackpad-scrolling
the tone browser, and a click tour across the screens. Each phase reports:
- process and UI-thread CPU, as a percentage of one core;
- paint passes per second, and repainted area in whole windows per second;
- stalls, meaning message-loop gaps over 50 ms;
- memory (macOS physical footprint, Linux RSS, Windows working set).
A workload that never takes effect (its target isn't found, or the knob or
grid never moves) is flagged on its row and fails the run. Build the testbed
as Release when measuring (-B build-ui-release -DCMAKE_BUILD_TYPE=Release),
since Debug numbers mostly measure JUCE's assertions. --live gives the
interactive window the same signal. Run it before and after a rendering
change with the mouse left alone and the window unobscured (a locked or
sleeping display drops the synthetic input and skews the paint costs), and
expect ±0.5% run-to-run noise; --json keeps the numbers for a diff.
-DT3K_BUILD_UI_TESTBED=ON on the plugin build adds the same target and
registers --selftest with ctest (CI runs it next to the DSP suite).
Scenario ids come from testbed/fixtures/scenarios.json: each is a mock
backend state (chain, session, banners, settings) plus, where a screen needs
interaction to reach, the drive steps in testbed/Scenarios.cpp; the
capture table flags ids whose drive is missing. The fixtures are the
testbed's own and are edited in place (fixtures/img holds the artwork the
mock image host serves).
Captures are compared against captures, before a change and after it:
git stash # or check out main
$UI --capture /tmp/t3k-before
git stash pop
$UI --capture /tmp/t3k-after --ref /tmp/t3k-beforeEvery row of the table should read 0.00 % except the scenarios the change
touched; a diff image (<id>.diff.png) lands next to each capture that
differs. The worst-tile figure is the one that catches a shifted control:
one line of misplaced text is a rounding error globally but lights up its
64px tile. Text and JPEG artwork rasterise identically run to run on one
machine, so anything non-zero is real. New screens with nothing to diff
against are reviewed by eye against the Figma mockups.
plugin/ui/
NativeEditor.* AudioProcessorEditor: owns UiPrefs, HttpClient, Tone3000Session,
Services, PluginRoot; scales the 1024-wide design space
NativeUi.cmake t3k_add_native_ui(<target>), T3kConfig.h, embedded assets
assets/ Roboto Mono, Arimo (Arial stand-in), brand SVGs (UiBinaryData)
core/ no JUCE components: Theme, Fonts, Icons (+ generated
LucideIcons.h, CustomIcons, GearGlyphs), Design, Paint,
TextFlow / RichText (wrapped and styled text), Tween /
AlphaTween (vblank-driven, via juce_animation),
DelayedCall, AsyncScope, Result, Help
(hint strings), Labels, Pitch, EqMath, KnobScale,
MeterScale, MidiCatalog, Alerts, Blur, Bitmap (photos
resampled once at device density), Wheel
model/ juce::var → structs: ChainState, Tone, AudioDeviceState,
MidiMapState (VarReader); ToneQuery (the browser's
filters → the API query string)
backend/ ui::Backend (everything the UI asks of the processor)
and ProcessorBackend over TONE3000Processor
services/ Services (one bundle per editor) and its members
(BrowserState: what the tone browser keeps between visits):
UiClock (the one 30 Hz tick every poller shares),
ChainStore, MeterStore, PresetStore, AudioDeviceStore,
MidiMapStore, UiPrefs, HintBus, Toast, Banners,
ParamBinding, AutoMeasure, SpectrumFeed, TunerFeed,
ModelLoads, LocalFiles, ImageLoader, ConnectionGate,
UpdateCheck, ToneLoadFlow, Zoom, Pointer (touch or
mouse, read off the input at run time); the TONE3000 stack:
HttpClient, OAuth (PKCE), LoopbackServer,
Tone3000Client, ToneSession / Tone3000Session
widgets/ reusable controls that know nothing about services
(Knob, PillButton, Popover, ContextMenu, ModalLayer,
DbMeter, DotMeter, TextField, DragScroller, …);
widgets/form/ is the
settings form kit (FormItem layout, rows, controls)
vendor/ third-party code carried in-tree, each with its licence:
qrcodegen (Nayuki's QR Code generator, MIT)
views/ screens, wiring widgets to services: PluginRoot,
PluginHeader, Faceplate, MainScreen, TunerView,
SignInScreen, …
gallery/ ChainView, GalleryLane, ToneTile, AddTile, StereoPanRail
block/ BlockDetail, BlockCard, BlockInfoPanel, BlockEqView
browser/ ToneBrowser (the Select tone takeover: search + FilterBar over
the card grid, Paginator pinned under it), FilterBar /
FilterChip / FilterMenu, ToneCard, Paginator, BrowserPrompt
settings/ SettingsScreen, PluginSettingsPage, SystemSettingsPage,
MidiMapSection, …
modals/ ConnectionModal, UpdateNotice
testbed/ UiTestbed: Main (--capture/--compare/--selftest/--bench),
Host (the root over the mocks, fitted like NativeEditor),
MockBackend, MockSession, MockSignal (the bench's moving
signal), Scenarios (drives), Drive (find/click/drag
helpers + the a11y audit), Compare, Bench, ProcessStats,
SelfTests, fixtures/
script/gen-lucide-icons.mjs regenerates core/LucideIcons.h from the Lucide icon set
One screen element is one C++ component. Each .h opens with a comment
saying what the component is and the layout facts that fixed its numbers.
Many of those headers name the React component the numbers were ported
from (port of KnobControl.tsx); that is lineage, recorded in
../docs/native-ui.md §6, not a pointer to living code.
- Design space. Everything is laid out in the 1024 × 578 design box;
NativeEditorapplies oneAffineTransform. Never scale by hand. The one view that holds its size on screen instead (the tone browser's body, so a bigger window shows more results) counter-scales byServices::zoom, the factor the shell publishes on every fit; aPopoveradopts its anchor's scale, so menus opened from it are 1x too. The 578 is a minimum: a taller window stretches the column between header and faceplate (core/WindowFit.h,PluginRoot::setStretch), and a screen in that column keeps its title under the header and centres the rest in the room, or fills it. Lay out againstgetHeight(), not the constant; a scenario'sstretchrenders the testbed that way (main-mono-stretched). - Ownership.
NativeEditor→Services→PluginRoot→ views. Views hold references to the services they use and register as listeners in their constructor, deregister in their destructor. No singletons, no globals beyond the constexpr tables incore/. - Data flow. Stores are the only callers of
Backend; views subscribe to stores, read plain structs, call store actions. Optimistic edits live in the store, reconciled on the next revision. A store action refreshes and notifies synchronously, so a view'schainChangedruns inside the click that caused it: a sync must never rebuild the control whose handler is on the stack (re-select it instead), or the handler's closure is freed under it (seeBlockCard::syncHeader). - Async. Anything that lands later goes through
AsyncScope::wrap(orjuce::Component::SafePointer) so a closed editor never gets a callback. HTTP runs onHttpClient's pool, images on the same pool, the OAuth listener on its own thread; all deliver on the message thread. - Text. Line boxes come from
Fonts::normalLineHeightandFonts::cssBaseline(the design's line-height and baseline rules), withTextFlowfor wrapping/clamping/ellipsis andRichTextfor mixed runs and links. Fractional layout positions are kept and snapped only at draw time (FormItem::subpixelTop,TextFlow::draw). - Focus and keys. Nothing is focused until Tab or an explicit
focus()/grabKeyboardFocus(); a click focuses only text fields, so the host's Space / Enter keep working after mouse work. Every button derives fromwidgets/Clickable(Tab-focusable, never by click, named for screen readers from its text or help lead); a new focusable control follows the same twoset…KeyboardFocuscalls and gives itself a name (setTitle, button text or a "Name: …" help hint).PluginRoot::FocusPolicyowns the rest (Tab from nothing, Escape / press elsewhere blur). Decorative components callsetAccessible(false); status that only paints elsewhere goes throughhelp::announce(). See native-ui.md §5.8a. - Pixels. New or changed visuals get a scenario (an entry in
testbed/fixtures/scenarios.json) and a matching drive inScenarios.cppif it needs one. The before/after capture diff is the acceptance test; a new screen is reviewed by eye against the Figma mockups. - Logic. Pure logic (parsers, state machines, math) is tested in
testbed/SelfTests.cpp, onejuce::UnitTestper file it covers. - Icons. Lucide glyphs come from
script/gen-lucide-icons.mjs(add the name to itsICONSlist, run it, useIcon::<Name>) and are never hand-edited; brand and gear artwork are inCustomIcons/GearGlyphswith their SVG source noted. - Style. Two files per component, CamelCase,
t3k::ui;-Wshadowclean; comments explain the why, and cite the design rule a number comes from.
OAuth (PKCE) runs in the system browser on desktop and in an in-app
ASWebAuthenticationSession sheet on iOS (services/WebAuth; App Store
review rejects a hand-off to Safari, see docs/ios.md).
Tone3000Session::login starts LoopbackServer on 127.0.0.1:<ephemeral>,
opens the authorize URL with redirect_uri=http://localhost:<port>/
(the iOS sheet follows that redirect too), and the plugin shows the
SignInScreen for as long as the session's AuthFlow is not idle: a full
page in the tone browser's slot (Select Tone mockups 13342:46017 and
13342:46055) with a bare ← that abandons the sign-in, the loading dots over
"Sign in with your browser", and under them the two ways round a browser
that did not open. Every entry point (account menu, info panel, the tone
browser's CTAs) shows this same screen; only the landing differs: a login
started from the tone browser (LoginIntent::browse) comes back to it,
the rest land on the chain.
The browser launch is best effort — JUCE reports success once the launcher forks, so a browser that refuses to start (Chromium under root on Linux) gives no error — hence the fallbacks, always offered:
- Copy Link ("Browser didn't open? Copy link to sign in, then return
here.") puts the authorize URL (
AuthFlow::authorizeUrl) on the clipboard, for any browser on this machine: the loopback listener takes the redirect from whichever browser completes it. - Sign in on a different device (a grey text link; hand cursor, no
hover state) runs the device flow (RFC 8628,
startDeviceFlow) on its own page, "Sign in on your phone":POST /oauth/device_authorization, then two cards with OR between them, a QR code (widgets/QrCode) ofverification_uri_complete, and "Visit tone3000.com/activate, sign in, and enter this code:" over theuser_code(the address is underlined so it reads as one, but is not a link: it is for typing into the other device), while the session polls/oauth/tokenwith the device-code grant at the server's interval (slow_downadds 5 s;expired_token,access_deniedand the code's own deadline end it with a reason and a New code button).
The launch is attempted on every platform regardless; only a launcher that
refuses outright is reported (AuthFlow::browserProblem, the copy says
so), and a browser that starts and dies simply leaves the user on the
fallbacks. All paths feed the same token store: the first to finish signs
in and the others are dropped. The redirect is checked against the PKCE state and
exchanged for tokens (Tone3000Client, persisted in UiPrefs, refreshed
transparently with a single in-flight refresh and one 401 retry). Closing
the editor stops the listener and the polling; a stale callback is ignored.
If the listener cannot bind (a machine whose loopback interface has no
127.0.0.1), no browser could hand the redirect back, so login() skips
the browser and starts the device flow: the screen opens on the device
page with no Copy Link.
The iOS AUv3 can do neither browser path (an app extension cannot open
Safari or present the sheet), but the device flow never leaves the
process, so Tone3000Session::Config::signInViaApp turns login() into:
already signed in through the TONE3000 app, whose preferences file (and so
whose tokens) the plugin shares through the App Group container? Then the
gated action goes ahead. Otherwise the flow enters leaving with
AuthFlow::deviceOnly set and the device code requested at once, so the
SignInScreen opens on the device page itself (no browser page, no Copy
Link, a line under the cards that the app's sign-in counts too, and the
expired-code page offers New code alone), while the session also re-reads
the shared file every second (kAppSignInPollMs); the code's approval or
the app's sign-in, whichever lands first, finishes like any sign-in. The
testbed's MockSession plays this with api.authorize: "app"
(load-oauth-app-device*).
The screen takes over everything under the header (meters, chain and
faceplate; PluginRoot mounts it only while open and hides what it
covers). Searching needs a session. Signed out, the screen is a preview
instead: the gear chips alone (FilterBar::setGearOnly) over TONE3000's
trending feed (ToneSession::listTrending, GET /tones/trending[?gear=],
which takes no token; the feed's one filter is the gear, and the pick is
the same ToneQuery::gear so it carries into the search after sign-in),
with a BrowserPrompt CTA under the cards ("Discover a zillion more tones."
/ "Sign in or create free account"). A card click opens the sign-in page
(the bare ← back to the cards, the prompt centred); either button runs the
browse-intent login, which lands back on this same screen signed in
(browser-signed-out*). Signed in, ToneBrowser pins a search box and a FilterBar
above the card grid and the Paginator below it; the grid scrolls between
the two, fading out under each, and asks ToneSession::searchTones for one
page at a time. The ← row zooms with the window; the body under it does
not: it is laid out in screen pixels under the zoom, the search box and
filter row keep their 1x height and widen with the column, the cards keep
their 1x height and widen to fill two columns, and go three-up once three
fit at the default width (browser-zoom-wide, browser-zoom-three-up,
browser-zoom-menu; a scenario's zoom sizes the testbed window).
BrowserState(services/, one per editor) is what the screen keeps between visits: theToneQuery, whether the filter row is unfolded, the page and the page's results (flagged when they are the signed-out trending feed, so a return across a sign-in or out fetches instead). The browser is mounted only while open, so coming back renders the last page at once with no fetch; it refreshes on the next search, filter change or page turn. Closing the editor forgets it.ToneQuery(model) is the one place the filters live: text, sort, gear, format, tags / makes / creators, calibrated, verified, profile. It builds the API path itself (requestPath):/tones/searchwith the query string, or/tones/{downloaded,favorited,created}with the title search and gear alone when a profile filter is set. The plugin's NAM architecture always rides along (the API ignores it for IR, and omitting it falls back to a legacy A1-only default). The default sort (best match with text, else trending) is stored as no pick, so Trending never reads as a filter. Unit-tested inSelfTests.cpp.FilterBaredits the state's query through one horizontally scrolling row of chips (FilterChip): the filters button, then (once unfolded, pushing the rest right) Sort, Format, Tags, Makes, Creators, Calibrated, a divider, then verified, the profile chip (the user's avatar alone until a profile is picked) and the gear chips. A chip holding a value shows it with an × that clears it (Sort: back to the default); the filters button carries a dot while any of the unfolded ones are set. A profile filter parks the catalog-only controls (dimmed, hint says why) without losing their values; the search box stays live, matching titles within the stream; IR gear (cabinet, space) or the IR format parks Calibrated the same way, and the flag stays out of the request (ToneQuery::calibratedInForce).FilterMenuis the dropdown (Popover). Single-pick menus (Sort, Format, Profile) show the current value in white; multi-pick ones add a check column. Rows may lead with an icon (Favorites' bookmark) or a creator avatar. Taxonomy menus add a search field and look their rows up fromToneSession::listTaxonomy(debounced, a newer lookup cancels the one in flight). Any pick closes the menu.- The search box submits on Enter (or its ×, or Escape, which clear it); every filter change fetches page 1 at once. An older page arriving after a newer one is dropped.