Skip to content

bind system

CyanideX edited this page Aug 16, 2026 · 2 revisions

Bind System

The bind system (Controls.bind()) wraps your settings table so controls read, write, save, and reset automatically. You define the settings structure once; each control call handles the rest. It also drives search dimming and category-aware section headers that raw controls can't do.

Why Use Bind?

Without bind, every control needs manual read/write/save logic:

-- Raw: repeat for every single control
local val, changed = controls.SliderFloat(icon, "id_brightness", settings.brightness, 0, 10)
if changed then
    settings.brightness = val
    saveSettings()
end

With bind, you declare once and each control handles everything:

-- Bind: declare once, render cleanly
local c = controls.bind(settings, defaults, saveSettings)
c:SliderFloat(icon, "brightness", 0, 10)  -- reads, writes, resets, saves

What you get for free:

  1. Auto read/write — each control uses data[key] directly
  2. Right-click reset — resets to defaults[key] with no extra code
  3. Search dimming — controls that don't match the active query dim automatically
  4. Category-aware headers — section headers dim when none of their controls match the search
Capability Bind (c:Method) Raw (controls.Method)
Auto read from data table Yes No (manual)
Auto write on change Yes No (manual)
Right-click reset to defaults Yes No (manual)
onSave callback Yes No (manual)
Search dimming on controls Yes (automatic via defs) No
SectionHeader search dimming Yes (category-aware) No (always full opacity)
Def-driven config (icon, min, max, tooltip) Yes No
Per-control onChange Yes Yes

Raw controls remain useful for one-off UI elements that don't map to persisted settings. But for settings panels, bind is strictly better: you get search integration, reset, and save for free while writing less code per control.

Defs Table

The defs table is a Lua table keyed by setting key, where each entry describes metadata about that control: its display label, which category it belongs to, and what search terms should match it. When passed to Controls.bind() via bindOpts.defs, the bind system uses this metadata to drive search dimming, resolve default field values (icon, min, max, tooltip, items), and link controls to their section headers.

Required Fields

The bind system never errors on a partial def, but search dimming and category headers only work for entries that carry these three fields, so treat them as required:

Field Type Description
label string Display text used for search matching. This is the primary search target alongside searchTerms.
category string Links this control to its SectionHeader. When a search is active, a SectionHeader dims if no defs in its category match the query.
searchTerms string Additional space-separated terms that the search algorithm checks. Typically includes the label plus related keywords.

Optional Fields

These fields provide default values to the bound control. If you pass the same field explicitly in the control's opts table, the explicit value takes precedence.

Field Type Description
tooltip string Tooltip text shown on hover. Also included in search matching when searchTooltips is enabled.
icon string IconGlyphs key used as the control's icon. Resolved when the control call omits its icon argument.
min number Minimum value for sliders/drags. Resolved when the control call passes nil for min.
max number Maximum value for sliders/drags. Resolved when the control call passes nil for max.
items table Array of string labels for Combo/StringCombo. Resolved when the control call passes nil for items.
format string Printf-style format string for slider/drag display (e.g. "%.1f").
percent boolean When true, the slider displays a percentage of the min-max range instead of the raw value.
transform table A {read, write} table for value transformation between storage and display. read(stored) converts to display value, write(displayed) converts back.
onChange function Callback fired after a value change: onChange(newValue, key). Fires in addition to the global onSave.
alwaysShowTooltip boolean When true, the tooltip is shown even without hover (used by Checkbox).

How Category Links to SectionHeader

The category field is the glue between controls and section headers. When you call c:SectionHeader(text, category), the bind system checks whether any defs entry with that category matches the current search query. If none match, the header (and all controls beneath it) are visually dimmed.

This means you never write per-control dimming logic. You assign each def a category, render a SectionHeader with the same category string, and the bind system handles the rest.

Categories can use dot-separated hierarchies (e.g. "appearance.colors", "appearance.layout") for fine-grained grouping. A SectionHeader using a parent category like "appearance" will check all defs whose category starts with "appearance".

Complete Example

A settings panel with three categories and multiple entries per category:

local defs = {
    -- Display category
    brightness  = { label = "Brightness",       category = "display",  searchTerms = "brightness light" },
    contrast    = { label = "Contrast",         category = "display",  searchTerms = "contrast tone" },
    gamma       = { label = "Gamma",            category = "display",  searchTerms = "gamma curve", min = 0.5, max = 3.0, format = "%.2f" },

    -- Behavior category
    autoSave    = { label = "Auto Save",        category = "behavior", searchTerms = "auto save persist", tooltip = "Save settings automatically on change" },
    snapToGrid  = { label = "Snap to Grid",     category = "behavior", searchTerms = "snap grid align" },
    gridSize    = { label = "Grid Size",        category = "behavior", searchTerms = "grid size spacing", min = 4, max = 64, icon = "Grid" },

    -- Alerts category
    showNotify  = { label = "Show Notifications", category = "alerts", searchTerms = "notification alert popup" },
    soundOn     = { label = "Sound Effects",      category = "alerts", searchTerms = "sound audio sfx", tooltip = "Play a sound on notification" },
    volume      = { label = "Volume",             category = "alerts", searchTerms = "volume level loudness", min = 0, max = 100, percent = true },
}

