Settings rows
Declarative settings-row kinds for plugin chart types.
Chart types registered through @luxalgo/vela/plugin can declare a settings section that the
chart-settings dialog renders as its own tab:
registerChartType({
id: 'mytype',
settings: {
title: 'My Type',
visibility: 'active', // 'active' (default): tab shown only while the style is
// active; 'always': shown whenever the type is registered
rows: [
{ kind: 'heading', label: 'Levels' }, // key-less: TOC group (or flat in-tab title)
{ kind: 'toggle', key: 'highlights', label: 'Highlights', defval: true,
colors: [{ key: 'highlightColor', label: 'Highlight color', defval: '#e0b400' }] },
{ kind: 'header', label: 'Colors' }, // in-group subgroup title (not a TOC entry)
{ kind: 'number', key: 'levels', label: 'Max levels', defval: 20, min: 5, max: 50, step: 1,
when: { key: 'highlights', equals: true } }, // shown only while the toggle is on
{ kind: 'color', key: 'buyColor', label: 'Buy color', defval: '#089981' },
{ kind: 'select', key: 'mode', label: 'Display', options: ['bid-ask', 'delta'], defval: 'bid-ask' },
{ kind: 'range', label: 'Volume', minKey: 'minVolume', maxKey: 'maxVolume',
defval: 0, min: 0, max: 1e9, step: 1, placeholder: 'Off' },
{ kind: 'row', label: 'Imbalance', // the COMPOSITE row: any
toggle: { key: 'imb', defval: false }, // mix of inline controls
controls: [
{ kind: 'select', key: 'imbMode', label: 'Mode', options: ['ratio', 'diff'], defval: 'ratio' },
{ kind: 'number', key: 'imbPct', label: 'Threshold (%)', defval: 300, min: 100, max: 900 },
{ kind: 'color', key: 'imbInk', label: 'Highlight', defval: '#e0b400' },
] },
],
// layout: 'grouped', // promote the headings to a group TOC beside the rows
},
});The data flow (one loop, no plugin wiring)
- The dialog builds the tab from the descriptors, reading current values from the
renderer config's
chartTypes[<id>]bag (defvaluntil first edit). - An edit emits a config patch
{ chartTypes: { [id]: { [key]: value } } }— so the values persist throughgetConfig()/applyConfig()(templates, JSON export) automatically. - The renderer's
applyConfigdetects the per-type change, pushes the merged values on the type's<id>-settingsnative-data channel (its renderer layer receives them asargs.settingson the next frame) and raisesonChartTypeSettingsChange— which the core forwards to the type's data engine asSeriesDataEngine.onSettings(values).
Nothing else to wire: declaring the section is enough for rendering, persistence, layer delivery, and engine delivery.
The row contract — one composite shape, sugar on top
SettingsRowDescriptor (in src/chart-types/registry.ts) is a discriminated union on
kind. Two key-less variants organize the pane without storing values:
heading— a GROUP title. Flat sections render it inline; structuredinstances/subsections(andlayout: 'grouped'sections) promote it to the group TOC beside the rows.header— an in-pane subgroup title (same visual as a flat heading). Inside a structured pane it stays in the rows column so you can cluster rows inside a TOC group (Colors / Values under Display) without adding another TOC entry.
Every value row reduces to ONE canonical shape — the composite row kind: a label,
an optional leading toggle ({ key, defval }, a checkbox storing a boolean; while
off, the row's controls dim and ignore input), and an ordered list of inline
controls in the control column:
{ kind: 'row', label: 'Imbalance', toggle: { key: 'imb', defval: false }, controls: [
{ kind: 'select', key: 'imbMode', label: 'Mode', options: ['ratio', 'diff'], defval: 'ratio' },
{ kind: 'number', key: 'imbPct', label: 'Threshold (%)', defval: 300, min: 100, max: 900 },
{ kind: 'color', key: 'imbInk', label: 'Highlight', defval: '#e0b400' },
] }SettingsInlineControl kinds: number (compact input; with placeholder, an input
at the default renders empty showing it and clearing stores the default back — the
placeholder names the unset state, 'Off' for 0-disables bounds), color (a swatch
opening the shared picker), width (the classic drawing-bar 1–5 px weights, each
option previewed as a line at that weight, storing a px number), select (options
may be bare strings or [value, label] pairs when the stored id differs from the
display text), and hint (display-only dimmed text between controls — the – of a
min–max pair, a unit; stores nothing). Each keyed control stores under its own bag key,
its label is its tooltip. Any mix, any order, no per-combination SDK surface — this is
what keeps panes STATIC where a conditionally revealed row would jump the layout.
A control may carry its own when gate (same shape as a row's): it appears and
disappears live as other values change, and is exempt from the toggle-off dim — its
gate already says when it matters, and it may exist specifically for the off state
(a mode's two colors while it is on, its one alternative while off).
The remaining kinds are sugar over the composite (see normalizeSettingsRow):
toggle— a checkbox row; optional inlinenumber,colors(swatches), andwidthattachments render in that order. Prefer inline attachments over separate rows gated on the toggle whenever the toggle governs one or two values.number/color/select— one control on its own row.range— a min–max pair (minKey/maxKey, both seeded from the shareddefval), i.e. two number controls around a–hint,placeholderas above.
Prefer the sugar for the common shapes (it reads better); reach for row when the
combination has no sugar — never add a new one-off attachment field for it.
Two registry helpers keep every consumer on the same contract, both exported from
@luxalgo/vela/plugin (an alternate settings view — React, native menus — should render from
them, not re-interpret the union):
normalizeSettingsRow(row)— the canonical composite shape of any value row.settingsRowValueKeys(row)— every key a row stores ({ key, type, defval }): what the dialog seeds from and whatfactoryResetConfigrestores, so no key can fall through a kind-specific walk.
One more soft-disable seam: subsection enableKey — while that bag boolean is
false, every row except the matching toggle stays visible but soft-disabled (grayed /
non-interactive), so the pane remains browseable with the feature off. Put the toggle
inside a group (e.g. Display), not above the TOC.
Conditions (when)
Every row — headings included — may carry a when gate: a SettingsRowCondition
({ key, equals } or { key, anyOf: [...] }) or a readonly array of them (AND-ed). The
row is shown only while the gate passes against the section's current values (stored
value, else that key's defval). The dialog re-evaluates gates live on every edit — no
rebuild — so dependent rows appear exactly when they matter (e.g. a manual-size input
only while sizeMode is 'manual', mode-specific colors only in that mode). The
evaluation helper is exported as settingsRowVisible(when, bag).
Gated values are still stored and delivered — hiding a row never clears its value; consumers decide what a hidden-but-set value means (usually: the gating toggle already disables the feature).
The indicator inputs dialog speaks the same condition vocabulary: an InputSchema
may carry a when (InputCondition / InputWhen, evaluated by inputVisible(when, bag)), and the gear dialog re-applies it live on every edit — rows hide and return,
a group heading or tab whose inputs are all gated out leaves with them, and hidden
inputs keep their values. Inputs sharing an inline= row show while any member's
gate passes.
Duplicate keys across gated rows are supported. Several when-gated rows may store
under the same key(s) — the pattern for per-mode rows over one shared state (each mode
gets its own row label, e.g. "Volume gradient" / "Delta gradient", while the gradient
toggle and colors stay one stored value). The dialog re-syncs every keyed control from
the values bag on each edit, so the hidden twins never show stale state when they come
back. Keep at most one such row visible at a time (mutually exclusive gates).
Structured sections: instances, group TOC, subsections, placement
A big section can go beyond the flat form. The lightest upgrade is layout: 'grouped' on a rows-only section: the same rows render with their headings promoted
to a group TOC — a sticky column inside the pane, to the right of the dialog's tab
rail — instead of inline titles (no instance strip). Beyond that, instead of rows,
declare instances:
the pane then opens with an instance tab strip — one tab per present instance, a
dashed + that turns on the next absent one, and an × on the active removable tab.
Presence is the boolean at the instance's enableKey (in the same per-type bag); an
instance without enableKey is always present and not removable (the base instance).
settings: {
title: 'My Type',
placement: 'after-symbol', // rail position: right under Symbol ('end' = default)
instances: [
{ label: 'Block 1', rows: block('') }, // base — not removable
{ label: 'Block 2', enableKey: 'b2Enabled', rows: block('b2') }, // '+' adds, '×' removes
],
subsections: [
{ title: 'Overlay', rows: overlayRows }, // indented rail entry under the tab
],
},Inside an instance (and inside every subsection) the heading rows become a group
TOC on the left of the pane: selecting an entry shows only that group's rows. header
rows stay in the rows column as subgroup titles within the active group. Rows before the
first heading form the always block, visible above every group (put an enable toggle
there). A group whose value rows are all gated out by when — or whose heading's own
when fails — leaves the TOC; the TOC hides entirely when no group is live, which is
how a subsection collapses to just its enable toggle while switched off.
subsections add indented entries under the section's rail tab, each with its own
pane of rows (same TOC treatment). placement: 'after-symbol' puts the tab (and its
subsections) directly under Symbol instead of after the built-in tabs.
Everything stays one flat per-type bag: instance and subsection keys are ordinary
keys (use a prefix convention like b2Size), +/× just write the enableKey boolean,
and consumers keep receiving a single object — the structured form is pure dialog
presentation.
Adding a NEW inline control kind
Because every value row renders through the composite path, new capability usually means a new inline control, not a new row kind. Four touch points, in order:
- The type — add a variant to the
SettingsInlineControlunion insrc/chart-types/registry.ts(keep it serializable: descriptors are data, never DOM or callbacks — that is what keeps them projectable by any view layer, React included). - The registry helpers — extend
settingsRowValueKeyswith the new control's stored key(s)/type (andnormalizeSettingsRowonly if a new sugar kind maps to it). Seeding and factory reset then cover it with no further wiring. - The dialog renderer — in
src/renderers/native/chrome/SettingsDialog.ts, add a branch toinlineControlbuilding the control's DOM: read the current value from the bag with a type guard falling back todefval, write throughput(key, value). - The docs — add the variant to the list above and to Plugin SDK.
A genuinely new ROW shape (a different scaffold, not a different control) is a variant
of SettingsRowDescriptor plus a mapping in normalizeSettingsRow — the render path
stays untouched.
Consumers (layers via args.settings, engines via onSettings) receive the raw stored
value — a new kind needs no changes on their side.
The visibility policy (host-hidden settings)
Hosts can hide any dialog entry via VelaOptions.settings.hidden /
renderer.setSettingsVisibility (see Options). For sections
declared here the ids are implicit — nothing to declare, nothing to wire:
- the tab is
type:<typeId>, a subsectiontype:<typeId>.<slug(title)>; - a value row is addressed by its stable bag key (
type:<typeId>.<key>— composite rows by their toggle key, else their first control key), aheading/headerby its label's slug. Hiding a heading takes its whole group, a header its subgroup.
The filtering happens at the DESCRIPTOR level in the dialog
(filterHiddenRows in src/renderers/native/chrome/settings-visibility.ts), after
seeding: the values bag still seeds from the full row set, so when gates keep reading
hidden keys' defaults, and hidden values keep persisting and reaching layers/engines —
the same "hidden ≠ cleared" contract as when itself. Rows sharing a key under
mutually exclusive gates share an id and hide together (they are one logical setting).
renderer.listSettingsIds() enumerates every addressable id, so hosts never read
plugin source to build a policy.
Rules
- Keys are scoped per type id — no cross-type collisions, no global registry.
- Descriptors must stay pure data (serializable) — the persistence and channel layers
structuredClone/spread them freely. visibility: 'active'is the default on purpose: a type's knobs appear when its style is on screen, mirroring how the per-style cosmetic sections behave in the Symbol tab.