Repository navigation
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.
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()
endWith 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, savesWhat you get for free:
- Auto read/write — each control uses
data[key]directly - Right-click reset — resets to
defaults[key]with no extra code - Search dimming — controls that don't match the active query dim automatically
- 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.
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.
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. |
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). |
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".
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 },
}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().
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.
| 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. |
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 dragA 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 5A 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)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)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 frameAll 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
onSavecallback fires automatically. -
Def resolution: If a
defstable 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.
SwatchGridandToggleButtonRoware the exceptions; they never dim.
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)
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 defsParameters:
| 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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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.
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.
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. |
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.
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} })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")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.
| 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).
| 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.
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.
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.
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.
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 matchThe matching algorithm is a multi-word substring search. It works as follows:
- Query parsing: The user's input is split into words (whitespace-separated) and lowercased.
-
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
searchTermsfield (additional keywords you define) - The def's
tooltiptext, included only whensearchTooltipsistruein bindOpts, or when the def has no label (icon-only controls always include tooltip in matching)
- The control's
- 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.
- 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()).
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 onlyaccentandbgColor -
c:SectionHeader("Layout", "appearance.layout")checks onlyfontSizeandspacing
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.
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.
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.
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.
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.
-- 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.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)-- 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-
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.
-
Search is zero-effort per control - Passing
searchanddefsinbindOptsis all that's needed. Every bound control and SectionHeader automatically participates in dimming. No per-control search logic. -
onSave fires on every change - The
saveSettingscallback writes JSON to disk whenever any control changes. You don't checkchangedper control or batch saves manually. -
Right-click reset works everywhere - Every bound control resets to
defaults[key]on right-click. Thedefaultstable is defined once alongside your settings structure. -
idPrefix prevents collisions - If your mod has multiple panels or uses WindowUtils alongside other mods,
idPrefixensures ImGui widget IDs don't clash. The raw key is still used fordata/defaultslookup. -
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.
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.
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()
endWith 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)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:SectionHeaderchecks whether any defs in its category match the query. If none do, the entire header dims. This is impossible with rawcontrols.SectionHeader. -
Right-click reset - Every bound control resets to
defaults[key]on right-click. -
Auto read/write/save - No manual
if changed then ... endblocks.
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.