Real-World Reference

For a production defs table, see LUTSwitcher3's ui/uidefs.lua. Its buildSettingsDefs() function constructs defs dynamically from a flat entries array, demonstrating the pattern for mods with many settings across multiple categories (prefs, dev, context, tooltips, alerts).

For how the bind system consumes defs internally (field resolution, search matching, dimming), see modules/controls/bind.lua, specifically applyDefAndSearch() and bindControl().

Controls.bind() API

Function Signature

local c = controls.bind(data, defaults, onSave, bindOpts)

Returns a bound context object (c) that provides all bound control methods. The context reads and writes values through data, resets via defaults, and calls onSave after every change.

Parameters

Parameter Type Required Description
data table Yes The settings table to read from and write to. Each bound control accesses data[key] directly.
defaults table or nil No Table of default values. When provided, every bound control supports right-click reset to defaults[key].
onSave function or nil No Callback invoked after any value change. Typically your save-to-disk function (e.g., writing JSON).
bindOpts table or nil No Options table controlling ID generation, search integration, and def-driven configuration.

data

The live settings table. Bound controls read their current value from data[key] each frame and write back to data[key] when the user makes a change. This is your single source of truth for settings state.

local settings = { brightness = 5, vsync = true, theme = 1 }
local c = controls.bind(settings, nil, nil)
c:SliderFloat(nil, "brightness", 0, 10)  -- reads settings.brightness, writes back on drag

defaults

A table with the same key structure as data. When a user right-clicks a bound control, the value resets to defaults[key]. If defaults is nil or defaults[key] is nil for a given control, right-click reset is disabled for that control.

local defaults = { brightness = 5, vsync = true, theme = 1 }
local c = controls.bind(settings, defaults, saveSettings)
-- Right-clicking the brightness slider resets settings.brightness to 5

onSave

A zero-argument callback fired after every value change on any bound control. This is where you persist settings to disk. It fires after the value is written to data[key] but before any per-control onChange callback.

local function saveSettings()
    json.encode(settings, "data/settings.json")
end
local c = controls.bind(settings, defaults, saveSettings)

bindOpts

An options table with the following fields:

Field Type Default Description
idPrefix string "" Prepended to the key for ImGui widget IDs. The raw key is still used for data/defaults lookup. Use this to avoid ID collisions when multiple bind contexts render controls with the same keys. Does not apply to Checkbox, whose ID is its label.
search Search instance or nil nil A wu.Search state object. When provided (along with defs), all bound controls automatically dim when they don't match the active search query.
defs table or nil nil The defs table mapping keys to their label, category, searchTerms, and optional field overrides. Required for search dimming and def-driven configuration.
searchTooltips boolean false When true, tooltip text from defs is included in search matching. By default only label and searchTerms are checked.
local c = controls.bind(settings, defaults, saveSettings, {
    idPrefix = "mymod_",
    search = mySearchState,
    defs = myDefs,
    searchTooltips = true,
})

Controls.unbind(ctx)

controls.unbind(ctx)

Returns a bind context to an internal pool for reuse. This is optional but recommended for panels that create and discard bind contexts frequently (e.g., per-frame binding in draw loops). Without calling unbind, contexts simply become garbage and are collected normally, but pooling avoids allocation overhead.

Call unbind when you're done rendering with a context for the current frame. Do not use the context after unbinding it.

local c = controls.bind(settings, defaults, saveSettings, opts)
-- ... render all controls ...
controls.unbind(c)  -- return to pool for next frame

Bound Control Methods

All bound control methods share the same core behavior:

  • Auto-read: The control reads its current value from data[key] each frame.
  • Auto-write: When the user changes the value, it's written back to data[key] immediately.
  • Right-click reset: Right-clicking resets the value to defaults[key] (when defaults are configured).
  • onSave firing: After any value change, the global onSave callback fires automatically.
  • Def resolution: If a defs table is configured, controls resolve missing arguments (icon, min, max, items, tooltip, format, percent, transform) from the def entry for that key.
  • Search dimming: When search is active, controls that don't match the query are rendered at reduced opacity. No per-control code needed. SwatchGrid and ToggleButtonRow are the exceptions; they never dim.

c:Checkbox(label, key, opts)

Renders a labeled checkbox bound to data[key] (boolean).

c:Checkbox("Enable VSync", "vsync")
c:Checkbox("Show FPS", "showFps", { tooltip = "Displays framerate counter" })

