# Shortcircuit UI

> Reference for the ShortCircuit XT UI layer (src/scxt-plugin/). Covers SCXTEditor structure, the dumb-terminal model, the attachment/offset pattern for wiring controls, sst-jucegui widgets, JSON layouts, begin-edit and undo gestures, and the workflow for adding a control.

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

---


# ShortCircuit XT UI Reference

**Repo:** `surge-synthesizer/shortcircuit-xt`. Paths are relative to the repo root.
**Framework:** JUCE, through `sst-jucegui` (`libs/sst/sst-jucegui/`).

Companion skills: `shortcircuit-streaming` (the message layer this talks to),
`shortcircuit-engine` (the state being displayed), `sst-param-metadata` (what drives
widget ranges and display strings).

---

## 1. The dumb terminal

**The UI is a display of engine state, never the source of truth.** It keeps a local copy
and updates it only when the engine says so.

```
user drags a knob
  → attachment.onGuiValueChanged
  → editor->sendToSerialization(SomeC2SMessage{offset, value})
  → engine applies it, sends s2c back
  → handler in SCXTEditorResponseHandlers.cpp updates editorDataCache
  → editorDataCache.fireAllNotificationsFor(thatStruct)
  → every subscribed attachment gets setValueFromModel()
  → widgets repaint
```

The value you see comes back from the engine. **Never assume a local change took effect** —
wait for the echo. A control that appears to work but does not persist is almost always one
that skipped this loop.

`SCXTEditor` registers a callback with `MessageController`; that callback runs on the
serialization thread and only queues. An `IdleTimer` drains the queue on the JUCE message
thread. Nothing touches engine memory from the UI.

The exception, and the only one: VU meters and the sample waveform read
`Engine::SharedUIMemoryState` directly, because pushing them through messaging at frame
rate would swamp the serialization thread.

---

## 2. SCXTEditor

`app/SCXTEditor.h` — the root component. It owns the message controller reference, the
`SCXTEditorDataCache`, the idle timer, and every screen. Fixed size:
`edWidth`/`edHeight` constants in that header.

Screens and overlays are `std::unique_ptr` members; `ls app/` and read the member list in
`SCXTEditor.h` rather than trusting a copied list, since screens get added.

Selection state lives on the editor, because many panels need it:

```cpp
std::optional<selection::SelectionManager::ZoneAddress> currentLeadZoneSelection;
selection::SelectionManager::selectedZones_t allZoneSelections;
int16_t selectedPart{0};
bool isSelected(const ZoneAddress &) const;
bool isAnyZoneFromGroupSelected(int groupIdx) const;
```

The lead selection is the one whose values panels display; the full set is what an edit
applies to. Read `doc/GroupOrZoneSelection.md` before touching selection behaviour.

Implementation is split out under `app/editor-impl/`: `SCXTEditor.cpp`,
`SCXTEditorResponseHandlers.cpp` (every `s2c` handler), `SCXTEditorMenus.cpp`,
`SCXTEditorDataCache.cpp`, `KeyBindings.cpp`.

---

## 3. Attachments

An attachment is the bridge between one field in a payload struct and one widget. It
implements the `sst-jucegui` data interface (`Continuous` for float, `Discrete` for
int/bool) on the widget side, and fires a `c2s` message on the engine side.

`connectors/PayloadDataAttachment.h` holds them all:

| Attachment | For |
|---|---|
| `PayloadDataAttachment<Payload, ValueType=float>` | Continuous float |
| `DiscretePayloadDataAttachment<Payload, ValueType=int32_t>` | Int / enum |
| `BooleanPayloadDataAttachment` | Bool, with an optional GUI-side inversion |
| `DirectBooleanPayloadDataAttachment` | Bool with no payload indirection |
| `DiscreteFromFloatAdapterAttachment` | A discrete widget over a float-typed field |
| `SamplePointDataAttachment` | Sample positions (int64 in sample space) |
| `DummyContinuous` | Placeholder source for a widget with nothing behind it yet |

### The offset trick

This is the core mechanism and worth understanding before writing any UI code.

The factory takes the payload struct `p` and a reference `val` to a field *inside* it, and
computes the byte offset:

```cpp
ptrdiff_t pdiff = (uint8_t *)&att.value - (uint8_t *)&p;
```

That offset goes in the `c2s` message. The engine, holding the same struct type, applies
the value at the same offset. That is why one message type covers every float in a struct
instead of needing one message per parameter — and why **`val` must be a reference into the
very object passed as `p`**. A copy, or a field of a different instance, produces a valid
compile and a wrong write.

### The factory

`SingleValueFactory<Attachment, Message>` builds attachment + widget + wiring in one call:

```cpp
using attachment_t = connectors::PayloadDataAttachment<engine::AdsrStorage>;
using fac = connectors::SingleValueFactory<attachment_t, cmsg::UpdateZoneOrGroupEGFloatValue>;

fac::attachAndAdd(adsrStorage, adsrStorage.a, this, attachments.A, sliders.A, forZone, idx);
//                ^payload      ^field         ^HasEditor  ^attachment  ^widget  ^extra msg args
```

