Skip to content

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 to plugin.json, built with the SettingsSchema builder. 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.

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 appended

This 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.

Every field needs a unique key except the static, valueless ones (section, label, divider, note, link), which render but never appear in defaults().

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
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
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.

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.

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.

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 empty
  • pattern — 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."}

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 with message, which is exactly the pattern you want for “validate this API key” or “ping this device.”

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:

  • bool is stored as the string "1" or "0", not a Python bool — coerce it yourself (self._pget("notify", "0") == "1") or go through schema.apply(), which coerces on load.
  • multiselect and list are stored as a JSON-encoded array — decode with json.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.

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 key on a value-carrying field
  • a duplicate key
  • a choice/multiselect field with neither options nor options_from
  • a show_when that references a key not present elsewhere in the schema
  • a default whose Python type doesn’t match its field type — this is a warning, not an error, since the app coerces on load

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.