Parameters:

Parameter Type Description
label string Checkbox label text. Also used for search matching.
key string Data table key (boolean value).
opts table or nil Options (see below).

opts fields: icon, default, tooltip, alwaysShowTooltip, onChange

Returns: newValue (boolean), changed (boolean)


c:SliderFloat(icon, key, min, max, opts)

Renders a float slider bound to data[key]. Hold Ctrl+click to type a value directly.

c:SliderFloat(IconGlyphs.Brightness, "brightness", 0, 10)
c:SliderFloat(nil, "gamma", nil, nil, { format = "%.2f" })  -- min/max from defs

Parameters:

Parameter Type Description
icon string or nil Icon glyph. If nil and def has icon, the def value is used.
key string Data table key (number value).
min number or nil Minimum value. Resolved from defs if nil.
max number or nil Maximum value. Resolved from defs if nil.
opts table or nil Options (see below).

opts fields: format, tooltip, cols, default, transform, percent, onChange

Returns: newValue (number), changed (boolean)


c:SliderInt(icon, key, min, max, opts)

Integer variant of SliderFloat. Identical API but operates on integer values.

c:SliderInt(IconGlyphs.Grid, "gridSize", 4, 64)

Parameters: Same as SliderFloat, but min/max and return value are integers.

opts fields: format, tooltip, cols, default, transform, percent, onChange

Returns: newValue (integer), changed (boolean)


c:DragFloat(icon, key, min, max, opts)

Renders a float drag control bound to data[key]. Drag to change the value, or Ctrl+click to type directly. Hold Shift for precision mode (slower drag speed).

c:DragFloat(nil, "offsetX", -100, 100, { speed = 0.5 })

Parameters:

Parameter Type Description
icon string or nil Icon glyph. Resolved from defs if nil.
key string Data table key (number value).
min number or nil Minimum value. Resolved from defs if nil.
max number or nil Maximum value. Resolved from defs if nil.
opts table or nil Options (see below).

opts fields: speed, format, tooltip, cols, default, transform, percent, precisionMultiplier, noPrecision, onChange

Returns: newValue (number), changed (boolean)


c:DragInt(icon, key, min, max, opts)

Integer variant of DragFloat. Identical API but operates on integer values.

c:DragInt(nil, "columns", 1, 12)

Parameters: Same as DragFloat, but with integer values.

opts fields: speed, format, tooltip, cols, default, transform, percent, precisionMultiplier, noPrecision, onChange

Returns: newValue (integer), changed (boolean)


c:DragFloatRow(icon, keys, min, max, opts)

Renders multiple float drag controls in a horizontal row, each bound to a different key. Useful for vector inputs (X/Y/Z) or grouped numeric fields.

c:DragFloatRow(IconGlyphs.Move, {"posX", "posY", "posZ"}, -1000, 1000, {
    speed = 1.0,
    drags = {
        { label = "X" },
        { label = "Y" },
        { label = "Z" },
    },
})

Parameters:

Parameter Type Description
icon string or nil Icon glyph displayed at the start of the row.
keys table Array of data table keys (one per drag in the row).
min number Minimum value for all drags.
max number Maximum value for all drags.
opts table or nil Options (see below).

opts fields: drags (per-drag overrides array), speed, cols, mode, onChange, def, tooltip, precisionMultiplier, noPrecision, spacing, id

Delta mode: When opts.mode = "delta", drags start at 0 each frame and report changes relative to the current value. In delta mode, the row calls opts.onChange(values, keys) instead of auto-writing to data. Delta mode still resets data[key] to defaults[key] on right-click.

Returns: values (table of numbers), anyChanged (boolean)


c:DragIntRow(icon, keys, min, max, opts)

Integer variant of DragFloatRow. Same API but renders integer drags and calls controls.DragIntRow internally.

c:DragIntRow(nil, {"r", "g", "b"}, 0, 255)

Parameters and opts: Same as DragFloatRow, but with integer values.

Returns: values (table of integers), anyChanged (boolean)


c:Combo(icon, key, items, opts)

Renders a dropdown combo bound to data[key] (integer index into the items array).

local themes = {"Dark", "Light", "System"}
c:Combo(IconGlyphs.Palette, "theme", themes)

Parameters:

Parameter Type Description
icon string or nil Icon glyph. Resolved from defs if nil.
key string Data table key (integer index, 1-based).
items table or nil Array of string labels. Resolved from defs if nil.
opts table or nil Options (see below).

opts fields: tooltip, cols, default, transform, onChange

Returns: newIndex (integer), changed (boolean)


c:StringCombo(icon, key, items, opts)

Like Combo, but data[key] stores the selected string value rather than an integer index. Useful when item lists may change order between versions, since stored values remain stable.