Variants: `attachAndAdd` (attachment + widget + `addAndMakeVisible`), `attachLabelAndAdd`
(also builds the label), `attach` (no add), `attachOnly` (no widget — for a control you
position yourself).

The factory does four things you would otherwise forget:

1. Pulls metadata via `datamodel::describeValue(p, val)`, so range, units and display
   strings come from the engine's `SC_DESCRIBE`. **You do not hand-write ranges in the UI.**
2. Wires `onGuiValueChanged` to send the message.
3. Wires begin/end-edit — see §4.
4. Registers the data-cache subscription so `s2c` updates reach the widget.

`addGuiStep` / `addGuiStepBeforeSend` bolt extra behaviour onto an attachment's change
callback without replacing the send.

### Subscriptions

`SCXTEditorDataCache` maps a memory range to the attachments watching it:

```cpp
void addSubscription(void *el, size_t soel, sst::jucegui::data::Continuous *);
void addSubscription(void *el, size_t soel, sst::jucegui::data::Discrete *);
template <typename P> void fireAllNotificationsFor(const P &p);
```

`HasEditor::addSubscription(val, att)` is the wrapper you actually call, and the factory
calls it for you. An `s2c` handler that overwrites a cached struct must call
`fireAllNotificationsFor` on it or nothing repaints.

---

## 4. Begin-edit and undo gestures

A drag should be one undo entry, not one per pixel. The engine always pushes a discrete
undo step; grouping comes from a gesture opened by a begin-edit message.

`connectors/BeginEditTraits.h` maps a `c2s` message type to an `EditSubtree`:

```cpp
template <> struct BeginEditTraits<cmsg::UpdateZoneOrGroupEGFloatValue>
{ static constexpr auto subtree{cmsg::EditSubtree::eg}; };
```

If your message has a specialization there, `configureUpdater` wires begin-edit
automatically and drags fold correctly. If it does not, every mouse movement becomes its
own undo entry — still correct, just noisy. **Adding a new edit message means adding a
`BeginEditTraits` specialization**; that is the whole fix.

A few controls are wired by hand where the traits path does not reach — bus effects and
channel-strip sends, mostly. Grep for `makeBeginEditSender` if you are working there.

---

## 5. Widgets

`libs/sst/sst-jucegui/include/sst/jucegui/components/` is the catalog — `ls` it. The set
grows, so check rather than assume. The ones that come up most:

**Continuous:** `Knob`, `HSlider`, `VSlider`, `HSliderFilled`, `DraggableTextEditableValue`.

**Discrete:** `MultiSwitch`, `ToggleButton`, `ToggleButtonRadioGroup`, `JogUpDownButton`,
`DraggableTextEditableDiscreteValue`, `DiscreteParamMenuBuilder`.

`DraggableTextEditableDiscreteValue` is the right choice for an integer parameter you want
typed as well as dragged. Prefer it to putting an int behind a float widget — the factory
recognizes both draggable-text types and wires the inline editor onto the popup for you.

**Other:** `Label`, `RuledLabel`, `MenuButton`, `TextPushButton`, `GlyphButton`,
`NamedPanel`, `TabbedComponent`, `ListView`, `TabularizedTreeViewer`, `VUMeter`,
`CompactPlot`, `ZoomContainer`, `SevenSegmentControl`, `ToolTip`, `TypeInOverlay`.

Lifecycle, when not using the factory:

```cpp
auto knob = std::make_unique<jcmp::Knob>();
knob->setSource(attachment.get());
editor->setupFloatWidget(knob.get(), attachment.get());   // or setupIntWidget
knob->setBounds(...);
addAndMakeVisible(*knob);
```

`setupFloatWidget` / `setupIntWidget` wire tooltips *and* begin/end-edit. Skipping them
gets you a control with no readout that breaks undo grouping.

`ContinuousParamEditor` (base of Knob and the sliders) carries modulation display —
`setModulationValuePM1`, `isModulationBipolar` — which is what draws the mod arcs.

---

## 6. JSON layouts

Processor and effect panels are laid out from JSON in `src/scxt-plugin/json-assets/`,
embedded at build time via CMakeRC as `scxtui_json_layouts`. This exists because there are
many processors with many parameters and hand-writing each panel is not worth it.

```json
{ "controls": [
  { "name": "delayTime", "binding": {"type": "float", "index": 0},
    "class": "knob70", "x": 10, "y": 30 } ] }
```

`binding.index` is the index into `ProcessorStorage::floatParams` / `intParams` — the fixed
arrays from `shortcircuit-engine` §5. Shared sizing classes are in
`shared-layouts/shared-classes.json`. Attributes include `visibleIf` / `enabledIf` for
conditional controls and `force-quantized`.

