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]: