dylanroscover / parameter-design
MUST READ before creating or designing custom parameters on any COMP: pages, styles, ranges, help text, naming, ParGroup gotchas.
<!-- Generated by Embody/Envoy - Do not remove this comment - sha:5454b92f34285f1a -->
Parameter Design
Ownership and Lifecycle
Code owns the schema; the user owns the value. A parameter's name, style, page, range, default and help text are yours to declare and re-assert. Its VALUE is the user's, and nothing you write may quietly discard it.
Extensions reinitialize on every source-file save, so every creation path must be get-or-create, never create-blindly:
def ensureCustomPage(comp, name):
for page in comp.customPages:
if page.name == name:
return page
return comp.appendCustomPage(name)
def ensureCustomPar(comp, page, name, style, **attrs):
"""Get-or-create one custom par: the Par, or the ParGroup for a tuplet."""
# Probe the WHOLE COMP by tupletName. Names are a flat per-COMP namespace,
# and a multi-value style (RGB, XYZ) is stored as <name>r/<name>g/<name>b,
# so a probe by .name never finds it and append*() (replace=True) would
# re-create it on every reinit.
found = [p for p in comp.customPars if p.tupletName == name]
if not found:
getattr(page, 'append' + style)(name)
found = [p for p in comp.customPars if p.tupletName == name]
elif found[0].style not in STYLE_FAMILY.get(style, (style,)):
raise ValueError('par %s is %s, declared %s -- refusing to replace it'
% (name, found[0].style, style))
for p in found: # symmetric: schema attrs re-applied
for key, value in attrs.items(): # on every reinit; the user's state
if key not in ('val', 'expr', 'bindExpr', 'mode', 'export'):
setattr(p, key, value) # is never touched -- and a typo RAISES
return found[0] if len(found) == 1 else found[0].parGroup
# TD reports a tuplet's style by family: RGB reads 'RGBA', XY/XYZ read 'XYZW'.
STYLE_FAMILY = {'RGB': ('RGB', 'RGBA'), 'XY': ('XY', 'XYZW'), 'XYZ': ('XYZ', 'XYZW')}Four rules that make it safe:
- Probe the whole COMP, not the page. Custom parameter names are a flat per-COMP namespace, and
append*()defaults toreplace=True: a page-scoped probe will happily destroy a same-named par living on another page. - Re-read after appending, by
tupletName.append*()returns a ParGroup, and a multi-value style is stored as component pars (Tintr/Tintg/Tintb) whosetupletNameis the name you declared;comp.par['Tint']isNone. Embody ships this exact helper asop.Embody.op('embody_pardef').module.ensureCustomPar. - Never
destroy()on style drift.Par.destroy()takes the user's value, expressions and exports with it. Report the mismatch and refuse; removal is a separate, deliberate act. - Apply declared attributes symmetrically. If you set
minwhen creating, set it when the par already exists too, or a schema change never reaches existing installs.
Parameter Callbacks
One dispatcher, not one promoted method per parameter. A parexec DAT that grows an elif par.name == ... chain drags a public-tier method onto the COMP for every branch (the three tiers in td-python.md). Name the handler for its parameter and let the DAT find it:
# the WHOLE onValueChange in the parexec DAT
def onValueChange(par, prev):
ext = parent.MyComp.ext.MyExt
handler = getattr(ext, '_on%sValueChange' % par.name, None)
if handler is None:
return
try:
handler(par, prev)
except Exception as e:
parent.MyComp.Error('%s handler failed: %s' % (par.name, e))# on the extension: tier 3, invisible on the COMP
def _onSpeedValueChange(self, par, prev):
self.ownerComp.op('./timer1').par.speed = par.eval()
def _onResetPulse(self, par):
self.rebuild()- Audit once at init that every
_on*handler matches a real parameter: a renamed par turns its handler into dead code that fails no test; the audit turns that into a warning. - Never swallow the exception silently: a handler that raises into nothing is indistinguishable from a parameter that does nothing.
- Pulse handlers take
(par); value handlers take(par, prev). The Parameter Execute DAT ships withbuiltinON as well ascustom: turnbuiltinoff for a custom-parameter dispatcher (/operator-gotchas).
Parameter, Storage, or Dependency?
Custom parameters are one of three ways to hold state on a COMP, and the choice is about lifetime, not taste:
| The state is | Use | Lives on | Survives an extension reinit | Survives a tox reload / TDXN rebuild |
|---|---|---|---|---|
| User-facing, persisted, part of the COMP's interface | custom parameter | the COMP | yes | yes |
| Durable bookkeeping the user should not see | storage (write through store()) | the COMP | yes | no (wiped) |
| A derived value that must recook whatever reads it | tdu.Dependency | the extension instance | no (rebuild in __init__) | no |
Publish once and let readers pull; never push a value into each consumer. comp.storage[k] = v, in-place mutation of a stored container, and a plain self.attr all update the value and notify nobody; store() and dep.val = x notify. fetch() without a default RAISES, fetch() searches UP the parents, unstore() is glob-matched, and a bind expression to a Dependency is two-way. The measured tables, the storage pickling rules and the Dependency traps are in references/state-lifetimes.md in this skill's folder (get_guidance(topic='parameter-design/state-lifetimes')).
Help Text
Every custom parameter MUST have help text set via par.help = "...". It appears as the tooltip when users hover the parameter name. Describe what the parameter controls and what its values mean.
- Good:
"Maximum number of rows displayed in the manager list. Set to 0 for unlimited." - Bad:
"Max rows"(restates the label) - Unacceptable: no help text at all
In TDXN files, "help": "..." rides in the parameter definition; Embody exports and imports it.
Section Breaks, Ordering, Pages
par.startSection = Trueon the first parameter of each logical group draws a separator above it ("startSection": truein TDXN).- Parameters appear in the order appended: primary controls first, secondary and advanced after, read-only status last. Reorder with
par.order(floats such as11.5insert between neighbours). - Group into pages by function with
comp.appendCustomPage('PageName')(creation order). Common pattern: Main or Settings, Tags, UI, About (read-only version, build, author).
Naming and Styles
Names start with one uppercase letter, then lowercase letters and digits only (Speed, Maxrows, Autosave, Envoyenable); no underscores, spaces or special characters.
| Use case | Style | Notes |
|---|---|---|
| On/off toggle | Toggle | Values are 0/1 |
| Fire-once action | Pulse | No persistent value |
| Enumerated choices | Menu | Set menuNames and menuLabels separately |
| Editable dropdown | StrMenu | Free text with suggestions |
| Numeric value | Float or Int | Set the range properties below |
| Text input | Str | Free-form string |
| File or folder path | File / Folder | Opens a system dialog |
| Operator reference | OP, COMP, TOP, CHOP, SOP, DAT, MAT | Filtered by family |
| Section header | Header | Visual label only, no value |
Numeric Ranges, Read-Only, Defaults
min/max are the limits, clampMin/clampMax decide whether they are enforced, normMin/normMax are what the slider covers visually:
p = page.appendFloat('Speed', label='Speed')[0]
p.default = 1.0
p.min = 0.0; p.max = 10.0
p.clampMin = True; p.clampMax = False # values above 10 allowed by typing
p.normMin = 0.0; p.normMax = 5.0 # slider covers 0-5
p.help = "Playback speed multiplier. 1.0 = normal speed."par.readOnly = Truefor status and informational parameters (version, build, connection state).- Always set
par.default: it enables Revert to Default and keeps TDXN round-trips consistent..defaultnever sets the value: afterappendFloat+p.default = 5.0the par still evaluates 0.0, and a latermin/clampMinclamps it to the min, not the default (verified 2025.33230, issue #94). Setp.val = p.defaulton the CREATE branch of get-or-create only, so a user's value is never overwritten. - All
page.append*()methods return a ParGroup: index with[0]for the Par.
Sequence Parameters
Resizable blocks (glslTOP uniforms, constantCHOP channels, mathmixPOP combines, custom sequences). Function Store's note on issue #94, "building sequence pars takes a bit more thinking than it should", is fair: three things are easy to get wrong.
- Reach the sequence, not the parameter.
comp.seq.<name>from the operator, orpar.sequencefrom any block parameter. The individual parameters are named<seq><index><par>(uniname0,uniname1); never build those names by hand when the sequence object is there. numBlocksis the get-or-create. Assigning it grows or shrinks in one step and is idempotent, which is what an extension that reinitializes on every save needs.insertBlock(i)/destroyBlock(i)only when position matters.
seq = comp.seq.uni
seq.numBlocks = max(seq.numBlocks, len(uniforms)) # grow, never shrink blindly
for i, u in enumerate(uniforms):
seq[i].par.uniname = u.name # block i, by index- Shrinking destroys values.
destroyBlockor loweringnumBlockstakes the block's values, expressions and exports with it, exactly likePar.destroy(). Code owns the block COUNT only where the count is genuinely derived; if the user can add blocks, grow to fit and leave the excess alone. - In Embody's TDXN, sequences are stored by BASE name (
sequences:keyed by sequence name, blocks holding only non-default values underuniname, notuniname0); the default block count is probed from a throwaway instance, and blocks are created in import Phase 2.5 before Phase 3 sets parameters. Full detail:docs/tdxn/specification.md.