# Spectrumworx UI

> Reference for the SpectrumWorx interface (src/gui/). Covers the editor and the EditorHost inversion, the module rack and why it is recomputed rather than diffed, the painted skin and its palette rule, ModuleUI/ModuleControl, the headless editor harness and show-ui render tests, and the ownership rules the interface must not break.

- Skill: `baconpaul/spectrumworx-ui` (Agent Skill)
- Install (CLI): `npx skillmds@latest add baconpaul/spectrumworx-ui`
- Raw SKILL.md: https://api.skillmd.com/api/skills/baconpaul/spectrumworx-ui/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: baconpaul (https://skillmd.com/u/baconpaul)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/baconpaul/spectrumworx-ui

---


# SpectrumWorx — the interface

**Repo:** `surge-synthesizer/SpectrumWorx`. Paths are relative to the repo root.
JUCE, above an engine that links none of it. `$B` is your CMake configure directory.

```bash
cmake --build $B --target sw-plugin-tests && $B/sw-plugin-tests
$B/sw-plugin-tests "[gui]"
cmake --build $B --target sw-show-ui        # the offscreen render tool
ctest --test-dir $B
```

Companion skills: `spectrumworx-engine` (who owns what — **read its §3 first**),
`spectrumworx-dsp` (effects).

There is no `doc/tech` document for the interface, so this skill carries more of its own
weight than the other two. The documents that constrain it are `threading_model.md`
(§1, §5 and §7 are about the editor), `parameter_system.md` and `undo-redo.md`.

---

## 1. The rule the interface exists under

**The engine holds no widget.** In 2016 `SW::Module` owned its own editor region as a
member, so every module the factory allocated carried that effect's JUCE widget storage
inline. Now `SpectrumWorxEditor` owns the strips and each one holds an
`IntrusivePtr<Module>` — **the reference runs from the interface to the engine and never
back.**

That is not a convention; it is what makes `sw-dsp` link no JUCE, which is checked by the
`engine-links-no-juce` ctest. See `spectrumworx-engine` §2.

The interface reads three channels from the engine and writes one:

| | |
|---|---|
| `ToEngine` ring | commands out — ordered, all delivered |
| `ToUI` ring | base-value echoes in, plus `Retire` on a ring of its own |
| `ValueMailbox` | modulated values in, as `const &` — coalescing, **painting only** |
| flags | chain changed, timing changed — carry no payload, cannot be dropped |

**`SpectrumWorxCLAP` owns all three, not the editor.** `paramsValue`, `paramsValueToText`
and `stateSave` are main-thread calls that happen with the window shut, so the model has
to outlive the editor and exist when there has never been one. The editor is handed
references at construction.

## 2. `EditorHost` — the dependency inversion

`sw-impl` links `sw-gui`, so `sw-gui` naming `SpectrumWorxCLAP` would be a cycle. Instead
`gui/editor/editorHost.hpp` declares what the editor needs from whatever hosts it.

Most of what the old editor asked the plugin for was really the engine's and is reached
through `core()`. `EditorHost` carries only what is genuinely the host's — the side
channel's sample file, presets, and the persisted settings.

> It is deliberately small. **Every function added there is one the editor cannot be
> tested without a plugin behind it** — which is exactly what the headless harness in §6
> would lose.

## 3. The module rack is recomputed, not diffed

`resyncModuleRack()` drops strips whose module has gone, builds strips for modules that
have none, and places every one where the main thread's chain says. A recomputation
rather than a diff, because between a click and the engine applying it the rack is what
the user asked for and the engine's chain is what is playing.

Three things ask for it, and all three are needed:

1. **Whatever changed the main thread's chain says so** — add, remove, preset load, each
   calling `refreshModuleRackAsync()`.
2. **The engine's echo says so**, through `chainChangedPending_`, for changes originating
   on the audio thread — a host writing a slot selector inside `process()`.
3. That echo is acted on **synchronously**, from `drainEngineEvents()` in
   `onMainThread()`.

Point 3 has a consequence that governs everything below: **a strip can be destroyed inside
a host callback, between one paint and the next.**

Which is why `resyncModuleRack()` opens by dismissing any open menu, and why
`detachFrom()` decides what to drop by asking each widget **what it is pointing at**
rather than asking the editor what is currently selected. The LFO display and the shared
module controls are children of the *editor*, each holding a raw `ModuleUI *`, and
deactivation deliberately leaves them alive while clearing the editor's records — so the
two questions have different answers exactly when a strip is being freed.

And point 1 is not redundant with point 2: a preset load fills the main thread's program
outright and only *queues* the engine's copy, so waiting for the echo would make the
picture depend on the host calling `process()` — which Logic does not do for an AudioUnit
on a track that is neither playing nor monitored. That was a live bug, and it is pinned by
a case that never calls `process()`.

## 4. Widgets