local modes = {"performance", "balanced", "quality"}
c:StringCombo(nil, "renderMode", modes)

Parameters:

Parameter Type Description
icon string or nil Icon glyph. Resolved from defs if nil.
key string Data table key (string value matching one of the items).
items table or nil Array of string labels. Resolved from defs if nil.
opts table or nil Options (see below).

opts fields: tooltip, cols, default, onChange

Returns: newValue (string), changed (boolean)


c:InputText(icon, key, opts)

Renders a text input field bound to data[key] (string).

c:InputText(IconGlyphs.Label, "playerName")

Parameters:

Parameter Type Description
icon string or nil Icon glyph. Resolved from defs if nil.
key string Data table key (string value).
opts table or nil Options (see below).

opts fields: maxLength, tooltip, alwaysShowTooltip, cols, onChange

Returns: newText (string), changed (boolean)


c:InputFloat(icon, key, opts)

Renders a numeric input field for float values bound to data[key].

c:InputFloat(nil, "customScale", { step = 0.1, format = "%.2f" })

Parameters:

Parameter Type Description
icon string or nil Icon glyph. Resolved from defs if nil.
key string Data table key (number value).
opts table or nil Options (see below).

opts fields: step, stepFast, format, tooltip, alwaysShowTooltip, cols, onChange

Returns: newValue (number), changed (boolean)


c:InputInt(icon, key, opts)

Integer variant of InputFloat.

c:InputInt(nil, "maxRetries", { step = 1 })

Parameters: Same as InputFloat, but operates on integer values.

opts fields: step, stepFast, tooltip, alwaysShowTooltip, cols, onChange

Returns: newValue (integer), changed (boolean)


c:ColorEdit4(icon, key, opts)

Renders a color picker bound to data[key] (a table of 4 floats: {r, g, b, a}).

c:ColorEdit4(IconGlyphs.Palette, "accentColor")

Parameters:

Parameter Type Description
icon string or nil Icon glyph. Resolved from defs if nil.
key string Data table key (table {r, g, b, a} with values 0-1).
opts table or nil Options (see below).

opts fields: tooltip, label, default, onChange

Returns: newColor (table), changed (boolean)


c:SwatchGrid(key, colors, opts)

Renders a grid of color swatches. Clicking a swatch sets data[key] to that color's hex string. Right-clicking the grid resets to defaults[key].

local palette = {
    { hex = "#FF0000", label = "Red" },
    { hex = "#00FF00", label = "Green" },
    { hex = "#0000FF", label = "Blue" },
}
c:SwatchGrid("accentHex", palette, { config = mySwatchConfig })

Parameters:

Parameter Type Description
key string Data table key (hex string value).
colors table Array of color entry tables (each with at least a hex field).
opts table or nil Options (see below).

opts fields: config (Swatch_Config table), onChange

The onChange callback receives (entry, key) where entry is the selected color entry table.

Returns: nothing. Read data[key] for the current selection.

Right-click reset only fires when data[key] differs from defaults[key], and it does not call onChange. This method does not participate in search dimming.


c:ToggleButtonRow(defs, opts)

Renders a row of toggle buttons, each bound to a boolean data[def.key]. Buttons display as active/inactive based on the current value. Click toggles, right-click resets to default.

c:ToggleButtonRow({
    { key = "showLabels", icon = IconGlyphs.Label, tooltip = "Show labels" },
    { key = "showIcons",  icon = IconGlyphs.Image, tooltip = "Show icons" },
    { key = "compact",    label = "Compact",       weight = 2 },
})
-- Mixed row: non-interactive label + toggle buttons
c:ToggleButtonRow({
    { type = "label", label = "View:", style = "label", width = 60 },
    { key = "showLabels", icon = IconGlyphs.Label, tooltip = "Show labels" },
    { key = "showIcons",  icon = IconGlyphs.Image, tooltip = "Show icons" },
})

Parameters:

Parameter Type Description
defs table Array of button definition tables.
opts table or nil Options: gap (spacing between buttons), id (row ID for min-width tracking).

Button def fields:

Field Type Description
key string Data table key (boolean value). Required.
icon string or nil Icon glyph for the button face.
label string or nil Text label (used if no icon). Falls back to key.
weight number or nil Flex weight for proportional sizing (default 1).
type string|nil Optional. When set to "label", renders a non-interactive display element instead of a toggle button. When set to "icon", renders a fixed-width icon-only display slot. Omitting type preserves existing toggle behavior.
tooltip string or nil Tooltip shown on hover.
onChange function or nil Callback: onChange(newValue). Fires on click and on right-click reset.

Defs with type = "label" render as non-interactive display elements at the flex-distributed width for that slot - no toggle state, no click handler. Defs with type = "icon" render as fixed-width icon-only display slots. Both are optional: omitting type preserves the standard toggle behavior for that slot.

