Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
97181a0
feat(nexus): add settings search (page jump)
Mestane Jun 24, 2026
bc38585
feat(nexus): scroll to the matching setting on search
Mestane Jun 24, 2026
604c67f
feat(nexus): highlight the matching setting on search
Mestane Jun 24, 2026
62e8194
feat(nexus): index all settings pages for search
Mestane Jun 24, 2026
1cff469
feat(nexus): index every settings page with breadcrumb trees and deep…
Mestane Jun 24, 2026
9950464
feat(nexus): generate settings index from QML at build time
Mestane Jun 25, 2026
6a4fd60
fix(nexus): limit settings search to top matches to keep it responsive
Mestane Jun 25, 2026
45fed95
fix(nexus): don't reload the page when jumping within the same sub-page
Mestane Jun 25, 2026
9934dd9
fix(nexus): scroll search target clear of the fade edges
Mestane Jun 25, 2026
6bd230d
fix(nexus): correct scroll bounds and re-highlight without scrolling
Mestane Jun 25, 2026
ea4241b
fix(nexus): scroll to other settings on the same page, skip only exac…
Mestane Jun 25, 2026
af0dae3
feat(nexus): build a real inverted index with ranking and auto-detect…
Mestane Jun 25, 2026
e02f734
feat(nexus): index setting descriptions and section headers too
Mestane Jun 25, 2026
d0b017f
feat(nexus): animate search scroll and use a double-pulse highlight
Mestane Jun 25, 2026
f61ff29
fix(nexus): wait for layout before scrolling on a freshly opened page
Mestane Jun 25, 2026
e59b315
feat(nexus): replace highlight flash with a border chase animation
Mestane Jun 25, 2026
9e965f1
feat(nexus): redesign search results with a cleaner location, title a…
Mestane Jun 25, 2026
68715e1
feat(nexus): animate search results with a ListView and diffed model
Mestane Jun 25, 2026
7fa2a58
perf(nexus): cache file reads, drop unused keywords from the index an…
Mestane Jun 25, 2026
f518331
chore: stop tracking __pycache__
Mestane Jun 25, 2026
0450d98
feat(nexus): use a double-pulse highlight instead of the border chase
Mestane Jun 26, 2026
154e823
feat(nexus): show the section header in sub-page result breadcrumbs
Mestane Jun 26, 2026
c27b1bf
fix(nexus): highlight the setting when jumping to a different sub-page
Mestane Jun 26, 2026
ff143bf
feat(nexus): collapse repeated names in result breadcrumbs
Mestane Jun 26, 2026
ce760c9
fix(nexus): rebuild the sub-page chain when jumping within the same page
Mestane Jun 26, 2026
1dcd0c0
feat(nexus): index the ethernet detail page settings
Mestane Jun 26, 2026
3fae2df
fix(nexus): select the ethernet device when deep-linking to its settings
Mestane Jun 26, 2026
f52cf3a
fix(nexus): wait for async content to settle before scrolling
Mestane Jun 26, 2026
954dc8a
fix(nexus): hide ethernet results from search when no ethernet is ava…
Mestane Jun 26, 2026
79e7961
fix(nexus): keep result ordering stable and show more matches
Mestane Jun 26, 2026
b4baf95
feat(nexus): index NavRow status text so descriptions are searchable
Mestane Jun 26, 2026
48636c8
fix(nexus): render all search results so no gap appears in the list
Mestane Jun 26, 2026
cc03713
feat(nexus): group search results by page with joined cards and dividers
Mestane Jun 27, 2026
d2ce165
fix(nexus): square the joined corners and space group headings evenly
Mestane Jun 27, 2026
4911223
fix(nexus): match the gap between result groups to the top margin
Mestane Jun 27, 2026
34677f5
fix(nexus): give bar sub-pages their full path through taskbar compon…
Mestane Jun 27, 2026
cb1900a
fix(nexus): match result card corners to the page tab radius
Mestane Jun 27, 2026
7b8d84b
fix(nexus): match result groups by page so reordering animates instea…
Mestane Jun 27, 2026
3e3998b
fix(nexus): drop add/remove fades so the result list stops flickering…
Mestane Jun 27, 2026
44d89d6
fix(nexus): make the result list fully static so fast typing leaves n…
Mestane Jun 27, 2026
67df313
feat(nexus): highlight the matched search terms in result titles and …
Mestane Jun 28, 2026
aac9e39
feat(nexus): colour the highlighted search matches as well as bolding…
Mestane Jun 28, 2026
50c9457
feat(nexus): colour search matches with the tertiary accent and drop …
Mestane Jun 28, 2026
79633b3
fix(nexus): use a font colour tag so the highlight actually renders i…
Mestane Jun 28, 2026
ba12dfa
fix(nexus): keep the hover layer above the labels so hover stops flic…
Mestane Jun 28, 2026
de56273
feat(nexus): use the primary accent for search match highlights
Mestane Jun 28, 2026
e5e0c07
feat(nexus): bake the settings index into the plugin instead of a con…
Mestane Jun 28, 2026
3d4a4c9
fix(nexus): restore the Quickshell import for the Variants type
Mestane Jun 28, 2026
763e99e
refactor(nexus): drop the underscore prefix from internal state and h…
Mestane Jun 28, 2026
9bec5fa
feat(nexus): Default applications and DefaultRow have been added to i…
Mestane Jun 28, 2026
00b25d1
feat(nexus): readme for indexing
Mestane Jun 28, 2026
c555a92
Move unavailable toggle settings (QuickToggle + Launcher) into cards
Mestane Jun 29, 2026
0f141e2
feat(nexus): add fuzzy fallback to settings search
Mestane Jun 29, 2026
d001194
Merge branch 'main' into feat/indexing-ui
Mestane Jun 30, 2026
d791862
fix: stopwords indexing are,not,out and notification
Mestane Jun 30, 2026
15a5ff0
highlight has been rewritten and optimized
Mestane Jul 2, 2026
62578be
feat(nexus): add animations for resultList
Mestane Jul 2, 2026
26997a0
added Per-card icons
Mestane Jul 3, 2026
1ceb3d6
Merge branch 'main' into feat/indexing-ui
Mestane Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
/.qmlls.ini
build/
.cache/
logs
logs
__pycache__/
19 changes: 19 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
208 changes: 208 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details><summary>Developer guide: how it works, and how to add or remove settings</summary>

### 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 <output.json>
```

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/<URI>/`).

`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 `<font color>` tag for display with `Text.StyledText`.
(StyledText supports `<font color>` but not CSS `<span style>`, 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.

</details>

## Credits

Thanks to the Hyprland discord community (especially the homies in #rice-discussion) for all the help and suggestions
Expand Down
6 changes: 6 additions & 0 deletions modules/nexus/NavPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ ColumnLayout {
property: "searchOpen"
value: searchField.text.length > 0
}

Binding {
target: root.nState
property: "searchText"
value: searchField.text
}
}

NavLocations {
Expand Down
46 changes: 45 additions & 1 deletion modules/nexus/NexusState.qml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ QtObject {
property bool animatingContainer
property int currentPageIdx
property list<int> subPageIdxStack
property list<int> pendingSubPath
property bool searchOpen
property string searchText
property string searchAnchor
property string lastAnchor

property string selectedWallpaperCategory
property BluetoothDevice selectedBtDevice
Expand All @@ -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);
Expand All @@ -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 = [];
}
}
Loading