diff --git a/.gitignore b/.gitignore
index c30a6d926..765a602fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@
/.qmlls.ini
build/
.cache/
-logs
\ No newline at end of file
+logs
+__pycache__/
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c9957f04d..a2badf685 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -51,6 +51,25 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wunused-lambda-capture)
endif()
+# Generate the settings search index from the page QML at build time so it always
+# matches the UI (see scripts/build-settings-index.py). Done up front so the
+# plugin can bake it into its binary as a resource (kept out of user-editable
+# config). Only needed when either the plugin or shell module is being built.
+if("plugin" IN_LIST ENABLE_MODULES OR "shell" IN_LIST ENABLE_MODULES)
+ find_package(Python3 COMPONENTS Interpreter REQUIRED)
+ set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json")
+ execute_process(
+ COMMAND ${Python3_EXECUTABLE}
+ "${CMAKE_SOURCE_DIR}/scripts/build-settings-index.py"
+ "${CMAKE_SOURCE_DIR}/modules/nexus"
+ "${SETTINGS_INDEX_JSON}"
+ RESULT_VARIABLE SETTINGS_INDEX_RESULT
+ )
+ if(NOT SETTINGS_INDEX_RESULT EQUAL 0)
+ message(FATAL_ERROR "Failed to build settings search index")
+ endif()
+endif()
+
if("extras" IN_LIST ENABLE_MODULES)
add_subdirectory(extras)
endif()
diff --git a/README.md b/README.md
index 5e6761690..9c774d885 100644
--- a/README.md
+++ b/README.md
@@ -909,6 +909,214 @@ The launcher pulls wallpapers from `~/Pictures/Wallpapers` by default. You can c
the launcher only shows an odd number of wallpapers at one time. If you only have 2 wallpapers, consider getting more
(or just putting one).
+## Indexing settings
+
+The settings panel (nexus) has a full-text search that lets users jump straight to any setting by name, description, or
+the section/page it lives under. The index is generated from the page QML at build time and baked into the plugin binary,
+so it always matches the UI and ships with the compiled module rather than as a user-editable file.
+
+Developer guide: how it works, and how to add or remove settings
+
+### How it works at a glance
+
+```
+ page QML files build-settings-index.py plugin binary
+ (ToggleRow, NavRow, …) ──► (parses QML, builds index) ──► (JSON embedded
+ + settingAnchor as a qrc resource)
+ │
+ ▼
+ SettingsSearcher.qml reads it
+ via CUtils.settingsIndex()
+ │
+ ▼
+ query() → grouped results →
+ NexusState.jumpToSetting()
+```
+
+The index is **generated, not hand-written**. The build script reads the page QML, finds every indexable row, and emits a
+JSON file. CMake bakes that JSON into the plugin binary so it ships with the compiled module rather than as a
+user-editable file. At runtime the search service reads it back out and serves queries from an inverted index.
+
+### Adding a setting to the search
+
+A row is indexed when two things are true:
+
+1. It is one of the indexable row types listed in `ROW_RE` in `scripts/build-settings-index.py` — currently `ToggleRow`,
+ `SliderRow`, `SelectRow`, `StepperRow`, `NavRow`, `InfoRow`, `PopupRow` (and its `DefaultRow` alias). These all derive
+ from `ConnectedRect`, which is what makes the deep-link scroll/flash work.
+2. It has a `settingAnchor` property set to a unique kebab-case id.
+
+So to make a setting searchable, add a `settingAnchor` to its row:
+
+```qml
+ToggleRow {
+ icon: "notifications"
+ label: qsTr("Show in fullscreen")
+ status: qsTr("Keep showing notifications over fullscreen apps")
+ settingAnchor: "notif-show-in-fullscreen" // ← add this
+ // …
+}
+```
+
+Then regenerate the index (see "Regenerating the index" below) and commit. That's it — everything else is automatic:
+
+- **Page, sub-pages, breadcrumbs** are discovered from the page tree (`PageRegistry.qml` for icons/labels,
+ `PageCompRegistry.qml` for the hierarchy), so you don't list them anywhere.
+- **The title** comes from the row's `label`.
+- **The description** comes from the row's `subtext` or `status`.
+- **The section** comes from the nearest `SectionHeader` above the row.
+- **Search tokens** (the inverted index) are built from all of the above.
+
+#### Choosing a good anchor
+
+The anchor is a stable id used for deep-linking, not shown to the user. Keep it kebab-case and prefix it with the page so
+ids stay unique and readable, e.g. `notif-default-timeout`, `apps-all-apps`, `ethernet-ip-address`. Once an anchor ships,
+avoid renaming it gratuitously — it's the durable handle for that setting.
+
+#### Indexing a new or different row type
+
+The generator only looks at the row types listed in `ROW_RE`. If a setting uses a component that isn't in that list,
+**it won't be indexed even if you add a `settingAnchor`** — the generator simply never sees it. This is an easy thing to
+miss: the setting works fine in the UI but never shows up in search.
+
+This is exactly what happened with the "Default applications" rows (Terminal, Audio, Media playback, File manager) on the
+Apps page. They use a `PopupRow` (via its `DefaultRow` alias) rather than a `ToggleRow`/`NavRow`, so they were invisible to
+search until `PopupRow`/`DefaultRow` were added to `ROW_RE`.
+
+To make a new row type indexable:
+
+1. **Confirm it derives from `ConnectedRect`.** This is required — the deep-link scroll-and-flash relies on
+ `ConnectedRect`'s `settingAnchor` and `flashHighlight()`. A component that isn't a `ConnectedRect` (e.g. a bare
+ `M3TextField`) can't be deep-linked and shouldn't be added.
+2. **Add the component name to `ROW_RE`** in `scripts/build-settings-index.py`. The generator matches on the literal name
+ as written in the QML, so if a page uses a local alias (like `DefaultRow` for `PopupRow`), add the alias too — or
+ better, add the underlying type and prefer using it directly.
+3. **Make sure its title/description come from the expected properties.** The generator reads the title from `label` or
+ `text`, and the description from `subtext` or `status` (see `LABEL_RE` and `SUBTEXT_RE`). If your component exposes
+ those under different names, either alias them or extend the regexes.
+4. Add `settingAnchor`s, regenerate, and commit.
+
+If you find yourself adding lots of one-off aliases, that's a sign the underlying row type (e.g. `PopupRow`) should be in
+`ROW_RE` directly so future pages using it are indexed automatically.
+
+### Removing a setting from the search
+
+There are four ways, depending on how broadly you want to exclude:
+
+1. **One setting** — delete its `settingAnchor`. The row stays in the UI but drops out of search. This is the usual case.
+2. **A title everywhere** — add the title to `SKIP_LABELS` in `scripts/build-settings-index.py`. Useful for generic labels
+ like `Muted` or `None` that would otherwise produce noise.
+3. **A whole page** — remove the `settingAnchor` from every row on that page.
+4. **Conditionally / at runtime** — filter in `NavLocations.qml`. This is how ethernet settings are hidden when no ethernet
+ is available: the results list drops entries whose anchor starts with `ethernet-` unless a wired connection exists. Use
+ this when "should it be searchable" depends on runtime state, not on the source.
+
+After options 1–3, regenerate the index and commit. Option 4 is pure QML and needs no regeneration.
+
+### Regenerating the index
+
+The build runs the generator automatically, so a normal `cmake --build` produces a fresh index. But `qs -c caelestia`
+(used for quick iteration) does **not** run CMake, so after any change that affects the index you must regenerate it
+manually before testing:
+
+```sh
+python3 scripts/build-settings-index.py modules/nexus
+```
+
+During development the simplest flow is to point it at a temporary file and rebuild the plugin once, or just run a full
+`cmake --build`. The committed source of truth is the generator and the page QML — there is no checked-in JSON to keep in
+sync (the index lives inside the plugin binary, see below).
+
+> **Note:** changes that affect the index — adding/removing a `settingAnchor`, editing a `label`/`subtext`/`status`/
+> `SectionHeader`, or restructuring pages — only show up after the index is regenerated. Pure styling or behaviour changes
+> to the search UI (`NavLocations.qml`, `SettingsSearcher.qml`) take effect with a plain `qs -c caelestia`.
+
+### Where the index lives
+
+The generated JSON is **embedded into the plugin binary as a Qt resource**, not installed as a config file. This keeps it
+out of the user-editable config tree (it can't be accidentally edited or deleted), and means it ships wherever the module
+is installed — manual build, AUR, Nix, all the same.
+
+The flow in CMake:
+
+1. `CMakeLists.txt` runs `build-settings-index.py` at configure time, before the plugin subdirectory, writing to
+ `${CMAKE_BINARY_DIR}/settings-index.json` (the `SETTINGS_INDEX_JSON` variable).
+2. `plugin/src/Caelestia/CMakeLists.txt` adds that file to the `caelestia-core` module as a `RESOURCES` entry, with
+ `QT_RESOURCE_ALIAS` mapping it to the stable path `settings-index.json` regardless of the build-dir layout.
+3. At runtime it is available at the qrc path `:/qt/qml/Caelestia/settings-index.json` (Qt's `qt_add_qml_module` prefixes
+ resources with `:/qt/qml//`).
+
+`CUtils::settingsIndex()` (in `plugin/src/Caelestia/cutils.{hpp,cpp}`) reads that resource and returns it as a string to
+QML.
+
+> Because `rcc` compresses embedded resources, you won't see the JSON text with `strings` on the `.so` — that's expected,
+> the data is there but zlib-compressed. To verify, log `CUtils.settingsIndex().length` from QML instead.
+
+### The generated JSON
+
+Schema (version 2):
+
+```jsonc
+{
+ "version": 2,
+ "entries": [
+ {
+ "pageIdx": 0, // index of the owning top-level page
+ "subPath": [2, 9], // sub-page navigation path (empty = main page)
+ "crumbIcons": ["palette", "…"], // breadcrumb icons, page → setting
+ "crumbLabels": ["Wallpaper", "…"], // breadcrumb labels
+ "title": "Display wallpaper", // the setting label
+ "section": "Wallpaper", // nearest SectionHeader, if any
+ "subtext": "…", // description (subtext/status)
+ "anchor": "wallpaper-display" // settingAnchor, used for deep-linking
+ }
+ // …
+ ],
+ "inverted": { "token": [entryIdx, …] }, // inverted index: token → matching entries
+ "ranking": { "token": { "entryIdx": weight } } // per-token relevance weights
+}
+```
+
+`title` weighs more than keyword tokens in ranking, so a query that hits a setting's name ranks above one that only hits
+its description.
+
+### Runtime pieces
+
+| File | Role |
+| --- | --- |
+| `scripts/build-settings-index.py` | Parses page QML, builds the index JSON. |
+| `SettingsSearcher.qml` | Singleton search service. Loads the index via `CUtils.settingsIndex()`, exposes `query(search)` over the inverted index, plus `highlight()` for match emphasis. |
+| `NavLocations.qml` | Renders grouped result cards, runtime filtering (e.g. ethernet), click-to-navigate. |
+| `NexusState.qml` | `jumpToSetting(pageIdx, subPath, anchor)` drives navigation + deferred scroll target. |
+| `common/PageBase.qml` | `scrollToAnchor()` scrolls to and flashes the target row once the page is ready (handles async-loaded content). |
+| `common/ConnectedRect.qml` | Base of the indexable rows; provides `settingAnchor` and the flash highlight. |
+| `plugin/src/Caelestia/cutils.{hpp,cpp}` | `settingsIndex()` returns the embedded JSON to QML. |
+
+#### Search internals
+
+`query(search)` tokenizes the input, looks up each token in the inverted index (exact match first, then prefix — so `wall`
+matches `wallpaper`), keeps only entries that match **all** tokens (AND semantics), sorts by summed relevance weight (ties
+broken by entry id for stability), and caps the result count. Each result is exposed as a `SettingEntry` QObject so the UI
+can bind to its fields.
+
+`highlight(text, search, colour)` wraps query-matched prefixes in a `` tag for display with `Text.StyledText`.
+(StyledText supports `` but not CSS ``, which is a common gotcha.)
+
+### Gotchas
+
+- **`qs -c` won't regenerate the index.** Always rerun the generator after index-affecting edits, or do a full build.
+- **Only `ConnectedRect`-derived rows can take a `settingAnchor`.** Plain `M3TextField`s and other non-`ConnectedRect`
+ components can't be deep-linked, so they can't be indexed this way.
+- **A `settingAnchor` does nothing if the row type isn't in `ROW_RE`.** The generator only sees the row types it's told
+ about, so a new component (or a page-local alias) needs adding to `ROW_RE` first — otherwise the setting works in the UI
+ but silently never appears in search. See "Indexing a new or different row type" above.
+- **Tokenization splits on non-alphanumerics.** A single query word won't match across a hyphen boundary in a hyphenated
+ name (e.g. `wifi` vs `Wi-Fi`): the result still appears via the index, but that exact word may not be highlighted.
+- **Anchors are forever-ish.** They're the deep-link handle; renaming one is a breaking change for anything that linked to
+ it.
+
+
+
## Credits
Thanks to the Hyprland discord community (especially the homies in #rice-discussion) for all the help and suggestions
diff --git a/modules/nexus/NavPane.qml b/modules/nexus/NavPane.qml
index b51f704c7..3c2e56f82 100644
--- a/modules/nexus/NavPane.qml
+++ b/modules/nexus/NavPane.qml
@@ -38,6 +38,12 @@ ColumnLayout {
property: "searchOpen"
value: searchField.text.length > 0
}
+
+ Binding {
+ target: root.nState
+ property: "searchText"
+ value: searchField.text
+ }
}
NavLocations {
diff --git a/modules/nexus/NexusState.qml b/modules/nexus/NexusState.qml
index 807aa756b..8dd4b0393 100644
--- a/modules/nexus/NexusState.qml
+++ b/modules/nexus/NexusState.qml
@@ -8,7 +8,11 @@ QtObject {
property bool animatingContainer
property int currentPageIdx
property list subPageIdxStack
+ property list pendingSubPath
property bool searchOpen
+ property string searchText
+ property string searchAnchor
+ property string lastAnchor
property string selectedWallpaperCategory
property BluetoothDevice selectedBtDevice
@@ -18,6 +22,7 @@ QtObject {
signal close
signal subPageOpened(idx: int)
signal subPageClosed
+ signal highlightSetting(anchor: string)
function openSubPage(idx: int): void {
subPageIdxStack.push(idx);
@@ -29,5 +34,44 @@ QtObject {
subPageIdxStack.pop();
}
- onCurrentPageIdxChanged: subPageIdxStack.length = 0
+ // Jump straight to a setting from search: open the page, then any sub-pages
+ // along subPath, then let the page scroll to the anchor. subPageIdxStack is
+ // filled directly so a freshly loaded StackPage opens the whole chain at
+ // once (see StackPage.Component.onCompleted), which avoids the half-open
+ // state that firing openSubPage signals one by one would cause.
+ function jumpToSetting(pageIdx: int, subPath: var, anchor: string): void {
+ const samePage = currentPageIdx === pageIdx;
+ const sameSub = subPageIdxStack.length === subPath.length && subPath.every((v, i) => subPageIdxStack[i] === v);
+ if (samePage && sameSub && anchor === lastAnchor) {
+ // Re-clicking the exact same setting: flash it again, don't scroll.
+ highlightSetting(anchor);
+ return;
+ }
+ lastAnchor = anchor;
+ if (samePage && sameSub) {
+ // Same page, different setting: just scroll to it.
+ searchAnchor = "";
+ searchAnchor = anchor;
+ return;
+ }
+ // Different page, or same page but different sub-page: point at the
+ // target sub-page chain and load the destination page, which scrolls to
+ // the anchor once it's ready.
+ searchAnchor = anchor;
+ if (!samePage) {
+ pendingSubPath = subPath.slice();
+ currentPageIdx = pageIdx;
+ } else {
+ // Same page: close back to the page root, then open the chain.
+ while (subPageIdxStack.length > 0)
+ closeSubPage();
+ for (let i = 0; i < subPath.length; i++)
+ openSubPage(subPath[i]);
+ }
+ }
+
+ onCurrentPageIdxChanged: {
+ subPageIdxStack = pendingSubPath;
+ pendingSubPath = [];
+ }
}
diff --git a/modules/nexus/SettingsSearcher.qml b/modules/nexus/SettingsSearcher.qml
new file mode 100644
index 000000000..0ab9f509f
--- /dev/null
+++ b/modules/nexus/SettingsSearcher.qml
@@ -0,0 +1,234 @@
+pragma Singleton
+
+import "../../utils/scripts/fzf.js" as Fzf
+import QtQuick
+import Quickshell
+import Caelestia
+import Caelestia.Config
+
+// Search service over the settings index. The index is generated at build time
+// from the page QML files by scripts/build-settings-index.py and baked into the
+// plugin binary (read via CUtils.settingsIndex), so it stays in sync with the UI
+// without any hand-maintained entries and without a user-editable data file.
+//
+// Unlike the launcher's fuzzy searcher, this uses the real inverted index +
+// ranking baked into the JSON: a query is tokenised, each token is looked up in
+// the inverted index (exact token or prefix), the matching entry ids are scored
+// with the precomputed per-token ranking, and the best entries are returned.
+// SettingEntry QObjects are produced via Variants so the result objects expose
+// the same properties the result list expects.
+Singleton {
+ id: root
+
+ // entries: forward index (one record per setting)
+ // inverted: token -> [entry id...]
+ // ranking: token -> { entry id (string): weight }
+ property var inverted: ({})
+ property var ranking: ({})
+ // Cache the highlight regex so it's compiled once per search rather than
+ // once per card per keystroke. Held inside a plain JS object (never
+ // reassigned) because highlight() runs inside text bindings: writing to a
+ // real property from there would notify and re-trigger the binding - a
+ // binding loop. Mutating an object's fields emits no change signals.
+ readonly property var highlightCache: ({
+ "search": "",
+ "pattern": null
+ })
+ // fzf finder over the entries (title + keywords), used as a fuzzy fallback
+ // when the exact/prefix index lookup comes up short. fzf is the same matcher
+ // the launcher uses, so typo and mid-word matching behave consistently.
+ property var fzfFinder: null
+
+ function query(search: string): list {
+ const tokens = root.tokenize(search);
+ if (tokens.length === 0)
+ return [];
+
+ // Accumulate a score per entry id across all query tokens. An entry must
+ // match every query token (AND), and its score is the sum of the ranking
+ // weights of the index tokens it matched, so results stay relevant.
+ const scores = ({});
+ const hitCounts = ({});
+ for (const token of tokens) {
+ const matches = root.lookup(token); // { id: weight }
+ for (const id in matches) {
+ scores[id] = (scores[id] ?? 0) + matches[id];
+ hitCounts[id] = (hitCounts[id] ?? 0) + 1;
+ }
+ }
+
+ // Sort by score, breaking ties by id so the order is stable (otherwise
+ // entries with equal scores can be dropped arbitrarily by the limit).
+ const ranked = Object.keys(scores).filter(id => hitCounts[id] === tokens.length).sort((a, b) => scores[b] - scores[a] || (parseInt(a) - parseInt(b))).slice(0, 25);
+
+ const all = entries.instances;
+ const out = ranked.map(id => all[parseInt(id)]).filter(e => e !== undefined);
+
+ // The inverted index only does exact/prefix matches. When it finds little
+ // or nothing - a typo ("trasparency") or a mid-word query ("paper") - fall
+ // back to fzf over the same entries. fzf hits that the index already
+ // returned are skipped, and the rest are appended after the (stronger)
+ // index results, so precise matches always lead.
+ if (out.length < 5 && root.fzfFinder) {
+ const seen = ({});
+ for (const id of ranked)
+ seen[id] = true;
+ const fuzzy = root.fzfFinder.find(search);
+ for (const r of fuzzy) {
+ const idx = r.item.idx;
+ if (seen[idx])
+ continue;
+ seen[idx] = true;
+ const entry = all[idx];
+ if (entry !== undefined)
+ out.push(entry);
+ if (out.length >= 25)
+ break;
+ }
+ }
+
+ return out;
+ }
+
+ // Look up a query token in the inverted index: exact match first, then any
+ // indexed token that starts with it (prefix search, so "wif" finds "wifi").
+ // Returns a map of entry id -> best ranking weight for that id.
+ function lookup(token: string): var {
+ const result = ({});
+ const exact = root.inverted[token] !== undefined;
+ const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
+ for (const key of keys) {
+ const rank = root.ranking[key] ?? ({});
+ for (const id of root.inverted[key]) {
+ const w = rank[id] ?? 0.1;
+ if (result[id] === undefined || w > result[id])
+ result[id] = w;
+ }
+ }
+
+ return result;
+ }
+
+ function tokenize(text: string): var {
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
+ }
+
+ // Wrap the parts of `text` that match the search in the given colour, for use
+ // with a StyledText in Text.StyledText format. Matches each query token as a
+ // prefix at a word boundary (mirroring how lookup matches), so "wall"
+ // highlights the start of "wallpaper". StyledText supports but
+ // not CSS . HTML-significant characters are escaped first so the
+ // rich-text parser doesn't choke on names with & < or >.
+ function highlight(text: string, search: string, colour: color): string {
+ const escaped = text.replace(/&/g, "&").replace(//g, ">");
+ if (search.length === 0)
+ return escaped;
+
+ // Rebuild the regex only when the search changes; every card reuses it.
+ const cache = root.highlightCache;
+ if (search !== cache.search) {
+ const tokens = root.tokenize(search);
+ cache.search = search;
+ if (tokens.length === 0) {
+ cache.pattern = null;
+ } else {
+ const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
+ cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
+ }
+ }
+
+ const pattern = cache.pattern;
+ if (!pattern)
+ return escaped;
+
+ // Skip building (and later rich-text parsing) an HTML string when this
+ // text has no match - which is most subtexts. The caller checks for a
+ // "$1`);
+ }
+
+ Component.onCompleted: {
+ try {
+ const data = JSON.parse(CUtils.settingsIndex());
+ entries.model = data.entries;
+ root.inverted = data.inverted ?? {};
+ root.ranking = data.ranking ?? {};
+ // One searchable string per entry: the title. fzf provides typo and
+ // mid-word matching over titles as a fallback when the exact/prefix
+ // index lookup comes up short.
+ const docs = data.entries.map((e, i) => ({
+ idx: i,
+ text: e.title
+ }));
+ root.fzfFinder = new Fzf.Finder(docs, {
+ selector: d => d.text,
+ limit: 25
+ });
+ } catch (e) {
+ entries.model = [];
+ root.inverted = {};
+ root.ranking = {};
+ root.fzfFinder = null;
+ }
+ }
+
+ Variants {
+ id: entries
+
+ SettingEntry {}
+ }
+
+ component SettingEntry: QtObject {
+ required property var modelData
+
+ readonly property int pageIdx: modelData.pageIdx
+ readonly property var subPath: modelData.subPath
+ readonly property var crumbIcons: modelData.crumbIcons
+ readonly property var crumbLabels: modelData.crumbLabels
+ readonly property string title: modelData.title
+ readonly property string section: modelData.section ?? ""
+ readonly property string subtext: modelData.subtext ?? ""
+ readonly property string anchor: modelData.anchor ?? ""
+ // The setting's own icon for the result card, baked by the index script.
+ readonly property string icon: modelData.icon ?? ""
+
+ // A non-empty togglePath means this is a plain on/off setting that can be
+ // flipped straight from the results (e.g. "background.wallpaperEnabled").
+ readonly property string togglePath: modelData.togglePath ?? ""
+ readonly property bool isToggle: togglePath.length > 0
+ // Live value of the config property, read by walking the path on
+ // GlobalConfig. Re-evaluates when that property changes.
+ readonly property bool toggleValue: {
+ if (!isToggle)
+ return false;
+ let obj = GlobalConfig;
+ const parts = togglePath.split(".");
+ for (const part of parts) {
+ if (obj === undefined || obj === null)
+ return false;
+ obj = obj[part];
+ }
+ return obj ?? false;
+ }
+
+ // Write `value` back to the config property the path points at.
+ function setToggle(value: bool): void {
+ if (!isToggle)
+ return;
+ const parts = togglePath.split(".");
+ let obj = GlobalConfig;
+ for (let k = 0; k < parts.length - 1; k++) {
+ if (obj === undefined || obj === null)
+ return;
+ obj = obj[parts[k]];
+ }
+ if (obj !== undefined && obj !== null)
+ obj[parts[parts.length - 1]] = value;
+ }
+ }
+}
diff --git a/modules/nexus/common/ConnectedRect.qml b/modules/nexus/common/ConnectedRect.qml
index df5b21ae4..d9dada004 100644
--- a/modules/nexus/common/ConnectedRect.qml
+++ b/modules/nexus/common/ConnectedRect.qml
@@ -4,12 +4,64 @@ import qs.components
import qs.services
StyledRect {
+ id: root
+
property bool first
property bool last
+ // Identifier used by the settings search to scroll to this row.
+ property string settingAnchor
+
+ // Briefly flash the row, used when the settings search jumps to it.
+ function flashHighlight(): void {
+ flash.restart();
+ }
color: Colours.tPalette.m3surfaceContainer
topLeftRadius: first ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall
topRightRadius: first ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall
bottomLeftRadius: last ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall
bottomRightRadius: last ? Tokens.rounding.extraLarge : Tokens.rounding.extraSmall
+
+ StyledRect {
+ id: highlight
+
+ anchors.fill: parent
+
+ radius: parent.radius
+ topLeftRadius: parent.topLeftRadius
+ topRightRadius: parent.topRightRadius
+ bottomLeftRadius: parent.bottomLeftRadius
+ bottomRightRadius: parent.bottomRightRadius
+ color: Colours.palette.m3primary
+ opacity: 0
+
+ SequentialAnimation {
+ id: flash
+
+ Anim {
+ target: highlight
+ property: "opacity"
+ to: 0.2
+ duration: Tokens.anim.durations.small
+ }
+ Anim {
+ target: highlight
+ property: "opacity"
+ to: 0.08
+ duration: Tokens.anim.durations.normal
+ }
+ Anim {
+ target: highlight
+ property: "opacity"
+ to: 0.2
+ duration: Tokens.anim.durations.small
+ }
+ Anim {
+ target: highlight
+ property: "opacity"
+ to: 0
+ duration: Tokens.anim.durations.extraLarge
+ }
+ }
+ }
}
diff --git a/modules/nexus/common/PageBase.qml b/modules/nexus/common/PageBase.qml
index 74c22fd69..9c33ee05d 100644
--- a/modules/nexus/common/PageBase.qml
+++ b/modules/nexus/common/PageBase.qml
@@ -19,9 +19,109 @@ ColumnLayout {
readonly property alias flickable: flickable
default property Item contentChild
+ // Enables a smooth scroll animation only for search jumps, so normal
+ // flicking stays instant.
+ property bool animateScroll: false
+
+ // When the settings search jumps to this page, scroll to the matching row.
+ function scrollToAnchor(anchor: string): bool {
+ if (!anchor || !contentChild)
+ return false;
+ const row = findAnchor(contentChild, anchor);
+ if (!row)
+ return false;
+ const pos = row.mapToItem(flickable.contentItem, 0, 0);
+ // Land the row below the top fade so it isn't dimmed by the edge effect,
+ // clamped to the flickable's real scroll range (which includes margins).
+ const inset = flickable.height * flickable.fadeAmount + Tokens.padding.large;
+ const minY = -flickable.topMargin;
+ const maxY = Math.max(minY, flickable.contentHeight + flickable.bottomMargin - flickable.height);
+ const target = Math.max(minY, Math.min(pos.y - inset, maxY));
+ root.animateScroll = true;
+ flickable.contentY = target;
+ Qt.callLater(() => root.animateScroll = false);
+ if (row.flashHighlight !== undefined) // qmllint disable missing-property
+ row.flashHighlight(); // qmllint disable missing-property
+ return true;
+ }
+
+ function findAnchor(item: Item, anchor: string): Item {
+ if (!item)
+ return null;
+ if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
+ return item;
+ const kids = item.children;
+ for (let i = 0; i < kids.length; i++) {
+ const found = findAnchor(kids[i], anchor);
+ if (found)
+ return found;
+ }
+ return null;
+ }
+
+ function applySearchAnchor(): void {
+ if (!nState.searchAnchor)
+ return;
+ scrollRetry.tries = 0;
+ scrollRetry.lastHeight = -1;
+ scrollRetry.stableFrames = 0;
+ scrollRetry.restart();
+ }
+
+ // Flash a row without scrolling (used when re-selecting the current setting).
+ function highlightAnchor(anchor: string): void {
+ const row = findAnchor(contentChild, anchor);
+ if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
+ row.flashHighlight(); // qmllint disable missing-property
+ }
spacing: Tokens.spacing.extraLargeIncreased
+ Component.onCompleted: applySearchAnchor()
+
+ Timer {
+ id: scrollRetry
+
+ property int tries: 0
+ property real lastHeight: -1
+ property int stableFrames: 0
+
+ interval: 16
+ repeat: true
+ onTriggered: {
+ // Pages like the ethernet detail load their content asynchronously
+ // (device info, IP config), so the layout keeps growing for a while.
+ // Wait until contentHeight has held steady for a few frames (or we've
+ // waited long enough) before scrolling, so the target doesn't drift.
+ const h = flickable.contentHeight;
+ if (h === lastHeight && h > flickable.height)
+ stableFrames++;
+ else
+ stableFrames = 0;
+ lastHeight = h;
+
+ const ready = stableFrames >= 3 || tries >= 30;
+ if (ready) {
+ if (root.scrollToAnchor(root.nState.searchAnchor))
+ root.nState.searchAnchor = "";
+ stop();
+ }
+ tries++;
+ }
+ }
+
+ Connections {
+ function onSearchAnchorChanged(): void {
+ root.applySearchAnchor();
+ }
+
+ function onHighlightSetting(anchor: string): void {
+ root.highlightAnchor(anchor);
+ }
+
+ target: root.nState
+ }
+
MouseArea { // Prevent clicks from reaching flickable
z: 1
implicitWidth: header.implicitWidth
@@ -71,6 +171,14 @@ ColumnLayout {
contentHeight: root.contentChild?.implicitHeight ?? 0
contentItem.children: [root.contentChild]
+ Behavior on contentY {
+ enabled: root.animateScroll
+
+ Anim {
+ type: Anim.DefaultSpatial
+ }
+ }
+
TapHandler {
onTapped: flickable.focus = true
}
diff --git a/modules/nexus/common/ToggleRow.qml b/modules/nexus/common/ToggleRow.qml
index 95198fe8a..08f01f034 100644
--- a/modules/nexus/common/ToggleRow.qml
+++ b/modules/nexus/common/ToggleRow.qml
@@ -11,8 +11,15 @@ StyledSwitch {
property string subtext
property alias first: bg.first
property alias last: bg.last
+ // Identifier used by the settings search to scroll to this row.
+ property string settingAnchor
readonly property alias bg: bg
+ // Briefly flash the row, used when the settings search jumps to it.
+ function flashHighlight(): void {
+ bg.flashHighlight();
+ }
+
Layout.fillWidth: true
horizontalPadding: Tokens.padding.largeIncreased
diff --git a/modules/nexus/navpane/NavLocations.qml b/modules/nexus/navpane/NavLocations.qml
index fc07bd197..b69f2c932 100644
--- a/modules/nexus/navpane/NavLocations.qml
+++ b/modules/nexus/navpane/NavLocations.qml
@@ -2,9 +2,11 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
+import Quickshell
import Caelestia.Config
import qs.components
import qs.components.containers
+import qs.components.controls
import qs.services
import qs.modules.nexus
@@ -13,6 +15,41 @@ VerticalFadeFlickable {
required property NexusState nState
+ readonly property string search: nState.searchText
+ readonly property bool searching: search.length > 0
+ readonly property var results: {
+ if (!searching)
+ return [];
+ const all = SettingsSearcher.query(search);
+ // The ethernet section hides itself when no ethernet device is available
+ // (e.g. the cable is unplugged), so drop its settings from the results
+ // too, otherwise the search would link to a page that isn't reachable.
+ if (Nmcli.hasAvailableEthernet)
+ return all;
+ return all.filter(e => !e.anchor.startsWith("ethernet-"));
+ }
+ // Results grouped by their top-level page, so the list can show one heading
+ // per page with the matching settings joined underneath it (like the
+ // Android settings search). Each group: { page, entries: [...] }.
+ readonly property var groups: {
+ const out = [];
+ const byPage = ({});
+ for (const e of results) {
+ const key = e.pageIdx;
+ if (byPage[key] === undefined) {
+ byPage[key] = {
+ "pageIdx": e.pageIdx,
+ "page": e.crumbLabels[0],
+ "icon": e.crumbIcons[0],
+ "entries": []
+ };
+ out.push(byPage[key]);
+ }
+ byPage[key].entries.push(e);
+ }
+ return out;
+ }
+
topMargin: Tokens.padding.large
bottomMargin: Tokens.padding.large
contentHeight: content.implicitHeight
@@ -31,7 +68,7 @@ VerticalFadeFlickable {
Repeater {
id: list
- model: PageRegistry.pages
+ model: root.searching ? [] : PageRegistry.pages
StyledRect {
id: item
@@ -40,8 +77,8 @@ VerticalFadeFlickable {
required property int index
readonly property bool isCurrentPage: index === root.nState.currentPageIdx
- readonly property bool isCategoryStart: index === 0 || PageRegistry.pages[index - 1].category !== modelData.category
- readonly property bool isCategoryEnd: index === list.model.length - 1 || PageRegistry.pages[index + 1].category !== modelData.category
+ readonly property bool isCategoryStart: index === 0 || PageRegistry.pages[index - 1]?.category !== modelData.category
+ readonly property bool isCategoryEnd: index === list.model.length - 1 || PageRegistry.pages[index + 1]?.category !== modelData.category
Layout.fillWidth: true
Layout.topMargin: index !== 0 && isCategoryStart ? Tokens.spacing.medium : 0
@@ -124,6 +161,254 @@ VerticalFadeFlickable {
}
}
}
+
+ Column {
+ id: resultList
+
+ Layout.fillWidth: true
+ spacing: Tokens.padding.large
+
+ add: Transition {
+ Anim {
+ type: Anim.DefaultEffects
+ property: "opacity"
+ from: 0
+ to: 1
+ }
+ }
+
+ move: Transition {
+ Anim {
+ properties: "x,y"
+ }
+
+ // A move may interrupt an in-flight add; drive opacity back to 1
+ // so the interrupted fade doesn't leave the group half-visible.
+ Anim {
+ type: Anim.DefaultEffects
+ property: "opacity"
+ to: 1
+ }
+ }
+
+ Repeater {
+ model: ScriptModel {
+ objectProp: "pageIdx"
+ values: root.groups
+ }
+
+ ColumnLayout {
+ id: group
+
+ required property var modelData
+ required property int index
+
+ width: resultList.width
+ spacing: Tokens.spacing.small
+
+ // Group heading: the top-level page name, shown once.
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.leftMargin: Tokens.padding.small
+ spacing: Tokens.spacing.small
+
+ MaterialIcon {
+ text: group.modelData.icon
+ color: Colours.palette.m3primary
+ fontStyle: Tokens.font.icon.small
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: group.modelData.page
+ color: Colours.palette.m3primary
+ font: Tokens.font.label.large
+ elide: Text.ElideRight
+ }
+ }
+
+ Column {
+ id: cardList
+
+ Layout.fillWidth: true
+ spacing: 0
+
+ add: Transition {
+ Anim {
+ type: Anim.DefaultEffects
+ property: "opacity"
+ from: 0
+ to: 1
+ }
+ }
+
+ move: Transition {
+ Anim {
+ properties: "x,y"
+ }
+
+ Anim {
+ type: Anim.DefaultEffects
+ property: "opacity"
+ to: 1
+ }
+ }
+
+ Repeater {
+ model: ScriptModel {
+ objectProp: "anchor"
+ values: group.modelData.entries
+ }
+
+ StyledRect {
+ id: result
+
+ required property var modelData
+ required property int index
+
+ readonly property bool isFirst: index === 0
+ readonly property bool isLast: index === group.modelData.entries.length - 1
+
+ width: cardList.width
+ implicitHeight: {
+ const h = resultLayout.implicitHeight + resultLayout.anchors.margins * 2;
+ return h % 2 === 0 ? h : h + 1;
+ }
+ // Joined card: round only the outer corners so the
+ // rows read as one block (square where they meet),
+ // matching the page tabs' corner radius.
+ topLeftRadius: isFirst ? Tokens.rounding.extraLarge : 0
+ topRightRadius: isFirst ? Tokens.rounding.extraLarge : 0
+ bottomLeftRadius: isLast ? Tokens.rounding.extraLarge : 0
+ bottomRightRadius: isLast ? Tokens.rounding.extraLarge : 0
+ color: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+
+ StyledRect {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: Tokens.padding.large
+ anchors.rightMargin: Tokens.padding.large
+ implicitHeight: 1
+ visible: !result.isLast
+ color: Qt.alpha(Colours.palette.m3outlineVariant, 0.5)
+ }
+
+ RowLayout {
+ id: resultLayout
+
+ anchors.fill: parent
+ anchors.margins: Tokens.padding.large
+ // Leave room on the right for the toggle switch.
+ anchors.rightMargin: result.modelData.togglePath ? toggle.width + Tokens.padding.large * 2 : Tokens.padding.large
+ spacing: Tokens.spacing.medium
+
+ // The setting's own icon, baked into the
+ // index per anchor.
+ MaterialIcon {
+ text: result.modelData.icon
+ color: Colours.palette.m3onSurfaceVariant
+ fontStyle: Tokens.font.icon.medium
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Tokens.spacing.small / 2
+
+ // Location line: "Section > sub", faint.
+ StyledText {
+ Layout.fillWidth: true
+ text: {
+ const labels = result.modelData.crumbLabels.slice(1);
+ const section = result.modelData.section;
+ const parts = section && section !== labels[labels.length - 1] ? labels.concat(section) : labels;
+ return parts.join(" \u203a ");
+ }
+ visible: text.length > 0
+ color: Colours.palette.m3onSurfaceVariant
+ font: Tokens.font.label.small
+ elide: Text.ElideRight
+ }
+
+ // The setting itself, most prominent.
+ StyledText {
+ Layout.fillWidth: true
+ text: SettingsSearcher.highlight(result.modelData.title, root.search, Colours.palette.m3primary)
+ // Only pay for rich-text parsing when the
+ // string actually carries a highlight tag.
+ textFormat: text.includes(" 0
+ text: SettingsSearcher.highlight(result.modelData.subtext, root.search, Colours.palette.m3primary)
+ // Most subtexts have no match, so skip the
+ // rich-text parse unless there's a highlight.
+ textFormat: text.includes(" GlobalConfig.general.apps.terminal = app.command
@@ -39,6 +40,7 @@ PageBase {
DefaultRow {
icon: "volume_up"
+ settingAnchor: "apps-default-audio"
label: qsTr("Audio")
status: GlobalConfig.general.apps.audio.join(" ")
onSelected: app => GlobalConfig.general.apps.audio = app.command
@@ -46,6 +48,7 @@ PageBase {
DefaultRow {
icon: "play_circle"
+ settingAnchor: "apps-default-playback"
label: qsTr("Media playback")
status: GlobalConfig.general.apps.playback.join(" ")
onSelected: app => GlobalConfig.general.apps.playback = app.command
@@ -54,6 +57,7 @@ PageBase {
DefaultRow {
last: true
icon: "folder"
+ settingAnchor: "apps-default-file-manager"
label: qsTr("File manager")
status: GlobalConfig.general.apps.explorer.join(" ")
onSelected: app => GlobalConfig.general.apps.explorer = app.command
@@ -68,6 +72,7 @@ PageBase {
first: true
last: true
icon: "apps"
+ settingAnchor: "apps-all-apps"
label: qsTr("All apps")
status: qsTr("Browse installed apps, set favourites and hidden")
onClicked: root.nState.openSubPage(1)
diff --git a/modules/nexus/pages/AudioPage.qml b/modules/nexus/pages/AudioPage.qml
index 09918df01..5c70d1479 100644
--- a/modules/nexus/pages/AudioPage.qml
+++ b/modules/nexus/pages/AudioPage.qml
@@ -23,6 +23,7 @@ PageBase {
SliderRow {
first: true
icon: Icons.getVolumeIcon(Audio.volume, Audio.muted)
+ settingAnchor: "audio-output"
label: qsTr("Output")
valueLabel: Math.round(value * 100) + "%"
value: Audio.volume
@@ -50,6 +51,7 @@ PageBase {
Layout.topMargin: Tokens.spacing.large - parent.spacing
first: true
icon: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted)
+ settingAnchor: "audio-input"
label: qsTr("Input")
valueLabel: Math.round(value * 100) + "%"
value: Audio.sourceVolume
diff --git a/modules/nexus/pages/BluetoothPage.qml b/modules/nexus/pages/BluetoothPage.qml
index 11e2b1cf3..224a12047 100644
--- a/modules/nexus/pages/BluetoothPage.qml
+++ b/modules/nexus/pages/BluetoothPage.qml
@@ -27,6 +27,7 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "bluetooth-bluetooth"
text: qsTr("Bluetooth")
font: Tokens.font.body.medium
horizontalPadding: Tokens.padding.largeIncreased
@@ -210,6 +211,7 @@ PageBase {
Layout.topMargin: Tokens.spacing.large - parent.spacing
first: true
+ settingAnchor: "bluetooth-discoverable"
text: qsTr("Discoverable")
subtext: qsTr("Allow nearby devices to find this one")
enabled: root.btEnabled
@@ -227,6 +229,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "bluetooth-pairable"
text: qsTr("Pairable")
subtext: qsTr("Allow nearby devices to pair with this one")
enabled: root.btEnabled
diff --git a/modules/nexus/pages/LanguageAndRegion.qml b/modules/nexus/pages/LanguageAndRegion.qml
index 9668f59ff..f68468d7b 100644
--- a/modules/nexus/pages/LanguageAndRegion.qml
+++ b/modules/nexus/pages/LanguageAndRegion.qml
@@ -138,6 +138,7 @@ PageBase {
SelectRow {
first: true
+ settingAnchor: "lang-temperature"
label: qsTr("Temperature")
subtext: qsTr("Units for weather temperatures")
menuItems: root.tempItems
@@ -147,6 +148,7 @@ PageBase {
SelectRow {
last: true
+ settingAnchor: "lang-system-temperatures"
label: qsTr("System temperatures")
subtext: qsTr("Units for CPU and GPU temperatures")
menuItems: root.tempItems
@@ -162,6 +164,7 @@ PageBase {
SelectRow {
first: true
last: true
+ settingAnchor: "lang-clock-format"
label: qsTr("Clock format")
subtext: qsTr("How times are shown across the shell")
menuItems: root.clockItems
diff --git a/modules/nexus/pages/NetworkPage.qml b/modules/nexus/pages/NetworkPage.qml
index 4e8f413bf..f19cee63f 100644
--- a/modules/nexus/pages/NetworkPage.qml
+++ b/modules/nexus/pages/NetworkPage.qml
@@ -62,6 +62,7 @@ PageBase {
ToggleRow {
Layout.topMargin: Nmcli.hasAvailableEthernet ? Tokens.spacing.large : 0
first: true
+ settingAnchor: "network-wi-fi"
text: qsTr("Wi-Fi")
font: Tokens.font.body.medium
horizontalPadding: Tokens.padding.largeIncreased
diff --git a/modules/nexus/pages/PanelsPage.qml b/modules/nexus/pages/PanelsPage.qml
index d49879bec..1f7498b3e 100644
--- a/modules/nexus/pages/PanelsPage.qml
+++ b/modules/nexus/pages/PanelsPage.qml
@@ -16,6 +16,7 @@ PageBase {
NavRow {
first: true
icon: "dashboard"
+ settingAnchor: "panels-dashboard"
label: qsTr("Dashboard")
status: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled")
onClicked: root.nState.openSubPage(1)
@@ -23,6 +24,7 @@ PageBase {
NavRow {
icon: "dock_to_bottom"
+ settingAnchor: "panels-taskbar"
label: qsTr("Taskbar")
status: Config.bar.persistent ? qsTr("Always visible") : Config.bar.showOnHover ? qsTr("Reveal on hover") : qsTr("Reveal on drag")
onClicked: root.nState.openSubPage(2)
@@ -30,6 +32,7 @@ PageBase {
NavRow {
icon: "apps"
+ settingAnchor: "panels-launcher"
label: qsTr("Launcher")
status: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled")
onClicked: root.nState.openSubPage(3)
@@ -38,6 +41,7 @@ PageBase {
NavRow {
last: true
icon: "dock_to_right"
+ settingAnchor: "panels-sidebar"
label: qsTr("Sidebar")
status: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled")
onClicked: root.nState.openSubPage(4)
diff --git a/modules/nexus/pages/ServicesPage.qml b/modules/nexus/pages/ServicesPage.qml
index c8ea20452..8c42a7e6b 100644
--- a/modules/nexus/pages/ServicesPage.qml
+++ b/modules/nexus/pages/ServicesPage.qml
@@ -87,6 +87,7 @@ PageBase {
first: true
last: true
icon: "notifications"
+ settingAnchor: "services-notifications"
label: qsTr("Notifications")
status: qsTr("Notifications, toasts, timeouts")
onClicked: root.nState.openSubPage(1)
@@ -99,6 +100,7 @@ PageBase {
StepperRow {
first: true
+ settingAnchor: "services-media-refresh"
label: qsTr("Media refresh")
subtext: qsTr("How often the media position updates (ms)")
value: GlobalConfig.dashboard.mediaUpdateInterval
@@ -109,6 +111,7 @@ PageBase {
}
StepperRow {
+ settingAnchor: "services-system-stats-refresh"
label: qsTr("System stats refresh")
subtext: qsTr("CPU, memory and GPU update interval (seconds)")
value: GlobalConfig.dashboard.resourceUpdateInterval / 1000
@@ -120,6 +123,7 @@ PageBase {
StepperRow {
last: true
+ settingAnchor: "services-wi-fi-rescan"
label: qsTr("Wi-Fi rescan")
subtext: qsTr("How often available networks are rescanned (seconds)")
value: GlobalConfig.nexus.networkRescanInterval / 1000
@@ -136,6 +140,7 @@ PageBase {
SelectRow {
first: true
+ settingAnchor: "services-lyrics-backend"
label: qsTr("Lyrics backend")
subtext: qsTr("Source used to fetch synced lyrics")
menuItems: root.lyricsItems
@@ -145,6 +150,7 @@ PageBase {
SelectRow {
last: true
+ settingAnchor: "services-default-player"
label: qsTr("Default player")
subtext: qsTr("Preferred media player when several are open")
menuItems: playerVariants.instances
@@ -161,6 +167,7 @@ PageBase {
StepperRow {
first: true
+ settingAnchor: "services-volume-step"
label: qsTr("Volume step")
subtext: qsTr("Amount the volume changes per scroll (%)")
value: Math.round(GlobalConfig.services.audioIncrement * 100)
@@ -171,6 +178,7 @@ PageBase {
}
StepperRow {
+ settingAnchor: "services-brightness-step"
label: qsTr("Brightness step")
subtext: qsTr("Amount the brightness changes per scroll (%)")
value: Math.round(GlobalConfig.services.brightnessIncrement * 100)
@@ -182,6 +190,7 @@ PageBase {
StepperRow {
last: true
+ settingAnchor: "services-max-volume"
label: qsTr("Max volume")
subtext: qsTr("Upper limit for output volume (%)")
value: Math.round(GlobalConfig.services.maxVolume * 100)
@@ -198,6 +207,7 @@ PageBase {
StepperRow {
first: true
+ settingAnchor: "services-visualiser-bars"
label: qsTr("Visualiser bars")
subtext: qsTr("Number of bars in the audio visualisers")
value: GlobalConfig.services.visualiserBars
@@ -208,6 +218,7 @@ PageBase {
}
ToggleRow {
+ settingAnchor: "services-smart-colour-scheme"
text: qsTr("Smart colour scheme")
subtext: qsTr("Derive theme mode and variant from the wallpaper")
checked: GlobalConfig.services.smartScheme
@@ -216,6 +227,7 @@ PageBase {
SelectRow {
last: true
+ settingAnchor: "services-gpu"
label: qsTr("GPU")
subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type")
menuOnTop: true
diff --git a/modules/nexus/pages/WallpaperAndStyle.qml b/modules/nexus/pages/WallpaperAndStyle.qml
index a02025333..652c37e11 100644
--- a/modules/nexus/pages/WallpaperAndStyle.qml
+++ b/modules/nexus/pages/WallpaperAndStyle.qml
@@ -172,6 +172,7 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "style-display-wallpaper"
text: qsTr("Display wallpaper")
checked: Config.background.wallpaperEnabled
onToggled: GlobalConfig.background.wallpaperEnabled = checked
@@ -180,6 +181,7 @@ PageBase {
ToggleRow {
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
+ settingAnchor: "style-transparency"
text: qsTr("Transparency")
subtext: qsTr("Base %1, layers %2").arg(Colours.transparency.base).arg(Colours.transparency.layers)
checked: Colours.transparency.enabled
@@ -190,6 +192,7 @@ PageBase {
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
last: true
+ settingAnchor: "style-dark-theme"
text: qsTr("Dark theme")
checked: !Colours.light
onToggled: Colours.setMode(checked ? "dark" : "light")
diff --git a/modules/nexus/pages/network/EthernetDetailPage.qml b/modules/nexus/pages/network/EthernetDetailPage.qml
index f3c982b4f..d178d520c 100644
--- a/modules/nexus/pages/network/EthernetDetailPage.qml
+++ b/modules/nexus/pages/network/EthernetDetailPage.qml
@@ -167,18 +167,21 @@ PageBase {
InfoRow {
first: true
icon: "link"
+ settingAnchor: "ethernet-status"
label: qsTr("Status")
value: root.device?.connected ? qsTr("Connected") : qsTr("Not connected")
}
InfoRow {
icon: "settings_ethernet"
+ settingAnchor: "ethernet-interface"
label: qsTr("Interface")
value: root.ifaceName || qsTr("—")
}
InfoRow {
icon: "speed"
+ settingAnchor: "ethernet-speed"
label: qsTr("Speed")
visible: Nmcli.ethernetSpeed.length > 0
value: Nmcli.ethernetSpeed
@@ -186,12 +189,14 @@ PageBase {
InfoRow {
icon: "lan"
+ settingAnchor: "ethernet-ip-address"
label: qsTr("IP address")
value: root.details?.ipAddress || qsTr("—")
}
InfoRow {
icon: "router"
+ settingAnchor: "ethernet-gateway"
label: qsTr("Gateway")
value: root.details?.gateway || qsTr("—")
}
@@ -199,6 +204,7 @@ PageBase {
InfoRow {
last: true
icon: "memory"
+ settingAnchor: "ethernet-mac-address"
label: qsTr("MAC address")
value: root.details?.macAddress || qsTr("—")
}
@@ -214,6 +220,7 @@ PageBase {
Layout.fillWidth: true
first: true
last: root.ipMethod === "auto"
+ settingAnchor: "ethernet-ip-assignment"
label: qsTr("IP assignment")
fallbackText: qsTr("Automatic (DHCP)")
fallbackIcon: "lan"
diff --git a/modules/nexus/pages/panels/DashboardPanel.qml b/modules/nexus/pages/panels/DashboardPanel.qml
index e168f124b..5ad03338e 100644
--- a/modules/nexus/pages/panels/DashboardPanel.qml
+++ b/modules/nexus/pages/panels/DashboardPanel.qml
@@ -25,6 +25,7 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "dash-enabled"
text: qsTr("Enabled")
checked: Config.dashboard.enabled
onToggled: GlobalConfig.dashboard.enabled = checked
@@ -32,6 +33,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "dash-show-on-hover"
text: qsTr("Show on hover")
subtext: qsTr("Reveal when the cursor reaches the screen edge")
checked: Config.dashboard.showOnHover
@@ -45,18 +47,21 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "dash-dashboard"
text: qsTr("Dashboard")
checked: Config.dashboard.showDashboard
onToggled: GlobalConfig.dashboard.showDashboard = checked
}
ToggleRow {
+ settingAnchor: "dash-media"
text: qsTr("Media")
checked: Config.dashboard.showMedia
onToggled: GlobalConfig.dashboard.showMedia = checked
}
ToggleRow {
+ settingAnchor: "dash-performance"
text: qsTr("Performance")
checked: Config.dashboard.showPerformance
onToggled: GlobalConfig.dashboard.showPerformance = checked
@@ -64,6 +69,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "dash-weather"
text: qsTr("Weather")
checked: Config.dashboard.showWeather
onToggled: GlobalConfig.dashboard.showWeather = checked
@@ -76,30 +82,35 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "dash-battery"
text: qsTr("Battery")
checked: Config.dashboard.performance.showBattery
onToggled: GlobalConfig.dashboard.performance.showBattery = checked
}
ToggleRow {
+ settingAnchor: "dash-gpu"
text: qsTr("GPU")
checked: Config.dashboard.performance.showGpu
onToggled: GlobalConfig.dashboard.performance.showGpu = checked
}
ToggleRow {
+ settingAnchor: "dash-cpu"
text: qsTr("CPU")
checked: Config.dashboard.performance.showCpu
onToggled: GlobalConfig.dashboard.performance.showCpu = checked
}
ToggleRow {
+ settingAnchor: "dash-memory"
text: qsTr("Memory")
checked: Config.dashboard.performance.showMemory
onToggled: GlobalConfig.dashboard.performance.showMemory = checked
}
ToggleRow {
+ settingAnchor: "dash-storage"
text: qsTr("Storage")
checked: Config.dashboard.performance.showStorage
onToggled: GlobalConfig.dashboard.performance.showStorage = checked
@@ -107,6 +118,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "dash-network"
text: qsTr("Network")
checked: Config.dashboard.performance.showNetwork
onToggled: GlobalConfig.dashboard.performance.showNetwork = checked
@@ -120,6 +132,7 @@ PageBase {
StepperRow {
first: true
last: true
+ settingAnchor: "dash-drag-threshold"
label: qsTr("Drag threshold")
subtext: qsTr("Pixels dragged before the dashboard opens")
value: Config.dashboard.dragThreshold
diff --git a/modules/nexus/pages/panels/LauncherPanel.qml b/modules/nexus/pages/panels/LauncherPanel.qml
index b78edcc29..14c55356f 100644
--- a/modules/nexus/pages/panels/LauncherPanel.qml
+++ b/modules/nexus/pages/panels/LauncherPanel.qml
@@ -25,6 +25,7 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "launcher-enabled"
text: qsTr("Enabled")
checked: Config.launcher.enabled
onToggled: GlobalConfig.launcher.enabled = checked
@@ -32,6 +33,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "launcher-show-on-hover"
text: qsTr("Show on hover")
subtext: qsTr("Reveal when the cursor reaches the screen edge")
checked: Config.launcher.showOnHover
@@ -45,6 +47,7 @@ PageBase {
StepperRow {
first: true
+ settingAnchor: "launcher-max-items-shown"
label: qsTr("Max items shown")
value: Config.launcher.maxShown
from: 1
@@ -54,6 +57,7 @@ PageBase {
}
StepperRow {
+ settingAnchor: "launcher-max-wallpapers"
label: qsTr("Max wallpapers")
value: Config.launcher.maxWallpapers
from: 1
@@ -64,6 +68,7 @@ PageBase {
StepperRow {
last: true
+ settingAnchor: "launcher-drag-threshold"
label: qsTr("Drag threshold")
subtext: qsTr("Pixels dragged before the launcher opens")
value: Config.launcher.dragThreshold
@@ -80,6 +85,7 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "launcher-vim-keybinds"
text: qsTr("Vim keybinds")
subtext: qsTr("Navigate results with Ctrl+hjkl")
checked: GlobalConfig.launcher.vimKeybinds
@@ -88,6 +94,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "launcher-enable-dangerous-actions"
text: qsTr("Enable dangerous actions")
subtext: qsTr("Allow actions that shut down or log out")
checked: GlobalConfig.launcher.enableDangerousActions
@@ -101,24 +108,28 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "launcher-apps"
text: qsTr("Apps")
checked: GlobalConfig.launcher.useFuzzy.apps
onToggled: GlobalConfig.launcher.useFuzzy.apps = checked
}
ToggleRow {
+ settingAnchor: "launcher-actions"
text: qsTr("Actions")
checked: GlobalConfig.launcher.useFuzzy.actions
onToggled: GlobalConfig.launcher.useFuzzy.actions = checked
}
ToggleRow {
+ settingAnchor: "launcher-schemes"
text: qsTr("Schemes")
checked: GlobalConfig.launcher.useFuzzy.schemes
onToggled: GlobalConfig.launcher.useFuzzy.schemes = checked
}
ToggleRow {
+ settingAnchor: "launcher-variants"
text: qsTr("Variants")
checked: GlobalConfig.launcher.useFuzzy.variants
onToggled: GlobalConfig.launcher.useFuzzy.variants = checked
@@ -126,6 +137,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "launcher-wallpapers"
text: qsTr("Wallpapers")
checked: GlobalConfig.launcher.useFuzzy.wallpapers
onToggled: GlobalConfig.launcher.useFuzzy.wallpapers = checked
diff --git a/modules/nexus/pages/panels/SidebarPanel.qml b/modules/nexus/pages/panels/SidebarPanel.qml
index 8d6782248..53b585f0d 100644
--- a/modules/nexus/pages/panels/SidebarPanel.qml
+++ b/modules/nexus/pages/panels/SidebarPanel.qml
@@ -24,6 +24,7 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "sidebar-enabled"
text: qsTr("Enabled")
checked: Config.sidebar.enabled
onToggled: GlobalConfig.sidebar.enabled = checked
@@ -31,6 +32,7 @@ PageBase {
StepperRow {
last: true
+ settingAnchor: "sidebar-drag-threshold"
label: qsTr("Drag threshold")
subtext: qsTr("Pixels dragged before the sidebar opens")
value: Config.sidebar.dragThreshold
diff --git a/modules/nexus/pages/panels/TaskbarPanel.qml b/modules/nexus/pages/panels/TaskbarPanel.qml
index 97102f686..79f4e4907 100644
--- a/modules/nexus/pages/panels/TaskbarPanel.qml
+++ b/modules/nexus/pages/panels/TaskbarPanel.qml
@@ -24,6 +24,7 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "taskbar-persistent"
text: qsTr("Persistent")
subtext: qsTr("Keep the bar visible at all times")
checked: Config.bar.persistent
@@ -31,6 +32,7 @@ PageBase {
}
ToggleRow {
+ settingAnchor: "taskbar-show-on-hover"
text: qsTr("Show on hover")
subtext: qsTr("Reveal the bar when the cursor reaches the screen edge")
checked: Config.bar.showOnHover
@@ -39,6 +41,7 @@ PageBase {
StepperRow {
last: true
+ settingAnchor: "taskbar-drag-threshold"
label: qsTr("Drag threshold")
subtext: qsTr("Pixels dragged before the bar reveals")
value: Config.bar.dragThreshold
@@ -56,6 +59,7 @@ PageBase {
NavRow {
first: true
icon: "workspaces"
+ settingAnchor: "taskbar-workspaces"
label: qsTr("Workspaces")
status: qsTr("Indicators, window icons")
onClicked: root.nState.openSubPage(5)
@@ -63,6 +67,7 @@ PageBase {
NavRow {
icon: "web_asset"
+ settingAnchor: "taskbar-active-window"
label: qsTr("Active window")
status: qsTr("Title display, popout")
onClicked: root.nState.openSubPage(6)
@@ -70,6 +75,7 @@ PageBase {
NavRow {
icon: "widgets"
+ settingAnchor: "taskbar-tray"
label: qsTr("Tray")
status: qsTr("System tray icons")
onClicked: root.nState.openSubPage(7)
@@ -77,6 +83,7 @@ PageBase {
NavRow {
icon: "signal_cellular_alt"
+ settingAnchor: "taskbar-status-icons"
label: qsTr("Status icons")
status: qsTr("Visible indicators")
onClicked: root.nState.openSubPage(8)
@@ -85,6 +92,7 @@ PageBase {
NavRow {
last: true
icon: "schedule"
+ settingAnchor: "taskbar-clock"
label: qsTr("Clock")
status: qsTr("Date, icon, background")
onClicked: root.nState.openSubPage(9)
@@ -97,6 +105,7 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "taskbar-workspaces-2"
text: qsTr("Workspaces")
subtext: qsTr("Scroll over the workspace indicator to switch workspaces")
checked: Config.bar.scrollActions.workspaces
@@ -104,6 +113,7 @@ PageBase {
}
ToggleRow {
+ settingAnchor: "taskbar-volume"
text: qsTr("Volume")
subtext: qsTr("Scroll on the top half of the bar to adjust volume")
checked: Config.bar.scrollActions.volume
@@ -112,6 +122,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "taskbar-brightness"
text: qsTr("Brightness")
subtext: qsTr("Scroll on the bottom half of the bar to adjust brightness")
checked: Config.bar.scrollActions.brightness
diff --git a/modules/nexus/pages/panels/taskbar/BarActiveWindow.qml b/modules/nexus/pages/panels/taskbar/BarActiveWindow.qml
index f51a1c4d5..5811bf053 100644
--- a/modules/nexus/pages/panels/taskbar/BarActiveWindow.qml
+++ b/modules/nexus/pages/panels/taskbar/BarActiveWindow.qml
@@ -18,18 +18,21 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "bar-aw-compact"
text: qsTr("Compact")
checked: Config.bar.activeWindow.compact
onToggled: GlobalConfig.bar.activeWindow.compact = checked
}
ToggleRow {
+ settingAnchor: "bar-aw-inverted"
text: qsTr("Inverted")
checked: Config.bar.activeWindow.inverted
onToggled: GlobalConfig.bar.activeWindow.inverted = checked
}
ToggleRow {
+ settingAnchor: "bar-aw-show-on-hover"
text: qsTr("Show on hover")
subtext: qsTr("Only show the active window title while hovering")
checked: Config.bar.activeWindow.showOnHover
@@ -38,6 +41,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "bar-aw-popout-on-hover"
text: qsTr("Popout on hover")
subtext: qsTr("Show a window details popout when hovering")
checked: Config.bar.popouts.activeWindow
diff --git a/modules/nexus/pages/panels/taskbar/BarClock.qml b/modules/nexus/pages/panels/taskbar/BarClock.qml
index b2b6aef17..719f2d062 100644
--- a/modules/nexus/pages/panels/taskbar/BarClock.qml
+++ b/modules/nexus/pages/panels/taskbar/BarClock.qml
@@ -18,12 +18,14 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "bar-clock-background"
text: qsTr("Background")
checked: Config.bar.clock.background
onToggled: GlobalConfig.bar.clock.background = checked
}
ToggleRow {
+ settingAnchor: "bar-clock-show-date"
text: qsTr("Show date")
checked: Config.bar.clock.showDate
onToggled: GlobalConfig.bar.clock.showDate = checked
@@ -31,6 +33,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "bar-clock-show-icon"
text: qsTr("Show icon")
checked: Config.bar.clock.showIcon
onToggled: GlobalConfig.bar.clock.showIcon = checked
diff --git a/modules/nexus/pages/panels/taskbar/BarStatusIcons.qml b/modules/nexus/pages/panels/taskbar/BarStatusIcons.qml
index bd0d91fe8..a50b33735 100644
--- a/modules/nexus/pages/panels/taskbar/BarStatusIcons.qml
+++ b/modules/nexus/pages/panels/taskbar/BarStatusIcons.qml
@@ -24,42 +24,49 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "bar-si-speakers"
text: qsTr("Speakers")
checked: Config.bar.status.showAudio
onToggled: GlobalConfig.bar.status.showAudio = checked
}
ToggleRow {
+ settingAnchor: "bar-si-microphone"
text: qsTr("Microphone")
checked: Config.bar.status.showMicrophone
onToggled: GlobalConfig.bar.status.showMicrophone = checked
}
ToggleRow {
+ settingAnchor: "bar-si-keyboard-layout"
text: qsTr("Keyboard layout")
checked: Config.bar.status.showKbLayout
onToggled: GlobalConfig.bar.status.showKbLayout = checked
}
ToggleRow {
+ settingAnchor: "bar-si-network"
text: qsTr("Network")
checked: Config.bar.status.showNetwork
onToggled: GlobalConfig.bar.status.showNetwork = checked
}
ToggleRow {
+ settingAnchor: "bar-si-wi-fi"
text: qsTr("Wi-Fi")
checked: Config.bar.status.showWifi
onToggled: GlobalConfig.bar.status.showWifi = checked
}
ToggleRow {
+ settingAnchor: "bar-si-bluetooth"
text: qsTr("Bluetooth")
checked: Config.bar.status.showBluetooth
onToggled: GlobalConfig.bar.status.showBluetooth = checked
}
ToggleRow {
+ settingAnchor: "bar-si-battery"
text: qsTr("Battery")
checked: Config.bar.status.showBattery
onToggled: GlobalConfig.bar.status.showBattery = checked
@@ -67,6 +74,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "bar-si-caps-lock"
text: qsTr("Caps lock")
checked: Config.bar.status.showLockStatus
onToggled: GlobalConfig.bar.status.showLockStatus = checked
@@ -80,6 +88,7 @@ PageBase {
ToggleRow {
first: true
last: true
+ settingAnchor: "bar-si-popout-on-hover"
text: qsTr("Popout on hover")
subtext: qsTr("Show a details popout when hovering the status icons")
checked: Config.bar.popouts.statusIcons
diff --git a/modules/nexus/pages/panels/taskbar/BarTray.qml b/modules/nexus/pages/panels/taskbar/BarTray.qml
index a02597fdb..b23296de5 100644
--- a/modules/nexus/pages/panels/taskbar/BarTray.qml
+++ b/modules/nexus/pages/panels/taskbar/BarTray.qml
@@ -18,18 +18,21 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "bar-tray-background"
text: qsTr("Background")
checked: Config.bar.tray.background
onToggled: GlobalConfig.bar.tray.background = checked
}
ToggleRow {
+ settingAnchor: "bar-tray-recolour-icons"
text: qsTr("Recolour icons")
checked: Config.bar.tray.recolour
onToggled: GlobalConfig.bar.tray.recolour = checked
}
ToggleRow {
+ settingAnchor: "bar-tray-compact"
text: qsTr("Compact")
checked: Config.bar.tray.compact
onToggled: GlobalConfig.bar.tray.compact = checked
@@ -37,6 +40,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "bar-tray-popout-on-hover"
text: qsTr("Popout on hover")
subtext: qsTr("Show the tray menu popout when hovering")
checked: Config.bar.popouts.tray
diff --git a/modules/nexus/pages/panels/taskbar/BarWorkspaces.qml b/modules/nexus/pages/panels/taskbar/BarWorkspaces.qml
index 814dc8626..635b3e17d 100644
--- a/modules/nexus/pages/panels/taskbar/BarWorkspaces.qml
+++ b/modules/nexus/pages/panels/taskbar/BarWorkspaces.qml
@@ -18,6 +18,7 @@ PageBase {
StepperRow {
first: true
+ settingAnchor: "bar-ws-shown"
label: qsTr("Shown")
subtext: qsTr("Number of workspaces displayed")
value: Config.bar.workspaces.shown
@@ -28,24 +29,28 @@ PageBase {
}
ToggleRow {
+ settingAnchor: "bar-ws-active-indicator"
text: qsTr("Active indicator")
checked: Config.bar.workspaces.activeIndicator
onToggled: GlobalConfig.bar.workspaces.activeIndicator = checked
}
ToggleRow {
+ settingAnchor: "bar-ws-active-trail"
text: qsTr("Active trail")
checked: Config.bar.workspaces.activeTrail
onToggled: GlobalConfig.bar.workspaces.activeTrail = checked
}
ToggleRow {
+ settingAnchor: "bar-ws-occupied-background"
text: qsTr("Occupied background")
checked: Config.bar.workspaces.occupiedBg
onToggled: GlobalConfig.bar.workspaces.occupiedBg = checked
}
ToggleRow {
+ settingAnchor: "bar-ws-show-windows"
text: qsTr("Show windows")
subtext: qsTr("Show icons of open windows on each workspace")
checked: Config.bar.workspaces.showWindows
@@ -53,12 +58,14 @@ PageBase {
}
ToggleRow {
+ settingAnchor: "bar-ws-windows-on-special-workspaces"
text: qsTr("Windows on special workspaces")
checked: Config.bar.workspaces.showWindowsOnSpecialWorkspaces
onToggled: GlobalConfig.bar.workspaces.showWindowsOnSpecialWorkspaces = checked
}
StepperRow {
+ settingAnchor: "bar-ws-max-window-icons"
label: qsTr("Max window icons")
value: Config.bar.workspaces.maxWindowIcons
from: 0
@@ -69,6 +76,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "bar-ws-per-monitor-workspaces"
text: qsTr("Per-monitor workspaces")
subtext: qsTr("Show each monitor's workspaces independently")
checked: GlobalConfig.bar.workspaces.perMonitorWorkspaces
diff --git a/modules/nexus/pages/services/NotificationsPage.qml b/modules/nexus/pages/services/NotificationsPage.qml
index 78a03b8c5..f581cd84f 100644
--- a/modules/nexus/pages/services/NotificationsPage.qml
+++ b/modules/nexus/pages/services/NotificationsPage.qml
@@ -54,6 +54,7 @@ PageBase {
SelectRow {
first: true
+ settingAnchor: "notif-show-in-fullscreen"
label: qsTr("Show in fullscreen")
subtext: qsTr("Whether notifications appear over fullscreen apps")
menuItems: root.notifFullscreenItems
@@ -62,6 +63,7 @@ PageBase {
}
ToggleRow {
+ settingAnchor: "notif-expire-automatically"
text: qsTr("Expire automatically")
subtext: qsTr("Dismiss notifications after their timeout")
checked: GlobalConfig.notifs.expire
@@ -69,6 +71,7 @@ PageBase {
}
ToggleRow {
+ settingAnchor: "notif-open-expanded"
text: qsTr("Open expanded")
subtext: qsTr("Show notifications expanded by default")
checked: GlobalConfig.notifs.openExpanded
@@ -76,6 +79,7 @@ PageBase {
}
StepperRow {
+ settingAnchor: "notif-default-timeout"
label: qsTr("Default timeout")
subtext: qsTr("Time before a notification dismisses (ms)")
value: GlobalConfig.notifs.defaultExpireTimeout
@@ -87,6 +91,7 @@ PageBase {
StepperRow {
last: true
+ settingAnchor: "notif-group-preview-count"
label: qsTr("Group preview count")
subtext: qsTr("Notifications shown per group before collapsing")
value: GlobalConfig.notifs.groupPreviewNum
@@ -103,6 +108,7 @@ PageBase {
SelectRow {
first: true
+ settingAnchor: "notif-show-in-fullscreen-2"
label: qsTr("Show in fullscreen")
subtext: qsTr("Whether toasts appear over fullscreen apps")
menuItems: root.toastFullscreenItems
@@ -112,6 +118,7 @@ PageBase {
StepperRow {
last: true
+ settingAnchor: "notif-visible-toasts"
label: qsTr("Visible toasts")
subtext: qsTr("Maximum number of toasts shown at once")
value: GlobalConfig.utilities.maxToasts
@@ -128,54 +135,63 @@ PageBase {
ToggleRow {
first: true
+ settingAnchor: "notif-charging-changes"
text: qsTr("Charging changes")
checked: GlobalConfig.utilities.toasts.chargingChanged
onToggled: GlobalConfig.utilities.toasts.chargingChanged = checked
}
ToggleRow {
+ settingAnchor: "notif-game-mode-changes"
text: qsTr("Game mode changes")
checked: GlobalConfig.utilities.toasts.gameModeChanged
onToggled: GlobalConfig.utilities.toasts.gameModeChanged = checked
}
ToggleRow {
+ settingAnchor: "notif-do-not-disturb-changes"
text: qsTr("Do not disturb changes")
checked: GlobalConfig.utilities.toasts.dndChanged
onToggled: GlobalConfig.utilities.toasts.dndChanged = checked
}
ToggleRow {
+ settingAnchor: "notif-audio-output-changes"
text: qsTr("Audio output changes")
checked: GlobalConfig.utilities.toasts.audioOutputChanged
onToggled: GlobalConfig.utilities.toasts.audioOutputChanged = checked
}
ToggleRow {
+ settingAnchor: "notif-audio-input-changes"
text: qsTr("Audio input changes")
checked: GlobalConfig.utilities.toasts.audioInputChanged
onToggled: GlobalConfig.utilities.toasts.audioInputChanged = checked
}
ToggleRow {
+ settingAnchor: "notif-caps-lock-changes"
text: qsTr("Caps lock changes")
checked: GlobalConfig.utilities.toasts.capsLockChanged
onToggled: GlobalConfig.utilities.toasts.capsLockChanged = checked
}
ToggleRow {
+ settingAnchor: "notif-num-lock-changes"
text: qsTr("Num lock changes")
checked: GlobalConfig.utilities.toasts.numLockChanged
onToggled: GlobalConfig.utilities.toasts.numLockChanged = checked
}
ToggleRow {
+ settingAnchor: "notif-keyboard-layout-changes"
text: qsTr("Keyboard layout changes")
checked: GlobalConfig.utilities.toasts.kbLayoutChanged
onToggled: GlobalConfig.utilities.toasts.kbLayoutChanged = checked
}
ToggleRow {
+ settingAnchor: "notif-vpn-changes"
text: qsTr("VPN changes")
checked: GlobalConfig.utilities.toasts.vpnChanged
onToggled: GlobalConfig.utilities.toasts.vpnChanged = checked
@@ -183,6 +199,7 @@ PageBase {
ToggleRow {
last: true
+ settingAnchor: "notif-now-playing"
text: qsTr("Now playing")
checked: GlobalConfig.utilities.toasts.nowPlaying
onToggled: GlobalConfig.utilities.toasts.nowPlaying = checked
diff --git a/plugin/cmake/qml-module.cmake b/plugin/cmake/qml-module.cmake
index 29dc5fc22..c01c350db 100644
--- a/plugin/cmake/qml-module.cmake
+++ b/plugin/cmake/qml-module.cmake
@@ -1,7 +1,7 @@
message(STATUS "QML install dir: ${CMAKE_INSTALL_PREFIX}/${INSTALL_QMLDIR}")
function(qml_module arg_TARGET)
- cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES")
+ cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES;RESOURCES")
set_source_files_properties(${arg_QML_SINGLETONS} PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
@@ -9,6 +9,7 @@ function(qml_module arg_TARGET)
URI ${arg_URI}
SOURCES ${arg_SOURCES}
QML_FILES ${arg_QML_FILES} ${arg_QML_SINGLETONS}
+ RESOURCES ${arg_RESOURCES}
DEPENDENCIES ${arg_DEPENDENCIES}
IMPORTS ${arg_IMPORTS}
OPTIONAL_IMPORTS ${arg_OPTIONAL_IMPORTS}
diff --git a/plugin/src/Caelestia/CMakeLists.txt b/plugin/src/Caelestia/CMakeLists.txt
index f4a7399d2..cd96e04d2 100644
--- a/plugin/src/Caelestia/CMakeLists.txt
+++ b/plugin/src/Caelestia/CMakeLists.txt
@@ -1,3 +1,8 @@
+# The settings index is generated into the build dir; map it to a stable resource
+# path (qrc:/qt/qml/Caelestia/settings-index.json) regardless of its source path.
+set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES
+ QT_RESOURCE_ALIAS "settings-index.json")
+
qml_module(caelestia-core
URI Caelestia
SOURCES
@@ -7,6 +12,8 @@ qml_module(caelestia-core
requests.cpp
toaster.cpp
imageanalyser.cpp
+ RESOURCES
+ "${SETTINGS_INDEX_JSON}"
LIBRARIES
Qt::Gui
Qt::Quick
diff --git a/plugin/src/Caelestia/cutils.cpp b/plugin/src/Caelestia/cutils.cpp
index 42d7570f1..6573a1691 100644
--- a/plugin/src/Caelestia/cutils.cpp
+++ b/plugin/src/Caelestia/cutils.cpp
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -143,6 +144,15 @@ qreal CUtils::clamp(qreal value, qreal min, qreal max) {
return qBound(min, value, max);
}
+QString CUtils::settingsIndex() {
+ QFile file(QStringLiteral(":/qt/qml/Caelestia/settings-index.json"));
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ qCWarning(lcCUtils) << "Failed to open embedded settings index";
+ return QString();
+ }
+ return QString::fromUtf8(file.readAll());
+}
+
namespace {
// DFS over the visual item tree (childItems), returning the first descendant matching the predicate. Unlike
diff --git a/plugin/src/Caelestia/cutils.hpp b/plugin/src/Caelestia/cutils.hpp
index cea5ad046..ae5b6d9d2 100644
--- a/plugin/src/Caelestia/cutils.hpp
+++ b/plugin/src/Caelestia/cutils.hpp
@@ -31,6 +31,11 @@ class CUtils : public QObject {
Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max);
+ // Returns the settings search index JSON, baked into the plugin binary at
+ // build time so it lives with the compiled module rather than in a
+ // user-editable config file.
+ Q_INVOKABLE static QString settingsIndex();
+
Q_INVOKABLE static QQuickItem* findChild(QQuickItem* root, const QString& name);
Q_INVOKABLE static QList findChildren(QQuickItem* root, const QString& name);
Q_INVOKABLE static QList findChildrenMatching(QQuickItem* root, const QString& pattern);
diff --git a/scripts/build-settings-index.py b/scripts/build-settings-index.py
new file mode 100644
index 000000000..d6f7f2d1e
--- /dev/null
+++ b/scripts/build-settings-index.py
@@ -0,0 +1,497 @@
+#!/usr/bin/env python3
+"""Build-time settings index extractor for the nexus settings search.
+
+Parses the nexus page QML files, PageRegistry.qml (page icons/labels) and
+PageCompRegistry.qml (page ordering and sub-page nesting) to produce a search
+index as JSON. Run at build time (see CMakeLists.txt); the shell loads the
+result at runtime via SettingsSearcher.qml.
+
+The output contains three parts:
+ - entries: forward index, one record per setting (title, anchor, nav path)
+ - inverted: token -> list of entry indices (classic inverted index)
+ - ranking: token -> {entry index: weight} precomputed match weights
+
+Nothing here is hand-maintained per page: page metadata comes from
+PageRegistry, the page tree from PageCompRegistry, and the directory layout is
+discovered by walking the pages folder.
+
+Usage: build-settings-index.py
+"""
+from __future__ import annotations
+
+import json
+import re
+import sys
+from collections import defaultdict
+from functools import lru_cache
+from pathlib import Path
+
+
+@lru_cache(maxsize=None)
+def read_lines(path: Path) -> tuple[str, ...]:
+ """Read a file's lines, cached so each page file is only read once."""
+ return tuple(path.read_text().splitlines())
+
+ROW_RE = re.compile(r'^\s*(ToggleRow|SliderRow|SelectRow|StepperRow|NavRow|InfoRow|PopupRow|DefaultRow)\s*\{')
+LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
+ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"')
+# A ToggleRow whose value is a plain config property can be flipped straight from
+# the search results. We capture the property path from `checked:` and require
+# `onToggled:` to write the same path back (a symmetric binding), so reading and
+# writing go through one path. Toggles bound to functions or multi-line handlers
+# are left without a path and just deep-link as usual.
+CHECKED_RE = re.compile(r'^\s*checked:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*$')
+ONTOGGLED_RE = re.compile(r'^\s*onToggled:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*=\s*checked\s*$')
+ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
+SKIP_LABELS = {"Muted", "None"}
+# Field weights for ranking: a token matching the title counts more than one
+# matching the keywords blob.
+FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4}
+STOPWORDS = {"the", "a", "an", "of", "notification", "are", "not", "out", "and", "or", "to", "on", "in", "for"}
+
+# The Material Symbols icon shown on each setting's search-result card, keyed
+# by its anchor. Hand-picked so every setting reads at a glance and none just
+# repeats its page's icon; anything unmapped (e.g. a newly added setting)
+# falls back to the deepest breadcrumb icon.
+SETTING_ICONS = {
+ "style-display-wallpaper": "wallpaper",
+ "style-transparency": "opacity",
+ "style-dark-theme": "dark_mode",
+ "network-wi-fi": "network_wifi",
+ "ethernet-status": "link",
+ "ethernet-interface": "settings_ethernet",
+ "ethernet-speed": "speed",
+ "ethernet-ip-address": "tag",
+ "ethernet-gateway": "router",
+ "ethernet-mac-address": "fingerprint",
+ "ethernet-ip-assignment": "tune",
+ "bluetooth-bluetooth": "bluetooth_connected",
+ "bluetooth-discoverable": "visibility",
+ "bluetooth-pairable": "add_link",
+ "audio-output": "speaker",
+ "audio-input": "mic",
+ "panels-dashboard": "space_dashboard",
+ "panels-taskbar": "toolbar",
+ "panels-launcher": "apps",
+ "panels-sidebar": "view_sidebar",
+ "dash-enabled": "toggle_on",
+ "dash-show-on-hover": "mouse",
+ "dash-dashboard": "tab",
+ "dash-media": "play_circle",
+ "dash-performance": "speed",
+ "dash-weather": "partly_cloudy_day",
+ "dash-battery": "battery_full",
+ "dash-gpu": "developer_board",
+ "dash-cpu": "memory",
+ "dash-memory": "memory_alt",
+ "dash-storage": "hard_drive",
+ "dash-network": "network_check",
+ "dash-drag-threshold": "drag_pan",
+ "taskbar-persistent": "push_pin",
+ "taskbar-show-on-hover": "mouse",
+ "taskbar-drag-threshold": "drag_pan",
+ "taskbar-workspaces": "workspaces",
+ "taskbar-active-window": "select_window",
+ "taskbar-tray": "widgets",
+ "taskbar-status-icons": "instant_mix",
+ "taskbar-clock": "schedule",
+ "taskbar-workspaces-2": "swipe",
+ "taskbar-volume": "volume_up",
+ "taskbar-brightness": "brightness_6",
+ "bar-ws-shown": "format_list_numbered",
+ "bar-ws-active-indicator": "radio_button_checked",
+ "bar-ws-active-trail": "gesture",
+ "bar-ws-occupied-background": "layers",
+ "bar-ws-show-windows": "grid_view",
+ "bar-ws-windows-on-special-workspaces": "star",
+ "bar-ws-max-window-icons": "filter_9_plus",
+ "bar-ws-per-monitor-workspaces": "desktop_windows",
+ "bar-aw-compact": "compress",
+ "bar-aw-inverted": "invert_colors",
+ "bar-aw-show-on-hover": "mouse",
+ "bar-aw-popout-on-hover": "open_in_new",
+ "bar-tray-background": "format_color_fill",
+ "bar-tray-recolour-icons": "format_paint",
+ "bar-tray-compact": "compress",
+ "bar-tray-popout-on-hover": "open_in_new",
+ "bar-si-speakers": "speaker",
+ "bar-si-microphone": "mic",
+ "bar-si-keyboard-layout": "keyboard",
+ "bar-si-network": "lan",
+ "bar-si-wi-fi": "network_wifi",
+ "bar-si-bluetooth": "bluetooth",
+ "bar-si-battery": "battery_full",
+ "bar-si-caps-lock": "keyboard_capslock_badge",
+ "bar-si-popout-on-hover": "open_in_new",
+ "bar-clock-background": "format_color_fill",
+ "bar-clock-show-date": "calendar_month",
+ "bar-clock-show-icon": "visibility",
+ "launcher-enabled": "toggle_on",
+ "launcher-show-on-hover": "mouse",
+ "launcher-max-items-shown": "format_list_numbered",
+ "launcher-max-wallpapers": "photo_library",
+ "launcher-drag-threshold": "drag_pan",
+ "launcher-vim-keybinds": "keyboard_command_key",
+ "launcher-enable-dangerous-actions": "warning",
+ "launcher-apps": "grid_view",
+ "launcher-actions": "bolt",
+ "launcher-schemes": "palette",
+ "launcher-variants": "contrast",
+ "launcher-wallpapers": "wallpaper",
+ "sidebar-enabled": "toggle_on",
+ "sidebar-drag-threshold": "drag_pan",
+ "apps-default-terminal": "terminal",
+ "apps-default-audio": "music_note",
+ "apps-default-playback": "play_circle",
+ "apps-default-file-manager": "folder",
+ "apps-all-apps": "view_apps",
+ "services-notifications": "notifications",
+ "services-media-refresh": "autorenew",
+ "services-system-stats-refresh": "monitoring",
+ "services-wi-fi-rescan": "wifi_find",
+ "services-lyrics-backend": "lyrics",
+ "services-default-player": "queue_music",
+ "services-volume-step": "volume_up",
+ "services-brightness-step": "brightness_6",
+ "services-max-volume": "vertical_align_top",
+ "services-visualiser-bars": "graphic_eq",
+ "services-smart-colour-scheme": "auto_awesome",
+ "services-gpu": "developer_board",
+ "notif-show-in-fullscreen": "fullscreen",
+ "notif-expire-automatically": "auto_delete",
+ "notif-open-expanded": "unfold_more",
+ "notif-default-timeout": "timer",
+ "notif-group-preview-count": "stacks",
+ "notif-show-in-fullscreen-2": "fullscreen",
+ "notif-visible-toasts": "filter_9_plus",
+ "notif-charging-changes": "battery_charging_full",
+ "notif-game-mode-changes": "sports_esports",
+ "notif-do-not-disturb-changes": "do_not_disturb_on",
+ "notif-audio-output-changes": "speaker",
+ "notif-audio-input-changes": "mic",
+ "notif-caps-lock-changes": "keyboard_capslock_badge",
+ "notif-num-lock-changes": "looks_one",
+ "notif-keyboard-layout-changes": "keyboard",
+ "notif-vpn-changes": "vpn_key",
+ "notif-now-playing": "play_circle",
+ "lang-temperature": "thermostat",
+ "lang-system-temperatures": "device_thermostat",
+ "lang-clock-format": "schedule",
+}
+
+
+def find_pages_dir(nexus: Path) -> Path:
+ return nexus / "pages"
+
+
+def discover_files(nexus: Path) -> dict[str, Path]:
+ """component name -> file path, discovered by walking pages/."""
+ files: dict[str, Path] = {}
+ for p in find_pages_dir(nexus).rglob("*.qml"):
+ files[p.stem] = p
+ return files
+
+
+def parse_page_registry(nexus: Path) -> list[tuple[str, str]]:
+ """Ordered (icon, label) for each *active* page entry in PageRegistry.
+ Commented-out entries are ignored, matching pageComps ordering."""
+ text = (nexus / "PageRegistry.qml").read_text()
+ out: list[tuple[str, str]] = []
+ # Each entry is a { ... } block with label/icon; commented lines start //.
+ # Walk brace blocks at the array level.
+ in_array = False
+ depth = 0
+ label = icon = None
+ for line in text.splitlines():
+ s = line.strip()
+ if "pages:" in s and "[" in s:
+ in_array = True
+ continue
+ if not in_array:
+ continue
+ if s.startswith("//"):
+ continue
+ if s.startswith("{"):
+ depth += 1
+ label = icon = None
+ continue
+ if s.startswith("}"):
+ if label is not None:
+ out.append((icon or "tune", label))
+ depth -= 1
+ continue
+ if depth >= 1:
+ m = LABEL_RE.match(line)
+ if m and label is None:
+ label = m.group(1)
+ mi = ICON_RE.match(line)
+ if mi and icon is None:
+ icon = mi.group(1)
+ return out
+
+
+def parse_page_comps(nexus: Path) -> list[list[str]]:
+ """Per top-level pageComps entry, ordered component names inside it."""
+ text = (nexus / "PageCompRegistry.qml").read_text()
+ body = text[text.index("pageComps:"):]
+ comps: list[list[str]] = []
+ current: list[str] | None = None
+ for line in body.splitlines():
+ if re.match(r"^ Component \{", line):
+ current = []
+ comps.append(current)
+ m = re.search(r"([A-Z][A-Za-z]+)\s*\{\}", line)
+ if m and current is not None:
+ comps.append(current) if False else current.append(m.group(1))
+ return comps
+
+
+def dedup_crumbs(labels: list[str], icons: list[str]) -> tuple[list[str], list[str]]:
+ """Drop consecutive duplicate labels (e.g. a section header that repeats the
+ page name), keeping icons aligned."""
+ out_labels: list[str] = []
+ out_icons: list[str] = []
+ for lbl, ico in zip(labels, icons):
+ if out_labels and out_labels[-1] == lbl:
+ continue
+ out_labels.append(lbl)
+ out_icons.append(ico)
+ return out_labels, out_icons
+
+
+def build_nav_map(nexus: Path, files: dict[str, Path]) -> dict[str, dict]:
+ comps = parse_page_comps(nexus)
+ registry = parse_page_registry(nexus)
+
+ # Top-level index -> (icon, label) from PageRegistry (same order as pageComps).
+ top_meta: dict[int, tuple[str, str]] = {}
+ for i, (icon, label) in enumerate(registry):
+ top_meta[i] = (icon, label)
+
+ # parentName -> {childPos: (icon, label, section)} from openSubPage() +
+ # nearby NavRow, remembering the section header the NavRow sits under.
+ nav_children: dict[str, dict[int, tuple[str, str, str]]] = {}
+ for names in comps:
+ for name in names:
+ pf = files.get(name)
+ if not pf:
+ continue
+ pending_icon = pending_label = None
+ section = "" # text of the most recent SectionHeader
+ expect_section = False # next label line is that header's text
+ for ln in read_lines(pf):
+ if SECTION_RE.match(ln):
+ expect_section = True
+ continue
+ ml = LABEL_RE.match(ln)
+ if ml:
+ if expect_section:
+ section = ml.group(1)
+ expect_section = False
+ else:
+ pending_label = ml.group(1)
+ continue
+ mi = ICON_RE.match(ln)
+ if mi:
+ pending_icon = mi.group(1)
+ mo = re.search(r"openSubPage\((\d+)\)", ln)
+ if mo:
+ pos = int(mo.group(1))
+ nav_children.setdefault(name, {})[pos] = (
+ pending_icon or "tune", pending_label or "", section)
+ pending_icon = pending_label = None
+
+ nav: dict[str, dict] = {}
+ for top_idx, names in enumerate(comps):
+ if not names:
+ continue
+ main = names[0]
+ main_icon, main_label = top_meta.get(top_idx, ("tune", main))
+ nav[main] = {"pageIdx": top_idx, "subPath": [],
+ "crumbIcons": [main_icon], "crumbLabels": [main_label]}
+ children = dict(nav_children.get(main, {}))
+ # Components that some other page opens via openSubPage. Those are reached
+ # through that page (e.g. the bar pages are opened from inside Taskbar's
+ # "Components" section), so they must not be linked directly here, which
+ # would give them a wrong, shorter breadcrumb and navigation path.
+ opened_via_subpage = set()
+ for owner, kids in nav_children.items():
+ # Find the group this owner component belongs to.
+ owner_group = next((ns for ns in comps if owner in ns), None)
+ if not owner_group:
+ continue
+ for kpos in kids:
+ if kpos < len(owner_group):
+ opened_via_subpage.add(owner_group[kpos])
+ # Fallback: a StackPage may list sub-pages (pos > 0) whose openSubPage()
+ # call lives in a separate component file we don't scan (e.g. the
+ # Ethernet detail page is opened from EthernetSection.qml). Link any such
+ # sub-page by its position, deriving a label from its component name -
+ # but skip ones already reached through another page.
+ for pos in range(1, len(names)):
+ if pos not in children and names[pos] not in opened_via_subpage:
+ label = re.sub(r"(Detail)?Page$", "", names[pos])
+ label = re.sub(r"(?= len(names):
+ continue
+ child = names[pos]
+ # Insert the section header (e.g. "Components") as a breadcrumb step
+ # between the parent page and the sub-page, when present.
+ labels = [main_label] + ([section] if section else []) + [label]
+ icons = [main_icon] + ([icon] if section else []) + [icon]
+ labels, icons = dedup_crumbs(labels, icons)
+ nav[child] = {"pageIdx": top_idx, "subPath": [pos],
+ "crumbIcons": icons,
+ "crumbLabels": labels}
+ for gpos, (gicon, glabel, gsection) in nav_children.get(child, {}).items():
+ if gpos >= len(names):
+ continue
+ glabels = labels + ([gsection] if gsection else []) + [glabel]
+ gicons = icons + ([gicon] if gsection else []) + [gicon]
+ glabels, gicons = dedup_crumbs(glabels, gicons)
+ nav[names[gpos]] = {
+ "pageIdx": top_idx, "subPath": [pos, gpos],
+ "crumbIcons": gicons,
+ "crumbLabels": glabels}
+ return nav
+
+
+def tokenize(text: str) -> list[str]:
+ toks: list[str] = []
+ # Process word by word (split on whitespace) so we only collapse separators
+ # inside a single word like "Wi-Fi" -> "wifi", not across a whole phrase.
+ for word in text.lower().split():
+ parts = [p for p in re.split(r"[^a-z0-9]+", word) if p]
+ for p in parts:
+ if p not in STOPWORDS and p not in toks:
+ toks.append(p)
+ if len(parts) > 1:
+ joined = "".join(parts)
+ if joined not in toks:
+ toks.append(joined)
+ return toks
+
+
+SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)')
+SECTION_RE = re.compile(r'^\s*SectionHeader\s*\{')
+
+
+def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict]:
+ entries: list[dict] = []
+ for comp, meta in nav.items():
+ pf = files.get(comp)
+ if not pf:
+ continue
+ lines = read_lines(pf)
+ section = "" # text of the most recent SectionHeader
+ i = 0
+ while i < len(lines):
+ # Track the current section header so its words are searchable too.
+ if SECTION_RE.match(lines[i]):
+ for j in range(i + 1, min(i + 4, len(lines))):
+ m = LABEL_RE.match(lines[j])
+ if m:
+ section = m.group(1)
+ break
+ row_match = ROW_RE.match(lines[i])
+ if row_match:
+ row_type = row_match.group(1)
+ label = anchor = subtext = None
+ checked_path = toggled_path = None
+ for j in range(i + 1, min(i + 12, len(lines))):
+ if label is None:
+ m = LABEL_RE.match(lines[j])
+ if m:
+ label = m.group(1)
+ if anchor is None:
+ a = ANCHOR_RE.match(lines[j])
+ if a:
+ anchor = a.group(1)
+ if subtext is None:
+ st = SUBTEXT_RE.match(lines[j])
+ if st:
+ subtext = st.group(1)
+ if checked_path is None:
+ ch = CHECKED_RE.match(lines[j])
+ if ch:
+ checked_path = ch.group(1)
+ if toggled_path is None:
+ tg = ONTOGGLED_RE.match(lines[j])
+ if tg:
+ toggled_path = tg.group(1)
+ # Only expose a toggle path when read and write target the same
+ # property (symmetric), so flipping from search is safe.
+ toggle_path = (
+ checked_path
+ if row_type == "ToggleRow" and checked_path and checked_path == toggled_path
+ else ""
+ )
+ if label and label not in SKIP_LABELS and anchor:
+ # keyword sources: breadcrumb path, section header, subtext.
+ extra = " ".join(meta["crumbLabels"]) + " " + section + " " + (subtext or "")
+ entries.append({
+ "pageIdx": meta["pageIdx"], "subPath": meta["subPath"],
+ "crumbIcons": meta["crumbIcons"],
+ "crumbLabels": meta["crumbLabels"],
+ "title": label, "anchor": anchor,
+ "section": section,
+ "subtext": subtext or "",
+ "togglePath": toggle_path,
+ "icon": SETTING_ICONS.get(anchor)
+ or (meta["crumbIcons"][-1] if meta["crumbIcons"] else ""),
+ "keywords": " ".join(sorted(set(tokenize(label + " " + extra)))),
+ })
+ i += 1
+ return entries
+
+
+def build_inverted_and_ranking(entries: list[dict]):
+ """Classic inverted index + precomputed per-token ranking weights."""
+ inverted: dict[str, list[int]] = defaultdict(list)
+ ranking: dict[str, dict[int, float]] = defaultdict(dict)
+ for idx, e in enumerate(entries):
+ fields = {"title": e["title"], "keywords": e["keywords"]}
+ seen: set[str] = set()
+ for field, text in fields.items():
+ weight = FIELD_WEIGHT.get(field, 0.2)
+ for tok in tokenize(text):
+ if idx not in inverted[tok]:
+ inverted[tok].append(idx)
+ # accumulate the strongest field weight for this token/entry
+ ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight)
+ seen.add(tok)
+ # sort each posting list by descending rank so runtime can stop early
+ for tok, ids in inverted.items():
+ ids.sort(key=lambda i: ranking[tok][i], reverse=True)
+ return inverted, {t: {str(k): v for k, v in d.items()} for t, d in ranking.items()}
+
+
+def main() -> int:
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 1
+ nexus = Path(sys.argv[1])
+ out = Path(sys.argv[2])
+ files = discover_files(nexus)
+ nav = build_nav_map(nexus, files)
+ entries = extract_settings(files, nav)
+ inverted, ranking = build_inverted_and_ranking(entries)
+ # keywords were only needed to build the inverted index; the runtime reads
+ # the index, not the per-entry keyword blob, so drop it to shrink the JSON.
+ for e in entries:
+ e.pop("keywords", None)
+ out.write_text(json.dumps({
+ "version": 2,
+ "entries": entries,
+ "inverted": inverted,
+ "ranking": ranking,
+ }, ensure_ascii=False, indent=2))
+ print(f"settings index: {len(entries)} entries, "
+ f"{len(inverted)} tokens -> {out}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())