Returns: nothing. Read data[def.key] for each button's state. No-op when defs is empty. This method does not participate in search dimming.


Common opts Fields

These fields are supported across most bound controls and can also be set in the defs table:

Field Type Description
tooltip string Tooltip text shown on hover. If set in defs and not in opts, the def value is used.
format string Printf-style format string for numeric display (e.g. "%.1f", "%d%%"). Applies to sliders and drags.
transform table A {read, write} table. read(storedValue) converts to display value, write(displayValue) converts back to storage. Useful for unit conversions or scale transformations.
percent boolean When true on a slider, displays the value as a percentage of the min-max range instead of the raw number.
onChange function Per-control callback fired after a value change: onChange(newValue, key). Fires after onSave. If set in both opts and defs, the opts value takes precedence.

Section Headers and Dimming

These methods handle visual grouping and search dimming for non-bound elements. Unlike the data-binding controls (Checkbox, SliderFloat, etc.), these don't read or write settings values. Instead, they manage the visual structure and search-awareness of your panel layout.

c:SectionHeader(text, category, spacingBefore, spacingAfter, iconGlyph)

Renders a section header (separator line + label text) that automatically dims when no defs in the given category match the active search query.

Parameter Type Required Description
text string Yes Section title text displayed after the separator.
category string Yes Category key checked against defs. If no defs entry with this category matches the search, the entire header dims.
spacingBefore number or nil No Vertical spacing (pixels) inserted before the separator.
spacingAfter number or nil No Vertical spacing (pixels) inserted after the label.
iconGlyph table or nil No A HeaderIconGlyph opts table rendered inline with the header text.

Auto-dim behavior: When search and defs are configured on the bind context and the search is non-empty, SectionHeader calls search:categoryHasMatch(category, defs, searchTooltips). If no match is found, the header renders at search.dimAlpha opacity. When search is empty or not configured, the header always renders at full opacity.

Internally this delegates to the raw controls.SectionHeader() for the actual separator and label rendering, wrapping it in an alpha push/pop when dimming applies.

c:SectionHeader("Display Settings", "display")
c:SectionHeader("Advanced", "advanced", 12, 4)
c:SectionHeader("Effects", "effects", nil, nil, { icon = IconGlyphs.Sparkles, color = {1,1,0,1} })

c:Header(text, category, iconGlyph)

Renders a plain text header (no separator) that auto-dims based on category match. Use this for sub-headings within a section when you don't want the visual weight of a full separator.

Parameter Type Required Description
text string Yes Header text rendered via ImGui.Text().
category string Yes Category key checked against defs for dimming.
iconGlyph table or nil No A HeaderIconGlyph opts table rendered after the text.

Auto-dim behavior: Identical to SectionHeader. When search is active and no defs in the category match, the text renders at reduced opacity. No-op when search or defs are not configured.

c:SectionHeader("Appearance", "appearance")
c:Checkbox("Dark Mode", "darkMode")

c:Header("Color Overrides", "appearance.colors")
c:ColorEdit4(nil, "accentColor")
c:ColorEdit4(nil, "bgColor")

c:BeginDim(key) / c:EndDim(dimmed)

A manual push/pop pair for applying search dimming to non-bound controls or custom UI elements. Use this when you have UI that isn't rendered through a bound control method but should still participate in search dimming.

c:BeginDim(key)
Parameter Type Required Description
key string Yes Setting key to look up in defs. The key's label and searchTerms are checked against the active query.

Returns: boolean - true if dimming was pushed (alpha reduced), false if not (no search active, key not in defs, or the key matches the query).

c:EndDim(dimmed)
Parameter Type Required Description
dimmed boolean Yes The value returned by BeginDim. Pass it directly to ensure the style var is only popped when it was actually pushed.

Pattern: Always call EndDim after BeginDim, passing the returned boolean. This ensures the ImGui style stack stays balanced regardless of whether dimming was applied.

-- Wrap a custom button in search dimming for the "exportPath" setting
local dimmed = c:BeginDim("exportPath")
if ImGui.Button("Browse...") then
    openFilePicker()
end
c:EndDim(dimmed)

When to use BeginDim/EndDim:

  • Custom ImGui widgets not covered by bound control methods
  • Third-party control calls that you want to dim with the rest of the category
  • Multi-element layouts (e.g., a label + button pair) that should dim as a unit

When you don't need it:

  • Standard bound controls (Checkbox, SliderFloat, Combo, etc.) handle dimming internally
  • SectionHeader and Header handle their own dimming via the category parameter

If search is nil, search:isEmpty() is true, or the key has no defs entry, BeginDim returns false and no style is pushed. This makes BeginDim/EndDim safe to call unconditionally without guarding against search state.

Source Reference