`connectors/JsonLayoutEngineSupport.{h,cpp}` hosts this: `resolveJsonPath()` finds the
document, `createBindAndPosition()` makes the widget and binds it. Read
`src/scxt-plugin/json-assets/README_JSON_ASSETS.md` for the full schema.

---

## 7. HasEditor

Any component needing engine access inherits `HasEditor` (`app/HasEditor.h`):

```cpp
template <typename T> void sendToSerialization(const T &msg);
template <typename W, typename A> void setupFloatWidget(W *, const A &);
template <typename W, typename A> void setupIntWidget(W *, const A &);
template <typename P, typename A> void addSubscription(const P &, A &);
template <typename T> void updateValueTooltip(const T &attachment);
```

---

## 8. S2C handlers

All in `app/editor-impl/SCXTEditorResponseHandlers.cpp`. A handler updates the cache, fires
notifications, and pokes anything that needs an explicit repaint:

```cpp
void SCXTEditor::onZoneOutputInfoUpdated(const zoneOutputInfoPayload_t &p)
{
    auto [active, info] = p;
    editorDataCache.zoneOutputInfo = info;
    editorDataCache.fireAllNotificationsFor(editorDataCache.zoneOutputInfo);
    editScreen->getZoneElements()->routingPane->repaint();
}
```

**Guard for the screen existing.** These arrive asynchronously and can land while a screen
is being rebuilt or before it exists; an unguarded dereference here is a real crash source.

---

## 9. Panel map

Read the headers rather than a copied tree — this moves. Starting points:

| Area | Where |
|---|---|
| Screen composition, tabs, transport | `app/SCXTEditor.h`, `app/shared/HeaderRegion.h` |
| Main editing screen | `app/edit-screen/EditScreen.h` |
| Zone/group panes (routing, LFO, ADSR, mod matrix, processors) | `app/edit-screen/components/` |
| Group settings and trigger cards | `app/edit-screen/components/GroupSettingsCard.h`, `GroupTriggersCard.h` |
| Group/zone tree | `app/edit-screen/components/GroupZoneTreeControl.h` |
| Mapping, variants, waveform, macros | `app/edit-screen/components/mapping-pane/` |
| Sample browser | `app/browser-ui/` |
| Mixer | `app/mixer-screen/` |
| Play screen | `app/play-screen/` |
| Part FX, channel strip, macro editor, shared cards | `app/shared/` |
| About, welcome, log, tuning, theme editor | `app/other-screens/` |
| Missing-sample resolution flow | `app/missing-resolution/` |
| Theme and colours | `src/scxt-plugin/theme/` |

`PartEditScreen` holds two structurally identical `ZoneOrGroupElements` — one for zone
scope, one for group scope. Most panes are written once and instantiated twice, so a change
to a pane usually needs checking in both modes.

---

## 10. Adding a control, end to end

Steps 1–3 are in `shortcircuit-streaming`: field, streaming trait, `SC_DESCRIBE`.

4. **Message.** Usually none — if the struct already has a
   `CLIENT_TO_SERIAL_CONSTRAINED` update message, the offset path covers your new field.
   Only add one for new behaviour, and if you do, add a `BeginEditTraits` specialization.

5. **Handler.** In `SCXTEditorResponseHandlers.cpp`, update the cache and call
   `fireAllNotificationsFor`. Guard the screen pointers.

6. **Widget.** In the panel:

```cpp
using attachment_t = connectors::PayloadDataAttachment<engine::Zone::ZoneOutputInfo>;
using fac = connectors::SingleValueFactory<attachment_t, cmsg::UpdateZoneOutputFloatValue>;

std::unique_ptr<attachment_t> myAttachment;   // members — must outlive the widget
std::unique_ptr<jcmp::Knob> myKnob;

fac::attachAndAdd(info, info.myNewParam, this, myAttachment, myKnob);
myKnob->setBounds(x, y, w, h);
```

Then position it in `resized()`.

7. **Test.** `tests/ui_basics.cpp` and `tests/value_edit_lag_tests.cpp` cover the wiring
   layer without a window.

---

## 11. Common mistakes

| Symptom | Cause |
|---|---|
| Widget invisible | No `addAndMakeVisible`, or never positioned in `resized()` |
| Moves but does not stick | No `s2c` echo, or handler did not `fireAllNotificationsFor` |
| Wrong value written in the engine | `val` is not a reference into the object passed as `p` |
| Wrong range or units | Missing `SC_DESCRIBE` field — fix it in the engine, not the UI |
| Crash on undo/redo or part switch | Handler dereferenced a screen without guarding |
| Every drag pixel is an undo entry | No `BeginEditTraits` specialization for the message |
| No tooltip | Built the widget by hand and skipped `setupFloatWidget` |
| Attachment destroyed early | Attachment must be a member living as long as the widget |
| JSON layout not found | Check `resolveJsonPath()` and the CMakeRC asset name |

