implement search bar functionality in settings

This commit is contained in:
2026-06-30 22:20:48 +02:00
parent 61019204d8
commit 2b89c8e4a1
30 changed files with 1215 additions and 1342 deletions
+52
View File
@@ -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
}
}
}
}
+109
View File
@@ -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
}
+5
View File
@@ -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
+6
View File
@@ -34,6 +34,12 @@ ColumnLayout {
target: root.sState
value: searchField.text.length > 0
}
Binding {
property: "searchText"
target: root.sState
value: searchField.text
}
}
NavLocations {
+218 -3
View File
@@ -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 {
+5
View File
@@ -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")
+2
View File
@@ -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) + "%"
@@ -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")
@@ -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")
@@ -20,6 +20,7 @@ PageBase {
first: true
from: 12
last: true
settingAnchor: "bar-tray-iconsize"
stepSize: 1
text: qsTr("Icon size")
to: 24
@@ -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")
@@ -28,6 +28,7 @@ PageBase {
checked: Config.dashboard.enabled
first: true
last: true
settingAnchor: "dashboard-enabled"
text: qsTr("Enabled")
onToggled: Config.dashboard.enabled = checked
@@ -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
@@ -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
@@ -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")
+5
View File
@@ -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")
@@ -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
@@ -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
+11
View File
@@ -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")
+5
View File
@@ -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")
+201
View File
@@ -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 <font color> but
// not CSS <span style>. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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, `<font color="${colour}">$1</font>`);
}
// 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<QtObject> {
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;
}
}
}
+45 -1
View File
@@ -8,14 +8,19 @@ QtObject {
property bool animatingContainer
property int currentPageIdx
property bool isWindow
property string lastAnchor
property list<int> pendingSubPath
property ShellScreen screen
property string searchAnchor
property bool searchOpen
property string searchText
property DesktopEntry selectedApp
property BluetoothDevice selectedBtDevice
property string selectedWallpaperCategory
property list<int> 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 = [];
}
}