For the complete list of bind methods and their implementation details, see modules/controls/bind.lua. The bindMethods table defines all available methods, and the module-level bindDispatch table maps control types to their underlying control functions along with which arguments each type accepts.

Search Integration

The bind system's search integration lets users filter settings panels by typing keywords, automatically dimming controls and headers that don't match. This works entirely through the bind context configuration, with no per-control wiring required.

Enabling Search Dimming

To enable search dimming, pass a wu.Search instance in bindOpts.search when creating your bind context:

local wu = GetMod("WindowUtils")
local controls = wu.Controls
local mySearch = wu.Search.new("my_settings_search")

local c = controls.bind(settings, defaults, saveSettings, {
    search = mySearch,
    defs = defs,
    searchTooltips = false,  -- optional, default false
})

When search is provided along with a defs table, every bound control method automatically checks whether its key matches the active search query. Non-matching controls render at reduced opacity (search.dimAlpha, default 0.25). Matching controls render at full opacity. When the search is empty, all controls render normally.

The search state needs a text input to drive it. controls.SearchBar(mySearch) (or controls.SearchBarPlain) renders one and calls setQuery for you, including clear-on-right-click. If you roll your own input, call mySearch:setQuery(text) when the text changes. Either way the bind system picks up the new query on the next control call.

How SectionHeader Dimming Works

c:SectionHeader(text, category) automatically dims when no defs in the given category match the current search query. This means entire sections visually fade out when they contain nothing relevant, giving users clear visual feedback about where matches exist.

The check calls search:categoryHasMatch(category, defs, searchTooltips) internally. If any def entry whose category field matches (exact or prefix) contains matching terms, the header stays at full opacity. Otherwise it dims.

c:SectionHeader("Display", "display")       -- dims if no "display" defs match
c:SectionHeader("Advanced", "display.adv")  -- dims if no "display.adv" defs match

The Search Matching Algorithm

The matching algorithm is a multi-word substring search. It works as follows:

  1. Query parsing: The user's input is split into words (whitespace-separated) and lowercased.
  2. Terms assembly: For each control, a search terms string is built from:
    • The control's label (from defs or passed directly to the control method)
    • The def's searchTerms field (additional keywords you define)
    • The def's tooltip text, included only when searchTooltips is true in bindOpts, or when the def has no label (icon-only controls always include tooltip in matching)
  3. Matching: All query words must appear as substrings in the combined terms string (case-insensitive). This is an AND match: typing "grid size" requires both "grid" and "size" to appear somewhere in the terms.
  4. Caching: Results are cached per key per query version, so repeated checks within a frame are free.

The matching is plain substring, not fuzzy. Typing "bri" matches "Brightness" because "bri" appears in the lowercased terms. Typing "bright dark" would not match a single control unless its terms contain both "bright" and "dark".

For full implementation details, see modules/search.lua (SearchState:matches() and SearchState:categoryHasMatch()).

Category Hierarchy (Dot-Separated)

Categories support dot-separated hierarchies for fine-grained grouping. A SectionHeader using a parent category checks all defs whose category starts with that prefix:

local defs = {
    darkMode   = { label = "Dark Mode",     category = "appearance",        searchTerms = "dark mode theme" },
    accent     = { label = "Accent Color",  category = "appearance.colors", searchTerms = "accent color" },
    bgColor    = { label = "Background",    category = "appearance.colors", searchTerms = "background color" },
    fontSize   = { label = "Font Size",     category = "appearance.layout", searchTerms = "font size text" },
    spacing    = { label = "Spacing",       category = "appearance.layout", searchTerms = "spacing gap" },
}

With this structure:

  • c:SectionHeader("Appearance", "appearance") checks all five defs (the parent matches defs with "appearance", "appearance.colors", and "appearance.layout")
  • c:SectionHeader("Colors", "appearance.colors") checks only accent and bgColor
  • c:SectionHeader("Layout", "appearance.layout") checks only fontSize and spacing

The prefix match uses string comparison: def.category == category or def.category starts with category .. ".". This means "app" would not accidentally match "appearance", since the check requires an exact match or a dot after the prefix.

BeginDim/EndDim for Non-Bound Controls

For custom UI elements that should participate in search dimming but aren't rendered through bound control methods, use the c:BeginDim(key) / c:EndDim(dimmed) pair. See Section Headers and Dimming for the full API reference and usage examples.

searchTooltips Option

By default, tooltip text is not included in search matching. Setting searchTooltips = true in your bindOpts expands the search to also check def tooltip strings:

local c = controls.bind(settings, defaults, saveSettings, {
    search = mySearch,
    defs = defs,
    searchTooltips = true,  -- tooltips are now searchable
})

This is useful when your tooltips contain descriptive information that users might search for. The tradeoff is that more controls will match broader queries, which may reduce the filtering usefulness for panels with verbose tooltips.

