From 1c68c391cd10b5726d082d487b97e7decb823512 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 10:06:12 +0200 Subject: desktop: settings search finds rows, and a hit reveals its row Search used to stop at the page: clicking a result opened it at the top and left the setting to be found by eye. Now each settings module keeps its rows as searchItems, {text, key}, reread on every window open since the page itself is not loaded until shown: git runs gitcfg list, and autostart watches its file. A row hit opens the page, and Page.reveal() finds the row whose objectName is the key, waiting up to 3s for the page's own data, scrolls to it and flashes it in the accent. The reveal lives in Page, so a page only names its rows. Git's long keyword list was a stand-in for exactly this and is cut to "gitconfig". The autostart page takes its path from the module now that both read the file. Also corrects the AGENTS note on missed hot reloads: it blamed sed -i replacing the file, but the Edit tool writes a new inode too and usually reloads. The cause is unknown; the note now says how to detect it and that a process that stops watching needs a restart. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 17 +++++--- desktop/Module.qml | 7 ++++ desktop/Page.qml | 60 +++++++++++++++++++++++++++ desktop/README.md | 15 ++++--- desktop/Settings.qml | 43 ++++++++++++++----- desktop/modules/autostart/AutostartModule.qml | 21 +++++++++- desktop/modules/autostart/AutostartPage.qml | 7 +++- desktop/modules/git/GitModule.qml | 18 +++++--- desktop/modules/git/GitPage.qml | 2 + 9 files changed, 161 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8ef844..4d1caba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -472,11 +472,18 @@ shell looked current and was not: a remote Markdown image the new filter blocks was fetched anyway. After editing anything under `shared/`, force a reload of every component that links it, the same way as above. -Hot reload does **not** survive a file being replaced rather than written. -`sed -i` writes a new file and renames it over the old one, and the watch on -the old one dies with it: an autostart glyph fixed that way stayed blank in -the running shell, with no reload in the log after the edit. Edit in place -(an editor, the agent's Edit tool), or force a reload as above afterwards. +Hot reload **sometimes does not fire** after an edit, with nothing logged. +Twice in one session an edit was followed by no `Reloading configuration` +line, and the running shell kept the old code: an autostart glyph stayed +blank, an IPC function stayed "not found". The cause is not established. +It was first blamed on `sed -i` replacing the file, but the Edit tool also +writes a new inode (measured) and its edits usually reload fine. Before +trusting what the shell shows after an edit, compare `qs log -p +--log-times` against the file's mtime, and force a reload as above if none +followed. If even the forced change logs nothing, the process has stopped +watching altogether (seen once, after a few dozen reloads in a session): +only a restart, `./reload.sh desktop` from the user's terminal, brings it +back. `qs -p ` runs one directly. `qs -p ipc call ` reaches a running one, which is how `SUPER+v` opens the VM drawer. diff --git a/desktop/Module.qml b/desktop/Module.qml index 448fa7b..a1817a4 100644 --- a/desktop/Module.qml +++ b/desktop/Module.qml @@ -46,6 +46,13 @@ QtObject { // settings buried inside the page, which is not loaded until opened. property list keywords: [] + // The rows inside a settings page, for the search: [{text, key}]. key is + // the objectName of the row in the page, which the window scrolls to and + // highlights. Kept by the module because the page is not loaded until + // opened; refresh() rereads it, and the window calls it on open. + property var searchItems: [] + function refresh() {} + // Whether the drawer's grid shows a tile. False for a module reached // some other way: the user page opens from the drawer's header row. property bool tiled: !settings diff --git a/desktop/Page.qml b/desktop/Page.qml index 8365c51..357c3a7 100644 --- a/desktop/Page.qml +++ b/desktop/Page.qml @@ -80,7 +80,47 @@ Item { color: Qt.alpha(Theme.text, 0.12) } + // Scrolls to the content row whose objectName is key and flashes it, for + // a settings search hit. Rows usually arrive after the page loads its + // data, so it looks for up to 3s before giving up. + function reveal(key) { + finder.key = key; + finder.tries = 0; + finder.restart(); + } + + function findNamed(item, key) { + if (item.objectName === key) return item; + for (let i = 0; i < item.children.length; i++) { + const f = page.findNamed(item.children[i], key); + if (f) return f; + } + return null; + } + + Timer { + id: finder + property string key: "" + property int tries: 0 + interval: 100 + repeat: true + onTriggered: { + const it = page.findNamed(holder, key); + if (!it) { if (++tries > 30) stop(); return; } + stop(); + const p = it.mapToItem(holder, 0, 0); + const flick = scroll.contentItem; + flick.contentY = Math.max(0, Math.min(p.y - 40, flick.contentHeight - flick.height)); + flash.x = p.x - 4; + flash.y = p.y - 2; + flash.width = it.width + 8; + flash.height = it.height + 4; + flashAnim.restart(); + } + } + ScrollView { + id: scroll anchors { top: rule.bottom; left: parent.left; right: parent.right; bottom: parent.bottom } anchors.topMargin: 12 clip: true @@ -92,4 +132,24 @@ Item { implicitHeight: childrenRect.height } } + + // The reveal highlight, over the row and in holder's coordinates. + // Declared out here, not in the ScrollView, which sizes from a single + // child; parent puts it in holder all the same. + Rectangle { + id: flash + parent: holder + z: 10 + radius: 8 + opacity: 0 + color: Qt.alpha(Theme.accent, 0.28) + border.width: 1 + border.color: Theme.accent + SequentialAnimation { + id: flashAnim + NumberAnimation { target: flash; property: "opacity"; to: 1; duration: 120 } + PauseAnimation { duration: 1400 } + NumberAnimation { target: flash; property: "opacity"; to: 0; duration: 600 } + } + } } diff --git a/desktop/README.md b/desktop/README.md index ed30620..42c2efb 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -39,11 +39,16 @@ gear tile opens it, and so does IPC: a short About. A page's back arrow and Escape return there; Escape on the landing page closes the window. -The search above the list matches each page's label and its module's -`keywords`, the settings buried inside the page; an entry matched by a -keyword shows which one. Enter opens the first match, Escape clears. Pages -load only when opened, so their live contents (a git alias, an autostart -command) are not searched, only the keywords. +The search above the list matches each page's label, its module's +`keywords`, and the rows inside it: every git value, every autostart line. +A row hit opens its page scrolled to that row, flashed in the accent. Enter +opens the first result, Escape clears. + +Pages load only when opened, so a module keeps its own copy of its rows, +`searchItems` (`[{text, key}]`), and the window calls its `refresh()` on +every open. `key` is the `objectName` of the row in the page; `Page.reveal()` +looks for it, waiting up to 3s for the page's data to arrive, scrolls to it +and flashes it, so a page needs no highlight code of its own. ## The modules diff --git a/desktop/Settings.qml b/desktop/Settings.qml index 52ca549..9882193 100644 --- a/desktop/Settings.qml +++ b/desktop/Settings.qml @@ -32,22 +32,40 @@ Scope { } property string query: "" - // The pages the search keeps, as {mod, hit}: hit is the keyword that - // matched when the label did not, shown under the entry. + // What the search keeps, as {mod, hit, key}. A page matched by its label + // or a keyword has key "" and opens at the top; hit is the keyword when + // the label did not match. Then each row inside a page that matches, from + // the module's searchItems, with key naming that row. + readonly property int perPage: 8 readonly property var found: { const q = root.query.trim().toLowerCase(); const out = []; for (const m of root.pages) { - if (q === "" || m.label.toLowerCase().includes(q) || m.name.includes(q)) { - out.push({ mod: m, hit: "" }); - continue; + if (q === "" || m.label.toLowerCase().includes(q) || m.name.includes(q)) + out.push({ mod: m, hit: "", key: "" }); + else { + const k = Array.from(m.keywords).find(w => w.toLowerCase().includes(q)); + if (k !== undefined) out.push({ mod: m, hit: k, key: "" }); } - const k = Array.from(m.keywords).find(w => w.toLowerCase().includes(q)); - if (k !== undefined) out.push({ mod: m, hit: k }); + if (q === "") continue; + // ponytail: the first perPage rows per page; a narrower query finds the rest. + for (const it of m.searchItems.filter(it => it.text.toLowerCase().includes(q)).slice(0, root.perPage)) + out.push({ mod: m, hit: it.text, key: it.key }); } return out; } + // The page is shown first so the Loader holds it, then asked to reveal. + signal reveal(string key) + function go(entry) { + root.page = entry.mod.name; + if (entry.key) root.reveal(entry.key); + } + + // The search index is the modules' own copy of their rows, reread on + // every open so it matches what the pages will show. + onOpenChanged: if (root.open) for (const m of root.pages) m.refresh() + // Null is the landing page. readonly property QtObject current: root.pages.find(m => m.name === root.page) ?? null @@ -96,7 +114,7 @@ Scope { font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } text: root.query onTextEdited: root.query = text - onAccepted: if (root.found.length) root.page = root.found[0].mod.name + onAccepted: if (root.found.length) root.go(root.found[0]) Keys.onEscapePressed: event => { if (root.query !== "") root.query = ""; else event.accepted = false; @@ -121,7 +139,7 @@ Scope { delegate: Rectangle { id: entry required property var modelData - readonly property bool selected: root.current === modelData.mod + readonly property bool selected: root.current === modelData.mod && modelData.key === "" width: side.width height: modelData.hit ? 46 : 36 radius: 8 @@ -159,7 +177,7 @@ Scope { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.page = entry.modelData.mod.name + onClicked: root.go(entry.modelData) } } } @@ -225,6 +243,11 @@ Scope { // The Page's back arrow steps out to the landing page, // dismiss closes the window, and go() moves to another // settings page by name. + Connections { + target: root + function onReveal(key) { if (pageLoader.item?.reveal) pageLoader.item.reveal(key); } + } + onLoaded: { if (item && item.back) item.back.connect(root.back); if (item && item.dismiss) item.dismiss.connect(root.close); diff --git a/desktop/modules/autostart/AutostartModule.qml b/desktop/modules/autostart/AutostartModule.qml index ef4c005..24c69da 100644 --- a/desktop/modules/autostart/AutostartModule.qml +++ b/desktop/modules/autostart/AutostartModule.qml @@ -9,24 +9,41 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +import Quickshell +import Quickshell.Io import QtQuick import "../.." +import "Autostart.js" as A // What Hyprland launches at login, from hypr-theme's autostart.lua. Set once // and left alone, so it lives in the settings window, not on a tile. Module { + id: am name: "autostart" label: "Autostart" // nf-fa-rocket, present in Inconsolata Nerd Font. - icon: "\uf135" + icon: "" settings: true keywords: ["login", "startup", "launch", "exec", "daemon", "session", "workspace", "silent", "scratchpad", "autostart.lua", "hyprland"] + readonly property string file: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + + "/hypr/sections/autostart.lua" + + // Every entry, as the page lists it; key is the row's objectName there. + function refresh() { fv.reload(); } + property FileView fv: FileView { + path: am.file + printErrors: false + watchChanges: true + onFileChanged: reload() + onLoaded: am.searchItems = A.parse(text()).map(e => ({ text: e.cmd, key: e.cmd })) + } + page: Component { Page { title: "Autostart" - AutostartPage { width: parent.width } + AutostartPage { width: parent.width; file: am.file } } } } diff --git a/desktop/modules/autostart/AutostartPage.qml b/desktop/modules/autostart/AutostartPage.qml index 1289a93..e0fa2c9 100644 --- a/desktop/modules/autostart/AutostartPage.qml +++ b/desktop/modules/autostart/AutostartPage.qml @@ -29,6 +29,8 @@ Column { spacing: 6 readonly property string home: Quickshell.env("HOME") + // autostart.lua, from the module. + required property string file property var entries: [] // Entry index -> the program's resolved path, "" when it is not found. property var found: ({}) @@ -48,8 +50,7 @@ Column { FileView { id: fv - path: (Quickshell.env("XDG_CONFIG_HOME") || page.home + "/.config") - + "/hypr/sections/autostart.lua" + path: page.file // Hyprland reloads its config on every change, so a half-written file // would reach it. atomicWrites: true @@ -253,6 +254,8 @@ Column { readonly property var parts: A.split(modelData.cmd) readonly property var where: A.workspace(parts.rule) readonly property string path: page.found[modelData.index] ?? "?" + // What a search hit scrolls to (AutostartModule's searchItems key). + objectName: modelData.cmd width: page.width height: 44 diff --git a/desktop/modules/git/GitModule.qml b/desktop/modules/git/GitModule.qml index aaa72a9..6aa0bc6 100644 --- a/desktop/modules/git/GitModule.qml +++ b/desktop/modules/git/GitModule.qml @@ -9,8 +9,10 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +import Quickshell.Io import QtQuick import "../.." +import "Git.js" as G // The global git config, in the settings window: set once and left alone. // The user page's "More git settings" opens it there. @@ -21,11 +23,17 @@ Module { // nf-dev-git, present in Inconsolata Nerd Font. icon: "" settings: true - keywords: ["gitconfig", "user", "name", "email", "signingkey", "signing", "gpg", - "alias", "core", "editor", "pager", "commit", "gpgsign", "init", - "defaultBranch", "branch", "pull", "push", "fetch", "rebase", "merge", - "diff", "log", "color", "credential", "lfs", "rerere", "tag", "status", - "safe", "directory"] + keywords: ["gitconfig"] + + // Every value, as the page lists it; key is the row's tag in GitPage. + function refresh() { proc.running = false; proc.running = true; } + property Process proc: Process { + command: [Qt.resolvedUrl("gitcfg").toString().replace(/^file:\/\//, ""), "list"] + stdout: StdioCollector { + onStreamFinished: gm.searchItems = text.startsWith("error=") ? [] + : G.parse(text).map(e => ({ text: e.name + " = " + e.value, key: e.name + "=" + e.value })) + } + } page: Component { Page { diff --git a/desktop/modules/git/GitPage.qml b/desktop/modules/git/GitPage.qml index 6edf085..c5d04d9 100644 --- a/desktop/modules/git/GitPage.qml +++ b/desktop/modules/git/GitPage.qml @@ -244,6 +244,8 @@ Column { // An Item with anchors, not a Row: a Row sizes to its children // and fixed widths in one overflow the page (AGENTS.md). Item { + // What a search hit scrolls to (GitModule's searchItems key). + objectName: row.tag width: parent.width height: 34 -- cgit v1.2.3