Files
Panama/config/dot/quickshell/scripts/panama-settings-commands
T

277 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate one launcher command per settings page.
Settings has a search index and the launcher has script commands, but they did
not know about each other: finding a setting meant opening Settings first and
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 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
Run it after adding a settings page or renaming a setting.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[4]
SHELL = REPO / "config/dot/quickshell"
ROUTES = SHELL / "services/SettingsRoutes.qml"
SEARCH = SHELL / "services/SettingsSearch.qml"
SCHEMA = SHELL / "config/PreferenceSchema.qml"
OUTPUT_DIR = REPO / "config/local/share/vicinae/scripts"
# Home is what the plain "Open Settings" command already lands on, so a second
# command for it would be a duplicate under a different name.
SKIP_PAGES = {"home"}
# Enough vocabulary to find the page, not so much that one page matches
# everything. Ordered by the schema, so the settings at the top of a page --
# the ones it is named for -- are the ones that survive the cut.
MAX_KEYWORDS = 12
GENERATED_MARKER = "# Generated by scripts/panama-settings-commands -- do not edit by hand."
class ParseError(RuntimeError):
"""A source file did not look the way this generator expects."""
def read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except OSError as error:
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*\}')
HIDDEN = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*category:\s*"([a-z-]+)"\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 hidden_leaves() -> list[tuple[str, str]]:
"""Leaves that are routable but draw no tab, as (id, label).
The manual is the one: reference material rather than a control surface, so
it is opened from About, a deep link, or a launcher command rather than
found by scanning a tab strip. Which makes the command below the main way
anybody reaches it, and dropping it because it has no tab would be exactly
the wrong conclusion.
They live outside `categories` because the reader above requires each
category to end `tabs: [...] }` and cross-checks every `page:` inside that
array; a hidden leaf declared in there would break both.
"""
source = read(ROUTES)
block = re.search(r"readonly property var hiddenLeaves: \[(.*?)\n \]", source, re.S)
if not block:
return []
found = HIDDEN.findall(block.group(1))
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
if len(found) != declared:
raise ParseError(
f"read {len(found)} of the {declared} hidden leaves in "
"SettingsRoutes.qml; that array no longer looks the way this "
"reader expects")
return [(page, label) for page, label, _category in found]
def pages() -> list[tuple[str, str]]:
"""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)]
leaves += hidden_leaves()
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]:
source = read(SEARCH)
table = re.search(r"readonly property var groupPages: \(\{(.*?)\n \}\)", source, re.S)
if not table:
raise ParseError("groupPages not found in SettingsSearch.qml")
return dict(re.findall(r'"([A-Za-z]+)":\s*"([a-z-]+)"', table.group(1)))
def extra_labels() -> dict[str, list[str]]:
"""Settings the system owns rather than Panama, which have no schema entry."""
source = read(SEARCH)
table = re.search(r"readonly property var extraEntries: \[(.*?)\n \]", source, re.S)
if not table:
return {}
labels: dict[str, list[str]] = {}
for label, page in re.findall(r'\{ label: "([^"]+)".*?page: "([a-z-]+)" \}', table.group(1)):
labels.setdefault(page, []).append(label)
return labels
def schema_labels() -> list[tuple[str, str]]:
"""(group, label) for every user-facing setting, in schema order."""
source = read(SCHEMA)
entries: list[tuple[str, str]] = []
chunks = source.split("key: ")
for chunk in chunks[1:]:
# Internal state is not a setting anyone searches for.
if re.search(r"internal:\s*true", chunk[:600]):
continue
group = re.search(r'group:\s*"([A-Za-z]+)"', chunk[:600])
label = re.search(r'label:\s*"([^"]+)"', chunk[:600])
if group and label:
entries.append((group.group(1), label.group(1)))
if not entries:
raise ParseError("no labelled settings found in PreferenceSchema.qml")
return entries
def keywords_for(page: str, routing: dict[str, str], schema: list[tuple[str, str]],
extras: dict[str, list[str]]) -> list[str]:
words: list[str] = []
for group, label in schema:
if routing.get(group) == page:
lowered = label.lower()
if lowered not in words:
words.append(lowered)
for label in extras.get(page, []):
lowered = label.lower()
if lowered not in words:
words.append(lowered)
return words[:MAX_KEYWORDS]
def command_for(page: str, label: str, words: list[str]) -> str:
"""One launcher command, titled "Settings: <page>".
The qualifier is not product branding -- that was deliberately dropped from
every command title. It is here because a bare page label collides with the
feature of the same name: "Screen Intelligence" is both a thing you open and
a page of settings about it, and two commands sharing one title are
indistinguishable in a launcher. Qualifying the page also groups all of them
under one word, which is what typing "settings" is for.
"""
# "settings" is always a keyword so typing it lists every page at once.
vocabulary = ["settings"] + [word for word in words if word != "settings"]
keywords = ", ".join(f'"{word}"' for word in vocabulary)
return f"""#!/usr/bin/env bash
{GENERATED_MARKER}
# @vicinae.schemaVersion 1
# @vicinae.title Settings: {label}
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open {label} in Settings.
# @vicinae.keywords [{keywords}]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page {page}
"""
def build() -> dict[Path, str]:
routing = group_pages()
schema = schema_labels()
extras = extra_labels()
files: dict[Path, str] = {}
for page, label in pages():
words = keywords_for(page, routing, schema, extras)
files[OUTPUT_DIR / f"settings-{page}"] = command_for(page, label, words)
return files
def existing() -> set[Path]:
return {path for path in OUTPUT_DIR.glob("settings-*")}
def main(arguments: list[str]) -> int:
check = arguments == ["--check"]
if arguments and not check:
print(__doc__, file=sys.stderr)
return 2
try:
files = build()
except ParseError as error:
# Loud and empty-handed: writing a partial set would silently drop the
# pages whose source stopped parsing.
print(f"panama-settings-commands: {error}", file=sys.stderr)
return 2
stale = [path for path, body in files.items()
if not path.is_file() or path.read_text(encoding="utf-8") != body]
orphaned = sorted(existing() - set(files))
if check:
for path in stale:
print(f"stale: {path.name}", file=sys.stderr)
for path in orphaned:
print(f"orphaned: {path.name}", file=sys.stderr)
if stale or orphaned:
print("panama-settings-commands: run it without --check to regenerate",
file=sys.stderr)
return 1
print(f"panama-settings-commands: {len(files)} commands are current")
return 0
for path in orphaned:
path.unlink()
print(f"removed {path.name}")
for path, body in files.items():
path.write_text(body, encoding="utf-8")
path.chmod(0o755)
print(f"panama-settings-commands: wrote {len(files)} commands"
+ (f", removed {len(orphaned)}" if orphaned else ""))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))