The sidebar was a flat scan of thirty-one rows; now it reads like a settings app. Multi-subject categories (Input, Network & Sharing, Applications, Users & Accounts, Privacy & Security, System) carry an Appearance-style tab strip above the page, drawn by the shell so the leaf pages themselves are untouched. The taxonomy lives in one new file, services/SettingsRoutes.qml; the sidebar, the strip, route validation, search breadcrumbs, and both generators derive from it. ShellState.settingsPage still holds leaf ids, so every deep link, IPC call, and search result keeps working — and now lands on the exact tab. Dictation moves out of Sound onto its own page under Input, with a handoff back to Sound for the microphone. The strip scrolls when System's nine tabs outgrow a tiled window. All 161 contracts pass. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
270 lines
11 KiB
Python
Executable File
270 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
|
||
"""Generate the settings reference from PreferenceSchema.qml.
|
||
|
||
Every other form of documentation in this repository has drifted at least once:
|
||
routing that pointed at a page not containing the setting, contracts that pinned
|
||
the bug they were meant to prevent, and a comment that failed to stop the person
|
||
who read it making the exact mistake it warned about. Prose describing 137
|
||
settings would drift the day after it was written.
|
||
|
||
So this is generated, and a contract fails when the committed copy no longer
|
||
matches what the schema says. The document cannot be wrong for longer than it
|
||
takes to run the suite.
|
||
|
||
Usage:
|
||
panama-settings-docs write docs/settings.md
|
||
panama-settings-docs --check exit 1 if the committed copy is stale
|
||
panama-settings-docs --stdout print without writing
|
||
|
||
Where each group of settings lives comes from SettingsSearch.qml's routing and
|
||
SettingsRoutes.qml's taxonomy, so a page renamed or moved into another category
|
||
is renamed here too rather than in a list somebody has to remember.
|
||
|
||
Parsing rather than importing: the schema is QML, there is no QML interpreter
|
||
here, and a regex reader that FAILS LOUDLY when it stops recognising the file is
|
||
better than a dependency on a shell that has to start a compositor.
|
||
"""
|
||
|
||
import argparse
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
|
||
# scripts/ -> quickshell/ -> dot/ -> config/ -> repo root. resolve() first, so
|
||
# this still works when invoked through the ~/.config/quickshell symlink.
|
||
ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||
SCHEMA = ROOT / "config/dot/quickshell/config/PreferenceSchema.qml"
|
||
SEARCH = ROOT / "config/dot/quickshell/services/SettingsSearch.qml"
|
||
ROUTES = ROOT / "config/dot/quickshell/services/SettingsRoutes.qml"
|
||
OUTPUT = ROOT / "docs/settings.md"
|
||
|
||
CATEGORY = re.compile(
|
||
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
|
||
re.S)
|
||
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
|
||
|
||
|
||
class SchemaError(RuntimeError):
|
||
"""The schema stopped looking the way this reader expects."""
|
||
|
||
|
||
def read_titles():
|
||
"""Leaf page id -> the name a person sees, from SettingsRoutes.
|
||
|
||
The sidebar is fifteen categories of tabs rather than a flat list, so a page
|
||
is named the way a hit names it in search: "System › Storage" for a tab,
|
||
"Displays" for a category that is a page by itself.
|
||
"""
|
||
text = ROUTES.read_text()
|
||
block = re.search(r"readonly property var categories: \[(.*?)\n \]", text, re.S)
|
||
if not block:
|
||
raise SchemaError("could not find the categories array in SettingsRoutes.qml")
|
||
titles = {}
|
||
read = 0
|
||
for page, label, tabs in CATEGORY.findall(block.group(1)):
|
||
found = TAB.findall(tabs)
|
||
read += 1 + len(found)
|
||
if found:
|
||
titles.update({tab: f"{label} › {tab_label}" for tab, tab_label in found})
|
||
else:
|
||
titles[page] = label
|
||
# A category this reader stopped recognising would take its pages' names
|
||
# with it, and the group that routed to one would then be documented as
|
||
# living nowhere. Every `page:` in the array has to be accounted for.
|
||
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
|
||
if not titles or read != declared:
|
||
raise SchemaError(
|
||
f"read {read} of the {declared} pages in SettingsRoutes.qml; the "
|
||
"categories array no longer looks the way this reader expects")
|
||
return titles
|
||
|
||
|
||
def read_routes():
|
||
"""group -> page id, from SettingsSearch."""
|
||
text = SEARCH.read_text()
|
||
block = re.search(r"groupPages:\s*\(\{(.*?)\}\)", text, re.S)
|
||
if not block:
|
||
raise SchemaError("could not find groupPages in SettingsSearch.qml")
|
||
routes = dict(re.findall(r'"([a-zA-Z]+)"\s*:\s*"([a-z-]+)"', block.group(1)))
|
||
if not routes:
|
||
raise SchemaError("groupPages matched but contained no routes")
|
||
return routes
|
||
|
||
|
||
def read_entries():
|
||
"""Every schema entry, as dicts, in declaration order."""
|
||
text = SCHEMA.read_text()
|
||
|
||
# Each entry begins at `key:` and ends at the closing brace of its block.
|
||
# Nested braces (options, hypr) are skipped by counting depth.
|
||
entries = []
|
||
# An entry is only found when `key:` is the first thing inside its brace, so
|
||
# a comment written INSIDE the literal rather than above it makes the whole
|
||
# setting invisible here -- silently, and the staleness contract cannot see
|
||
# it either, because regenerating reproduces the same omission. Three
|
||
# settings were undocumented this way before anyone noticed, one of them for
|
||
# weeks. Counting what was declared against what was parsed is what turns
|
||
# that from silence into an error.
|
||
declared = {m.group(1) for m in re.finditer(r'\bkey:\s*"([^"]+)"', text)}
|
||
parsed = {m.group(1) for m in re.finditer(r'\{\s*\n\s*key:\s*"([^"]+)"', text)}
|
||
invisible = sorted(declared - parsed)
|
||
if invisible:
|
||
raise SchemaError(
|
||
"these settings are declared but cannot be read, which means they would "
|
||
"be silently missing from the reference: " + ", ".join(invisible)
|
||
+ ". Move the comment above the entry's opening brace.")
|
||
|
||
for match in re.finditer(r'\{\s*\n\s*key:\s*"([^"]+)"', text):
|
||
name = match.group(1)
|
||
start = match.start()
|
||
depth = 0
|
||
end = None
|
||
for index in range(start, len(text)):
|
||
if text[index] == "{":
|
||
depth += 1
|
||
elif text[index] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
end = index
|
||
break
|
||
if end is None:
|
||
raise SchemaError(f"entry {name!r} is not brace-balanced")
|
||
body = text[start:end]
|
||
|
||
def scalar(field):
|
||
found = re.search(rf'\b{field}:\s*("([^"]*)"|[^,\n]+)', body)
|
||
if not found:
|
||
return None
|
||
return (found.group(2) if found.group(2) is not None
|
||
else found.group(1).strip())
|
||
|
||
entry = {
|
||
"key": name,
|
||
"type": scalar("type"),
|
||
"default": scalar("def"),
|
||
"group": scalar("group"),
|
||
"label": scalar("label"),
|
||
"detail": scalar("detail"),
|
||
"unit": scalar("unit"),
|
||
"min": scalar("min"),
|
||
"max": scalar("max"),
|
||
"internal": scalar("internal") == "true",
|
||
"option": scalar("option"),
|
||
"options": re.findall(r'\{\s*value:\s*("?[^",]+)"?,\s*label:\s*"([^"]+)"', body),
|
||
}
|
||
if not entry["type"] or not entry["group"]:
|
||
raise SchemaError(f"entry {name!r} is missing a type or group")
|
||
entries.append(entry)
|
||
|
||
if len(entries) < 50:
|
||
raise SchemaError(
|
||
f"only {len(entries)} entries parsed; the schema has far more, so "
|
||
"this reader no longer recognises the file"
|
||
)
|
||
return entries
|
||
|
||
|
||
def render(entries, routes, titles):
|
||
lines = [
|
||
"# Settings reference",
|
||
"",
|
||
"**Generated from `config/dot/quickshell/config/PreferenceSchema.qml`.**",
|
||
"Do not edit this file. Run `quickshell/scripts/panama-settings-docs`",
|
||
"after changing the schema; a contract fails when this copy is stale.",
|
||
"",
|
||
f"{len([e for e in entries if not e['internal']])} settings across "
|
||
f"{len({e['group'] for e in entries if not e['internal']})} groups. "
|
||
f"{len([e for e in entries if e['option']])} of them are applied to the "
|
||
"compositor and confirmed by reading the value back.",
|
||
"",
|
||
]
|
||
|
||
by_group = {}
|
||
for entry in entries:
|
||
by_group.setdefault(entry["group"], []).append(entry)
|
||
|
||
for group in sorted(by_group):
|
||
visible = [e for e in by_group[group] if not e["internal"]]
|
||
if not visible:
|
||
continue
|
||
page = routes.get(group)
|
||
# A routed page with no name used to fall back to the raw page id, so
|
||
# the group "gaming" documented itself as "Found on **gaming**" while
|
||
# every other group named a real page. The staleness contract could not
|
||
# see it: regenerating reproduced the same wrong file, so the copy was
|
||
# current and wrong at once. Refusing to render is what makes a group
|
||
# routed at a page that no longer exists impossible to miss.
|
||
if page is not None and page not in titles:
|
||
raise SchemaError(
|
||
f"group '{group}' routes to page '{page}', which is not a leaf page "
|
||
"in SettingsRoutes.qml; route it at one rather than letting the id "
|
||
"be printed as a name"
|
||
)
|
||
title = titles.get(page, page or "—")
|
||
lines += [f"## {group}", "", f"Found on **{title}**.", ""]
|
||
lines += ["| Setting | Default | What it does |", "|---|---|---|"]
|
||
for entry in visible:
|
||
default = entry["default"] or "—"
|
||
if entry["unit"]:
|
||
default = f"{default} {entry['unit']}"
|
||
if entry["options"]:
|
||
choices = ", ".join(label for _value, label in entry["options"])
|
||
detail = f"{entry['detail'] or ''} Choices: {choices}."
|
||
else:
|
||
detail = entry["detail"] or ""
|
||
if entry["min"] is not None and entry["max"] is not None:
|
||
# Schema details are written without trailing punctuation, so
|
||
# one is added before appending a second sentence.
|
||
if detail and not detail.rstrip().endswith((".", "!", "?")):
|
||
detail = detail.rstrip() + "."
|
||
detail += f" Range {entry['min']}–{entry['max']}."
|
||
label = entry["label"] or entry["key"]
|
||
compositor = f" `{entry['option']}`" if entry["option"] else ""
|
||
lines.append(
|
||
f"| **{label}**<br>`{entry['key']}`{compositor} | {default} | "
|
||
f"{detail.strip()} |"
|
||
)
|
||
lines.append("")
|
||
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--check", action="store_true")
|
||
parser.add_argument("--stdout", action="store_true")
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
rendered = render(read_entries(), read_routes(), read_titles())
|
||
except SchemaError as error:
|
||
# Loudly, and without writing. A partial reference is worse than a stale
|
||
# one: stale is caught by --check, partial reads as complete.
|
||
print(f"panama-settings-docs: {error}", file=sys.stderr)
|
||
return 2
|
||
|
||
if args.stdout:
|
||
print(rendered, end="")
|
||
return 0
|
||
|
||
if args.check:
|
||
if not OUTPUT.exists():
|
||
print("panama-settings-docs: docs/settings.md has never been generated",
|
||
file=sys.stderr)
|
||
return 1
|
||
if OUTPUT.read_text() != rendered:
|
||
print("panama-settings-docs: docs/settings.md is stale; re-run this "
|
||
"script and commit the result", file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||
OUTPUT.write_text(rendered)
|
||
print(f"wrote {OUTPUT.relative_to(ROOT)}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|