Special case: Controls with no label in their defs (icon-only controls) always include tooltip text in matching regardless of searchTooltips. This ensures icon-only controls remain discoverable through search.

Source Reference

For the search state implementation (query parsing, caching, matches(), categoryHasMatch()), see modules/search.lua. For how the bind system invokes search checks per control, see applyDefAndSearch() and buildSearchTerms() in modules/controls/bind.lua.

Complete Example

A full settings panel for a hypothetical CET mod, demonstrating the bind system end-to-end: defs table, JSON persistence, search integration, category-based section headers, and multiple bound control types.

Defs Table

-- ui/settingsDefs.lua
-- Each key maps to a setting in our data table.
-- category links controls to their SectionHeader for search dimming.

local defs = {
    -- Overlay category
    enabled       = { label = "Enable Overlay",    category = "overlay",  searchTerms = "enable overlay toggle on off" },
    opacity       = { label = "Overlay Opacity",   category = "overlay",  searchTerms = "opacity alpha transparency", min = 0.0, max = 1.0, format = "%.2f" },
    position      = { label = "Position",          category = "overlay",  searchTerms = "position anchor placement", items = {"Top Left", "Top Right", "Bottom Left", "Bottom Right"} },
    accentColor   = { label = "Accent Color",      category = "overlay",  searchTerms = "accent color tint", icon = "Palette" },

    -- Behavior category
    autoHide      = { label = "Auto-Hide in Menu", category = "behavior", searchTerms = "auto hide menu pause", tooltip = "Hides the overlay when the game menu is open" },
    fadeDelay     = { label = "Fade Delay",        category = "behavior", searchTerms = "fade delay seconds timeout", min = 0.0, max = 10.0, format = "%.1fs" },
    snapToEdge    = { label = "Snap to Edge",      category = "behavior", searchTerms = "snap edge magnetize" },
    snapDistance  = { label = "Snap Distance",     category = "behavior", searchTerms = "snap distance threshold pixels", min = 4, max = 64, icon = "Magnet" },

    -- Alerts category
    notifications = { label = "Show Notifications", category = "alerts", searchTerms = "notification alert toast popup" },
    soundEnabled  = { label = "Sound Effects",      category = "alerts", searchTerms = "sound audio sfx beep", tooltip = "Play a sound when notifications appear" },
    volume        = { label = "Volume",             category = "alerts", searchTerms = "volume loudness level", min = 0, max = 100, percent = true },
}

return defs

Init and Persistence

-- init.lua
local json = json  -- CET global
local wu, controls
local defs = require("ui/settingsDefs")

-- Default settings (used for right-click reset and first-run)
local defaults = {
    enabled = true,
    opacity = 0.85,
    position = 1,
    accentColor = {0.2, 0.6, 1.0, 1.0},
    autoHide = true,
    fadeDelay = 3.0,
    snapToEdge = true,
    snapDistance = 12,
    notifications = true,
    soundEnabled = true,
    volume = 75,
}

-- Live settings table (loaded from disk or initialized from defaults)
local settings = {}

local function loadSettings()
    local file = io.open("data/settings.json", "r")
    if file then
        local content = file:read("*a")
        file:close()
        local loaded = json.decode(content)
        -- Merge loaded values onto defaults so new keys get default values
        for k, v in pairs(defaults) do
            settings[k] = loaded[k] ~= nil and loaded[k] or v
        end
    else
        for k, v in pairs(defaults) do settings[k] = v end
    end
end

-- onSave callback: persists settings to JSON after every change
local function saveSettings()
    local file = io.open("data/settings.json", "w")
    if file then
        file:write(json.encode(settings))
        file:close()
    end
end

-- Grab WindowUtils at startup; GetMod only works after onInit
registerForEvent("onInit", function()
    loadSettings()
    wu = GetMod("WindowUtils")
    controls = wu and wu.Controls
end)

Panel Rendering

-- ui/settingsPanel.lua
-- Called each frame from your mod's onDraw handler.

local search = wu.Search.new("mymod_settings")  -- persistent search state (create once, reuse)

