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())