| | |
|---|---|
| `gui/gui.hpp` | `WidgetBase<Base>`, `PopupMenu`, `PaintedButton`, `DrawableText`, the `postMessage` helpers, `SkinLifetime` |
| `gui/modules/moduleUI.hpp` | `ModuleUI` — one strip; `ModuleKnob`, `ModuleLEDTextButton`, `TriggerButton` |
| `gui/modules/moduleControl.hpp` | `ModuleControl<ImplWidget>` / `ModuleControlBase` — the parameter-bearing behaviour a strip control has |
| `gui/editor/spectrumWorxEditor.hpp` | the editor: main area, module menu, drop indicator, sample area, LFO display, settings tabs, undo button |
| `gui/editor/zoomedEditor.hpp` | the editor drawn larger than the skin it is laid out in |
| `gui/preset_browser/` | the browser |
| `gui/painters/` | every painted element — knobs, strips, buttons, frames, capsules, arrows, glyphs, waveform |

A module control is generic over the parameter it carries; the parameter's *type* selects
quantization and menu behaviour through traits, which is how one knob class serves every
effect's parameters. That is the interface half of the dynamic parameter system — see
`parameter_system.md`.

**`SkinLifetime` owns JUCE's theme, not JUCE's lifetime.** It builds the `Theme` and
installs it as the default LookAndFeel while at least one editor exists. It does *not*
count JUCE up or down — the shim's `ScopedJuceInitialiser_GUI` does that. Closing an
editor used to call `shutdownJuce_GUI()` against a counter JUCE's own initialiser never
saw, which with two instances is one tearing down the message loop the other runs on.

## 5. The skin is painted, and the palette is a single file

The artwork is drawn in code rather than loaded as bitmaps, so the palette has to live
somewhere — and "a constant beside the drawing" is how this tree came to spell its accent
blue four ways, none chosen and no two three parts in 255 apart.

**`gui/colourMap.hpp` is the palette and nothing else may name a colour.** Enforced by the
`no-colours-outside-the-palette` ctest, a source scan.

The fix for anything it reports is to **add an enumerator for what the colour is *for*** and
call `getColour()`. Deriving from an existing one is fine and does not match:
`.withAlpha()`, `.brighter()` and friends say what they do. A gradient fading a colour out
wants `getColour(X).withAlpha(0.0f)` rather than a transparent enumerator — a gradient
interpolates the channels as well as the alpha.

`getColour()` is a switch rather than a table so the answer can grow a condition without
every call site learning about it. That is what let the palettes multiply without touching
two hundred call sites: only one is written out, the classic recolours turn its hue — and
a colour the artwork left neutral has no hue to turn, which keeps the greys grey with no
list of exceptions. The dark set is the one that is not a recolour; it inverts the chassis
and so names what it changes.

`gui/theme.hpp` is the LookAndFeel and sits in the same layer, below everything else in
`src/gui`. `gui/resources.hpp` reads bitmaps and fonts **out of the binary** — in 2016
they were found on disk through an installer-written paths file, so a plugin that had been
copied rather than installed came up with no skin.

## 6. Testing the interface headlessly

`tests/gui/editorHarness.hpp` is one plugin's worth of everything a `SpectrumWorxEditor`
reaches into, with **no host and no plugin format under it**. Editors are constructed
directly rather than through the CLAP shim, because what these cases test is our own
bookkeeping; the shim's half is stood in for by a `ScopedJuceInitialiser_GUI`.

The GUI cases live in `sw-plugin-tests` (`tests/CMakeLists.txt`, an explicit list) and
cover knobs and their menus, sliders and drags, module hover / header / drag, module
control focus, the module menu, the LFO display, the preset browser's navigation and save
button, the side-chain selector, palettes, paths, preferences, overlay panels, discrete
parameters, and two instances at once.

`tests/gui/twoInstanceTests.cpp` is the one to know: closing one editor must leave the
other's `MessageManager` alive, selection must stay independent, and ejecting a module and
then its ghost must not fault.

**`sw-show-ui`** renders a module offscreen per effect — one
`show-ui-renders-module-<Effect>` ctest each, registered by parsing `effectsList.hpp` and
driven by `SW_SHOW_UI_EFFECT`. That parse is fragile by construction: reflowing the effect
table once dropped seventeen of these tests *without failing*, which is why the configure
step now fatals when the parse count disagrees with the effect count.

**A menu is one of the things a headless editor cannot drive**, so anything reachable only
through a popup needs its case built at the engine end instead — see `spectrumworx-engine`
§8 on asserting at the far end.

## 7. What the interface must not do

- **Never touch engine state from a paint or a timer.** The audio thread owns the engine
  while activated; the interface talks to it only through the rings.
- **Never write a widget from the audio thread.** The engine's LFO update runs there;
  reaching a component from it is rule 1 of the threading model. This is why one editor
  redraw had no caller for the life of the port — its CLAP equivalent ran on the wrong
  thread.
- **A queued edit has to be asked for.** Nothing drains the command queue but `process()`,
  `paramsFlush()` and `deactivate()`. A knob gets that for free from the automation
  notification; a bulk change such as a preset must call `request_flush` itself.
- **Both copies, or neither.** An edit made in the interface must move the main thread's
  program *and* reach the engine. Writing only the strip's own object moves the display
  and the saved state and nothing anybody can hear — which is exactly what the LFO
  sync-mode buttons did until it was found.
- **Name a colour and the build tells you.** §5.
- **Menus are dismissed before a strip, a chain or a program is replaced.** §3.
- `LE_ASSERT` compiles to nothing under `NDEBUG`; anything a shipped build must not do
  needs a real check.