local function drawSettingsPanel()
    -- Search bar at the top of the panel, drives the search state
    controls.SearchBar(search)

    -- Create a bound context with search and defs integration.
    -- This single call wires up: auto read/write, right-click reset,
    -- search dimming, and def-driven field resolution.
    local c = controls.bind(settings, defaults, saveSettings, {
        idPrefix = "mymod_",       -- avoids ImGui ID collisions with other panels
        search = search,           -- enables automatic search dimming
        defs = defs,               -- links controls to categories and search terms
        searchTooltips = true,     -- include tooltip text in search matching
    })

    -- Overlay section: header dims when no overlay controls match search
    c:SectionHeader("Overlay", "overlay")
    c:Checkbox("Enable Overlay", "enabled")
    c:SliderFloat(nil, "opacity")                 -- min/max/format resolved from defs
    c:Combo(nil, "position")                      -- items resolved from defs
    c:ColorEdit4(nil, "accentColor")              -- icon resolved from defs ("Palette")

    -- Behavior section
    c:SectionHeader("Behavior", "behavior", 12)   -- 12px spacing before separator
    c:Checkbox("Auto-Hide in Menu", "autoHide")
    c:SliderFloat(nil, "fadeDelay")               -- format "%.1fs" from defs
    c:Checkbox("Snap to Edge", "snapToEdge")
    c:SliderInt(nil, "snapDistance")              -- icon "Magnet", min 4, max 64 from defs

    -- Alerts section
    c:SectionHeader("Alerts", "alerts", 12)
    c:Checkbox("Show Notifications", "notifications")
    c:Checkbox("Sound Effects", "soundEnabled")
    c:SliderInt(nil, "volume")                    -- percent display from defs

    -- Return context to pool (optional, avoids allocation next frame)
    controls.unbind(c)
end

Key Integration Points

  1. Defs drive everything - The defs table centralizes metadata (label, category, search terms, min/max, icon, format). Controls reference this by key, so adding a new setting means adding one defs entry and one render call.

  2. Search is zero-effort per control - Passing search and defs in bindOpts is all that's needed. Every bound control and SectionHeader automatically participates in dimming. No per-control search logic.

  3. onSave fires on every change - The saveSettings callback writes JSON to disk whenever any control changes. You don't check changed per control or batch saves manually.

  4. Right-click reset works everywhere - Every bound control resets to defaults[key] on right-click. The defaults table is defined once alongside your settings structure.

  5. idPrefix prevents collisions - If your mod has multiple panels or uses WindowUtils alongside other mods, idPrefix ensures ImGui widget IDs don't clash. The raw key is still used for data/defaults lookup.

  6. Unbind for pooling - Calling controls.unbind(c) at the end of the frame returns the context to an internal pool. On the next frame, controls.bind() reuses it instead of allocating a new table.

Migrating from Raw Controls

If you have an existing settings panel built with raw controls.* calls, migrating to the bind system is straightforward. The main benefit: every control gains search dimming automatically without any per-control dimming logic.

Before (Raw Controls)

With raw controls, you manually read values, check for changes, write back, and save. Section headers render at full opacity regardless of search state:

-- Raw controls: no search dimming, manual read/write/save per control
controls.SectionHeader("Display", 12, 4)

local val, changed = controls.Checkbox("VSync", settings.vsync)
if changed then
    settings.vsync = val
    saveSettings()
end

val, changed = controls.Checkbox("Show FPS", settings.showFps)
if changed then
    settings.showFps = val
    saveSettings()
end

controls.SectionHeader("Behavior", 12, 4)

val, changed = controls.Checkbox("Auto Save", settings.autoSave)
if changed then
    settings.autoSave = val
    saveSettings()
end

After (Bind System)

With bind, you declare a defs table once and render controls with single method calls. Search dimming, right-click reset, and auto-save all happen behind the scenes:

local defs = {
    vsync    = { label = "VSync",     category = "display",  searchTerms = "vsync vertical sync" },
    showFps  = { label = "Show FPS",  category = "display",  searchTerms = "fps framerate counter" },
    autoSave = { label = "Auto Save", category = "behavior", searchTerms = "auto save persist" },
}

local c = controls.bind(settings, defaults, saveSettings, {
    search = mySearch,
    defs = defs,
})

c:SectionHeader("Display", "display", 12, 4)
c:Checkbox("VSync", "vsync")
c:Checkbox("Show FPS", "showFps")

c:SectionHeader("Behavior", "behavior", 12, 4)
c:Checkbox("Auto Save", "autoSave")

controls.unbind(c)

What You Gain

Migrating to bind gives you these features with zero additional per-control code:

  • Search dimming on controls - When a search query is active, controls whose label/searchTerms don't match render at reduced opacity.
  • Search dimming on section headers - c:SectionHeader checks whether any defs in its category match the query. If none do, the entire header dims. This is impossible with raw controls.SectionHeader.
  • Right-click reset - Every bound control resets to defaults[key] on right-click.
  • Auto read/write/save - No manual if changed then ... end blocks.

Raw controls.SectionHeader Still Works

The raw controls.SectionHeader(label, spacingBefore, spacingAfter, iconGlyph) function remains available and unchanged. It renders a separator and label at full opacity regardless of search state. Use it for structural headers that shouldn't participate in search dimming (e.g., a top-level panel title).

The key difference: raw controls.SectionHeader has no category parameter and no awareness of the search system. If you want headers that dim when their category has no search matches, use the bound c:SectionHeader(text, category, ...) method instead.

Clone this wiki locally