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:
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:
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:
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:
- Pulls metadata via
datamodel::describeValue(p, val), so range, units and display strings come from the engine'sSC_DESCRIBE. You do not hand-write ranges in the UI. - Wires
onGuiValueChangedto send the message. - Wires begin/end-edit — see §4.
- Registers the data-cache subscription so
s2cupdates 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:
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:
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:
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.
{ "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):
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:
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.
Message. Usually none — if the struct already has a
CLIENT_TO_SERIAL_CONSTRAINEDupdate message, the offset path covers your new field. Only add one for new behaviour, and if you do, add aBeginEditTraitsspecialization.Handler. In
SCXTEditorResponseHandlers.cpp, update the cache and callfireAllNotificationsFor. Guard the screen pointers.Widget. In the panel:
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().
- Test.
tests/ui_basics.cppandtests/value_edit_lag_tests.cppcover 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 |