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
+14
View File
@@ -51,6 +51,20 @@ add_compile_options(
-Wunreachable-code -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) if("plugin" IN_LIST ENABLE_MODULES)
add_subdirectory(Plugins) add_subdirectory(Plugins)
+4 -13
View File
@@ -34,21 +34,11 @@ Item {
implicitHeight: 50 implicitHeight: 50
radius: Appearance.rounding.full radius: Appearance.rounding.full
MaterialIcon { SearchBar {
id: searchIcon
anchors.left: parent.left
anchors.margins: Appearance.padding.large
anchors.verticalCenter: parent.verticalCenter
text: "search"
}
CustomTextField {
id: searchField id: searchField
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: searchIcon.right anchors.left: parent.left
anchors.leftMargin: Appearance.spacing.small
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
color: DynamicColors.palette.m3onSurface color: DynamicColors.palette.m3onSurface
@@ -170,7 +160,6 @@ Item {
id: lineText id: lineText
color: DynamicColors.palette.m3onSurface color: DynamicColors.palette.m3onSurface
font.family: ClipHistory.previewIsCode ? Appearance.font.family.mono : Appearance.font.family.sans
height: lineText.paintedHeight + Appearance.padding.extraSmall * 2 height: lineText.paintedHeight + Appearance.padding.extraSmall * 2
text: lineRow.modelData.text.trim() text: lineRow.modelData.text.trim()
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
@@ -336,6 +325,8 @@ Item {
} }
model: ScriptModel { model: ScriptModel {
values: ClipHistory.fuzzyQuery(searchField.text) values: ClipHistory.fuzzyQuery(searchField.text)
onValuesChanged: view.currentIndex = 0
} }
onCurrentItemChanged: { onCurrentItemChanged: {
+1 -5
View File
@@ -52,11 +52,7 @@ Item {
placeholderText: qsTr("Type \"%1\" for commands").arg(Config.launcher.actionPrefix) placeholderText: qsTr("Type \"%1\" for commands").arg(Config.launcher.actionPrefix)
topPadding: Appearance.padding.larger topPadding: Appearance.padding.larger
Component.onCompleted: { Component.onCompleted: forceActiveFocus()
console.log(search.color);
console.log(search.placeholderTextColor);
forceActiveFocus();
}
Keys.onDownPressed: list.currentList?.decrementCurrentIndex() Keys.onDownPressed: list.currentList?.decrementCurrentIndex()
Keys.onEscapePressed: root.visibilities.launcher = false Keys.onEscapePressed: root.visibilities.launcher = false
Keys.onPressed: event => { Keys.onPressed: event => {
+52
View File
@@ -3,12 +3,64 @@ import qs.Components
import qs.Config import qs.Config
CustomRect { CustomRect {
id: root
property bool first property bool first
property bool last property bool last
property string settingAnchor
function flashHighlight(): void {
flash.restart();
}
bottomLeftRadius: last ? Appearance.rounding.large : Appearance.rounding.extraSmall bottomLeftRadius: last ? Appearance.rounding.large : Appearance.rounding.extraSmall
bottomRightRadius: last ? Appearance.rounding.large : Appearance.rounding.extraSmall bottomRightRadius: last ? Appearance.rounding.large : Appearance.rounding.extraSmall
color: DynamicColors.tPalette.m3surfaceContainer color: DynamicColors.tPalette.m3surfaceContainer
topLeftRadius: first ? Appearance.rounding.large : Appearance.rounding.extraSmall topLeftRadius: first ? Appearance.rounding.large : Appearance.rounding.extraSmall
topRightRadius: 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 { ColumnLayout {
id: root 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) readonly property int cappedWidth: Math.min(800, width)
default property Item contentChild default property Item contentChild
readonly property alias flickable: flickable readonly property alias flickable: flickable
@@ -16,8 +19,106 @@ ColumnLayout {
required property SettingsState sState required property SettingsState sState
required property string title 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 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 MouseArea { // Prevent clicks from reaching flickable
Layout.bottomMargin: -flickable.topMargin // Extra height to block clicks on flickable top margin Layout.bottomMargin: -flickable.topMargin // Extra height to block clicks on flickable top margin
@@ -69,6 +170,14 @@ ColumnLayout {
fadeAmount: 0.1 fadeAmount: 0.1
topMargin: Appearance.padding.large topMargin: Appearance.padding.large
Behavior on contentY {
enabled: root.animateScroll
Anim {
type: Anim.DefaultSpatial
}
}
TapHandler { TapHandler {
onTapped: flickable.focus = true onTapped: flickable.focus = true
} }
+5
View File
@@ -9,8 +9,13 @@ CustomSwitch {
readonly property alias bg: bg readonly property alias bg: bg
property alias first: bg.first property alias first: bg.first
property alias last: bg.last property alias last: bg.last
property string settingAnchor
property string subtext property string subtext
function flashHighlight(): void {
bg.flashHighlight();
}
Layout.fillWidth: true Layout.fillWidth: true
cLayer: 2 cLayer: 2
horizontalPadding: Appearance.padding.largeIncreased horizontalPadding: Appearance.padding.largeIncreased
+6
View File
@@ -34,6 +34,12 @@ ColumnLayout {
target: root.sState target: root.sState
value: searchField.text.length > 0 value: searchField.text.length > 0
} }
Binding {
property: "searchText"
target: root.sState
value: searchField.text
}
} }
NavLocations { NavLocations {
+218 -3
View File
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell
import qs.Components import qs.Components
import qs.Config import qs.Config
import qs.Modules.Settings import qs.Modules.Settings
@@ -9,7 +10,36 @@ import qs.Modules.Settings
VerticalFadeFlickable { VerticalFadeFlickable {
id: root 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 required property SettingsState sState
readonly property string search: sState.searchText
readonly property bool searching: search.length > 0
bottomMargin: Appearance.padding.large bottomMargin: Appearance.padding.large
contentHeight: content.implicitHeight contentHeight: content.implicitHeight
@@ -30,14 +60,14 @@ VerticalFadeFlickable {
Repeater { Repeater {
id: list id: list
model: PageRegistry.pages model: root.searching ? [] : PageRegistry.pages
CustomRect { CustomRect {
id: item id: item
required property int index required property int index
readonly property bool isCategoryEnd: index === list.model.length - 1 || 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 isCategoryStart: index === 0 || PageRegistry.pages[index - 1]?.category !== modelData.category
readonly property bool isCurrentPage: index === root.sState.currentPageIdx readonly property bool isCurrentPage: index === root.sState.currentPageIdx
required property var modelData 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 { component RadiusBehavior: Behavior {
+5
View File
@@ -30,6 +30,7 @@ PageBase {
DefaultRow { DefaultRow {
first: true first: true
icon: "terminal" icon: "terminal"
settingAnchor: "apps-default-terminal"
status: Config.general.apps.terminal.join(" ") status: Config.general.apps.terminal.join(" ")
text: qsTr("Terminal") text: qsTr("Terminal")
@@ -38,6 +39,7 @@ PageBase {
DefaultRow { DefaultRow {
icon: "volume_up" icon: "volume_up"
settingAnchor: "apps-default-audio"
status: Config.general.apps.audio.join(" ") status: Config.general.apps.audio.join(" ")
text: qsTr("Audio") text: qsTr("Audio")
@@ -46,6 +48,7 @@ PageBase {
DefaultRow { DefaultRow {
icon: "play_circle" icon: "play_circle"
settingAnchor: "apps-default-playback"
status: Config.general.apps.playback.join(" ") status: Config.general.apps.playback.join(" ")
text: qsTr("Media playback") text: qsTr("Media playback")
@@ -55,6 +58,7 @@ PageBase {
DefaultRow { DefaultRow {
icon: "folder" icon: "folder"
last: true last: true
settingAnchor: "apps-default-file-manager"
status: Config.general.apps.explorer.join(" ") status: Config.general.apps.explorer.join(" ")
text: qsTr("File manager") text: qsTr("File manager")
@@ -70,6 +74,7 @@ PageBase {
first: true first: true
icon: "apps" icon: "apps"
last: true last: true
settingAnchor: "apps-all-apps"
status: qsTr("Browse installed apps, set favorites and hidden") status: qsTr("Browse installed apps, set favorites and hidden")
text: qsTr("All apps") text: qsTr("All apps")
+2
View File
@@ -25,6 +25,7 @@ PageBase {
first: true first: true
icon: Icons.getVolumeIcon(Audio.volume, Audio.muted) icon: Icons.getVolumeIcon(Audio.volume, Audio.muted)
label: qsTr("Output") label: qsTr("Output")
settingAnchor: "audio-output"
value: Audio.volume value: Audio.volume
valueLabel: Math.round(value * 100) + "%" valueLabel: Math.round(value * 100) + "%"
@@ -55,6 +56,7 @@ PageBase {
first: true first: true
icon: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted) icon: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted)
label: qsTr("Input") label: qsTr("Input")
settingAnchor: "audio-input"
value: Audio.sourceVolume value: Audio.sourceVolume
valueLabel: Math.round(value * 100) + "%" valueLabel: Math.round(value * 100) + "%"
@@ -50,6 +50,7 @@ PageBase {
first: true first: true
last: true last: true
menuItems: root.clockFormats menuItems: root.clockFormats
settingAnchor: "bar-clock-format"
subtext: qsTr("Change how time is displayed in the widget") subtext: qsTr("Change how time is displayed in the widget")
text: qsTr("Time format") text: qsTr("Time format")
@@ -25,6 +25,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.bar.tray.showAudio checked: Config.bar.tray.showAudio
first: true first: true
settingAnchor: "bar-status-speakers"
text: qsTr("Speakers") text: qsTr("Speakers")
onToggled: Config.bar.tray.showAudio = checked onToggled: Config.bar.tray.showAudio = checked
@@ -32,6 +33,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.bar.tray.showMicrophone checked: Config.bar.tray.showMicrophone
settingAnchor: "bar-status-mic"
text: qsTr("Microphone") text: qsTr("Microphone")
onToggled: Config.bar.tray.showMicrophone = checked onToggled: Config.bar.tray.showMicrophone = checked
@@ -62,6 +64,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.bar.tray.showPower checked: Config.bar.tray.showPower
last: true last: true
settingAnchor: "bar-status-power"
text: qsTr("Battery") text: qsTr("Battery")
onToggled: Config.bar.tray.showPower = checked onToggled: Config.bar.tray.showPower = checked
@@ -75,6 +78,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.bar.popouts.audio checked: Config.bar.popouts.audio
first: true first: true
settingAnchor: "bar-status-audio-popout"
subtext: qsTr("Show a details popout when hovering the audio icons") subtext: qsTr("Show a details popout when hovering the audio icons")
text: qsTr("Audio popout on hover") text: qsTr("Audio popout on hover")
@@ -84,6 +88,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.bar.popouts.upower checked: Config.bar.popouts.upower
last: true last: true
settingAnchor: "bar-status-power-popout"
subtext: qsTr("Show a details popout when hovering the power icon") subtext: qsTr("Show a details popout when hovering the power icon")
text: qsTr("Power popout on hover") text: qsTr("Power popout on hover")
@@ -20,6 +20,7 @@ PageBase {
first: true first: true
from: 12 from: 12
last: true last: true
settingAnchor: "bar-tray-iconsize"
stepSize: 1 stepSize: 1
text: qsTr("Icon size") text: qsTr("Icon size")
to: 24 to: 24
@@ -26,6 +26,7 @@ PageBase {
checked: Config.bar.autoHide checked: Config.bar.autoHide
first: true first: true
last: true last: true
settingAnchor: "bar-autohide"
subtext: qsTr("Hide the bar, reveal on hover") subtext: qsTr("Hide the bar, reveal on hover")
text: qsTr("Auto hide") text: qsTr("Auto hide")
@@ -40,6 +41,7 @@ PageBase {
NavRow { NavRow {
first: true first: true
icon: "widgets" icon: "widgets"
settingAnchor: "bar-tray"
status: qsTr("System tray icons") status: qsTr("System tray icons")
text: qsTr("Tray") text: qsTr("Tray")
@@ -48,6 +50,7 @@ PageBase {
NavRow { NavRow {
icon: "signal_cellular_alt" icon: "signal_cellular_alt"
settingAnchor: "bar-statusicons"
status: qsTr("Visible indicators") status: qsTr("Visible indicators")
text: qsTr("Status icons") text: qsTr("Status icons")
@@ -57,6 +60,7 @@ PageBase {
NavRow { NavRow {
icon: "schedule" icon: "schedule"
last: true last: true
settingAnchor: "bar-clock"
status: qsTr("Date, icon, background") status: qsTr("Date, icon, background")
text: qsTr("Clock") text: qsTr("Clock")
@@ -28,6 +28,7 @@ PageBase {
checked: Config.dashboard.enabled checked: Config.dashboard.enabled
first: true first: true
last: true last: true
settingAnchor: "dashboard-enabled"
text: qsTr("Enabled") text: qsTr("Enabled")
onToggled: Config.dashboard.enabled = checked onToggled: Config.dashboard.enabled = checked
@@ -28,6 +28,7 @@ PageBase {
checked: Config.launcher.enabled checked: Config.launcher.enabled
first: true first: true
last: true last: true
settingAnchor: "launcher-enabled"
text: qsTr("Enabled") text: qsTr("Enabled")
onToggled: Config.launcher.enabled = checked onToggled: Config.launcher.enabled = checked
@@ -41,6 +42,7 @@ PageBase {
SpinRow { SpinRow {
first: true first: true
from: 1 from: 1
settingAnchor: "launcher-max-items-shown"
stepSize: 1 stepSize: 1
text: qsTr("Max items shown") text: qsTr("Max items shown")
to: 20 to: 20
@@ -51,6 +53,7 @@ PageBase {
SpinRow { SpinRow {
from: 1 from: 1
settingAnchor: "launcher-max-wallpapers"
stepSize: 1 stepSize: 1
text: qsTr("Max wallpapers") text: qsTr("Max wallpapers")
to: 30 to: 30
@@ -62,6 +65,7 @@ PageBase {
SpinRow { SpinRow {
from: 0 from: 0
last: true last: true
settingAnchor: "launcher-drag-threshold"
stepSize: 5 stepSize: 5
subtext: qsTr("Pixels dragged before the launcher opens") subtext: qsTr("Pixels dragged before the launcher opens")
text: qsTr("Drag threshold") text: qsTr("Drag threshold")
@@ -80,6 +84,7 @@ PageBase {
checked: Config.launcher.enableDangerousActions checked: Config.launcher.enableDangerousActions
first: true first: true
last: true last: true
settingAnchor: "launcher-enable-dangerous-actions"
subtext: qsTr("Allow actions that shut down or log out") subtext: qsTr("Allow actions that shut down or log out")
text: qsTr("Enable dangerous actions") text: qsTr("Enable dangerous actions")
@@ -94,6 +99,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.launcher.useFuzzy.apps checked: Config.launcher.useFuzzy.apps
first: true first: true
settingAnchor: "launcher-apps"
text: qsTr("Apps") text: qsTr("Apps")
onToggled: Config.launcher.useFuzzy.apps = checked onToggled: Config.launcher.useFuzzy.apps = checked
@@ -101,20 +107,15 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.launcher.useFuzzy.actions checked: Config.launcher.useFuzzy.actions
settingAnchor: "launcher-actions"
text: qsTr("Actions") text: qsTr("Actions")
onToggled: Config.launcher.useFuzzy.actions = checked onToggled: Config.launcher.useFuzzy.actions = checked
} }
ToggleRow {
checked: Config.launcher.useFuzzy.schemes
text: qsTr("Schemes")
onToggled: Config.launcher.useFuzzy.schemes = checked
}
ToggleRow { ToggleRow {
checked: Config.launcher.useFuzzy.variants checked: Config.launcher.useFuzzy.variants
settingAnchor: "launcher-variants"
text: qsTr("Variants") text: qsTr("Variants")
onToggled: Config.launcher.useFuzzy.variants = checked onToggled: Config.launcher.useFuzzy.variants = checked
@@ -123,6 +124,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.launcher.useFuzzy.wallpapers checked: Config.launcher.useFuzzy.wallpapers
last: true last: true
settingAnchor: "launcher-wallpapers"
text: qsTr("Wallpapers") text: qsTr("Wallpapers")
onToggled: Config.launcher.useFuzzy.wallpapers = checked onToggled: Config.launcher.useFuzzy.wallpapers = checked
@@ -28,6 +28,7 @@ PageBase {
checked: Config.dashboard.performance.enabled checked: Config.dashboard.performance.enabled
first: true first: true
last: true last: true
settingAnchor: "resources-enabled"
text: qsTr("Enabled") text: qsTr("Enabled")
onToggled: Config.dashboard.performance.enabled = checked onToggled: Config.dashboard.performance.enabled = checked
@@ -41,6 +42,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.dashboard.performance.showBattery checked: Config.dashboard.performance.showBattery
first: true first: true
settingAnchor: "resources-battery"
text: qsTr("Battery") text: qsTr("Battery")
onToggled: Config.dashboard.performance.showBattery = checked onToggled: Config.dashboard.performance.showBattery = checked
@@ -48,6 +50,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.dashboard.performance.showGpu checked: Config.dashboard.performance.showGpu
settingAnchor: "resources-gpu"
text: qsTr("GPU") text: qsTr("GPU")
onToggled: Config.dashboard.performance.showGpu = checked onToggled: Config.dashboard.performance.showGpu = checked
@@ -55,6 +58,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.dashboard.performance.showCpu checked: Config.dashboard.performance.showCpu
settingAnchor: "resources-cpu"
text: qsTr("CPU") text: qsTr("CPU")
onToggled: Config.dashboard.performance.showCpu = checked onToggled: Config.dashboard.performance.showCpu = checked
@@ -62,6 +66,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.dashboard.performance.showMemory checked: Config.dashboard.performance.showMemory
settingAnchor: "resources-memory"
text: qsTr("Memory") text: qsTr("Memory")
onToggled: Config.dashboard.performance.showMemory = checked onToggled: Config.dashboard.performance.showMemory = checked
@@ -69,6 +74,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.dashboard.performance.showStorage checked: Config.dashboard.performance.showStorage
settingAnchor: "resources-storage"
text: qsTr("Storage") text: qsTr("Storage")
onToggled: Config.dashboard.performance.showStorage = checked onToggled: Config.dashboard.performance.showStorage = checked
@@ -77,6 +83,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.dashboard.performance.showNetwork checked: Config.dashboard.performance.showNetwork
last: true last: true
settingAnchor: "resources-network"
text: qsTr("Network") text: qsTr("Network")
onToggled: Config.dashboard.performance.showNetwork = checked onToggled: Config.dashboard.performance.showNetwork = checked
@@ -26,6 +26,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.sidebar.enabled checked: Config.sidebar.enabled
first: true first: true
settingAnchor: "sidebar-enabled"
text: qsTr("Enabled") text: qsTr("Enabled")
onToggled: Config.sidebar.enabled = checked onToggled: Config.sidebar.enabled = checked
@@ -34,6 +35,7 @@ PageBase {
SpinRow { SpinRow {
from: 0 from: 0
last: true last: true
settingAnchor: "sidebar-drag-threshold"
stepSize: 5 stepSize: 5
subtext: qsTr("Pixels dragged before the sidebar opens") subtext: qsTr("Pixels dragged before the sidebar opens")
text: qsTr("Drag threshold") text: qsTr("Drag threshold")
+5
View File
@@ -16,6 +16,7 @@ PageBase {
NavRow { NavRow {
first: true first: true
icon: "dock_to_bottom" icon: "dock_to_bottom"
settingAnchor: "panels-bar"
status: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover") status: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover")
text: qsTr("Bar") text: qsTr("Bar")
@@ -24,6 +25,7 @@ PageBase {
NavRow { NavRow {
icon: "dashboard" icon: "dashboard"
settingAnchor: "panels-dashboard"
status: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") status: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled")
text: qsTr("Dashboard") text: qsTr("Dashboard")
@@ -32,6 +34,7 @@ PageBase {
NavRow { NavRow {
icon: "insert_chart" icon: "insert_chart"
settingAnchor: "panels-resources"
status: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled") status: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled")
text: qsTr("Resources") text: qsTr("Resources")
@@ -40,6 +43,7 @@ PageBase {
NavRow { NavRow {
icon: "apps" icon: "apps"
settingAnchor: "panels-launcher"
status: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") status: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled")
text: qsTr("Launcher") text: qsTr("Launcher")
@@ -49,6 +53,7 @@ PageBase {
NavRow { NavRow {
icon: "dock_to_right" icon: "dock_to_right"
last: true last: true
settingAnchor: "panels-sidebar"
status: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") status: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled")
text: qsTr("Sidebar") text: qsTr("Sidebar")
@@ -28,6 +28,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.screenshot.enable_pp checked: Config.screenshot.enable_pp
first: true first: true
settingAnchor: "screenshot-enable"
text: qsTr("Enable effects") text: qsTr("Enable effects")
onToggled: Config.screenshot.enable_pp = checked onToggled: Config.screenshot.enable_pp = checked
@@ -37,6 +38,7 @@ PageBase {
active: Config.screenshot.mode === "manual" ? menuItems[0] : menuItems[1] active: Config.screenshot.mode === "manual" ? menuItems[0] : menuItems[1]
enabled: Config.screenshot.enable_pp enabled: Config.screenshot.enable_pp
last: true last: true
settingAnchor: "screenshot-mode"
subtext: qsTr("Automatic or manual effect values") subtext: qsTr("Automatic or manual effect values")
text: qsTr("Effects mode") text: qsTr("Effects mode")
@@ -67,6 +69,7 @@ PageBase {
checked: Config.screenshot.rounding checked: Config.screenshot.rounding
enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual"
first: true first: true
settingAnchor: "screenshot-enable-rounded-corners"
text: qsTr("Enable rounded corners") text: qsTr("Enable rounded corners")
onToggled: Config.screenshot.rounding = checked onToggled: Config.screenshot.rounding = checked
@@ -76,6 +79,7 @@ PageBase {
enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.rounding enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.rounding
from: 0 from: 0
last: true last: true
settingAnchor: "screenshot-corner-radius"
stepSize: 1 stepSize: 1
text: qsTr("Corner radius") text: qsTr("Corner radius")
to: 50 to: 50
@@ -95,6 +99,7 @@ PageBase {
checked: Config.screenshot.shadow checked: Config.screenshot.shadow
enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual"
first: true first: true
settingAnchor: "screenshot-enable-shadow"
text: qsTr("Enable shadow") text: qsTr("Enable shadow")
onToggled: Config.screenshot.shadow = checked onToggled: Config.screenshot.shadow = checked
@@ -103,6 +108,7 @@ PageBase {
SpinRow { SpinRow {
enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow
from: 0 from: 0
settingAnchor: "screenshot-shadow-blur-amount"
stepSize: 1 stepSize: 1
text: qsTr("Shadow blur amount") text: qsTr("Shadow blur amount")
to: 100 to: 100
@@ -117,6 +123,7 @@ PageBase {
SpinRow { SpinRow {
enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow
from: -100 from: -100
settingAnchor: "screenshot-shadow-horizontal-offset"
stepSize: 10 stepSize: 10
text: qsTr("Shadow horizontal offset") text: qsTr("Shadow horizontal offset")
to: 100 to: 100
@@ -132,6 +139,7 @@ PageBase {
enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.shadow
from: -100 from: -100
last: true last: true
settingAnchor: "screenshot-shadow-vertical-offset"
stepSize: 10 stepSize: 10
text: qsTr("Shadow vertical offset") text: qsTr("Shadow vertical offset")
to: 100 to: 100
@@ -25,6 +25,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.notifs.showInFullscreen checked: Config.notifs.showInFullscreen
first: true first: true
settingAnchor: "notif-show-in-fullscreen"
subtext: qsTr("Whether notifications appear over fullscreen apps") subtext: qsTr("Whether notifications appear over fullscreen apps")
text: qsTr("Show in fullscreen") text: qsTr("Show in fullscreen")
@@ -33,6 +34,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.notifs.expire checked: Config.notifs.expire
settingAnchor: "notif-expire-automatically"
subtext: qsTr("Dismiss notifications after their timeout") subtext: qsTr("Dismiss notifications after their timeout")
text: qsTr("Expire automatically") text: qsTr("Expire automatically")
@@ -41,6 +43,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.notifs.openExpanded checked: Config.notifs.openExpanded
settingAnchor: "notif-open-expanded"
subtext: qsTr("Show notifications expanded by default") subtext: qsTr("Show notifications expanded by default")
text: qsTr("Open expanded") text: qsTr("Open expanded")
@@ -49,6 +52,7 @@ PageBase {
SpinRow { SpinRow {
from: 1000 from: 1000
settingAnchor: "notif-default-timeout"
stepSize: 500 stepSize: 500
subtext: qsTr("Time before a notification dismisses (ms)") subtext: qsTr("Time before a notification dismisses (ms)")
text: qsTr("Default timeout") text: qsTr("Default timeout")
@@ -61,6 +65,7 @@ PageBase {
SpinRow { SpinRow {
from: 1 from: 1
last: true last: true
settingAnchor: "notif-group-preview-count"
stepSize: 1 stepSize: 1
subtext: qsTr("Notifications shown per group before collapsing") subtext: qsTr("Notifications shown per group before collapsing")
text: qsTr("Group preview count") text: qsTr("Group preview count")
@@ -78,6 +83,7 @@ PageBase {
SpinRow { SpinRow {
first: true first: true
from: 1 from: 1
settingAnchor: "notif-visible-toasts"
stepSize: 1 stepSize: 1
subtext: qsTr("Maximum number of toasts shown at once") subtext: qsTr("Maximum number of toasts shown at once")
text: qsTr("Visible toasts") text: qsTr("Visible toasts")
@@ -89,6 +95,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.utilities.toasts.chargingChanged checked: Config.utilities.toasts.chargingChanged
settingAnchor: "notif-charging-changes"
text: qsTr("Charging changes") text: qsTr("Charging changes")
onToggled: Config.utilities.toasts.chargingChanged = checked onToggled: Config.utilities.toasts.chargingChanged = checked
@@ -96,6 +103,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.utilities.toasts.gameModeChanged checked: Config.utilities.toasts.gameModeChanged
settingAnchor: "notif-game-mode-changes"
text: qsTr("Game mode changes") text: qsTr("Game mode changes")
onToggled: Config.utilities.toasts.gameModeChanged = checked onToggled: Config.utilities.toasts.gameModeChanged = checked
@@ -110,6 +118,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.utilities.toasts.audioOutputChanged checked: Config.utilities.toasts.audioOutputChanged
settingAnchor: "notif-audio-output-changes"
text: qsTr("Audio output changes") text: qsTr("Audio output changes")
onToggled: Config.utilities.toasts.audioOutputChanged = checked onToggled: Config.utilities.toasts.audioOutputChanged = checked
@@ -118,6 +127,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.utilities.toasts.audioInputChanged checked: Config.utilities.toasts.audioInputChanged
last: true last: true
settingAnchor: "notif-audio-input-changes"
text: qsTr("Audio input changes") text: qsTr("Audio input changes")
onToggled: Config.utilities.toasts.audioInputChanged = checked onToggled: Config.utilities.toasts.audioInputChanged = checked
+11
View File
@@ -71,6 +71,7 @@ PageBase {
first: true first: true
icon: "notifications" icon: "notifications"
last: true last: true
settingAnchor: "services-notifications"
status: qsTr("Notifications, toasts, timeouts") status: qsTr("Notifications, toasts, timeouts")
text: qsTr("Notifications") text: qsTr("Notifications")
@@ -85,6 +86,7 @@ PageBase {
SpinRow { SpinRow {
first: true first: true
from: 100 from: 100
settingAnchor: "services-media-refresh"
stepSize: 50 stepSize: 50
subtext: qsTr("How often the media position updates (ms)") subtext: qsTr("How often the media position updates (ms)")
text: qsTr("Media refresh") text: qsTr("Media refresh")
@@ -97,6 +99,7 @@ PageBase {
SpinRow { SpinRow {
from: 0.5 from: 0.5
last: true last: true
settingAnchor: "services-system-stats-refresh"
stepSize: 0.5 stepSize: 0.5
subtext: qsTr("CPU, memory and GPU update interval (seconds)") subtext: qsTr("CPU, memory and GPU update interval (seconds)")
text: qsTr("System stats refresh") text: qsTr("System stats refresh")
@@ -118,6 +121,7 @@ PageBase {
first: true first: true
last: true last: true
menuItems: playerVariants.instances menuItems: playerVariants.instances
settingAnchor: "services-default-player"
subtext: qsTr("Preferred media player when several are open") subtext: qsTr("Preferred media player when several are open")
text: qsTr("Default player") text: qsTr("Default player")
@@ -132,6 +136,7 @@ PageBase {
SpinRow { SpinRow {
first: true first: true
from: 1 from: 1
settingAnchor: "services-volume-step"
stepSize: 1 stepSize: 1
subtext: qsTr("Amount the volume changes per input (%)") subtext: qsTr("Amount the volume changes per input (%)")
text: qsTr("Volume step") text: qsTr("Volume step")
@@ -143,6 +148,7 @@ PageBase {
SpinRow { SpinRow {
from: 1 from: 1
settingAnchor: "services-brightness-step"
stepSize: 1 stepSize: 1
subtext: qsTr("Amount the brightness changes per input (%)") subtext: qsTr("Amount the brightness changes per input (%)")
text: qsTr("Brightness step") text: qsTr("Brightness step")
@@ -154,6 +160,7 @@ PageBase {
SpinRow { SpinRow {
from: 1 from: 1
settingAnchor: "services-brightness-minimum"
stepSize: 1 stepSize: 1
subtext: qsTr("Lowest allowed brightness (%)") subtext: qsTr("Lowest allowed brightness (%)")
text: qsTr("Brightness minimum") text: qsTr("Brightness minimum")
@@ -166,6 +173,7 @@ PageBase {
SpinRow { SpinRow {
from: 50 from: 50
last: true last: true
settingAnchor: "services-max-volume"
stepSize: 5 stepSize: 5
subtext: qsTr("Upper limit for output volume (%)") subtext: qsTr("Upper limit for output volume (%)")
text: qsTr("Max volume") text: qsTr("Max volume")
@@ -183,6 +191,7 @@ PageBase {
SpinRow { SpinRow {
first: true first: true
from: 10 from: 10
settingAnchor: "services-visualizer-resolution"
stepSize: 2 stepSize: 2
subtext: qsTr("Resolution of the audio visualizer") subtext: qsTr("Resolution of the audio visualizer")
text: qsTr("Visualizer resolution") text: qsTr("Visualizer resolution")
@@ -194,6 +203,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.general.color.smart checked: Config.general.color.smart
settingAnchor: "services-smart-color-scheme"
subtext: qsTr("Derive theme mode from the wallpaper") subtext: qsTr("Derive theme mode from the wallpaper")
text: qsTr("Smart color scheme") text: qsTr("Smart color scheme")
@@ -205,6 +215,7 @@ PageBase {
last: true last: true
menuItems: root.gpuItems menuItems: root.gpuItems
menuOnTop: true menuOnTop: true
settingAnchor: "services-gpu"
subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type") subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type")
text: qsTr("GPU") text: qsTr("GPU")
+5
View File
@@ -49,6 +49,7 @@ PageBase {
ToggleRow { ToggleRow {
checked: Config.background.enabled checked: Config.background.enabled
first: true first: true
settingAnchor: "style-display-wallpaper"
text: qsTr("Display wallpaper") text: qsTr("Display wallpaper")
onToggled: Config.background.enabled = checked onToggled: Config.background.enabled = checked
@@ -57,6 +58,7 @@ PageBase {
ToggleRow { ToggleRow {
Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing
checked: DynamicColors.transparency.enabled checked: DynamicColors.transparency.enabled
settingAnchor: "style-transparency"
subtext: qsTr("Base %1, layers %2").arg(DynamicColors.transparency.base).arg(DynamicColors.transparency.layers) subtext: qsTr("Base %1, layers %2").arg(DynamicColors.transparency.base).arg(DynamicColors.transparency.layers)
text: qsTr("Transparency") text: qsTr("Transparency")
@@ -67,6 +69,7 @@ PageBase {
Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing
checked: !DynamicColors.light checked: !DynamicColors.light
last: true last: true
settingAnchor: "style-dark-theme"
text: qsTr("Dark theme") text: qsTr("Dark theme")
onToggled: DynamicColors.setMode(checked ? "dark" : "light") onToggled: DynamicColors.setMode(checked ? "dark" : "light")
@@ -88,6 +91,7 @@ PageBase {
checked: Config.general.color.scheduleDark checked: Config.general.color.scheduleDark
first: true 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) subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
text: qsTr("Schedule dark mode") text: qsTr("Schedule dark mode")
@@ -129,6 +133,7 @@ PageBase {
Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing
checked: Config.general.color.scheduleHyprsunset checked: Config.general.color.scheduleHyprsunset
last: true last: true
settingAnchor: "style-schedule-hyprsunset"
subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
text: qsTr("Schedule hyprsunset") 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 bool animatingContainer
property int currentPageIdx property int currentPageIdx
property bool isWindow property bool isWindow
property string lastAnchor
property list<int> pendingSubPath
property ShellScreen screen property ShellScreen screen
property string searchAnchor
property bool searchOpen property bool searchOpen
property string searchText
property DesktopEntry selectedApp property DesktopEntry selectedApp
property BluetoothDevice selectedBtDevice property BluetoothDevice selectedBtDevice
property string selectedWallpaperCategory property string selectedWallpaperCategory
property list<int> subPageIdxStack property list<int> subPageIdxStack
signal close signal close
signal highlightSetting(anchor: string)
signal subPageClosed signal subPageClosed
signal subPageOpened(idx: int) signal subPageOpened(idx: int)
@@ -24,10 +29,49 @@ QtObject {
subPageIdxStack.pop(); 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 { function openSubPage(idx: int): void {
subPageIdxStack.push(idx); subPageIdxStack.push(idx);
subPageOpened(idx); subPageOpened(idx);
} }
onCurrentPageIdxChanged: subPageIdxStack.length = 0 onCurrentPageIdxChanged: {
subPageIdxStack = pendingSubPath;
pendingSubPath = [];
}
} }
+6 -1
View File
@@ -24,11 +24,12 @@ set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
qt_standard_project_setup(REQUIRES 6.9) qt_standard_project_setup(REQUIRES 6.9)
function(qml_module arg_TARGET) 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} qt_add_qml_module(${arg_TARGET}
URI ${arg_URI} URI ${arg_URI}
VERSION 1.0 VERSION 1.0
SOURCES ${arg_SOURCES} SOURCES ${arg_SOURCES}
RESOURCES ${arg_RESOURCES}
) )
qt_query_qml_module(${arg_TARGET} 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}) target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES})
endfunction() endfunction()
set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIAS "settings-index.json")
qml_module(ZShell qml_module(ZShell
URI ZShell URI ZShell
SOURCES SOURCES
@@ -57,6 +60,8 @@ qml_module(ZShell
toaster.hpp toaster.cpp toaster.hpp toaster.cpp
qalculator.hpp qalculator.cpp qalculator.hpp qalculator.cpp
zutils.hpp zutils.cpp zutils.hpp zutils.cpp
RESOURCES
"${SETTINGS_INDEX_JSON}"
LIBRARIES LIBRARIES
Qt::Gui Qt::Gui
Qt::Quick Qt::Quick
+10
View File
@@ -10,6 +10,7 @@
#include <qjsprimitivevalue.h> #include <qjsprimitivevalue.h>
#include <qloggingcategory.h> #include <qloggingcategory.h>
#include <qqmlengine.h> #include <qqmlengine.h>
#include <qfile.h>
Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg) 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); 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 #ifndef ZSHELL_VERSION
#define ZSHELL_VERSION "" #define ZSHELL_VERSION ""
#endif #endif
+2
View File
@@ -32,6 +32,8 @@ class ZUtils : public QObject {
Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max); Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max);
Q_INVOKABLE static QString settingsIndex();
[[nodiscard]] QString version() const; [[nodiscard]] QString version() const;
[[nodiscard]] QString qtVersion() const; [[nodiscard]] QString qtVersion() const;
}; };
File diff suppressed because it is too large Load Diff
+466
View File
@@ -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 <settings-dir> <output-json>
"""
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"(?<!^)(?=[A-Z])", " ", label)
children[pos] = (main_icon, label, "")
for pos, (icon, label, section) in children.items():
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)
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())