277 lines
11 KiB
Python
Executable File
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:]))
|