From 2b89c8e4a1e9938e8c26837c9e7471f07fb66603 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 30 Jun 2026 22:20:48 +0200 Subject: [PATCH 1/3] implement search bar functionality in settings --- CMakeLists.txt | 14 + Modules/Clipboard/Content.qml | 17 +- Modules/Launcher/Content.qml | 6 +- Modules/Settings/Common/ConnectedRect.qml | 52 + Modules/Settings/Common/PageBase.qml | 109 ++ Modules/Settings/Common/ToggleRow.qml | 5 + Modules/Settings/NavPane.qml | 6 + Modules/Settings/NavPane/NavLocations.qml | 221 ++- Modules/Settings/Pages/AppsPage.qml | 5 + Modules/Settings/Pages/AudioPage.qml | 2 + .../Settings/Pages/Panels/Bar/BarClock.qml | 1 + .../Pages/Panels/Bar/BarStatusIcons.qml | 5 + Modules/Settings/Pages/Panels/Bar/BarTray.qml | 1 + Modules/Settings/Pages/Panels/BarPanel.qml | 4 + .../Settings/Pages/Panels/DashboardPanel.qml | 1 + .../Settings/Pages/Panels/LauncherPanel.qml | 16 +- .../Settings/Pages/Panels/ResourcesPanel.qml | 7 + .../Settings/Pages/Panels/SidebarPanel.qml | 2 + Modules/Settings/Pages/PanelsPage.qml | 5 + Modules/Settings/Pages/ScreenshotPage.qml | 8 + .../Pages/Services/NotificationsPage.qml | 10 + Modules/Settings/Pages/ServicesPage.qml | 11 + Modules/Settings/Pages/WallpaperPage.qml | 5 + Modules/Settings/SettingsSearcher.qml | 201 +++ Modules/Settings/SettingsState.qml | 46 +- Plugins/ZShell/CMakeLists.txt | 7 +- Plugins/ZShell/zutils.cpp | 10 + Plugins/ZShell/zutils.hpp | 2 + scripts/SettingsIndex.mjs | 1312 ----------------- scripts/build-settings-index.py | 466 ++++++ 30 files changed, 1215 insertions(+), 1342 deletions(-) create mode 100644 Modules/Settings/SettingsSearcher.qml delete mode 100644 scripts/SettingsIndex.mjs create mode 100644 scripts/build-settings-index.py diff --git a/CMakeLists.txt b/CMakeLists.txt index ec4d8e7..1681ef4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,20 @@ add_compile_options( -Wunreachable-code ) +if("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/Settings" + "${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("plugin" IN_LIST ENABLE_MODULES) add_subdirectory(Plugins) diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index 8df9f84..152c552 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -34,21 +34,11 @@ Item { implicitHeight: 50 radius: Appearance.rounding.full - MaterialIcon { - id: searchIcon - - anchors.left: parent.left - anchors.margins: Appearance.padding.large - anchors.verticalCenter: parent.verticalCenter - text: "search" - } - - CustomTextField { + SearchBar { id: searchField anchors.bottom: parent.bottom - anchors.left: searchIcon.right - anchors.leftMargin: Appearance.spacing.small + anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top color: DynamicColors.palette.m3onSurface @@ -170,7 +160,6 @@ Item { id: lineText color: DynamicColors.palette.m3onSurface - font.family: ClipHistory.previewIsCode ? Appearance.font.family.mono : Appearance.font.family.sans height: lineText.paintedHeight + Appearance.padding.extraSmall * 2 text: lineRow.modelData.text.trim() verticalAlignment: Text.AlignVCenter @@ -336,6 +325,8 @@ Item { } model: ScriptModel { values: ClipHistory.fuzzyQuery(searchField.text) + + onValuesChanged: view.currentIndex = 0 } onCurrentItemChanged: { diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 0bc84c4..3fafb77 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -52,11 +52,7 @@ Item { placeholderText: qsTr("Type \"%1\" for commands").arg(Config.launcher.actionPrefix) topPadding: Appearance.padding.larger - Component.onCompleted: { - console.log(search.color); - console.log(search.placeholderTextColor); - forceActiveFocus(); - } + Component.onCompleted: forceActiveFocus() Keys.onDownPressed: list.currentList?.decrementCurrentIndex() Keys.onEscapePressed: root.visibilities.launcher = false Keys.onPressed: event => { diff --git a/Modules/Settings/Common/ConnectedRect.qml b/Modules/Settings/Common/ConnectedRect.qml index e273ae4..8bb9829 100644 --- a/Modules/Settings/Common/ConnectedRect.qml +++ b/Modules/Settings/Common/ConnectedRect.qml @@ -3,12 +3,64 @@ import qs.Components import qs.Config CustomRect { + id: root + property bool first property bool last + property string settingAnchor + + function flashHighlight(): void { + flash.restart(); + } bottomLeftRadius: last ? Appearance.rounding.large : Appearance.rounding.extraSmall bottomRightRadius: last ? Appearance.rounding.large : Appearance.rounding.extraSmall color: DynamicColors.tPalette.m3surfaceContainer topLeftRadius: first ? Appearance.rounding.large : Appearance.rounding.extraSmall topRightRadius: first ? Appearance.rounding.large : Appearance.rounding.extraSmall + + CustomRect { + id: highlight + + anchors.fill: parent + bottomLeftRadius: parent.bottomLeftRadius + bottomRightRadius: parent.bottomRightRadius + color: DynamicColors.palette.m3primary + opacity: 0 + radius: parent.radius + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + + SequentialAnimation { + id: flash + + Anim { + duration: Appearance.anim.durations.small + property: "opacity" + target: highlight + to: 0.2 + } + + Anim { + duration: Appearance.anim.durations.normal + property: "opacity" + target: highlight + to: 0.08 + } + + Anim { + duration: Appearance.anim.durations.small + property: "opacity" + target: highlight + to: 0.2 + } + + Anim { + duration: Appearance.anim.durations.extraLarge + property: "opacity" + target: highlight + to: 0 + } + } + } } diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index 60d9088..0a474ea 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -9,6 +9,9 @@ import qs.Config ColumnLayout { id: root + // Enables a smooth scroll animation only for search jumps, so normal + // flicking stays instant. + property bool animateScroll: false readonly property int cappedWidth: Math.min(800, width) default property Item contentChild readonly property alias flickable: flickable @@ -16,8 +19,106 @@ ColumnLayout { required property SettingsState sState required property string title + function applySearchAnchor(): void { + if (!sState.searchAnchor) + return; + scrollRetry.tries = 0; + scrollRetry.lastHeight = -1; + scrollRetry.stableFrames = 0; + scrollRetry.restart(); + } + + 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; + } + + // 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 + } + + // 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 + Appearance.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; + } + spacing: Appearance.spacing.large + Component.onCompleted: applySearchAnchor() + + Timer { + id: scrollRetry + + property real lastHeight: -1 + property int stableFrames: 0 + property int tries: 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.sState.searchAnchor)) + root.sState.searchAnchor = ""; + stop(); + } + tries++; + } + } + + Connections { + function onHighlightSetting(anchor: string): void { + root.highlightAnchor(anchor); + } + + function onSearchAnchorChanged(): void { + root.applySearchAnchor(); + } + + target: root.sState + } + MouseArea { // Prevent clicks from reaching flickable Layout.bottomMargin: -flickable.topMargin // Extra height to block clicks on flickable top margin @@ -69,6 +170,14 @@ ColumnLayout { fadeAmount: 0.1 topMargin: Appearance.padding.large + Behavior on contentY { + enabled: root.animateScroll + + Anim { + type: Anim.DefaultSpatial + } + } + TapHandler { onTapped: flickable.focus = true } diff --git a/Modules/Settings/Common/ToggleRow.qml b/Modules/Settings/Common/ToggleRow.qml index 25e4168..77dd3e8 100644 --- a/Modules/Settings/Common/ToggleRow.qml +++ b/Modules/Settings/Common/ToggleRow.qml @@ -9,8 +9,13 @@ CustomSwitch { readonly property alias bg: bg property alias first: bg.first property alias last: bg.last + property string settingAnchor property string subtext + function flashHighlight(): void { + bg.flashHighlight(); + } + Layout.fillWidth: true cLayer: 2 horizontalPadding: Appearance.padding.largeIncreased diff --git a/Modules/Settings/NavPane.qml b/Modules/Settings/NavPane.qml index b87a45c..ab5ffa6 100644 --- a/Modules/Settings/NavPane.qml +++ b/Modules/Settings/NavPane.qml @@ -34,6 +34,12 @@ ColumnLayout { target: root.sState value: searchField.text.length > 0 } + + Binding { + property: "searchText" + target: root.sState + value: searchField.text + } } NavLocations { diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index 678c82b..18326d9 100644 --- a/Modules/Settings/NavPane/NavLocations.qml +++ b/Modules/Settings/NavPane/NavLocations.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import Quickshell import qs.Components import qs.Config import qs.Modules.Settings @@ -9,7 +10,36 @@ import qs.Modules.Settings VerticalFadeFlickable { id: root + // 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; + } + readonly property var results: { + if (!searching) + return []; + const all = SettingsSearcher.query(search); + return all; + } required property SettingsState sState + readonly property string search: sState.searchText + readonly property bool searching: search.length > 0 bottomMargin: Appearance.padding.large contentHeight: content.implicitHeight @@ -30,14 +60,14 @@ VerticalFadeFlickable { Repeater { id: list - model: PageRegistry.pages + model: root.searching ? [] : PageRegistry.pages CustomRect { id: item required property int index - 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 + readonly property bool isCategoryStart: index === 0 || PageRegistry.pages[index - 1]?.category !== modelData.category readonly property bool isCurrentPage: index === root.sState.currentPageIdx required property var modelData @@ -120,6 +150,191 @@ VerticalFadeFlickable { } } } + + ListView { + id: resultList + + // Grouped results: the model is one entry per top-level page, and + // each delegate renders that page's heading plus the matching + // settings joined into a single rounded card (first/last rounded, + // middles square, thin dividers between them), like the Android + // settings search. A ScriptModel diffs the groups so only changed + // ones animate. Scrolling is delegated to the outer flickable. + Layout.fillWidth: true + cacheBuffer: 10000 + implicitHeight: contentHeight + interactive: false + spacing: Appearance.padding.large + + delegate: ColumnLayout { + id: group + + required property int index + required property var modelData + + spacing: Appearance.spacing.small + width: resultList.width + + // Group heading: the top-level page name, shown once. + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Appearance.padding.small + spacing: Appearance.spacing.small + + MaterialIcon { + color: DynamicColors.palette.m3primary + font.pointSize: Appearance.font.size.small + text: group.modelData.icon + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3primary + elide: Text.ElideRight + font.pointSize: Appearance.font.size.large + text: group.modelData.page + } + } + + // The matching settings, joined into one card. + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Repeater { + model: group.modelData.entries + + CustomRect { + id: result + + required property int index + readonly property bool isFirst: index === 0 + readonly property bool isLast: index === group.modelData.entries.length - 1 + required property var modelData + + Layout.fillWidth: true + bottomLeftRadius: isLast ? Appearance.rounding.large : 0 + bottomRightRadius: isLast ? Appearance.rounding.large : 0 + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + 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 ? Appearance.rounding.large : 0 + topRightRadius: isFirst ? Appearance.rounding.large : 0 + + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.large + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.large + color: Qt.alpha(DynamicColors.palette.m3outlineVariant, 0.5) + implicitHeight: 1 + visible: !result.isLast + } + + ColumnLayout { + id: resultLayout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + // Leave room on the right for the toggle switch. + anchors.rightMargin: result.modelData.togglePath ? toggle.width + Appearance.padding.large * 2 : Appearance.padding.large + spacing: Appearance.spacing.small / 2 + + // Location line: deepest icon + "Section > sub", faint. + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3onSurfaceVariant + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + 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 + } + + // The setting itself, most prominent. + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3onSurface + elide: Text.ElideRight + font.pointSize: Appearance.font.size.medium + text: SettingsSearcher.highlight(result.modelData.title, root.search, DynamicColors.palette.m3primary) + textFormat: Text.StyledText + } + + // Optional description, faintest and smallest. + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: SettingsSearcher.highlight(result.modelData.subtext, root.search, DynamicColors.palette.m3primary) + textFormat: Text.StyledText + visible: result.modelData.subtext.length > 0 + } + } + + StateLayer { + anchors.fill: parent + radius: 0 + z: 1 + + onClicked: { + root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor); + } + } + + CustomSwitch { + id: toggle + + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.large + anchors.verticalCenter: parent.verticalCenter + cLayer: 3 + checked: result.modelData.toggleValue + scale: 0.85 + transformOrigin: Item.Right + visible: result.modelData.togglePath + z: 2 + + onToggled: result.modelData.setToggle(checked) + } + } + } + } + } + + // The list's implicitHeight tracks contentHeight; while items animate + // their position the reported height fluctuates, which left gaps in + // the surrounding layout on fast typing. So additions, removals and + // reordering are all instant - no transitions - keeping the height + // correct at every frame. + model: ScriptModel { + // Match groups by their page so content updates in place rather + // than rebuilding the delegate when ranking shifts the order. + objectProp: "pageIdx" + values: root.groups + } + } + + CustomText { + Layout.fillWidth: true + Layout.topMargin: Appearance.padding.large + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.medium + horizontalAlignment: Text.AlignHCenter + text: qsTr("No matching settings") + visible: root.searching && root.results.length === 0 + } } component RadiusBehavior: Behavior { diff --git a/Modules/Settings/Pages/AppsPage.qml b/Modules/Settings/Pages/AppsPage.qml index cd53b40..49be244 100644 --- a/Modules/Settings/Pages/AppsPage.qml +++ b/Modules/Settings/Pages/AppsPage.qml @@ -30,6 +30,7 @@ PageBase { DefaultRow { first: true icon: "terminal" + settingAnchor: "apps-default-terminal" status: Config.general.apps.terminal.join(" ") text: qsTr("Terminal") @@ -38,6 +39,7 @@ PageBase { DefaultRow { icon: "volume_up" + settingAnchor: "apps-default-audio" status: Config.general.apps.audio.join(" ") text: qsTr("Audio") @@ -46,6 +48,7 @@ PageBase { DefaultRow { icon: "play_circle" + settingAnchor: "apps-default-playback" status: Config.general.apps.playback.join(" ") text: qsTr("Media playback") @@ -55,6 +58,7 @@ PageBase { DefaultRow { icon: "folder" last: true + settingAnchor: "apps-default-file-manager" status: Config.general.apps.explorer.join(" ") text: qsTr("File manager") @@ -70,6 +74,7 @@ PageBase { first: true icon: "apps" last: true + settingAnchor: "apps-all-apps" status: qsTr("Browse installed apps, set favorites and hidden") text: qsTr("All apps") diff --git a/Modules/Settings/Pages/AudioPage.qml b/Modules/Settings/Pages/AudioPage.qml index f0e1882..050b23b 100644 --- a/Modules/Settings/Pages/AudioPage.qml +++ b/Modules/Settings/Pages/AudioPage.qml @@ -25,6 +25,7 @@ PageBase { first: true icon: Icons.getVolumeIcon(Audio.volume, Audio.muted) label: qsTr("Output") + settingAnchor: "audio-output" value: Audio.volume valueLabel: Math.round(value * 100) + "%" @@ -55,6 +56,7 @@ PageBase { first: true icon: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted) label: qsTr("Input") + settingAnchor: "audio-input" value: Audio.sourceVolume valueLabel: Math.round(value * 100) + "%" diff --git a/Modules/Settings/Pages/Panels/Bar/BarClock.qml b/Modules/Settings/Pages/Panels/Bar/BarClock.qml index 109cf36..6501dec 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarClock.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarClock.qml @@ -50,6 +50,7 @@ PageBase { first: true last: true menuItems: root.clockFormats + settingAnchor: "bar-clock-format" subtext: qsTr("Change how time is displayed in the widget") text: qsTr("Time format") diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index 3c9561a..b2655d5 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -25,6 +25,7 @@ PageBase { ToggleRow { checked: Config.bar.tray.showAudio first: true + settingAnchor: "bar-status-speakers" text: qsTr("Speakers") onToggled: Config.bar.tray.showAudio = checked @@ -32,6 +33,7 @@ PageBase { ToggleRow { checked: Config.bar.tray.showMicrophone + settingAnchor: "bar-status-mic" text: qsTr("Microphone") onToggled: Config.bar.tray.showMicrophone = checked @@ -62,6 +64,7 @@ PageBase { ToggleRow { checked: Config.bar.tray.showPower last: true + settingAnchor: "bar-status-power" text: qsTr("Battery") onToggled: Config.bar.tray.showPower = checked @@ -75,6 +78,7 @@ PageBase { ToggleRow { checked: Config.bar.popouts.audio first: true + settingAnchor: "bar-status-audio-popout" subtext: qsTr("Show a details popout when hovering the audio icons") text: qsTr("Audio popout on hover") @@ -84,6 +88,7 @@ PageBase { ToggleRow { checked: Config.bar.popouts.upower last: true + settingAnchor: "bar-status-power-popout" subtext: qsTr("Show a details popout when hovering the power icon") text: qsTr("Power popout on hover") diff --git a/Modules/Settings/Pages/Panels/Bar/BarTray.qml b/Modules/Settings/Pages/Panels/Bar/BarTray.qml index 9f8d096..2fe962c 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarTray.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarTray.qml @@ -20,6 +20,7 @@ PageBase { first: true from: 12 last: true + settingAnchor: "bar-tray-iconsize" stepSize: 1 text: qsTr("Icon size") to: 24 diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index 4bfc80d..9b344eb 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -26,6 +26,7 @@ PageBase { checked: Config.bar.autoHide first: true last: true + settingAnchor: "bar-autohide" subtext: qsTr("Hide the bar, reveal on hover") text: qsTr("Auto hide") @@ -40,6 +41,7 @@ PageBase { NavRow { first: true icon: "widgets" + settingAnchor: "bar-tray" status: qsTr("System tray icons") text: qsTr("Tray") @@ -48,6 +50,7 @@ PageBase { NavRow { icon: "signal_cellular_alt" + settingAnchor: "bar-statusicons" status: qsTr("Visible indicators") text: qsTr("Status icons") @@ -57,6 +60,7 @@ PageBase { NavRow { icon: "schedule" last: true + settingAnchor: "bar-clock" status: qsTr("Date, icon, background") text: qsTr("Clock") diff --git a/Modules/Settings/Pages/Panels/DashboardPanel.qml b/Modules/Settings/Pages/Panels/DashboardPanel.qml index a8e0cef..1aa3f93 100644 --- a/Modules/Settings/Pages/Panels/DashboardPanel.qml +++ b/Modules/Settings/Pages/Panels/DashboardPanel.qml @@ -28,6 +28,7 @@ PageBase { checked: Config.dashboard.enabled first: true last: true + settingAnchor: "dashboard-enabled" text: qsTr("Enabled") onToggled: Config.dashboard.enabled = checked diff --git a/Modules/Settings/Pages/Panels/LauncherPanel.qml b/Modules/Settings/Pages/Panels/LauncherPanel.qml index 630896e..8adcd25 100644 --- a/Modules/Settings/Pages/Panels/LauncherPanel.qml +++ b/Modules/Settings/Pages/Panels/LauncherPanel.qml @@ -28,6 +28,7 @@ PageBase { checked: Config.launcher.enabled first: true last: true + settingAnchor: "launcher-enabled" text: qsTr("Enabled") onToggled: Config.launcher.enabled = checked @@ -41,6 +42,7 @@ PageBase { SpinRow { first: true from: 1 + settingAnchor: "launcher-max-items-shown" stepSize: 1 text: qsTr("Max items shown") to: 20 @@ -51,6 +53,7 @@ PageBase { SpinRow { from: 1 + settingAnchor: "launcher-max-wallpapers" stepSize: 1 text: qsTr("Max wallpapers") to: 30 @@ -62,6 +65,7 @@ PageBase { SpinRow { from: 0 last: true + settingAnchor: "launcher-drag-threshold" stepSize: 5 subtext: qsTr("Pixels dragged before the launcher opens") text: qsTr("Drag threshold") @@ -80,6 +84,7 @@ PageBase { checked: Config.launcher.enableDangerousActions first: true last: true + settingAnchor: "launcher-enable-dangerous-actions" subtext: qsTr("Allow actions that shut down or log out") text: qsTr("Enable dangerous actions") @@ -94,6 +99,7 @@ PageBase { ToggleRow { checked: Config.launcher.useFuzzy.apps first: true + settingAnchor: "launcher-apps" text: qsTr("Apps") onToggled: Config.launcher.useFuzzy.apps = checked @@ -101,20 +107,15 @@ PageBase { ToggleRow { checked: Config.launcher.useFuzzy.actions + settingAnchor: "launcher-actions" text: qsTr("Actions") onToggled: Config.launcher.useFuzzy.actions = checked } - ToggleRow { - checked: Config.launcher.useFuzzy.schemes - text: qsTr("Schemes") - - onToggled: Config.launcher.useFuzzy.schemes = checked - } - ToggleRow { checked: Config.launcher.useFuzzy.variants + settingAnchor: "launcher-variants" text: qsTr("Variants") onToggled: Config.launcher.useFuzzy.variants = checked @@ -123,6 +124,7 @@ PageBase { ToggleRow { checked: Config.launcher.useFuzzy.wallpapers last: true + settingAnchor: "launcher-wallpapers" text: qsTr("Wallpapers") onToggled: Config.launcher.useFuzzy.wallpapers = checked diff --git a/Modules/Settings/Pages/Panels/ResourcesPanel.qml b/Modules/Settings/Pages/Panels/ResourcesPanel.qml index 3c5cde4..11e9f5b 100644 --- a/Modules/Settings/Pages/Panels/ResourcesPanel.qml +++ b/Modules/Settings/Pages/Panels/ResourcesPanel.qml @@ -28,6 +28,7 @@ PageBase { checked: Config.dashboard.performance.enabled first: true last: true + settingAnchor: "resources-enabled" text: qsTr("Enabled") onToggled: Config.dashboard.performance.enabled = checked @@ -41,6 +42,7 @@ PageBase { ToggleRow { checked: Config.dashboard.performance.showBattery first: true + settingAnchor: "resources-battery" text: qsTr("Battery") onToggled: Config.dashboard.performance.showBattery = checked @@ -48,6 +50,7 @@ PageBase { ToggleRow { checked: Config.dashboard.performance.showGpu + settingAnchor: "resources-gpu" text: qsTr("GPU") onToggled: Config.dashboard.performance.showGpu = checked @@ -55,6 +58,7 @@ PageBase { ToggleRow { checked: Config.dashboard.performance.showCpu + settingAnchor: "resources-cpu" text: qsTr("CPU") onToggled: Config.dashboard.performance.showCpu = checked @@ -62,6 +66,7 @@ PageBase { ToggleRow { checked: Config.dashboard.performance.showMemory + settingAnchor: "resources-memory" text: qsTr("Memory") onToggled: Config.dashboard.performance.showMemory = checked @@ -69,6 +74,7 @@ PageBase { ToggleRow { checked: Config.dashboard.performance.showStorage + settingAnchor: "resources-storage" text: qsTr("Storage") onToggled: Config.dashboard.performance.showStorage = checked @@ -77,6 +83,7 @@ PageBase { ToggleRow { checked: Config.dashboard.performance.showNetwork last: true + settingAnchor: "resources-network" text: qsTr("Network") onToggled: Config.dashboard.performance.showNetwork = checked diff --git a/Modules/Settings/Pages/Panels/SidebarPanel.qml b/Modules/Settings/Pages/Panels/SidebarPanel.qml index e1f02bb..cfa6795 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -26,6 +26,7 @@ PageBase { ToggleRow { checked: Config.sidebar.enabled first: true + settingAnchor: "sidebar-enabled" text: qsTr("Enabled") onToggled: Config.sidebar.enabled = checked @@ -34,6 +35,7 @@ PageBase { SpinRow { from: 0 last: true + settingAnchor: "sidebar-drag-threshold" stepSize: 5 subtext: qsTr("Pixels dragged before the sidebar opens") text: qsTr("Drag threshold") diff --git a/Modules/Settings/Pages/PanelsPage.qml b/Modules/Settings/Pages/PanelsPage.qml index 2200b96..30973d8 100644 --- a/Modules/Settings/Pages/PanelsPage.qml +++ b/Modules/Settings/Pages/PanelsPage.qml @@ -16,6 +16,7 @@ PageBase { NavRow { first: true icon: "dock_to_bottom" + settingAnchor: "panels-bar" status: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover") text: qsTr("Bar") @@ -24,6 +25,7 @@ PageBase { NavRow { icon: "dashboard" + settingAnchor: "panels-dashboard" status: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Dashboard") @@ -32,6 +34,7 @@ PageBase { NavRow { icon: "insert_chart" + settingAnchor: "panels-resources" status: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Resources") @@ -40,6 +43,7 @@ PageBase { NavRow { icon: "apps" + settingAnchor: "panels-launcher" status: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Launcher") @@ -49,6 +53,7 @@ PageBase { NavRow { icon: "dock_to_right" last: true + settingAnchor: "panels-sidebar" status: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Sidebar") diff --git a/Modules/Settings/Pages/ScreenshotPage.qml b/Modules/Settings/Pages/ScreenshotPage.qml index 83b75a1..a97d47c 100644 --- a/Modules/Settings/Pages/ScreenshotPage.qml +++ b/Modules/Settings/Pages/ScreenshotPage.qml @@ -28,6 +28,7 @@ PageBase { ToggleRow { checked: Config.screenshot.enable_pp first: true + settingAnchor: "screenshot-enable" text: qsTr("Enable effects") onToggled: Config.screenshot.enable_pp = checked @@ -37,6 +38,7 @@ PageBase { active: Config.screenshot.mode === "manual" ? menuItems[0] : menuItems[1] enabled: Config.screenshot.enable_pp last: true + settingAnchor: "screenshot-mode" subtext: qsTr("Automatic or manual effect values") text: qsTr("Effects mode") @@ -67,6 +69,7 @@ PageBase { checked: Config.screenshot.rounding enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" first: true + settingAnchor: "screenshot-enable-rounded-corners" text: qsTr("Enable rounded corners") onToggled: Config.screenshot.rounding = checked @@ -76,6 +79,7 @@ PageBase { enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.rounding from: 0 last: true + settingAnchor: "screenshot-corner-radius" stepSize: 1 text: qsTr("Corner radius") to: 50 @@ -95,6 +99,7 @@ PageBase { checked: Config.screenshot.shadow enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" first: true + settingAnchor: "screenshot-enable-shadow" text: qsTr("Enable shadow") onToggled: Config.screenshot.shadow = checked @@ -103,6 +108,7 @@ PageBase { SpinRow { enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow from: 0 + settingAnchor: "screenshot-shadow-blur-amount" stepSize: 1 text: qsTr("Shadow blur amount") to: 100 @@ -117,6 +123,7 @@ PageBase { SpinRow { enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow from: -100 + settingAnchor: "screenshot-shadow-horizontal-offset" stepSize: 10 text: qsTr("Shadow horizontal offset") to: 100 @@ -132,6 +139,7 @@ PageBase { enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow from: -100 last: true + settingAnchor: "screenshot-shadow-vertical-offset" stepSize: 10 text: qsTr("Shadow vertical offset") to: 100 diff --git a/Modules/Settings/Pages/Services/NotificationsPage.qml b/Modules/Settings/Pages/Services/NotificationsPage.qml index 2b0a90c..e1e04d0 100644 --- a/Modules/Settings/Pages/Services/NotificationsPage.qml +++ b/Modules/Settings/Pages/Services/NotificationsPage.qml @@ -25,6 +25,7 @@ PageBase { ToggleRow { checked: Config.notifs.showInFullscreen first: true + settingAnchor: "notif-show-in-fullscreen" subtext: qsTr("Whether notifications appear over fullscreen apps") text: qsTr("Show in fullscreen") @@ -33,6 +34,7 @@ PageBase { ToggleRow { checked: Config.notifs.expire + settingAnchor: "notif-expire-automatically" subtext: qsTr("Dismiss notifications after their timeout") text: qsTr("Expire automatically") @@ -41,6 +43,7 @@ PageBase { ToggleRow { checked: Config.notifs.openExpanded + settingAnchor: "notif-open-expanded" subtext: qsTr("Show notifications expanded by default") text: qsTr("Open expanded") @@ -49,6 +52,7 @@ PageBase { SpinRow { from: 1000 + settingAnchor: "notif-default-timeout" stepSize: 500 subtext: qsTr("Time before a notification dismisses (ms)") text: qsTr("Default timeout") @@ -61,6 +65,7 @@ PageBase { SpinRow { from: 1 last: true + settingAnchor: "notif-group-preview-count" stepSize: 1 subtext: qsTr("Notifications shown per group before collapsing") text: qsTr("Group preview count") @@ -78,6 +83,7 @@ PageBase { SpinRow { first: true from: 1 + settingAnchor: "notif-visible-toasts" stepSize: 1 subtext: qsTr("Maximum number of toasts shown at once") text: qsTr("Visible toasts") @@ -89,6 +95,7 @@ PageBase { ToggleRow { checked: Config.utilities.toasts.chargingChanged + settingAnchor: "notif-charging-changes" text: qsTr("Charging changes") onToggled: Config.utilities.toasts.chargingChanged = checked @@ -96,6 +103,7 @@ PageBase { ToggleRow { checked: Config.utilities.toasts.gameModeChanged + settingAnchor: "notif-game-mode-changes" text: qsTr("Game mode changes") onToggled: Config.utilities.toasts.gameModeChanged = checked @@ -110,6 +118,7 @@ PageBase { ToggleRow { checked: Config.utilities.toasts.audioOutputChanged + settingAnchor: "notif-audio-output-changes" text: qsTr("Audio output changes") onToggled: Config.utilities.toasts.audioOutputChanged = checked @@ -118,6 +127,7 @@ PageBase { ToggleRow { checked: Config.utilities.toasts.audioInputChanged last: true + settingAnchor: "notif-audio-input-changes" text: qsTr("Audio input changes") onToggled: Config.utilities.toasts.audioInputChanged = checked diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index 7241194..8751219 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -71,6 +71,7 @@ PageBase { first: true icon: "notifications" last: true + settingAnchor: "services-notifications" status: qsTr("Notifications, toasts, timeouts") text: qsTr("Notifications") @@ -85,6 +86,7 @@ PageBase { SpinRow { first: true from: 100 + settingAnchor: "services-media-refresh" stepSize: 50 subtext: qsTr("How often the media position updates (ms)") text: qsTr("Media refresh") @@ -97,6 +99,7 @@ PageBase { SpinRow { from: 0.5 last: true + settingAnchor: "services-system-stats-refresh" stepSize: 0.5 subtext: qsTr("CPU, memory and GPU update interval (seconds)") text: qsTr("System stats refresh") @@ -118,6 +121,7 @@ PageBase { first: true last: true menuItems: playerVariants.instances + settingAnchor: "services-default-player" subtext: qsTr("Preferred media player when several are open") text: qsTr("Default player") @@ -132,6 +136,7 @@ PageBase { SpinRow { first: true from: 1 + settingAnchor: "services-volume-step" stepSize: 1 subtext: qsTr("Amount the volume changes per input (%)") text: qsTr("Volume step") @@ -143,6 +148,7 @@ PageBase { SpinRow { from: 1 + settingAnchor: "services-brightness-step" stepSize: 1 subtext: qsTr("Amount the brightness changes per input (%)") text: qsTr("Brightness step") @@ -154,6 +160,7 @@ PageBase { SpinRow { from: 1 + settingAnchor: "services-brightness-minimum" stepSize: 1 subtext: qsTr("Lowest allowed brightness (%)") text: qsTr("Brightness minimum") @@ -166,6 +173,7 @@ PageBase { SpinRow { from: 50 last: true + settingAnchor: "services-max-volume" stepSize: 5 subtext: qsTr("Upper limit for output volume (%)") text: qsTr("Max volume") @@ -183,6 +191,7 @@ PageBase { SpinRow { first: true from: 10 + settingAnchor: "services-visualizer-resolution" stepSize: 2 subtext: qsTr("Resolution of the audio visualizer") text: qsTr("Visualizer resolution") @@ -194,6 +203,7 @@ PageBase { ToggleRow { checked: Config.general.color.smart + settingAnchor: "services-smart-color-scheme" subtext: qsTr("Derive theme mode from the wallpaper") text: qsTr("Smart color scheme") @@ -205,6 +215,7 @@ PageBase { last: true menuItems: root.gpuItems menuOnTop: true + settingAnchor: "services-gpu" subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type") text: qsTr("GPU") diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index a07c68d..81abe9f 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -49,6 +49,7 @@ PageBase { ToggleRow { checked: Config.background.enabled first: true + settingAnchor: "style-display-wallpaper" text: qsTr("Display wallpaper") onToggled: Config.background.enabled = checked @@ -57,6 +58,7 @@ PageBase { ToggleRow { Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing checked: DynamicColors.transparency.enabled + settingAnchor: "style-transparency" subtext: qsTr("Base %1, layers %2").arg(DynamicColors.transparency.base).arg(DynamicColors.transparency.layers) text: qsTr("Transparency") @@ -67,6 +69,7 @@ PageBase { Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing checked: !DynamicColors.light last: true + settingAnchor: "style-dark-theme" text: qsTr("Dark theme") onToggled: DynamicColors.setMode(checked ? "dark" : "light") @@ -88,6 +91,7 @@ PageBase { checked: Config.general.color.scheduleDark first: true + settingAnchor: "style-schedule-dark-mode" subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) text: qsTr("Schedule dark mode") @@ -129,6 +133,7 @@ PageBase { Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing checked: Config.general.color.scheduleHyprsunset last: true + settingAnchor: "style-schedule-hyprsunset" subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) text: qsTr("Schedule hyprsunset") diff --git a/Modules/Settings/SettingsSearcher.qml b/Modules/Settings/SettingsSearcher.qml new file mode 100644 index 0000000..cbb108d --- /dev/null +++ b/Modules/Settings/SettingsSearcher.qml @@ -0,0 +1,201 @@ +pragma Singleton + +import "../../scripts/fzf.js" as Fzf +import QtQuick +import Quickshell +import ZShell +import qs.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 + + // 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 + + // entries: forward index (one record per setting) + // inverted: token -> [entry id...] + // ranking: token -> { entry id (string): weight } + property var inverted: ({}) + property var ranking: ({}) + + // 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, ">"); + const tokens = tokenize(search); + if (tokens.length === 0) + return escaped; + const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + const pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi"); + return escaped.replace(pattern, `$1`); + } + + // 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 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; + } + + function tokenize(text: string): var { + return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0); + } + + Component.onCompleted: { + try { + const data = JSON.parse(ZUtils.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 { + readonly property string anchor: modelData.anchor ?? "" + readonly property var crumbIcons: modelData.crumbIcons + readonly property var crumbLabels: modelData.crumbLabels + readonly property bool isToggle: togglePath.length > 0 + required property var modelData + readonly property int pageIdx: modelData.pageIdx + readonly property string section: modelData.section ?? "" + readonly property var subPath: modelData.subPath + readonly property string subtext: modelData.subtext ?? "" + readonly property string title: modelData.title + + // 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 ?? "" + // 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 = Config; + 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 = Config; + 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/Settings/SettingsState.qml b/Modules/Settings/SettingsState.qml index 06a753d..e7f8f55 100644 --- a/Modules/Settings/SettingsState.qml +++ b/Modules/Settings/SettingsState.qml @@ -8,14 +8,19 @@ QtObject { property bool animatingContainer property int currentPageIdx property bool isWindow + property string lastAnchor + property list pendingSubPath property ShellScreen screen + property string searchAnchor property bool searchOpen + property string searchText property DesktopEntry selectedApp property BluetoothDevice selectedBtDevice property string selectedWallpaperCategory property list subPageIdxStack signal close + signal highlightSetting(anchor: string) signal subPageClosed signal subPageOpened(idx: int) @@ -24,10 +29,49 @@ QtObject { subPageIdxStack.pop(); } + // 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]); + } + } + function openSubPage(idx: int): void { subPageIdxStack.push(idx); subPageOpened(idx); } - onCurrentPageIdxChanged: subPageIdxStack.length = 0 + onCurrentPageIdxChanged: { + subPageIdxStack = pendingSubPath; + pendingSubPath = []; + } } diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index edf225f..ce7cdd6 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -24,11 +24,12 @@ set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") qt_standard_project_setup(REQUIRES 6.9) function(qml_module arg_TARGET) - cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES") + cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES;RESOURCES") qt_add_qml_module(${arg_TARGET} URI ${arg_URI} VERSION 1.0 SOURCES ${arg_SOURCES} + RESOURCES ${arg_RESOURCES} ) qt_query_qml_module(${arg_TARGET} @@ -47,6 +48,8 @@ function(qml_module arg_TARGET) target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES}) endfunction() +set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIAS "settings-index.json") + qml_module(ZShell URI ZShell SOURCES @@ -57,6 +60,8 @@ qml_module(ZShell toaster.hpp toaster.cpp qalculator.hpp qalculator.cpp zutils.hpp zutils.cpp + RESOURCES + "${SETTINGS_INDEX_JSON}" LIBRARIES Qt::Gui Qt::Quick diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp index 8265485..4aef1fe 100644 --- a/Plugins/ZShell/zutils.cpp +++ b/Plugins/ZShell/zutils.cpp @@ -10,6 +10,7 @@ #include #include #include +#include Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg) @@ -170,6 +171,15 @@ qreal ZUtils::clamp(qreal value, qreal min, qreal max) { return qBound(min, value, max); } +QString ZUtils::settingsIndex() { + QFile file(QStringLiteral(":/qt/qml/ZShell/settings-index.json")); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qCWarning(lcZUtils) << "Failed to open embedded settings index"; + return QString(); + } + return QString::fromUtf8(file.readAll()); +} + #ifndef ZSHELL_VERSION #define ZSHELL_VERSION "" #endif diff --git a/Plugins/ZShell/zutils.hpp b/Plugins/ZShell/zutils.hpp index 1e037cf..1e9b3f8 100644 --- a/Plugins/ZShell/zutils.hpp +++ b/Plugins/ZShell/zutils.hpp @@ -32,6 +32,8 @@ class ZUtils : public QObject { Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max); + Q_INVOKABLE static QString settingsIndex(); + [[nodiscard]] QString version() const; [[nodiscard]] QString qtVersion() const; }; diff --git a/scripts/SettingsIndex.mjs b/scripts/SettingsIndex.mjs deleted file mode 100644 index 319853d..0000000 --- a/scripts/SettingsIndex.mjs +++ /dev/null @@ -1,1312 +0,0 @@ -// Settings search index -// Each entry contains: name, category, categoryName, section, keywords -// - name: The display name of the setting (must match exactly) -// - category: The category key used for navigation -// - categoryName: Human-readable category name for display -// - section: The section header within the category -// - keywords: Additional search terms for better discoverability - -export const settingsIndex = [ - // GENERAL CATEGORY - // General section - { - name: "Logo", - category: "general", - categoryName: "General", - section: "General", - keywords: ["branding", "icon", "image"], - }, - { - name: "Wallpaper path", - category: "general", - categoryName: "General", - section: "General", - keywords: ["background", "image", "path"], - }, - { - name: "Desktop icons", - category: "general", - categoryName: "General", - section: "General", - keywords: ["icons", "desktop", "show"], - }, - { - name: "Date format", - category: "general", - categoryName: "General", - section: "General", - keywords: ["date", "time", "format"], - }, - // Color section - { - name: "Scheme mode", - category: "general", - categoryName: "General", - section: "Color", - keywords: ["dark mode", "light mode", "theme", "color scheme"], - }, - { - name: "Scheme type", - category: "general", - categoryName: "General", - section: "Color", - keywords: [ - "vibrant", - "expressive", - "monochrome", - "neutral", - "tonal", - "fidelity", - "content", - "rainbow", - "fruit salad", - ], - }, - { - name: "Automatic color scheme", - category: "general", - categoryName: "General", - section: "Color", - keywords: ["auto", "wallpaper", "generate", "color"], - }, - { - name: "Smart color scheme", - category: "general", - categoryName: "General", - section: "Color", - keywords: ["intelligent", "adaptive", "color"], - }, - { - name: "Schedule dark mode", - category: "general", - categoryName: "General", - section: "Color", - keywords: ["dark mode", "night", "time", "schedule", "automatic"], - }, - { - name: "Schedule Hyprsunset", - category: "general", - categoryName: "General", - section: "Color", - keywords: [ - "night light", - "blue light", - "temperature", - "warm", - "schedule", - ], - }, - // Default Apps section - { - name: "Terminal", - category: "general", - categoryName: "General", - section: "Default Apps", - keywords: ["console", "command", "shell", "default app"], - }, - { - name: "Audio", - category: "general", - categoryName: "General", - section: "Default Apps", - keywords: ["sound", "volume", "mixer", "default app"], - }, - { - name: "Playback", - category: "general", - categoryName: "General", - section: "Default Apps", - keywords: ["media", "player", "music", "video", "default app"], - }, - { - name: "Explorer", - category: "general", - categoryName: "General", - section: "Default Apps", - keywords: ["file manager", "files", "browser", "default app"], - }, - - // WALLPAPER CATEGORY - { - name: "Enable wallpaper rendering", - category: "wallpaper", - categoryName: "Wallpaper", - section: "Wallpaper", - keywords: ["background", "enable", "show"], - }, - { - name: "Fade duration", - category: "wallpaper", - categoryName: "Wallpaper", - section: "Wallpaper", - keywords: ["transition", "animation", "crossfade"], - }, - - // BAR CATEGORY - // Bar section - { - name: "Auto hide", - category: "bar", - categoryName: "Bar", - section: "Bar", - keywords: ["hide", "visibility", "autohide", "panel"], - }, - { - name: "Height", - category: "bar", - categoryName: "Bar", - section: "Bar", - keywords: ["size", "panel", "thickness"], - }, - { - name: "Rounding", - category: "bar", - categoryName: "Bar", - section: "Bar", - keywords: ["corners", "radius", "rounded"], - }, - { - name: "Border", - category: "bar", - categoryName: "Bar", - section: "Bar", - keywords: ["outline", "stroke", "edge"], - }, - - { - name: "Smoothing", - category: "bar", - categoryName: "Bar", - section: "Bar", - keywords: ["smoothing", "rounding"], - }, - // System tray section - { - name: "Tray icon size", - category: "bar", - categoryName: "Bar", - section: "Tray", - keywords: ["tray", "icon", "size"], - }, - // Popouts section - { - name: "Tray", - category: "bar", - categoryName: "Bar", - section: "Popouts", - keywords: ["system tray", "icons", "popout"], - }, - { - name: "Audio", - category: "bar", - categoryName: "Bar", - section: "Popouts", - keywords: ["sound", "volume", "popout"], - }, - { - name: "Active window", - category: "bar", - categoryName: "Bar", - section: "Popouts", - keywords: ["window title", "current", "popout"], - }, - { - name: "Resources", - category: "bar", - categoryName: "Bar", - section: "Popouts", - keywords: ["cpu", "memory", "usage", "popout"], - }, - { - name: "Clock", - category: "bar", - categoryName: "Bar", - section: "Popouts", - keywords: ["time", "date", "popout"], - }, - { - name: "Network", - category: "bar", - categoryName: "Bar", - section: "Popouts", - keywords: ["wifi", "internet", "connection", "popout"], - }, - { - name: "Power", - category: "bar", - categoryName: "Bar", - section: "Popouts", - keywords: ["battery", "upower", "charging", "popout"], - }, - // Entries section - { - name: "Bar entries", - category: "bar", - categoryName: "Bar", - section: "Entries", - keywords: ["modules", "widgets", "items", "order"], - }, - // Dock section - { - name: "Enable dock", - category: "bar", - categoryName: "Bar", - section: "Dock", - keywords: ["taskbar", "show", "visibility"], - }, - { - name: "Dock height", - category: "bar", - categoryName: "Bar", - section: "Dock", - keywords: ["size", "taskbar", "thickness"], - }, - { - name: "Hover to reveal", - category: "bar", - categoryName: "Bar", - section: "Dock", - keywords: ["autohide", "mouse", "show"], - }, - { - name: "Pin on startup", - category: "bar", - categoryName: "Bar", - section: "Dock", - keywords: ["always visible", "pinned", "show"], - }, - { - name: "Pinned apps", - category: "bar", - categoryName: "Bar", - section: "Dock", - keywords: ["favorites", "applications", "shortcuts"], - }, - { - name: "Ignored app regexes", - category: "bar", - categoryName: "Bar", - section: "Dock", - keywords: ["filter", "exclude", "hide", "regex"], - }, - - // LOCKSCREEN CATEGORY - { - name: "Recolor logo", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["logo", "color", "tint"], - }, - { - name: "Enable fingerprint", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["fprint", "biometric", "unlock"], - }, - { - name: "Max fingerprint tries", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["attempts", "limit", "fprint"], - }, - { - name: "Show notification details", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["notification", "hide", "privacy"], - }, - { - name: "Show notification icon", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["notification", "hide", "icon"], - }, - { - name: "Blur amount", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["background", "blur", "effect"], - }, - { - name: "Height multiplier", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["size", "scale", "height"], - }, - { - name: "Aspect ratio", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["ratio", "width", "height"], - }, - { - name: "Center width", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Lockscreen", - keywords: ["size", "width", "center"], - }, - { - name: "Idle Monitors", - category: "lockscreen", - categoryName: "Lockscreen", - section: "Idle", - keywords: ["timeout", "sleep", "idle", "lock", "suspend"], - }, - - // SERVICES CATEGORY - // Services section - { - name: "Weather location", - category: "services", - categoryName: "Services", - section: "Services", - keywords: ["city", "location", "weather"], - }, - { - name: "Use Fahrenheit", - category: "services", - categoryName: "Services", - section: "Services", - keywords: ["temperature", "celsius", "units"], - }, - { - name: "Use twelve hour clock", - category: "services", - categoryName: "Services", - section: "Services", - keywords: ["time", "format", "12h", "24h", "am pm"], - }, - { - name: "Enable ddcutil service", - category: "services", - categoryName: "Services", - section: "Services", - keywords: ["monitor", "brightness", "ddc"], - }, - { - name: "GPU type", - category: "services", - categoryName: "Services", - section: "Services", - keywords: ["graphics", "nvidia", "amd", "intel"], - }, - // Media section - { - name: "Audio increment", - category: "services", - categoryName: "Services", - section: "Media", - keywords: ["volume", "step", "increment"], - }, - { - name: "Brightness increment", - category: "services", - categoryName: "Services", - section: "Media", - keywords: ["screen", "step", "increment"], - }, - { - name: "Minimum brightness", - category: "services", - categoryName: "Services", - section: "Media", - keywords: ["brightness", "minimum", "screen"], - }, - { - name: "Max volume", - category: "services", - categoryName: "Services", - section: "Media", - keywords: ["audio", "limit", "maximum"], - }, - { - name: "Default player", - category: "services", - categoryName: "Services", - section: "Media", - keywords: ["music", "media", "mpris"], - }, - { - name: "Visualizer bars", - category: "services", - categoryName: "Services", - section: "Media", - keywords: ["audio", "visualization", "cava"], - }, - { - name: "Player aliases", - category: "services", - categoryName: "Services", - section: "Media", - keywords: ["mpris", "rename", "alias"], - }, - - // NOTIFICATIONS CATEGORY - // Notifications section - { - name: "Expire notifications", - category: "notifications", - categoryName: "Notifications", - section: "Notifications", - keywords: ["auto dismiss", "timeout", "expire"], - }, - { - name: "Default expire timeout", - category: "notifications", - categoryName: "Notifications", - section: "Notifications", - keywords: ["duration", "time", "dismiss"], - }, - { - name: "App notification cooldown", - category: "notifications", - categoryName: "Notifications", - section: "Notifications", - keywords: ["rate limit", "spam", "cooldown"], - }, - { - name: "Clear threshold", - category: "notifications", - categoryName: "Notifications", - section: "Notifications", - keywords: ["swipe", "dismiss", "threshold"], - }, - { - name: "Expand threshold", - category: "notifications", - categoryName: "Notifications", - section: "Notifications", - keywords: ["swipe", "expand", "threshold"], - }, - { - name: "Action on click", - category: "notifications", - categoryName: "Notifications", - section: "Notifications", - keywords: ["click", "action", "default"], - }, - { - name: "Group preview count", - category: "notifications", - categoryName: "Notifications", - section: "Notifications", - keywords: ["group", "stack", "preview"], - }, - // Sizes section - { - name: "Width", - category: "notifications", - categoryName: "Notifications", - section: "Sizes", - keywords: ["notification", "size", "width"], - }, - { - name: "Image size", - category: "notifications", - categoryName: "Notifications", - section: "Sizes", - keywords: ["notification", "image", "icon"], - }, - { - name: "Badge size", - category: "notifications", - categoryName: "Notifications", - section: "Sizes", - keywords: ["notification", "badge", "app icon"], - }, - - // SIDEBAR CATEGORY - { - name: "Enable sidebar", - category: "sidebar", - categoryName: "Sidebar", - section: "Sidebar", - keywords: ["show", "panel", "side"], - }, - { - name: "Width", - category: "sidebar", - categoryName: "Sidebar", - section: "Sidebar", - keywords: ["size", "panel", "width"], - }, - - // UTILITIES CATEGORY - // Utilities section - { - name: "Enable utilities", - category: "utilities", - categoryName: "Utilities", - section: "Utilities", - keywords: ["show", "enable"], - }, - { - name: "Max toasts", - category: "utilities", - categoryName: "Utilities", - section: "Utilities", - keywords: ["notifications", "limit", "maximum"], - }, - { - name: "Panel width", - category: "utilities", - categoryName: "Utilities", - section: "Utilities", - keywords: ["size", "width"], - }, - { - name: "Toast width", - category: "utilities", - categoryName: "Utilities", - section: "Utilities", - keywords: ["notification", "size", "width"], - }, - // Clipboard section - { - name: "Enable clipboard history viewer", - category: "utilities", - categoryName: "Utilities", - section: "Clipboard", - keywords: ["enable", "clipboard"], - }, - { - name: "Max entries visible", - category: "utilities", - categoryName: "Utilities", - section: "Clipboard", - keywords: ["clipboard", "max"], - }, - { - name: "Entry height", - category: "utilities", - categoryName: "Utilities", - section: "Clipboard", - keywords: ["height", "entry"], - }, - { - name: "Entry width", - category: "utilities", - categoryName: "Utilities", - section: "Clipboard", - keywords: ["width", "entry"], - }, - { - name: "Minimum preview width", - category: "utilities", - categoryName: "Utilities", - section: "Clipboard", - keywords: ["minimum", "preview", "width"], - }, - { - name: "Maximum preview width", - category: "utilities", - categoryName: "Utilities", - section: "Clipboard", - keywords: ["maximum", "preview", "width"], - }, - // Toasts section - { - name: "Config loaded", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "config"], - }, - { - name: "Charging changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "battery", "power"], - }, - { - name: "Game mode changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "gaming"], - }, - { - name: "Do not disturb changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "dnd", "quiet"], - }, - { - name: "Audio output changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "speaker", "headphones"], - }, - { - name: "Audio input changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "microphone"], - }, - { - name: "Caps lock changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "keyboard"], - }, - { - name: "Num lock changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "keyboard"], - }, - { - name: "Keyboard layout changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "language", "input"], - }, - { - name: "VPN changed", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "vpn", "network"], - }, - { - name: "Now playing", - category: "utilities", - categoryName: "Utilities", - section: "Toasts", - keywords: ["toast", "notification", "music", "media"], - }, - // VPN section - { - name: "Enable VPN integration", - category: "utilities", - categoryName: "Utilities", - section: "VPN", - keywords: ["vpn", "network", "connection"], - }, - { - name: "Provider", - category: "utilities", - categoryName: "Utilities", - section: "VPN", - keywords: ["vpn", "service", "provider"], - }, - - // DASHBOARD CATEGORY - // Dashboard section - { - name: "Enable dashboard", - category: "dashboard", - categoryName: "Dashboard", - section: "Dashboard", - keywords: ["show", "enable", "panel"], - }, - { - name: "Media update interval", - category: "dashboard", - categoryName: "Dashboard", - section: "Dashboard", - keywords: ["refresh", "interval", "music"], - }, - { - name: "Resource update interval", - category: "dashboard", - categoryName: "Dashboard", - section: "Dashboard", - keywords: ["refresh", "interval", "cpu", "memory"], - }, - { - name: "Drag threshold", - category: "dashboard", - categoryName: "Dashboard", - section: "Dashboard", - keywords: ["swipe", "gesture", "threshold"], - }, - // Performance section - { - name: "Show battery", - category: "dashboard", - categoryName: "Dashboard", - section: "Performance", - keywords: ["power", "battery", "visibility"], - }, - { - name: "Show GPU", - category: "dashboard", - categoryName: "Dashboard", - section: "Performance", - keywords: ["graphics", "gpu", "visibility"], - }, - { - name: "Show CPU", - category: "dashboard", - categoryName: "Dashboard", - section: "Performance", - keywords: ["processor", "cpu", "visibility"], - }, - { - name: "Show memory", - category: "dashboard", - categoryName: "Dashboard", - section: "Performance", - keywords: ["ram", "memory", "visibility"], - }, - { - name: "Show storage", - category: "dashboard", - categoryName: "Dashboard", - section: "Performance", - keywords: ["disk", "storage", "visibility"], - }, - { - name: "Show network", - category: "dashboard", - categoryName: "Dashboard", - section: "Performance", - keywords: ["internet", "network", "visibility"], - }, - // Layout Sizes section (read-only) - { - name: "Tab indicator height", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "tab", "indicator"], - }, - { - name: "Tab indicator spacing", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "tab", "spacing"], - }, - { - name: "Info width", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "info", "width"], - }, - { - name: "Info icon size", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "icon"], - }, - { - name: "Date time width", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "clock", "date"], - }, - { - name: "Media width", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "music", "player"], - }, - { - name: "Media progress sweep", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "progress", "arc"], - }, - { - name: "Media progress thickness", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "progress", "stroke"], - }, - { - name: "Resource progress thickness", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "progress", "stroke"], - }, - { - name: "Weather width", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "weather"], - }, - { - name: "Media cover art size", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "album", "artwork"], - }, - { - name: "Media visualiser size", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "visualizer", "cava"], - }, - { - name: "Resource size", - category: "dashboard", - categoryName: "Dashboard", - section: "Layout Sizes", - keywords: ["size", "cpu", "memory"], - }, - - // APPEARANCE CATEGORY - // Scale section - { - name: "Rounding scale", - category: "appearance", - categoryName: "Appearance", - section: "Scale", - keywords: ["corners", "radius", "scale"], - }, - { - name: "Spacing scale", - category: "appearance", - categoryName: "Appearance", - section: "Scale", - keywords: ["gap", "margin", "scale"], - }, - { - name: "Padding scale", - category: "appearance", - categoryName: "Appearance", - section: "Scale", - keywords: ["padding", "inset", "scale"], - }, - { - name: "Font size scale", - category: "appearance", - categoryName: "Appearance", - section: "Scale", - keywords: ["text", "font", "scale"], - }, - { - name: "Animation duration scale", - category: "appearance", - categoryName: "Appearance", - section: "Scale", - keywords: ["animation", "speed", "duration", "scale"], - }, - { - name: "Deform animation scale", - category: "appearance", - categoryName: "Appearance", - section: "Scale", - keywords: ["animation", "deform", "scale"], - }, - // Fonts section - { - name: "Sans family", - category: "appearance", - categoryName: "Appearance", - section: "Fonts", - keywords: ["font", "typeface", "sans-serif"], - }, - { - name: "Monospace family", - category: "appearance", - categoryName: "Appearance", - section: "Fonts", - keywords: ["font", "typeface", "mono", "code"], - }, - { - name: "Material family", - category: "appearance", - categoryName: "Appearance", - section: "Fonts", - keywords: ["font", "icons", "material"], - }, - { - name: "Clock family", - category: "appearance", - categoryName: "Appearance", - section: "Fonts", - keywords: ["font", "time", "clock"], - }, - // Animation section - { - name: "Media GIF speed adjustment", - category: "appearance", - categoryName: "Appearance", - section: "Animation", - keywords: ["gif", "speed", "animation"], - }, - { - name: "Session GIF speed", - category: "appearance", - categoryName: "Appearance", - section: "Animation", - keywords: ["gif", "speed", "animation", "login"], - }, - // Transparency section - { - name: "Enable transparency", - category: "appearance", - categoryName: "Appearance", - section: "Transparency", - keywords: ["opacity", "translucent", "blur"], - }, - { - name: "Base opacity", - category: "appearance", - categoryName: "Appearance", - section: "Transparency", - keywords: ["opacity", "alpha", "transparency"], - }, - { - name: "Layer opacity", - category: "appearance", - categoryName: "Appearance", - section: "Transparency", - keywords: ["opacity", "alpha", "layers"], - }, - - // OSD CATEGORY - // On Screen Display section - { - name: "Enable OSD", - category: "osd", - categoryName: "On screen display", - section: "On Screen Display", - keywords: ["show", "enable", "overlay"], - }, - { - name: "Hide delay", - category: "osd", - categoryName: "On screen display", - section: "On Screen Display", - keywords: ["timeout", "duration", "dismiss"], - }, - { - name: "Enable brightness OSD", - category: "osd", - categoryName: "On screen display", - section: "On Screen Display", - keywords: ["screen", "brightness", "overlay"], - }, - { - name: "Enable microphone OSD", - category: "osd", - categoryName: "On screen display", - section: "On Screen Display", - keywords: ["mic", "audio", "overlay"], - }, - { - name: "Brightness on all monitors", - category: "osd", - categoryName: "On screen display", - section: "On Screen Display", - keywords: ["multi-monitor", "all screens"], - }, - // Sizes section - { - name: "Slider width", - category: "osd", - categoryName: "On screen display", - section: "Sizes", - keywords: ["size", "osd", "width"], - }, - { - name: "Slider height", - category: "osd", - categoryName: "On screen display", - section: "Sizes", - keywords: ["size", "osd", "height"], - }, - - // SCREENSHOT CATEGORY - // Screenshot section - { - name: "Enable effects", - category: "screenshot", - categoryName: "Screenshot", - section: "Screenshot", - keywords: ["effects", "shadow", "screenshot"], - }, - { - name: "Effects mode", - category: "screenshot", - categoryName: "Screenshot", - section: "Screenshot", - keywords: ["effects", "mode"], - }, - { - name: "Corner radius", - category: "screenshot", - categoryName: "Screenshot", - section: "Screenshot", - keywords: ["corner", "radius"], - }, - { - name: "Enable shadow", - category: "screenshot", - categoryName: "Screenshot", - section: "Screenshot", - keywords: ["drop", "shadow"], - }, - { - name: "Enable rounded corners", - category: "screenshot", - categoryName: "Screenshot", - section: "Screenshot", - keywords: ["rounded", "corners"], - }, - { - name: "Shadow blur amount", - category: "screenshot", - categoryName: "Screenshot", - section: "Screenshot", - keywords: ["blur", "shadow", "radius"], - }, - // { - // name: "Shadow color", - // category: "screenshot", - // categoryName: "Screenshot", - // section: "Screenshot", - // keywords: ["color", "shadow"], - // }, - { - name: "Shadow offset X", - category: "screenshot", - categoryName: "Screenshot", - section: "Screenshot", - keywords: ["offset", "shadow"], - }, - { - name: "Shadow offset Y", - category: "screenshot", - categoryName: "Screenshot", - section: "Screenshot", - keywords: ["offset", "shadow"], - }, - - // LAUNCHER CATEGORY - // Launcher section - { - name: "Max apps shown", - category: "launcher", - categoryName: "Launcher", - section: "Launcher", - keywords: ["limit", "apps", "search results"], - }, - { - name: "Max wallpapers shown", - category: "launcher", - categoryName: "Launcher", - section: "Launcher", - keywords: ["limit", "wallpapers", "search results"], - }, - { - name: "Action prefix", - category: "launcher", - categoryName: "Launcher", - section: "Launcher", - keywords: ["command", "prefix", "action"], - }, - { - name: "Special prefix", - category: "launcher", - categoryName: "Launcher", - section: "Launcher", - keywords: ["command", "prefix", "special"], - }, - { - name: "Use UWSM launch command", - category: "launcher", - categoryName: "Launcher", - section: "Launcher", - keywords: ["command", "uwsm", "systemd"], - }, - // Fuzzy Search section - { - name: "Apps", - category: "launcher", - categoryName: "Launcher", - section: "Fuzzy Search", - keywords: ["fuzzy", "search", "applications"], - }, - { - name: "Actions", - category: "launcher", - categoryName: "Launcher", - section: "Fuzzy Search", - keywords: ["fuzzy", "search", "actions"], - }, - { - name: "Schemes", - category: "launcher", - categoryName: "Launcher", - section: "Fuzzy Search", - keywords: ["fuzzy", "search", "color schemes"], - }, - { - name: "Variants", - category: "launcher", - categoryName: "Launcher", - section: "Fuzzy Search", - keywords: ["fuzzy", "search", "variants"], - }, - { - name: "Wallpapers", - category: "launcher", - categoryName: "Launcher", - section: "Fuzzy Search", - keywords: ["fuzzy", "search", "backgrounds"], - }, - // Sizes section - { - name: "Item width", - category: "launcher", - categoryName: "Launcher", - section: "Sizes", - keywords: ["size", "app", "width"], - }, - { - name: "Item height", - category: "launcher", - categoryName: "Launcher", - section: "Sizes", - keywords: ["size", "app", "height"], - }, - { - name: "Wallpaper width", - category: "launcher", - categoryName: "Launcher", - section: "Sizes", - keywords: ["size", "wallpaper", "width"], - }, - { - name: "Wallpaper height", - category: "launcher", - categoryName: "Launcher", - section: "Sizes", - keywords: ["size", "wallpaper", "height"], - }, - // Actions section - { - name: "Launcher actions", - category: "launcher", - categoryName: "Launcher", - section: "Actions", - keywords: ["commands", "shortcuts", "actions"], - }, -]; - -// Helper function to search with keywords first, then fuzzy fallback -export function searchSettings(query, fuzzyModule) { - if (!query || query.trim() === "") { - return []; - } - - const q = query.toLowerCase().trim(); - const results = []; - const seen = new Set(); - - // 1. Exact keyword match (highest priority) - for (const setting of settingsIndex) { - const key = `${setting.category}:${setting.name}`; - if (seen.has(key)) continue; - - for (const keyword of setting.keywords) { - if (keyword.toLowerCase() === q) { - results.push( - Object.assign({}, setting, { - matchType: "exact-keyword", - score: 1.0, - }) - ); - seen.add(key); - break; - } - } - } - - // 2. Partial keyword match - for (const setting of settingsIndex) { - const key = `${setting.category}:${setting.name}`; - if (seen.has(key)) continue; - - for (const keyword of setting.keywords) { - if (keyword.toLowerCase().includes(q)) { - results.push( - Object.assign({}, setting, { - matchType: "partial-keyword", - score: 0.8, - }) - ); - seen.add(key); - break; - } - } - } - - // 3. Name contains query - for (const setting of settingsIndex) { - const key = `${setting.category}:${setting.name}`; - if (seen.has(key)) continue; - - if (setting.name.toLowerCase().includes(q)) { - results.push( - Object.assign({}, setting, { - matchType: "name-contains", - score: 0.7, - }) - ); - seen.add(key); - } - } - - // 4. Fuzzy match on name (fallback for typos) - if (fuzzyModule && results.length < 10) { - const fuzzyTargets = settingsIndex - .filter((s) => !seen.has(`${s.category}:${s.name}`)) - .map((s) => Object.assign({}, s, { _searchTarget: s.name })); - - if (fuzzyTargets.length > 0) { - const fuzzyResults = fuzzyModule.go(query, fuzzyTargets, { - key: "_searchTarget", - limit: 10 - results.length, - threshold: 0.3, - }); - - for (const r of fuzzyResults) { - const setting = r.obj; - results.push( - Object.assign({}, setting, { - matchType: "fuzzy", - score: r.score * 0.6, - _searchTarget: undefined, - }) - ); - } - } - } - - // Sort by score descending - results.sort((a, b) => b.score - a.score); - - return results; -} diff --git a/scripts/build-settings-index.py b/scripts/build-settings-index.py new file mode 100644 index 0000000..d8a0419 --- /dev/null +++ b/scripts/build-settings-index.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +"""Build-time settings index extractor for the settings settings search. + +Parses the settings 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|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|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*(?:Config)\.([\w.]+)\s*$') +ONTOGGLED_RE = re.compile( + r'^\s*onToggled:\s*(?: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", "and", "or", "to", "on", "in", "for"} + + +def find_pages_dir(settings: Path) -> Path: + return settings / "Pages" + + +def discover_files(settings: Path) -> dict[str, Path]: + """component name -> file path, discovered by walking pages/.""" + files: dict[str, Path] = {} + for p in find_pages_dir(settings).rglob("*.qml"): + files[p.stem] = p + return files + + +PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)') +PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"') + + +def parse_page_registry(settings: Path) -> list[tuple[str, str]]: + text = (settings / "PageRegistry.qml").read_text().splitlines() + + start = next( + i for i, line in enumerate(text) + if re.search(r'\bpages\s*:\s*\[', line) + ) + + out: list[tuple[str, str]] = [] + i = start + 1 + + while i < len(text): + line = text[i].strip() + + if line.startswith("]"): + break + + if line.startswith("//") or not line: + i += 1 + continue + + if line.startswith("{"): + name = None + icon = None + i += 1 + + while i < len(text): + s = text[i].strip() + + if s.startswith("}"): + if name is not None: + out.append((icon or "tune", name)) + break + + if name is None: + m = PAGE_NAME_RE.match(text[i]) + if m: + name = m.group(1) + + if icon is None: + mi = PAGE_ICON_RE.match(text[i]) + if mi: + icon = mi.group(1) + + i += 1 + + i += 1 + + return out + + +BLOCK_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$') + + +def _strip_comment(line: str) -> str: + return line.split("//", 1)[0].rstrip() + + +def parse_block(lines: list[str], i: int) -> tuple[str, list[tuple[str, list]], int]: + line = _strip_comment(lines[i]).strip() + m = BLOCK_RE.match(line) + if not m: + raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}") + + name = m.group(1) + i += 1 + children: list[tuple[str, list]] = [] + + while i < len(lines): + s = _strip_comment(lines[i]).strip() + if not s: + i += 1 + continue + + if s.startswith("}"): + return name, children, i + 1 + + if BLOCK_RE.match(s): + child_name, child_children, i = parse_block(lines, i) + children.append((child_name, child_children)) + continue + + i += 1 + + raise ValueError(f"Unterminated block: {name}") + + +def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: + name, children = block + + # Outer array entry: Component { ... } + if name != "Component": + return [name] + + for child_name, child_children in children: + # StackPage { Component { FooPage { } } ... } + if child_name == "StackPage": + out: list[str] = [] + for grand_name, grand_children in child_children: + if grand_name == "Component": + out.extend(collect_page_names((grand_name, grand_children))) + return out + + # Component { PlaceholderComp { } } + if child_name != "Component": + return [child_name] + + return [] + + +def parse_page_comps(settings: Path) -> list[list[str]]: + text = (settings / "PageCompRegistry.qml").read_text().splitlines() + + start = next( + i for i, line in enumerate(text) + if re.search(r'\bpageComps\s*:\s*\[', _strip_comment(line)) + ) + + comps: list[list[str]] = [] + i = start + 1 + + while i < len(text): + s = _strip_comment(text[i]).strip() + if not s: + i += 1 + continue + if s.startswith("]"): + break + + if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component": + block = parse_block(text, i) + names = collect_page_names((block[0], block[1])) + if names: + comps.append(names) + i = block[2] + continue + + i += 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(settings: Path, files: dict[str, Path]) -> dict[str, dict]: + comps = parse_page_comps(settings) + registry = parse_page_registry(settings) + + # 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, + "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 + settings = Path(sys.argv[1]) + out = Path(sys.argv[2]) + files = discover_files(settings) + nav = build_nav_map(settings, 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}") + print("files:", len(files)) + print("comps:", len(parse_page_comps(settings))) + print("registry:", len(parse_page_registry(settings))) + print("nav:", len(nav)) + print("entries:", len(entries)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 865b5bda5359ab6df2020ee6430bd633112cc7e0 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 30 Jun 2026 22:27:37 +0200 Subject: [PATCH 2/3] chore: cleanup comments --- Modules/Settings/Common/PageBase.qml | 25 +++------ Modules/Settings/NavPane/NavLocations.qml | 26 --------- Modules/Settings/SettingsSearcher.qml | 48 +---------------- Modules/Settings/SettingsState.qml | 11 ---- scripts/build-settings-index.py | 65 ++--------------------- 5 files changed, 11 insertions(+), 164 deletions(-) diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index 0a474ea..4bf164f 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -9,8 +9,6 @@ import qs.Config ColumnLayout { id: root - // Enables a smooth scroll animation only for search jumps, so normal - // flicking stays instant. property bool animateScroll: false readonly property int cappedWidth: Math.min(800, width) default property Item contentChild @@ -31,7 +29,7 @@ ColumnLayout { function findAnchor(item: Item, anchor: string): Item { if (!item) return null; - if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property + if (item.settingAnchor !== undefined && item.settingAnchor === anchor) return item; const kids = item.children; for (let i = 0; i < kids.length; i++) { @@ -42,14 +40,12 @@ ColumnLayout { return null; } - // 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 + if (row && row.flashHighlight !== undefined) + row.flashHighlight(); } - // When the settings search jumps to this page, scroll to the matching row. function scrollToAnchor(anchor: string): bool { if (!anchor || !contentChild) return false; @@ -57,8 +53,6 @@ ColumnLayout { 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 + Appearance.padding.large; const minY = -flickable.topMargin; const maxY = Math.max(minY, flickable.contentHeight + flickable.bottomMargin - flickable.height); @@ -66,8 +60,8 @@ ColumnLayout { 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 + if (row.flashHighlight !== undefined) + row.flashHighlight(); return true; } @@ -86,10 +80,6 @@ ColumnLayout { 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++; @@ -119,9 +109,8 @@ ColumnLayout { target: root.sState } - MouseArea { // Prevent clicks from reaching flickable - Layout.bottomMargin: -flickable.topMargin // Extra height to block clicks on flickable top margin - + MouseArea { + Layout.bottomMargin: -flickable.topMargin implicitHeight: header.implicitHeight - Layout.bottomMargin implicitWidth: header.implicitWidth z: 1 diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index 18326d9..a65fd36 100644 --- a/Modules/Settings/NavPane/NavLocations.qml +++ b/Modules/Settings/NavPane/NavLocations.qml @@ -10,9 +10,6 @@ import qs.Modules.Settings VerticalFadeFlickable { id: root - // 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 = ({}); @@ -154,12 +151,6 @@ VerticalFadeFlickable { ListView { id: resultList - // Grouped results: the model is one entry per top-level page, and - // each delegate renders that page's heading plus the matching - // settings joined into a single rounded card (first/last rounded, - // middles square, thin dividers between them), like the Android - // settings search. A ScriptModel diffs the groups so only changed - // ones animate. Scrolling is delegated to the outer flickable. Layout.fillWidth: true cacheBuffer: 10000 implicitHeight: contentHeight @@ -175,7 +166,6 @@ VerticalFadeFlickable { spacing: Appearance.spacing.small width: resultList.width - // Group heading: the top-level page name, shown once. RowLayout { Layout.fillWidth: true Layout.leftMargin: Appearance.padding.small @@ -196,7 +186,6 @@ VerticalFadeFlickable { } } - // The matching settings, joined into one card. ColumnLayout { Layout.fillWidth: true spacing: 0 @@ -220,9 +209,6 @@ VerticalFadeFlickable { 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 ? Appearance.rounding.large : 0 topRightRadius: isFirst ? Appearance.rounding.large : 0 @@ -242,11 +228,9 @@ VerticalFadeFlickable { anchors.fill: parent anchors.margins: Appearance.padding.large - // Leave room on the right for the toggle switch. anchors.rightMargin: result.modelData.togglePath ? toggle.width + Appearance.padding.large * 2 : Appearance.padding.large spacing: Appearance.spacing.small / 2 - // Location line: deepest icon + "Section > sub", faint. CustomText { Layout.fillWidth: true color: DynamicColors.palette.m3onSurfaceVariant @@ -261,7 +245,6 @@ VerticalFadeFlickable { visible: text.length > 0 } - // The setting itself, most prominent. CustomText { Layout.fillWidth: true color: DynamicColors.palette.m3onSurface @@ -271,7 +254,6 @@ VerticalFadeFlickable { textFormat: Text.StyledText } - // Optional description, faintest and smallest. CustomText { Layout.fillWidth: true color: DynamicColors.palette.m3outline @@ -312,15 +294,7 @@ VerticalFadeFlickable { } } } - - // The list's implicitHeight tracks contentHeight; while items animate - // their position the reported height fluctuates, which left gaps in - // the surrounding layout on fast typing. So additions, removals and - // reordering are all instant - no transitions - keeping the height - // correct at every frame. model: ScriptModel { - // Match groups by their page so content updates in place rather - // than rebuilding the delegate when ranking shifts the order. objectProp: "pageIdx" values: root.groups } diff --git a/Modules/Settings/SettingsSearcher.qml b/Modules/Settings/SettingsSearcher.qml index cbb108d..eb61042 100644 --- a/Modules/Settings/SettingsSearcher.qml +++ b/Modules/Settings/SettingsSearcher.qml @@ -6,37 +6,13 @@ import Quickshell import ZShell import qs.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 - // 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 - - // entries: forward index (one record per setting) - // inverted: token -> [entry id...] - // ranking: token -> { entry id (string): weight } property var inverted: ({}) property var ranking: ({}) - // 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, ">"); const tokens = tokenize(search); @@ -47,9 +23,6 @@ Singleton { return escaped.replace(pattern, `$1`); } - // 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; @@ -71,31 +44,21 @@ Singleton { 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 } + const matches = root.lookup(token); 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) @@ -127,9 +90,6 @@ Singleton { 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 @@ -164,12 +124,7 @@ Singleton { readonly property var subPath: modelData.subPath readonly property string subtext: modelData.subtext ?? "" readonly property string title: modelData.title - - // 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 ?? "" - // 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; @@ -183,7 +138,6 @@ Singleton { return obj ?? false; } - // Write `value` back to the config property the path points at. function setToggle(value: bool): void { if (!isToggle) return; diff --git a/Modules/Settings/SettingsState.qml b/Modules/Settings/SettingsState.qml index e7f8f55..79e80d8 100644 --- a/Modules/Settings/SettingsState.qml +++ b/Modules/Settings/SettingsState.qml @@ -29,35 +29,24 @@ QtObject { subPageIdxStack.pop(); } - // 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++) diff --git a/scripts/build-settings-index.py b/scripts/build-settings-index.py index d8a0419..a5315e1 100644 --- a/scripts/build-settings-index.py +++ b/scripts/build-settings-index.py @@ -1,22 +1,3 @@ -#!/usr/bin/env python3 -"""Build-time settings index extractor for the settings settings search. - -Parses the settings 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 @@ -29,7 +10,6 @@ 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()) @@ -37,18 +17,11 @@ ROW_RE = re.compile( r'^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|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*(?:Config)\.([\w.]+)\s*$') ONTOGGLED_RE = re.compile( r'^\s*onToggled:\s*(?: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", "and", "or", "to", "on", "in", "for"} @@ -58,7 +31,6 @@ def find_pages_dir(settings: Path) -> Path: def discover_files(settings: Path) -> dict[str, Path]: - """component name -> file path, discovered by walking pages/.""" files: dict[str, Path] = {} for p in find_pages_dir(settings).rglob("*.qml"): files[p.stem] = p @@ -159,12 +131,10 @@ def parse_block(lines: list[str], i: int) -> tuple[str, list[tuple[str, list]], def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: name, children = block - # Outer array entry: Component { ... } if name != "Component": return [name] for child_name, child_children in children: - # StackPage { Component { FooPage { } } ... } if child_name == "StackPage": out: list[str] = [] for grand_name, grand_children in child_children: @@ -172,7 +142,6 @@ def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: out.extend(collect_page_names((grand_name, grand_children))) return out - # Component { PlaceholderComp { } } if child_name != "Component": return [child_name] @@ -212,8 +181,6 @@ def parse_page_comps(settings: Path) -> list[list[str]]: 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): @@ -228,13 +195,10 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: comps = parse_page_comps(settings) registry = parse_page_registry(settings) - # 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: @@ -242,8 +206,8 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: 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 + section = "" + expect_section = False for ln in read_lines(pf): if SECTION_RE.match(ln): expect_section = True @@ -275,24 +239,14 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: 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]) @@ -302,8 +256,6 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: if pos >= 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) @@ -325,8 +277,6 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: 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: @@ -350,10 +300,9 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict] if not pf: continue lines = read_lines(pf) - section = "" # text of the most recent SectionHeader + section = "" 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]) @@ -386,15 +335,12 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict] 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({ @@ -412,7 +358,6 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict] 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): @@ -423,10 +368,8 @@ def build_inverted_and_ranking(entries: list[dict]): 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()} @@ -442,8 +385,6 @@ def main() -> int: nav = build_nav_map(settings, 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({ From 355106cb2b7ab69e92eae7540dce15dc4950d27a Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 30 Jun 2026 22:40:08 +0200 Subject: [PATCH 3/3] add radius anim to search results --- Modules/Settings/NavPane/NavLocations.qml | 32 +++++++++++------------ 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index a65fd36..ec0e1fb 100644 --- a/Modules/Settings/NavPane/NavLocations.qml +++ b/Modules/Settings/NavPane/NavLocations.qml @@ -173,7 +173,7 @@ VerticalFadeFlickable { MaterialIcon { color: DynamicColors.palette.m3primary - font.pointSize: Appearance.font.size.small + font.pointSize: Appearance.font.size.large text: group.modelData.icon } @@ -188,7 +188,7 @@ VerticalFadeFlickable { ColumnLayout { Layout.fillWidth: true - spacing: 0 + spacing: Appearance.spacing.extraSmall / 2 Repeater { model: group.modelData.entries @@ -202,25 +202,23 @@ VerticalFadeFlickable { required property var modelData Layout.fillWidth: true - bottomLeftRadius: isLast ? Appearance.rounding.large : 0 - bottomRightRadius: isLast ? Appearance.rounding.large : 0 + bottomLeftRadius: layer.pressed ? Appearance.rounding.medium : isLast ? Appearance.rounding.large : Appearance.rounding.extraSmall + bottomRightRadius: layer.pressed ? Appearance.rounding.medium : isLast ? Appearance.rounding.large : Appearance.rounding.extraSmall color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) implicitHeight: { const h = resultLayout.implicitHeight + resultLayout.anchors.margins * 2; return h % 2 === 0 ? h : h + 1; } - topLeftRadius: isFirst ? Appearance.rounding.large : 0 - topRightRadius: isFirst ? Appearance.rounding.large : 0 + topLeftRadius: layer.pressed ? Appearance.rounding.medium : isFirst ? Appearance.rounding.large : Appearance.rounding.extraSmall + topRightRadius: layer.pressed ? Appearance.rounding.medium : isFirst ? Appearance.rounding.large : Appearance.rounding.extraSmall - CustomRect { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.leftMargin: Appearance.padding.large - anchors.right: parent.right - anchors.rightMargin: Appearance.padding.large - color: Qt.alpha(DynamicColors.palette.m3outlineVariant, 0.5) - implicitHeight: 1 - visible: !result.isLast + RadiusBehavior on bottomLeftRadius { + } + RadiusBehavior on bottomRightRadius { + } + RadiusBehavior on topLeftRadius { + } + RadiusBehavior on topRightRadius { } ColumnLayout { @@ -266,8 +264,8 @@ VerticalFadeFlickable { } StateLayer { - anchors.fill: parent - radius: 0 + id: layer + z: 1 onClicked: {