Fold thirty-one settings pages into fifteen categories with tabs

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
This commit is contained in:
Gabriel Brown
2026-08-23 20:21:31 -04:00
parent 50077a0c31
commit 5490fd285d
35 changed files with 816 additions and 288 deletions
@@ -8,11 +8,16 @@ searching there. These commands close that gap, so typing "night light" into the
launcher opens the page that owns it.
The vocabulary is derived from the same sources the in-app search uses --
the page list in SettingsSidebar.qml, the group routing in SettingsSearch.qml,
the taxonomy in SettingsRoutes.qml, the group routing in SettingsSearch.qml,
and the labels in PreferenceSchema.qml -- so a setting that is searchable inside
Settings is searchable from the launcher without anyone maintaining a second
list.
One command per *leaf* page, which is what the sidebar's categories bottom out
in: a category with tabs contributes its tabs, a category without them is a leaf
itself. Landing the launcher on a category would mean landing on whichever tab
happened to be first, which is not what the person typing "printers" asked for.
panama-settings-commands write the commands
panama-settings-commands --check fail if what is on disk is stale
@@ -27,7 +32,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[4]
SHELL = REPO / "config/dot/quickshell"
SIDEBAR = SHELL / "modules/settings/SettingsSidebar.qml"
ROUTES = SHELL / "services/SettingsRoutes.qml"
SEARCH = SHELL / "services/SettingsSearch.qml"
SCHEMA = SHELL / "config/PreferenceSchema.qml"
OUTPUT_DIR = REPO / "config/local/share/vicinae/scripts"
@@ -55,13 +60,51 @@ def read(path: Path) -> str:
raise ParseError(f"could not read {path}") from error
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*\}')
def categories() -> list[tuple[str, str, list[tuple[str, str]]]]:
"""The sidebar taxonomy, in order, as (id, label, tabs)."""
source = read(ROUTES)
block = re.search(r"readonly property var categories: \[(.*?)\n \]", source, re.S)
if not block:
raise ParseError("categories not found in SettingsRoutes.qml")
found = [(page, label, TAB.findall(tabs))
for page, label, tabs in CATEGORY.findall(block.group(1))]
# A category whose literal stops matching -- a reordered field, an icon
# written as anything but a string -- would otherwise go missing without a
# word, taking its pages' commands with it. So every `page:` written in the
# array has to be accounted for by something this reader recognised.
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
parsed = sum(1 + len(tabs) for _page, _label, tabs in found)
if not found or parsed != declared:
raise ParseError(
f"read {parsed} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects")
return found
def pages() -> list[tuple[str, str]]:
"""The settings pages, in sidebar order, as (id, label)."""
source = read(SIDEBAR)
found = re.findall(r'\{ page: "([a-z-]+)", label: "([^"]+)"', source)
if not found:
raise ParseError("no pages found in SettingsSidebar.qml")
return [(page, label) for page, label in found if page not in SKIP_PAGES]
"""The leaf pages, in sidebar order, as (id, label).
A category with tabs is a group, not somewhere anyone lands: its tabs are
the pages, and each tab is named by its own label rather than the group's.
A category without tabs is a leaf itself.
"""
leaves: list[tuple[str, str]] = []
for page, label, tabs in categories():
leaves += tabs or [(page, label)]
ids = [page for page, _label in leaves]
duplicated = sorted({page for page in ids if ids.count(page) > 1})
if duplicated:
# Two leaves sharing an id write one file between them, so the second
# would quietly overwrite the first's title.
raise ParseError("these leaf pages are declared twice in SettingsRoutes.qml: "
+ ", ".join(duplicated))
return [(page, label) for page, label in leaves if page not in SKIP_PAGES]
def group_pages() -> dict[str, str]:
@@ -17,6 +17,10 @@ Usage:
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.
@@ -32,28 +36,50 @@ import sys
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"
# Page id -> the name a person sees in the sidebar.
PAGE_TITLES = {
"home": "Home", "appearance": "Appearance", "displays": "Displays",
"connectivity": "Network & Devices", "home-phone": "Home & Phone",
"desktop": "Desktop & Dock", "sound": "Sound",
"notifications": "Notifications & Focus",
"screen-intelligence": "Screen Intelligence", "shortcuts": "Keyboard",
"mouse": "Mouse & Touchpad", "privacy": "Privacy & Security",
"region": "Region & Language", "accounts": "Online Accounts",
"accessibility": "Accessibility", "power": "Power & Lock",
"datetime": "Date & Time", "applications": "Applications",
"services": "System Health", "about": "About",
"gaming": "Gaming",
}
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()
@@ -139,7 +165,7 @@ def read_entries():
return entries
def render(entries, routes):
def render(entries, routes, titles):
lines = [
"# Settings reference",
"",
@@ -163,18 +189,19 @@ def render(entries, routes):
if not visible:
continue
page = routes.get(group)
# A routed page with no title used to fall back to the raw page id, so
# 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 the next
# page added here impossible to miss.
if page is not None and page not in PAGE_TITLES:
# 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 has no entry in "
"PAGE_TITLES; add one rather than letting the id be printed as a name"
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 = PAGE_TITLES.get(page, page or "—")
title = titles.get(page, page or "—")
lines += [f"## {group}", "", f"Found on **{title}**.", ""]
lines += ["| Setting | Default | What it does |", "|---|---|---|"]
for entry in visible:
@@ -210,7 +237,7 @@ def main():
args = parser.parse_args()
try:
rendered = render(read_entries(), read_routes())
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.