Plugin settings
Most settings panels are just a handful of fields — an API key, a poll interval, a toggle — that don’t need hand-written Qt. There are two ways to work with them:
settings.schema.json— a declarative field list next toplugin.json, built with theSettingsSchemabuilder. The Mixlar app renders this schema into a real, working settings panel — no Qt required.self._pget(key, default)/self._pset(key, value)— the low-level per-key read/write API described in Plugin API, which any plugin can use directly and which the renderer itself writes through.
Building a schema
Section titled “Building a schema”Build a schema with the fluent SettingsSchema builder and save it next to
plugin.json:
from mixlar.settings_schema import SettingsSchema
schema = (SettingsSchema() .text("api_key", "API Key", help="From your account settings page.") .number("poll_seconds", "Poll interval (s)", default=30, min=5, max=300) .boolean("notify", "Show notifications", default=True) .choice("unit", "Units", options=[ {"value": "c", "label": "Celsius"}, {"value": "f", "label": "Fahrenheit"}, ]) .color("accent", "Accent color", default="#ff4d14"))
schema.save("settings.schema.json") # or a package directory — the filename is appendedThis produces:
{ "schema": 1, "fields": [ {"key": "api_key", "type": "text", "label": "API Key", "default": "", "help": "From your account settings page."}, {"key": "poll_seconds", "type": "number", "label": "Poll interval (s)", "default": 30, "min": 5, "max": 300}, {"key": "notify", "type": "bool", "label": "Show notifications", "default": true}, {"key": "unit", "type": "choice", "label": "Units", "options": [ {"value": "c", "label": "Celsius"}, {"value": "f", "label": "Fahrenheit"} ], "default": "c"}, {"key": "accent", "type": "color", "label": "Accent color", "default": "#ff4d14"} ]}"schema": 1 is the format version the app’s renderer understands — call it
schema v2 in conversation (it’s the second generation of the settings
system, after imperative build_settings_ui), but the JSON field stays 1
since it’s the first — and only — declarative schema revision so far.
Field reference
Section titled “Field reference”Every field needs a unique key except the static, valueless ones (section,
label, divider, note, link), which render but never appear in
defaults().
Static
Section titled “Static”| type | keys | notes |
|---|---|---|
section / heading |
label |
a section heading; groups the fields that follow it visually |
label |
label |
a plain line of static text |
divider |
— | a horizontal rule |
note |
text, style |
a callout; style is one of info, tip, warning, danger |
link |
label, url |
a clickable link row |
Inputs
Section titled “Inputs”| type | keys | notes |
|---|---|---|
text / password |
default, placeholder, pattern, min_length, max_length, required |
password masks input; both validate the same way |
number |
default, min, max, step |
numeric spinner |
slider |
default, min, max, step |
numeric drag slider, same range keys as number |
bool |
default |
checkbox / toggle; stored as "1" / "0" |
choice |
options or options_from, default |
dropdown; static list or a plugin-supplied dynamic list — see below |
multiselect |
options or options_from, default |
same as choice but multiple picks; stored as a JSON array |
color |
default |
hex color picker |
file |
filter, default |
file picker; filter is a Qt-style filter string, e.g. "Images (*.png *.jpg)" |
folder |
default |
folder picker |
icon |
default |
FontAwesome icon id picker (e.g. "fa5s.bolt") |
hotkey |
default |
key-combo capture field |
list |
default |
free-form list of strings, stored as a JSON array |
Actions
Section titled “Actions”| type | keys | notes |
|---|---|---|
button |
label, action |
fires plugin.on_settings_action(action) on click |
test |
label, action |
fires plugin.on_settings_test(action); renders a ✓/✗ result inline — see Action & test buttons |
options on choice / multiselect is a list of {"value": ..., "label": ...}
objects (or plain strings, used as both value and label). default for
choice falls back to the first option’s value if omitted.
Dynamic options
Section titled “Dynamic options”A choice or multiselect field can populate its options from the plugin
itself instead of a static list, using options_from in place of options:
{"key": "light_id", "type": "choice", "label": "Light", "options_from": "list_lights"}The app calls list_lights() on the plugin instance every time the panel
opens (and whenever the user hits refresh, if the panel offers one). The
method returns a list of either plain strings or {"value", "label"}
objects:
class GoveePlugin(MixlarPlugin): plugin_id = "govee"
def list_lights(self): # Query the Govee API for the user's devices. devices = self._govee_client.list_devices() return [ {"value": d.device_id, "label": f"{d.name} ({d.model})"} for d in devices ]With that, the settings panel shows a live-populated dropdown of the user’s actual Govee lights — no hardcoded list, no separate config step.
Conditional fields
Section titled “Conditional fields”Any input field can declare show_when to appear only when another field’s
current value matches a condition:
{"key": "auto", "type": "bool", "label": "Auto brightness", "default": false},{"key": "brightness", "type": "slider", "label": "Brightness", "min": 0, "max": 100, "default": 80, "show_when": {"key": "auto", "truthy": true}}show_when supports three shapes, keyed off another field’s key:
| shape | matches when |
|---|---|
{"key": "...", "equals": value} |
the target field’s value equals value exactly |
{"key": "...", "in": [...]} |
the target field’s value is one of the listed values |
{"key": "...", "truthy": true} |
the target field’s value is truthy (a checked bool, a non-empty string, a nonzero number) |
The panel re-evaluates every show_when live as the user edits fields, so in
the example above, brightness only appears once auto is switched on.
Validation
Section titled “Validation”text, password, choice, and other input fields support inline
validation, checked as the user types or on save:
required— the field can’t be left emptypattern— a regex the value must match (e.g."^[A-Za-z0-9_-]+$"for an API key)min_length/max_length— string length bounds
A field that fails validation shows an inline error under it and blocks save until it’s fixed:
{"key": "api_key", "type": "text", "label": "API Key", "required": true, "pattern": "^[A-Za-z0-9]{32}$", "help": "32-character key from your account page."}Action & test buttons
Section titled “Action & test buttons”button fires a one-way action; test fires an action and expects a
pass/fail result back, which the panel renders inline.
{"type": "button", "label": "Reset cache", "action": "reset_cache"},{"type": "test", "label": "Test connection", "action": "test_api_key"}The plugin implements the corresponding hooks:
class MyPlugin(MixlarPlugin): def on_settings_action(self, action): if action == "reset_cache": self._cache.clear()
def on_settings_test(self, action): if action == "test_api_key": key = self._pget("api_key", "") ok = self._client.verify_key(key) return (ok, "Key is valid." if ok else "Key was rejected by the API.") return (False, f"Unknown test action: {action}")on_settings_action(action)— no return value; just do the thing.on_settings_test(action)— returns(ok: bool, message: str). The panel shows a ✓ or ✗ next to the button along withmessage, which is exactly the pattern you want for “validate this API key” or “ping this device.”
Reading in the plugin
Section titled “Reading in the plugin”Values the renderer collects are written through the same _pget/_pset
pair every plugin already uses — there’s no separate storage path:
from mixlar.settings_schema import load_schema
class MyPlugin(MixlarPlugin): def on_load(self, settings): super().on_load(settings) schema = load_schema(self._pkg_dir) # None if no settings.schema.json if schema: self._config = schema.apply({k: self._pget(k) for k in schema.defaults()})A couple of types are stored with a specific shape, so read them back the same way:
boolis stored as the string"1"or"0", not a Pythonbool— coerce it yourself (self._pget("notify", "0") == "1") or go throughschema.apply(), which coerces on load.multiselectandlistare stored as a JSON-encoded array — decode withjson.loads(self._pget("tags", "[]")).
In practice most plugins just read individual values with
self._pget(key, default) (see Plugin API)
— the schema’s defaults() is there so those two never drift apart. Keep the
schema as the single source of truth for what a default is.
SettingsSchema API
Section titled “SettingsSchema API”| method | does |
|---|---|
to_dict() / to_json(indent=2) |
serialize to {"schema": 1, "fields": [...]} |
save(pkg_dir_or_path) |
write settings.schema.json; accepts a package directory or a full path |
SettingsSchema.load(path) |
read a schema back from a package dir or file path |
.from_dict(d) / .from_json(s) |
read a schema back from an already-parsed dict or a JSON string |
validate() |
returns (errors, warnings) — see below |
defaults() |
{key: default_value} for every non-valueless field |
apply(store) |
store merged over defaults() — fills any gaps, keeps whatever the store already has |
validate() catches:
- an unknown
type - a missing or empty
keyon a value-carrying field - a duplicate
key - a
choice/multiselectfield with neitheroptionsnoroptions_from - a
show_whenthat references akeynot present elsewhere in the schema - a
defaultwhose Python type doesn’t match its field type — this is a warning, not an error, since the app coerces on load
Headless resolution
Section titled “Headless resolution”apply() is what lets tests, the emulator, or CI resolve a settings store
against a schema with no UI at all:
schema = SettingsSchema.load("settings.schema.json")resolved = schema.apply({"poll_seconds": 60})# {"api_key": "", "poll_seconds": 60, "notify": True, "unit": "c", "accent": "#ff4d14"}This is exactly the shape a plugin’s on_load needs: read whatever the host
persisted, fill anything missing from the schema’s defaults, and go — no
special-casing for a setting a user has never touched. It’s also unaffected
by whether the app renders the schema or not — options_from, show_when,
button, and test are all UI-only concerns that apply() ignores.