From 23d2b31c0afa9fc0b7524eea0e7570035fbff44c Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 27 Aug 2026 16:57:02 +0200 Subject: [PATCH 01/21] expandable dialogs for color scheme, fonts and scheduling modes --- Modules/Settings/Common/DialogRowButton.qml | 18 +- .../Settings/Common/DialogSelectButton.qml | 11 +- Modules/Settings/Common/RowButton.qml | 26 +- Modules/Settings/Common/SearchPopup.qml | 128 +++ Modules/Settings/Common/TimeDialogRow.qml | 249 +++++ Modules/Settings/Common/TimeDialogSelect.qml | 50 + .../{OverlayRow.qml => TimeDialogToggle.qml} | 12 +- Modules/Settings/Common/TimeInput.qml | 940 ++++++++---------- Modules/Settings/Common/ToggleRow.qml | 14 +- Modules/Settings/Content.qml | 7 +- Modules/Settings/PageCompRegistry.qml | 4 + Modules/Settings/Pages.qml | 13 + .../Pages/Panels/Bar/BarStatusIcons.qml | 2 + .../Settings/Pages/Panels/SidebarPanel.qml | 4 + .../Settings/Pages/Wallpaper/ColorsFonts.qml | 327 ++++++ Modules/Settings/Pages/WallpaperPage.qml | 107 +- Modules/Settings/PopupManager.qml | 20 - Modules/Settings/PopupOverlay.qml | 47 - Modules/Settings/SettingsState.qml | 1 + cli/src/zshell/subcommands/scheme.py | 7 +- 20 files changed, 1325 insertions(+), 662 deletions(-) create mode 100644 Modules/Settings/Common/SearchPopup.qml create mode 100644 Modules/Settings/Common/TimeDialogRow.qml create mode 100644 Modules/Settings/Common/TimeDialogSelect.qml rename Modules/Settings/Common/{OverlayRow.qml => TimeDialogToggle.qml} (86%) create mode 100644 Modules/Settings/Pages/Wallpaper/ColorsFonts.qml delete mode 100644 Modules/Settings/PopupManager.qml delete mode 100644 Modules/Settings/PopupOverlay.qml diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index 1d4d547..80cc881 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -13,15 +13,21 @@ Item { property bool acceptAllowed: true required property string acceptLabel required property Component content + property bool first: false required property string header property int horizontalContentMargin - required property string icon + property string icon + property alias iconLabel: openButton.iconLabel required property string label + property bool last: false property bool open property real openHeight: Math.min(rootParent.height * 0.8, 600) property real openWidth: Math.min(rootParent.width * 0.8, 400) required property Item rootParent + property alias rowButton: openButton property bool separateContent + required property string settingAnchor + property string subtext: "" signal accepted signal cancelled @@ -56,6 +62,7 @@ Item { enabled: false hoverEnabled: enabled parent: root.open ? root.rootParent : root + preventStealing: true onClicked: root.open = false } @@ -75,7 +82,8 @@ Item { backdrop.enabled: true dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased - dialogBg.radius: Tokens.rounding.largeIncreased + dialogBg.topLeftRadius: Tokens.rounding.largeIncreased + dialogBg.topRightRadius: Tokens.rounding.largeIncreased dialogContent.opacity: 1 dialogWrapper.height: root.openHeight dialogWrapper.width: root.openWidth @@ -103,7 +111,7 @@ Item { } Anim { - properties: "opacity,radius,bottomLeftRadius,bottomRightRadius" + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" type: Anim.DefaultEffects } @@ -166,6 +174,8 @@ Item { opacity: 0 sourceComponent: MouseArea { + preventStealing: true + onWheel: event => event.accepted = true ColumnLayout { @@ -216,6 +226,7 @@ Item { TextButton { horizontalPadding: Tokens.padding.largeIncreased isRound: true + stateLayer.preventStealing: true text: qsTr("Cancel") type: TextButton.Text verticalPadding: Tokens.padding.small @@ -230,6 +241,7 @@ Item { enabled: root.acceptAllowed horizontalPadding: Tokens.padding.largeIncreased isRound: true + stateLayer.preventStealing: true text: root.acceptLabel type: TextButton.Text verticalPadding: Tokens.padding.small diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 3cce148..a3a9c74 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -91,7 +91,14 @@ DialogRowButton { } onOpenChanged: { - if (open) - selectedItem = null; + if (open) { + root.rootParent.interactive = false; + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } else { + root.rootParent.interactive = true; + } } } diff --git a/Modules/Settings/Common/RowButton.qml b/Modules/Settings/Common/RowButton.qml index 5d2953d..50d1c3d 100644 --- a/Modules/Settings/Common/RowButton.qml +++ b/Modules/Settings/Common/RowButton.qml @@ -17,6 +17,7 @@ ConnectedRect { property alias subtext: subLabel.text property alias text: label.text property string trailingIcon + property string activeItem signal clicked(event: MouseEvent) @@ -40,15 +41,16 @@ ConnectedRect { spacing: Tokens.spacing.medium Behavior on opacity { - Anim { - } + Anim {} } MaterialIcon { id: iconLabel color: Colors.palette.m3onSurfaceVariant + visible: root.icon !== "" fill: 1 + text: root.icon font.pointSize: Tokens.font.size.normal } @@ -79,6 +81,26 @@ ConnectedRect { } } + Loader { + active: root.activeItem && root.subtext + asynchronous: true + visible: active + Layout.fillHeight: true + + sourceComponent: CustomRect { + color: Colors.palette.m3secondaryContainer + radius: Tokens.rounding.full + implicitWidth: text.implicitWidth + Tokens.padding.medium * 2 + + CustomText { + id: text + anchors.centerIn: parent + color: Colors.palette.m3onSecondaryContainer + text: root.activeItem + } + } + } + Loader { active: root.trailingIcon asynchronous: true diff --git a/Modules/Settings/Common/SearchPopup.qml b/Modules/Settings/Common/SearchPopup.qml new file mode 100644 index 0000000..193fd1a --- /dev/null +++ b/Modules/Settings/Common/SearchPopup.qml @@ -0,0 +1,128 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +DialogRowButton { + id: root + + required property var model + property var selectedItem + property var initialSelect + + acceptAllowed: !!selectedItem + horizontalContentMargin: -Tokens.padding.extraSmall + separateContent: true + + content: Component { + ColumnLayout { + VerticalFadeListView { + bottomMargin: Tokens.padding.large + Layout.fillHeight: true + Layout.fillWidth: true + model: ScriptModel { + values: { + const s = searchField.text; + var regex = new RegExp(s, "i"); + + return root.model.filter(n => regex.test(n)); + } + } + spacing: 0 + topMargin: Tokens.padding.large + + delegate: CustomRect { + id: item + + required property var modelData + readonly property bool selected: root.selectedItem === modelData + + anchors.left: ListView.view.contentItem.left + anchors.margins: 1 // Gets cut off for some reason without this + anchors.right: ListView.view.contentItem.right + color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) + implicitHeight: label.implicitHeight + Tokens.padding.small * 2 + radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium + + Behavior on radius { + Anim { + type: Anim.SlowEffects + } + } + + StateLayer { + id: stateLayer + + onClicked: root.selectedItem = item.modelData + } + + CustomText { + id: label + + anchors.left: parent.left + anchors.margins: Tokens.padding.large + anchors.right: item.selected ? checkIcon.left : parent.right + anchors.rightMargin: item.selected ? Tokens.spacing.medium : anchors.margins + anchors.verticalCenter: parent.verticalCenter + color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + elide: Text.ElideRight + font.pointSize: Tokens.font.size.smaller + text: item.modelData + } + + MaterialIcon { + id: checkIcon + + anchors.margins: Tokens.padding.large + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: Colors.palette.m3onTertiaryContainer + font.pointSize: Tokens.font.size.normal + opacity: item.selected ? 1 : 0 + text: "check" + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + } + + SearchBar { + id: searchField + + Layout.fillWidth: true + bg.border.color: Colors.palette.m3outlineVariant + bg.color: Colors.tPalette.m3surfaceContainerLowest + clearIcon.font.pointSize: Tokens.font.size.large + clearIcon.padding: Tokens.padding.extraSmall + font.pointSize: Tokens.font.size.small + placeholderText: qsTr("Search...") + searchIcon.anchors.leftMargin: Tokens.padding.large + searchIcon.font.pointSize: Tokens.font.size.large + + Behavior on bg.border.color { + CAnim {} + } + } + } + } + + onOpenChanged: { + if (open) { + root.rootParent.interactive = false; + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } else { + root.rootParent.interactive = true; + } + } +} diff --git a/Modules/Settings/Common/TimeDialogRow.qml b/Modules/Settings/Common/TimeDialogRow.qml new file mode 100644 index 0000000..25bc7cb --- /dev/null +++ b/Modules/Settings/Common/TimeDialogRow.qml @@ -0,0 +1,249 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Blobs +import qs.Components +import ZShell.Config +import qs.Services + +Item { + id: root + + property bool acceptAllowed: true + required property string acceptLabel + required property Component content + required property string header + property int horizontalContentMargin + property string icon + required property string label + property alias iconLabel: openButton.iconText + property alias rowButton: openButton + property alias contentItem: dialogContent.item + property string subtext: "" + property bool open + property real openHeight: Math.min(rootParent.height * 0.8, 400) + property real openWidth: Math.min(rootParent.width * 0.8, 600) + required property Item rootParent + property bool first: false + property bool last: false + required property string settingAnchor + property color openColor: Colors.palette.m3surfaceContainerHighest + + signal accepted + signal cancelled + + function reparentWrapper(): void { + const newParent = open ? rootParent : root; + const pos = dialogWrapper.mapToItem(newParent, 0, 0); + dialogWrapper.parent = newParent; + dialogWrapper.x = pos.x; + dialogWrapper.y = pos.y; + } + + Layout.fillWidth: true + implicitHeight: openButton.implicitHeight + z: open || dialogTransition.running ? 2 : 0 + + BlobGroup { + id: blobGroup + + color: root.open ? root.openColor : Colors.tPalette.m3surfaceContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: root.open ? root.rootParent : root + + onClicked: root.open = false + } + + Item { + id: dialogWrapper + + height: openButton.implicitHeight + width: root.width + z: 1 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + backdrop.enabled: true + dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased + dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased + dialogBg.topRightRadius: Tokens.rounding.largeIncreased + dialogBg.topLeftRadius: Tokens.rounding.largeIncreased + dialogContent.opacity: 1 + dialogWrapper.height: root.openHeight + dialogWrapper.width: root.openWidth + dialogWrapper.x: (root.rootParent.width - root.openWidth) / 2 + dialogWrapper.y: (root.rootParent.height - root.openHeight) / 2 + elevation.opacity: 1 + openButton.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: root.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + bottomLeftRadius: dialogBg.bottomLeftRadius + bottomRightRadius: dialogBg.bottomRightRadius + level: 4 + opacity: 0 + radius: dialogBg.radius + + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + } + + BlobRect { + id: dialogBg + + anchors.fill: parent + bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + } + + TimeDialogToggle { + id: openButton + + anchors.left: parent.left + anchors.right: parent.right + color: "transparent" + height: Math.min(implicitHeight, parent.height) + subtext: root.subtext + last: root.last + first: root.first + text: root.label + + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + + onClicked: root.open = true + } + + Loader { + id: dialogContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + onWheel: event => event.accepted = true + preventStealing: true + acceptedButtons: Qt.NoButton + cursorShape: undefined + enabled: dialogContent.opacity > 0 + clip: true + implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2 + implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin + + ColumnLayout { + id: innerLayout + anchors.bottomMargin: Tokens.padding.largeIncreased + anchors.fill: parent + anchors.margins: Tokens.padding.extraLarge + spacing: 0 + + CustomText { + font.pointSize: Tokens.font.size.large + text: root.header + } + + Loader { + Layout.fillWidth: true + Layout.leftMargin: root.horizontalContentMargin + Layout.topMargin: Tokens.spacing.medium + Layout.bottomMargin: Tokens.spacing.medium + Layout.rightMargin: root.horizontalContentMargin + sourceComponent: root.content + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.extraSmall + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: qsTr("Cancel") + type: TextButton.Text + verticalPadding: Tokens.padding.small + stateLayer.preventStealing: true + + onClicked: { + root.cancelled(); + root.open = false; + } + } + + TextButton { + enabled: root.acceptAllowed + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: root.acceptLabel + type: TextButton.Text + stateLayer.preventStealing: true + verticalPadding: Tokens.padding.small + + onClicked: { + root.accepted(); + root.open = false; + } + } + } + } + } + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + } + } +} diff --git a/Modules/Settings/Common/TimeDialogSelect.qml b/Modules/Settings/Common/TimeDialogSelect.qml new file mode 100644 index 0000000..33967dc --- /dev/null +++ b/Modules/Settings/Common/TimeDialogSelect.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import ZShell.Config +import qs.Helpers +import qs.Components +import qs.Services + +TimeDialogRow { + id: root + + required property int start + required property int end + property int endCurrent + property int startCurrent + openColor: Colors.palette.m3surfaceContainer + openHeight: Math.min(rootParent.height * 0.8, contentItem?.implicitHeight ?? 0) + openWidth: Math.min(rootParent.width * 0.8, contentItem?.implicitWidth ?? 0) + + function keyFor(item: var): string { + return item.id; + } + + function labelFor(item: var): string { + return item.label; + } + + horizontalContentMargin: -Tokens.padding.extraSmall + + content: Component { + TimeInput { + id: timeContent + start: root.start + end: root.end + + onEndEdit: value => root.endCurrent = value + onStartEdit: value => root.startCurrent = value + } + } + + onOpenChanged: { + if (open) { + root.rootParent.interactive = false; + root.z = 5; + } else { + root.rootParent.interactive = true; + root.z = 1; + } + } +} diff --git a/Modules/Settings/Common/OverlayRow.qml b/Modules/Settings/Common/TimeDialogToggle.qml similarity index 86% rename from Modules/Settings/Common/OverlayRow.qml rename to Modules/Settings/Common/TimeDialogToggle.qml index f7a6dae..0b540fa 100644 --- a/Modules/Settings/Common/OverlayRow.qml +++ b/Modules/Settings/Common/TimeDialogToggle.qml @@ -10,12 +10,14 @@ ConnectedRect { property alias checked: switchButton.checked property int horizontalPadding: Tokens.padding.largeIncreased - required property Component popup property alias subtext: subtext.text property alias text: text.text + property alias icon: icon + property string iconText: "open_in_new" property int verticalPadding: Tokens.padding.small - signal clicked(checked: bool) + signal clicked + signal toggled(checked: bool) Layout.fillWidth: true implicitHeight: layout.implicitHeight + verticalPadding * 2 @@ -50,11 +52,11 @@ ConnectedRect { anchors.right: switchButton.left anchors.rightMargin: Tokens.spacing.medium anchors.verticalCenter: parent.verticalCenter - text: "open_in_new" + text: root.iconText } StateLayer { - onClicked: PopupManager.requestOpen(root.popup) + onClicked: root.clicked() } CustomSwitch { @@ -64,6 +66,6 @@ ConnectedRect { anchors.rightMargin: root.horizontalPadding anchors.verticalCenter: parent.verticalCenter - onClicked: root.clicked(checked) + onClicked: root.toggled(checked) } } diff --git a/Modules/Settings/Common/TimeInput.qml b/Modules/Settings/Common/TimeInput.qml index 05e8052..c8bfd2b 100644 --- a/Modules/Settings/Common/TimeInput.qml +++ b/Modules/Settings/Common/TimeInput.qml @@ -6,15 +6,13 @@ import qs.Components import qs.Helpers import qs.Services -CustomClippingRect { +ColumnLayout { id: root - required property var object - required property list settings - property bool shouldBeActive: true - - signal applySettings(startTime: int, endTime: int) - signal close + required property int start + required property int end + signal endEdit(time: int) + signal startEdit(time: int) function convertHour(timeValue: int): int { return Math.floor(timeValue / 60); @@ -28,555 +26,459 @@ CustomClippingRect { return hour * 60 + minute; } - color: Colors.palette.m3surfaceContainer - implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin - implicitWidth: column.implicitWidth + column.anchors.margins * 2 - radius: Tokens.rounding.largeIncreased + spacing: Tokens.spacing.medium - ColumnLayout { - id: column + RowLayout { + CustomRect { + id: startHourRect - anchors.left: parent.left - anchors.margins: Tokens.padding.largeIncreased - anchors.right: parent.right - anchors.top: parent.top - spacing: Tokens.spacing.medium + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: startHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } + } + + TextFieldBase { + id: startHourField + + function setConfigText(): string { + var val = root.convertHour(root.start); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (startHourField.text.length >= 2) { + startHourField.text = "0" + startHourField.text[0]; + } else if (startHourField.text.length === 1) { + startHourField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + startHourField.text = setConfigText(); + startHourField.focus = false; + } else if (event.key === Qt.Key_Return) { + startHourField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + startMinuteField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + endMinuteField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = startHourField.text.length; + + if (textLen >= 2 && startHourField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(startHourField.text + digit); + } else { + val = parseInt(startHourField.text[1] + digit); + } + + val = Math.max(0, Math.min(23, val)); + + if (textLen >= 2 && val < 10) { + startHourField.text = "0" + val; + } else { + startHourField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(startMinuteField.text); + const hours = root.convertToMinutes(parseInt(startHourField.text)); + root.startEdit(hours + mins); + } + } + } CustomText { - text: qsTr("Select time") + id: startSeparator + + font.pointSize: Tokens.font.size.extraLarge + text: ":" } - RowLayout { - CustomRect { - id: startHourRect + CustomRect { + id: startMinuteRect - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: startHourField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { - } - } - } - - TextFieldBase { - id: startHourField - - function setConfigText(setting: string): string { - var val = root.convertHour(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[1]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (startHourField.text.length >= 2) { - startHourField.text = "0" + startHourField.text[0]; - } else if (startHourField.text.length === 1) { - startHourField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - startHourField.text = setConfigText(root.settings[1]); - startHourField.focus = false; - } else if (event.key === Qt.Key_Return) { - startHourField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - startMinuteField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - endMinuteField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = startHourField.text.length; - - if (textLen >= 2 && startHourField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(startHourField.text + digit); - } else { - val = parseInt(startHourField.text[1] + digit); - } - - val = Math.max(0, Math.min(23, val)); - - if (textLen >= 2 && val < 10) { - startHourField.text = "0" + val; - } else { - startHourField.text = val.toString(); - } - } - - event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (startHourField.text === "") - return; - var val = parseInt(startHourField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== startHourField.text) - startHourField.text = newText; - } - } - } - - CustomText { - id: startSeparator - - font.pointSize: Tokens.font.size.extraLarge - text: ":" - } + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small CustomRect { - id: startMinuteRect + anchors.fill: parent + border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: startMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small + Behavior on border.width { + Anim {} + } + } - CustomRect { - anchors.fill: parent - border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: startMinuteField.focus ? 2 : 0 - radius: parent.radius - border.width + TextFieldBase { + id: startMinuteField - Behavior on border.width { - Anim { - } + function setConfigText(): string { + var val = root.convertMinute(root.start); + if (val === 0) { + return "00"; } + return String(val); } - TextFieldBase { - id: startMinuteField - - function setConfigText(setting: string): string { - var val = root.convertMinute(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[1]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (startMinuteField.text.length >= 2) { - startMinuteField.text = "0" + startMinuteField.text[0]; - } else if (startMinuteField.text.length === 1) { - startMinuteField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - startMinuteField.text = setConfigText(root.settings[1]); - startMinuteField.focus = false; - } else if (event.key === Qt.Key_Return) { - startMinuteField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - endHourField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - startHourField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = startMinuteField.text.length; - - if (textLen >= 2 && startMinuteField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(startMinuteField.text + digit); - } else { - val = parseInt(startMinuteField.text[1] + digit); - } - - val = Math.max(0, Math.min(59, val)); - - if (textLen >= 2 && val < 10) { - startMinuteField.text = "0" + val; - } else { - startMinuteField.text = val.toString(); - } - } + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (startMinuteField.text === "") - return; - var val = parseInt(startMinuteField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== startMinuteField.text) - startMinuteField.text = newText; - } - } - } - - Item { - id: spacer - - Layout.preferredWidth: Tokens.spacing.largeIncreased - } - - CustomRect { - id: endHourRect - - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: endHourField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { + if (startMinuteField.text.length >= 2) { + startMinuteField.text = "0" + startMinuteField.text[0]; + } else if (startMinuteField.text.length === 1) { + startMinuteField.text = "0"; } - } - } - - TextFieldBase { - id: endHourField - - function setConfigText(setting: string): string { - var val = root.convertHour(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[2]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (endHourField.text.length >= 2) { - endHourField.text = "0" + endHourField.text[0]; - } else if (endHourField.text.length === 1) { - endHourField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - endHourField.text = setConfigText(root.settings[2]); - endHourField.focus = false; - } else if (event.key === Qt.Key_Return) { - endHourField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - endMinuteField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - startMinuteField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = endHourField.text.length; - - if (textLen >= 2 && endHourField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(endHourField.text + digit); - } else { - val = parseInt(endHourField.text[1] + digit); - } - - val = Math.max(0, Math.min(23, val)); - - if (textLen >= 2 && val < 10) { - endHourField.text = "0" + val; - } else { - endHourField.text = val.toString(); - } - } - + return; + } else if (event.key === Qt.Key_Escape) { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (endHourField.text === "") - return; - var val = parseInt(endHourField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== endHourField.text) - endHourField.text = newText; - } - } - } - - CustomText { - id: endSeparator - - font.pointSize: Tokens.font.size.extraLarge - text: ":" - } - - CustomRect { - id: endMinuteRect - - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: endMinuteField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { - } - } - } - - TextFieldBase { - id: endMinuteField - - function setConfigText(setting: string): string { - var val = root.convertMinute(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); + startMinuteField.text = setConfigText(); + startMinuteField.focus = false; + } else if (event.key === Qt.Key_Return) { + startMinuteField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + endHourField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + startHourField.focus = true; } - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[2]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (endMinuteField.text.length >= 2) { - endMinuteField.text = "0" + endMinuteField.text[0]; - } else if (endMinuteField.text.length === 1) { - endMinuteField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - endMinuteField.text = setConfigText(root.settings[2]); - endMinuteField.focus = false; - } else if (event.key === Qt.Key_Return) { - endMinuteField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - startHourField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - endHourField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = endMinuteField.text.length; - - if (textLen >= 2 && endMinuteField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(endMinuteField.text + digit); - } else { - val = parseInt(endMinuteField.text[1] + digit); - } - - val = Math.max(0, Math.min(59, val)); - - if (textLen >= 2 && val < 10) { - endMinuteField.text = "0" + val; - } else { - endMinuteField.text = val.toString(); - } - } - + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (endMinuteField.text === "") + var digit = event.text; + var textLen = startMinuteField.text.length; + + if (textLen >= 2 && startMinuteField.text[0] !== '0') { return; - var val = parseInt(endMinuteField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== endMinuteField.text) - endMinuteField.text = newText; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(startMinuteField.text + digit); + } else { + val = parseInt(startMinuteField.text[1] + digit); + } + + val = Math.max(0, Math.min(59, val)); + + if (textLen >= 2 && val < 10) { + startMinuteField.text = "0" + val; + } else { + startMinuteField.text = val.toString(); + } } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(startMinuteField.text); + const hours = root.convertToMinutes(parseInt(startHourField.text)); + root.startEdit(hours + mins); } } } - RowLayout { - CustomText { - Layout.preferredWidth: spacer.x + spacer.width - text: qsTr("Start") + Item { + id: spacer + + Layout.preferredWidth: Tokens.spacing.largeIncreased + } + + CustomRect { + id: endHourRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: endHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } } - CustomText { - Layout.preferredWidth: endMinuteRect.width + endHourRect.width - text: qsTr("End") + TextFieldBase { + id: endHourField + + function setConfigText(): string { + var val = root.convertHour(root.end); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (endHourField.text.length >= 2) { + endHourField.text = "0" + endHourField.text[0]; + } else if (endHourField.text.length === 1) { + endHourField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + endHourField.text = setConfigText(); + endHourField.focus = false; + } else if (event.key === Qt.Key_Return) { + endHourField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + endMinuteField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + startMinuteField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = endHourField.text.length; + + if (textLen >= 2 && endHourField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(endHourField.text + digit); + } else { + val = parseInt(endHourField.text[1] + digit); + } + + val = Math.max(0, Math.min(23, val)); + + if (textLen >= 2 && val < 10) { + endHourField.text = "0" + val; + } else { + endHourField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(endMinuteField.text); + const hours = root.convertToMinutes(parseInt(endHourField.text)); + root.endEdit(hours + mins); + } + } + } + + CustomText { + id: endSeparator + + font.pointSize: Tokens.font.size.extraLarge + text: ":" + } + + CustomRect { + id: endMinuteRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: endMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } + } + + TextFieldBase { + id: endMinuteField + + function setConfigText(): string { + var val = root.convertMinute(root.end); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (endMinuteField.text.length >= 2) { + endMinuteField.text = "0" + endMinuteField.text[0]; + } else if (endMinuteField.text.length === 1) { + endMinuteField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + endMinuteField.text = setConfigText(); + endMinuteField.focus = false; + } else if (event.key === Qt.Key_Return) { + endMinuteField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + startHourField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + endHourField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = endMinuteField.text.length; + + if (textLen >= 2 && endMinuteField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(endMinuteField.text + digit); + } else { + val = parseInt(endMinuteField.text[1] + digit); + } + + val = Math.max(0, Math.min(59, val)); + + if (textLen >= 2 && val < 10) { + endMinuteField.text = "0" + val; + } else { + endMinuteField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(endMinuteField.text); + const hours = root.convertToMinutes(parseInt(endHourField.text)); + root.endEdit(hours + mins); + } } } } RowLayout { - id: buttonRow - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.margins: Tokens.padding.largeIncreased - anchors.right: parent.right - anchors.top: column.bottom - anchors.topMargin: Tokens.spacing.medium - - Item { - id: buttonSpacer - - Layout.fillWidth: true + CustomText { + Layout.preferredWidth: spacer.x + spacer.width + text: qsTr("Start") } - ButtonRow { - spacing: Tokens.spacing.medium - - IconTextButton { - font.pointSize: Tokens.font.size.normal - icon: "close" - inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) - inactiveOnColor: Colors.palette.m3onSurfaceVariant - isRound: true - isToggle: false - shapeMorph: true - shapeMorphExpansion: pressed ? 12 : 0 - text: "Cancel" - - onClicked: root.close() - } - - IconTextButton { - font.pointSize: Tokens.font.size.normal - icon: "check" - isRound: true - isToggle: false - shapeMorph: true - shapeMorphExpansion: pressed ? 12 : 0 - text: "Apply" - - onClicked: { - const start = root.convertToMinutes(parseInt(startHourField.text)) + parseInt(startMinuteField.text); - const end = root.convertToMinutes(parseInt(endHourField.text)) + parseInt(endMinuteField.text); - root.applySettings(start, end); - } - } + CustomText { + Layout.preferredWidth: endMinuteRect.width + endHourRect.width + text: qsTr("End") } } } diff --git a/Modules/Settings/Common/ToggleRow.qml b/Modules/Settings/Common/ToggleRow.qml index 09f22f5..36fb583 100644 --- a/Modules/Settings/Common/ToggleRow.qml +++ b/Modules/Settings/Common/ToggleRow.qml @@ -12,6 +12,7 @@ CustomSwitch { property alias last: bg.last property string settingAnchor property string subtext + property string iconText function flashHighlight(): void { bg.flashHighlight(); @@ -63,8 +64,7 @@ CustomSwitch { text: root.text Behavior on opacity { - Anim { - } + Anim {} } } @@ -81,10 +81,16 @@ CustomSwitch { visible: root.subtext Behavior on opacity { - Anim { - } + Anim {} } } + + MaterialIcon { + id: icon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.iconText ?? "" + } } } diff --git a/Modules/Settings/Content.qml b/Modules/Settings/Content.qml index 66b616a..75a05c2 100644 --- a/Modules/Settings/Content.qml +++ b/Modules/Settings/Content.qml @@ -24,8 +24,7 @@ CustomClippingRect { radius: Tokens.rounding.largeIncreased + Tokens.padding.small Behavior on blobColor { - CAnim { - } + CAnim {} } TapHandler { @@ -70,8 +69,4 @@ CustomClippingRect { anchors.top: parent.top sState: root.sState } - - PopupOverlay { - anchors.fill: parent - } } diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 22aa679..444fb1e 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -31,6 +31,10 @@ QtObject { WallpaperSelect { } } + + Component { + ColorsFonts {} + } } }, Component { diff --git a/Modules/Settings/Pages.qml b/Modules/Settings/Pages.qml index f853699..ad148b1 100644 --- a/Modules/Settings/Pages.qml +++ b/Modules/Settings/Pages.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Components import ZShell.Config +import qs.Services Item { id: root @@ -41,6 +42,18 @@ Item { objectName: "PageContainer" Component.onCompleted: root.loadPage(root.sState.currentPageIdx) + + CustomRect { + anchors.fill: parent + color: Qt.alpha(Colors.palette.m3shadow, 0.3) + opacity: root.sState.dimmed ? 1 : 0 + visible: opacity > 0 + z: 0 + + Behavior on opacity { + Anim {} + } + } } Connections { diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index ae17efa..e6e5394 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -56,6 +56,7 @@ PageBase { header: qsTr("Add new entry") icon: "add" label: qsTr("Add entry") + last: true model: { const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id)); return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({ @@ -64,6 +65,7 @@ PageBase { })); } rootParent: root.flickable + settingAnchor: "panels-bar-status-icons-add-item" onAccepted: { if (!selectedItem) diff --git a/Modules/Settings/Pages/Panels/SidebarPanel.qml b/Modules/Settings/Pages/Panels/SidebarPanel.qml index e99a36e..cef7a3f 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -88,6 +88,10 @@ PageBase { enabled: Object.keys(model).length > 0 header: qsTr("Add new entry") icon: "add" + onOpenChanged: { + root.sState.dimmed = open; + } + settingAnchor: "panels-sidebar-quick-toggles-add-item" label: qsTr("Add entry") model: { const present = new Set(Config.utilities.quickToggles.values.map(item => item.id)); diff --git a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml new file mode 100644 index 0000000..44a490e --- /dev/null +++ b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Paths +import qs.Services +import qs.Modules.Settings.Common + +PageBase { + id: root + + readonly property list fonts: Qt.fontFamilies() + readonly property var schemes: [ + { + id: "onedark", + label: qsTr("One Dark") + }, + { + id: "nord", + label: qsTr("Nord") + }, + { + id: "dracula", + label: qsTr("Dracula") + }, + { + id: "darkgreen", + label: qsTr("Darkgreen") + }, + { + id: "solarized", + label: qsTr("Solarized") + }, + { + id: "rosepine", + label: qsTr("Rosepine") + }, + { + id: "gruvbox", + label: qsTr("Gruvbox") + }, + { + id: "catppuccin", + label: qsTr("Catppuccin") + }, + { + id: "tokyonight", + label: qsTr("Tokyo Night") + }, + { + id: "shadotheme", + label: qsTr("Shadotheme") + } + ] + + isSubPage: true + title: qsTr("Colors & font") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + SectionHeader { + first: true + text: qsTr("Colors") + } + + ToggleRow { + text: qsTr("Dynamic colors") + first: true + subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.general.color.schemeGeneration ? "enabled" : "disabled") + checked: Config.general.color.schemeGeneration + settingAnchor: "colors-fonts-dynamic-colors" + + onToggled: { + Config.general.color.schemeGeneration = checked; + + if (checked) + Colors.setMode(Config.general.color.mode); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-scheme" + model: root.schemes + acceptLabel: qsTr("Confirm") + enabled: !Config.general.color.schemeGeneration + header: qsTr("Select scheme") + rowButton.activeItem: root.schemes.find(s => { + var regex = new RegExp(Config.colors.presets.name, "i"); + return regex.test(s.id); + }).label ?? "" + label: qsTr("Preset scheme") + subtext: qsTr("Select preset color scheme") + rootParent: root.flickable + + FileView { + id: view + path: `${Paths.state}/presets.json` + + watchChanges: true + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property var presets + } + } + + initialSelect: Config.colors.presets.name.toLowerCase() + + onAccepted: { + if (!selectedItem) + return; + + const s = selectedItem.toLowerCase(); + let v = ""; + let a = ""; + const existingVariant = Config.colors.presets.variant; + const existingAccent = Config.colors.presets.accent; + const variantKeys = Object.keys(adapter.presets[s].variants); + + if (existingVariant !== "" && variantKeys.some(v => v === existingVariant)) + v = existingVariant; + + if (v !== "" && existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent)) + a = existingAccent; + + if (a !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + else if (v !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]); + else + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${variantKeys[0]}`]); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-variant" + // qmlformat off + model: { + const v = adapter.presets[Config.colors.presets.name].variants; + const map = Object.keys(v).map(v => ({ + id: v, + label: qsTr("%1%2") + .arg(v.charAt(0).toUpperCase()) + .arg(v.slice(1).toLowerCase()) + })); + + return map; + } + // qmlformat on + acceptLabel: qsTr("Confirm") + enabled: !Config.general.color.schemeGeneration && model.length > 1 + header: qsTr("Select variant") + rowButton.activeItem: { + const v = Config.colors.presets.variant ?? ""; + if (v === "") + return ""; + return qsTr("%1%2").arg(v.charAt(0).toUpperCase()).arg(v.slice(1).toLowerCase()); + } + label: qsTr("Variant") + subtext: qsTr("Select preset variant") + rootParent: root.flickable + + initialSelect: Config.colors.presets.variant + + onAccepted: { + if (!selectedItem) + return; + + const v = selectedItem.toLowerCase(); + const s = Config.colors.presets.name; + const existingAccent = Config.colors.presets.accent; + let a = ""; + + if (existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent)) + a = existingAccent; + + if (a !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + else + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-accent" + // qmlformat off + model: { + const a = adapter.presets[Config.colors.presets.name].variants[Config.colors.presets.variant]?.accents ?? []; + + if (a.length === 0) + return a; + + const map = a.map(a => ({ + id: a, + label: qsTr("%1%2") + .arg(a.charAt(0).toUpperCase()) + .arg(a.slice(1).toLowerCase()) + })); + + return map; + } + // qmlformat on + acceptLabel: qsTr("Confirm") + enabled: !Config.general.color.schemeGeneration && model.length > 1 + header: qsTr("Select accent") + rowButton.activeItem: { + const a = Config.colors.presets.accent ?? ""; + if (a === "") + return ""; + return qsTr("%1%2").arg(a.charAt(0).toUpperCase()).arg(a.slice(1).toLowerCase()); + } + label: qsTr("Accent") + last: true + subtext: qsTr("Select preset accent") + rootParent: root.flickable + + initialSelect: Config.colors.presets.accent + + onAccepted: { + if (!selectedItem) + return; + + const a = selectedItem.toLowerCase(); + const v = Config.colors.presets.variant; + const s = Config.colors.presets.name; + + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + } + } + + SectionHeader { + text: qsTr("Fonts") + } + + SearchPopup { + id: selectSansFont + + settingAnchor: "colors-fonts-sans-font" + model: root.fonts + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + separateContent: false + header: qsTr("Select font") + first: true + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.sans, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Sans font") + subtext: qsTr("Select desired sans font") + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.sans + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.sans = selectedItem; + } + } + + SearchPopup { + id: selectMonoFont + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + settingAnchor: "colors-fonts-mono-font" + separateContent: false + header: qsTr("Select font") + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.mono, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Mono font") + subtext: qsTr("Select desired mono font") + model: root.fonts + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.mono + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.mono = selectedItem; + } + } + + SearchPopup { + id: selectClockFont + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + separateContent: false + settingAnchor: "colors-fonts-clock-font" + header: qsTr("Select font") + last: true + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.clock, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Clock font") + subtext: qsTr("Select desired clock font") + model: root.fonts + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.clock + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.clock = selectedItem; + } + } + } +} diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index 40274ad..5e7035c 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -33,18 +33,35 @@ PageBase { } } - IconTextButton { + ButtonRow { Layout.alignment: Qt.AlignHCenter - enabled: Config.background.enabled - horizontalPadding: Tokens.padding.extraLarge - icon: "wallpaper" - isRound: true - shapeMorph: true - text: qsTr("Wallpapers") - type: IconTextButton.Tonal - verticalPadding: Tokens.padding.small + spacing: Tokens.spacing.small - onClicked: root.sState.openSubPage(1) // Wallpaper page + IconTextButton { + enabled: Config.background.enabled + horizontalPadding: Tokens.padding.extraLarge + icon: "wallpaper" + isRound: true + shapeMorph: true + text: qsTr("Wallpapers") + type: IconTextButton.Tonal + verticalPadding: Tokens.padding.small + + onClicked: root.sState.openSubPage(1) // Wallpaper page + } + + IconTextButton { + enabled: Config.background.enabled + horizontalPadding: Tokens.padding.extraLarge + icon: "palette" + isRound: true + shapeMorph: true + text: qsTr("Colors & font") + type: IconTextButton.Tonal + verticalPadding: Tokens.padding.small + + onClicked: root.sState.openSubPage(2) // Colors & font page + } } ToggleRow { @@ -76,9 +93,7 @@ PageBase { onToggled: Colors.setMode(checked ? "dark" : "light") } - OverlayRow { - id: darkMode - + TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); d.setMinutes(Config.general.color.scheduleDarkEnd); @@ -90,35 +105,30 @@ PageBase { return Qt.formatTime(d, "hh:mm AP"); } - checked: Config.general.color.scheduleDark + rowButton.checked: Config.general.color.scheduleDark + rowButton.onToggled: Config.general.color.scheduleDark = rowButton.checked + start: Config.general.color.scheduleDarkStart + end: Config.general.color.scheduleDarkEnd first: true settingAnchor: "style-schedule-dark-mode" + acceptLabel: qsTr("Apply") + rootParent: root.flickable + header: qsTr("Select time") subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - text: qsTr("Schedule dark mode") + label: qsTr("Schedule dark mode") - popup: Component { - TimeInput { - object: Config.general.color - settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"] - - onApplySettings: (start, end) => { - Config.general.color.scheduleDarkStart = start; - Config.general.color.scheduleDarkEnd = end; - ModeScheduler.checkStartup(); - PopupManager.requestClose(); - } - onClose: PopupManager.requestClose() - } + onOpenChanged: { + root.sState.dimmed = open; } - onClicked: value => { - Config.general.color.scheduleDark = value; + onAccepted: { + Config.general.color.scheduleDarkStart = startCurrent; + Config.general.color.scheduleDarkEnd = endCurrent; + ModeScheduler.checkStartup(); } } - OverlayRow { - id: hyprsunset - + TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); d.setMinutes(Config.general.color.scheduleHyprsunsetEnd); @@ -131,29 +141,26 @@ PageBase { } Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing - checked: Config.general.color.scheduleHyprsunset + rowButton.checked: Config.general.color.scheduleHyprsunset + rowButton.onToggled: Config.general.color.scheduleHyprsunset = rowButton.checked + start: Config.general.color.scheduleHyprsunsetStart + end: Config.general.color.scheduleHyprsunsetEnd last: true settingAnchor: "style-schedule-hyprsunset" + acceptLabel: qsTr("Apply") + rootParent: root.flickable + header: qsTr("Select time") subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - text: qsTr("Schedule hyprsunset") + label: qsTr("Schedule hyprsunset") - popup: Component { - TimeInput { - object: Config.general.color - settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"] - - onApplySettings: (start, end) => { - Config.general.color.scheduleHyprsunsetStart = start; - Config.general.color.scheduleHyprsunsetEnd = end; - Hyprsunset.checkStartup(); - PopupManager.requestClose(); - } - onClose: PopupManager.requestClose() - } + onOpenChanged: { + root.sState.dimmed = open; } - onClicked: value => { - Config.general.color.scheduleHyprsunset = value; + onAccepted: { + Config.general.color.scheduleHyprsunsetStart = startCurrent; + Config.general.color.scheduleHyprsunsetEnd = endCurrent; + Hyprsunset.checkStartup(); } } } diff --git a/Modules/Settings/PopupManager.qml b/Modules/Settings/PopupManager.qml deleted file mode 100644 index 4b37c02..0000000 --- a/Modules/Settings/PopupManager.qml +++ /dev/null @@ -1,20 +0,0 @@ -pragma Singleton - -import Quickshell -import QtQuick - -Singleton { - id: root - - property bool closed: true - property Component currentPopup: null - - function requestClose(): void { - closed = true; - } - - function requestOpen(component: Component): void { - currentPopup = component; - closed = false; - } -} diff --git a/Modules/Settings/PopupOverlay.qml b/Modules/Settings/PopupOverlay.qml deleted file mode 100644 index d18440e..0000000 --- a/Modules/Settings/PopupOverlay.qml +++ /dev/null @@ -1,47 +0,0 @@ -import QtQuick -import ZShell.Config -import qs.Components -import qs.Services - -CustomRect { - id: root - - readonly property bool closing: PopupManager.closed - readonly property var currentPopup: PopupManager.currentPopup - property bool shouldBeVisible: loader.status === Loader.Ready - - color: Qt.alpha(Colors.palette.m3shadow, 0.3) - opacity: shouldBeVisible && !closing ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - Anim { - } - } - - onVisibleChanged: if (!visible) - PopupManager.currentPopup = null - - CustomMouseArea { - anchors.fill: parent - hoverEnabled: true - preventStealing: true - propagateComposedEvents: false - - onClicked: { - const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x; - const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y; - if (insideItemHeight && insideItemWidth) - return; - - PopupManager.requestClose(); - } - } - - Loader { - id: loader - - anchors.centerIn: parent - sourceComponent: root.currentPopup - } -} diff --git a/Modules/Settings/SettingsState.qml b/Modules/Settings/SettingsState.qml index 79e80d8..11933e1 100644 --- a/Modules/Settings/SettingsState.qml +++ b/Modules/Settings/SettingsState.qml @@ -16,6 +16,7 @@ QtObject { property string searchText property DesktopEntry selectedApp property BluetoothDevice selectedBtDevice + property bool dimmed property string selectedWallpaperCategory property list subPageIdxStack diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index ed60e8b..2a65612 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -94,9 +94,8 @@ def list_presets( if v.accents: entry["accents"] = sorted(v.accents) entry["default_accent"] = sorted(v.accents)[0] - variants[v.id] = entry - out[meta.name] = { - "id": sid, + variants[v.id.lower()] = entry + out[sid.lower()] = { "variants": variants, } print(json.dumps({"presets": out}, indent=2)) @@ -670,7 +669,7 @@ def generate( name = palette_obj.scheme flavor = palette_obj.variant - display_name = schemes[p_scheme].name + display_name = schemes[p_scheme].id config["colors"]["presets"] = { "name": display_name, "variant": p_variant, From 5330ae71d22169751c29bc13421aa7b39aed8f4d Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 13:53:37 +0200 Subject: [PATCH 02/21] add nightlight hyprland plugin + rename hyprsunsetmanager -> nightlightmanager to control both --- Helpers/Hyprsunset.qml | 4 +- Plugins/ZShell/CMakeLists.txt | 8 + Plugins/ZShell/Config/general.hpp | 2 + Plugins/ZShell/HyprPlugins/CMakeLists.txt | 12 + Plugins/ZShell/HyprPlugins/colortemp.hpp | 37 ++ Plugins/ZShell/HyprPlugins/main.cpp | 457 ++++++++++++++++++ Plugins/ZShell/Services/CMakeLists.txt | 4 +- Plugins/ZShell/Services/hyprsunsetmanager.cpp | 148 ------ Plugins/ZShell/Services/hyprsunsetmanager.hpp | 71 --- Plugins/ZShell/Services/nightlightmanager.cpp | 444 +++++++++++++++++ Plugins/ZShell/Services/nightlightmanager.hpp | 122 +++++ 11 files changed, 1087 insertions(+), 222 deletions(-) create mode 100644 Plugins/ZShell/HyprPlugins/CMakeLists.txt create mode 100644 Plugins/ZShell/HyprPlugins/colortemp.hpp create mode 100644 Plugins/ZShell/HyprPlugins/main.cpp delete mode 100644 Plugins/ZShell/Services/hyprsunsetmanager.cpp delete mode 100644 Plugins/ZShell/Services/hyprsunsetmanager.hpp create mode 100644 Plugins/ZShell/Services/nightlightmanager.cpp create mode 100644 Plugins/ZShell/Services/nightlightmanager.hpp diff --git a/Helpers/Hyprsunset.qml b/Helpers/Hyprsunset.qml index ca82be2..540091d 100644 --- a/Helpers/Hyprsunset.qml +++ b/Helpers/Hyprsunset.qml @@ -26,12 +26,14 @@ Singleton { service.toggle(); } - HyprsunsetManager { + NightlightManager { id: service activeAuto: Config.general.color.scheduleHyprsunset endTime: root.end startTime: root.start temp: root.temp + fadeDuration: Config.general.color.nativeFadeDuration / 1000 + useNativeNightlight: Config.general.color.useNativeNightlight } } diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index d260f80..85f7df0 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -79,3 +79,11 @@ add_subdirectory(Services) add_subdirectory(Components) add_subdirectory(Blobs) add_subdirectory(Config) + +pkg_check_modules(HYPRLAND_PROBE hyprland) +if(HYPRLAND_PROBE_FOUND) + add_subdirectory(HyprPlugins) +else() + message(STATUS + "hyprland development headers not found; skipping zshell-nightlight plugin") +endif() diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 9d0f8fd..9f6a70f 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -98,6 +98,8 @@ class ColorSettings : public ConfigObject { CFG_PROPERTY(int, hyprsunsetTemp, 2600) CFG_PROPERTY(QString, mode, QStringLiteral("dark")) CFG_PROPERTY(bool, neovimColors, false) + CFG_PROPERTY(bool, useNativeNightlight, true) + CFG_PROPERTY(int, nativeFadeDuration, 2000) CFG_PROPERTY(bool, scheduleDark, false) CFG_PROPERTY(int, scheduleDarkEnd, 600) CFG_PROPERTY(int, scheduleDarkStart, 1140) diff --git a/Plugins/ZShell/HyprPlugins/CMakeLists.txt b/Plugins/ZShell/HyprPlugins/CMakeLists.txt new file mode 100644 index 0000000..0316838 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/CMakeLists.txt @@ -0,0 +1,12 @@ +pkg_check_modules(HYPRLAND REQUIRED hyprland) + +add_library(zshell-nightlight SHARED main.cpp) + +target_compile_features(zshell-nightlight PRIVATE cxx_std_23) +target_include_directories(zshell-nightlight PRIVATE ${HYPRLAND_INCLUDE_DIRS}) +target_link_directories(zshell-nightlight PRIVATE ${HYPRLAND_LIBRARY_DIRS}) +target_compile_options(zshell-nightlight PRIVATE ${HYPRLAND_CFLAGS_OTHER}) + +target_compile_options(zshell-nightlight PRIVATE -fno-gnu-unique) + +install(TARGETS zshell-nightlight LIBRARY DESTINATION "${INSTALL_LIBDIR}/plugins") diff --git a/Plugins/ZShell/HyprPlugins/colortemp.hpp b/Plugins/ZShell/HyprPlugins/colortemp.hpp new file mode 100644 index 0000000..25f9c05 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/colortemp.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include + +namespace ZShell::services { + +inline std::array kelvinToGain(int kelvin) { + const double t = std::clamp(kelvin, 1000, 40000) / 100.0; + double r, g, b; + + r = t <= 66.0 + ? 1.0 + : std::clamp( + 1.29293618606 * std::pow(t - 60.0, -0.1332047592), 0.0, 1.0); + g = t <= 66.0 + ? std::clamp(0.39008157876 * std::log(t) - 0.63184144378, 0.0, 1.0) + : std::clamp( + 1.12989086089 * std::pow(t - 60.0, -0.0755148492), 0.0, 1.0); + b = t >= 66.0 + ? 1.0 + : (t <= 19.0 + ? 0.0 + : std::clamp( + 0.54320678911 * std::log(t - 10.0) - 1.19625408914, + 0.0, + 1.0)); + + return {static_cast(r), static_cast(g), static_cast(b)}; +} + +inline float easeInOutCubic(float x) { + return x < 0.5f ? 4.f * x * x * x + : 1.f - std::pow(-2.f * x + 2.f, 3.f) / 2.f; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp new file mode 100644 index 0000000..ad2080e --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -0,0 +1,457 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define private public +#include +#include +#include +#undef private + +#include + +extern "C" { +#include +#include +} + +#include "colortemp.hpp" + +using ZShell::services::easeInOutCubic; +using ZShell::services::kelvinToGain; + +using Render::IFramebuffer; +using Render::GL::CHyprOpenGLImpl; +using Render::GL::g_pHyprOpenGL; + +inline HANDLE PHANDLE = nullptr; + +static std::filesystem::path g_runtimeDir{"/tmp"}; +static std::filesystem::path g_cacheBase{"/tmp/.cache"}; + +static void initEnvPaths() { + if (const char* env = getenv("XDG_RUNTIME_DIR")) + g_runtimeDir = env; + + if (const char* env = getenv("XDG_CACHE_HOME")) { + if (*env) + g_cacheBase = env; + } else if (const char* home = getenv("HOME")) { + g_cacheBase = std::filesystem::path(home) / ".cache"; + } +} + +static std::ofstream& debugLog() { + static std::ofstream log( + g_runtimeDir / "zshell-nightlight-debug.log", + std::ios::app); + return log; +} + +static std::mutex g_logMutex; +static std::atomic g_logSeq{0}; + +static void dlog(const std::string& msg) { + std::lock_guard lk(g_logMutex); + auto& log = debugLog(); + log << "[" << g_logSeq++ << "][tid=" << std::this_thread::get_id() << "] " + << msg << std::endl; +} + +static constexpr int BAKED_STEPS = 24; + +static std::string glslFloat(float v) { + std::ostringstream ss; + ss << std::fixed << std::setprecision(6) << v; + return ss.str(); +} + +static std::filesystem::path cacheDir() { + return g_cacheBase / "zshell" / "night-light"; +} + +static std::filesystem::path shaderPath() { + return cacheDir() / "nightlight.frag"; +} + +static std::filesystem::path bakedShaderPath(const std::array& gain) { + return cacheDir() / + ("nightlight-" + glslFloat(gain[0]) + "-" + glslFloat(gain[1]) + + "-" + glslFloat(gain[2]) + ".frag"); +} + +static std::string defaultShaderSource() { + return "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "uniform vec3 tint;\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * tint, pixColor.a);\n" + "}\n"; +} + +static std::string bakedShaderSource(const std::array& gain) { + std::ostringstream src; + src << "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "const vec3 GAIN = vec3(" + << glslFloat(gain[0]) << "," << glslFloat(gain[1]) << "," + << glslFloat(gain[2]) + << ");\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * GAIN, pixColor.a);\n" + "}\n"; + return src.str(); +} + +static void ensureShaderFile( + const std::filesystem::path& path, const std::string& source) { + std::error_code ec; + if (std::filesystem::is_regular_file(path, ec)) return; + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream out(path, std::ios::trunc); + out << source; +} + +static bool g_active = false; +static bool g_uniformMode = false; +static bool g_modeDecided = false; +static std::array g_currentGain{1.f, 1.f, 1.f}; + +static int g_currentKelvin = 6500; +static int g_lastKelvin = 6500; + +struct STransition { + bool active = false; + std::chrono::steady_clock::time_point start; + float durationSec = 0.4f; + std::array fromGain{1.f, 1.f, 1.f}; + std::array toGain{1.f, 1.f, 1.f}; + int lastBakedStep = -1; +}; +static STransition g_transition; + +static void postStateEvent(bool enabled, bool animating); + +static bool gainIsNeutral(const std::array& gain) { + for (float v : gain) + if (std::abs(v - 1.f) > 0.001f) return false; + return true; +} + +static CFunctionHook* g_pBeginHook = nullptr; +using origBegin = void (*)( + CHyprOpenGLImpl*, + PHLMONITOR, + const CRegion&, + SP, + std::optional); + +static CFunctionHook* g_pSaveMirrorHook = nullptr; +using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&); + +static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) { + const bool saved = thisptr->m_applyFinalShader; + thisptr->m_applyFinalShader = false; + const bool result = (*reinterpret_cast( + g_pSaveMirrorHook->m_original))(thisptr, box); + thisptr->m_applyFinalShader = saved; + return result; +} + +static void installShader() { + auto* gl = g_pHyprOpenGL.get(); + if (!gl) + return; + + ensureShaderFile(shaderPath(), defaultShaderSource()); + gl->applyScreenShader(shaderPath().string()); + + auto* shader = gl->m_finalScreenShader.get(); + if (shader && shader->program() >= 1 && + shader->getUniformLocation(SHADER_TINT) != -1) { + g_uniformMode = true; + } else { + g_uniformMode = false; + if (!g_modeDecided) + dlog( + "shader has no 'tint' uniform, falling back to " + "per-step recompiled shaders"); + } + g_modeDecided = true; +} + +static void setUniformGain(const std::array& gain) { + auto* gl = g_pHyprOpenGL.get(); + auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr; + if (!gl || !shader) + return; + + gl->useShader(gl->m_finalScreenShader); + shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); +} + +static void hkBegin( + CHyprOpenGLImpl* thisptr, + PHLMONITOR mon, + const CRegion& damage, + SP fb, + std::optional finalDamage) { + (*reinterpret_cast( + g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage); + + if (fb) { + auto* renderer = g_pHyprRenderer.get(); + if (renderer) + renderer->m_renderData.blockScreenShader = true; + } + + if (!g_active) return; + + auto* gl = g_pHyprOpenGL.get(); + if (!gl) return; + + if (gl->m_finalScreenShader.get() && + gl->m_finalScreenShader->program() < 1) + installShader(); + if (!gl->m_finalScreenShader.get() || + gl->m_finalScreenShader->program() < 1) + return; + + if (g_transition.active) { + auto* monPtr = mon.get(); + if (!monPtr) + return; + const float elapsed = + std::chrono::duration( + std::chrono::steady_clock::now() - g_transition.start) + .count(); + const float t = + std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f); + const float e = easeInOutCubic(t); + + for (size_t i = 0; i < 3; ++i) + g_currentGain[i] = + g_transition.fromGain[i] + + (g_transition.toGain[i] - g_transition.fromGain[i]) * e; + + if (g_uniformMode) { + setUniformGain(g_currentGain); + } else { + const int step = static_cast(e * BAKED_STEPS); + if (step != g_transition.lastBakedStep) { + g_transition.lastBakedStep = step; + ensureShaderFile( + bakedShaderPath(g_currentGain), + bakedShaderSource(g_currentGain)); + gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + } + } + + if (t >= 1.f) { + g_transition.active = false; + if (gainIsNeutral(g_currentGain)) { + g_active = false; + gl->applyScreenShader(""); + } + postStateEvent(g_active, false); + } else { + monPtr->m_forceFullFrames = 2; + monPtr->scheduleFrame(); + } + } else if (g_uniformMode) { + setUniformGain(g_currentGain); + } +} + +static constexpr int KELVIN_MIN = 2000; +static constexpr int KELVIN_MAX = 6500; + +static int luaNightlightSet(lua_State* L) { + const int rawKelvin = static_cast(luaL_checknumber(L, 1)); + const float durationSec = + lua_gettop(L) >= 2 ? static_cast(luaL_checknumber(L, 2)) : 0.4f; + + const int targetKelvin = std::clamp(rawKelvin, KELVIN_MIN, KELVIN_MAX); + if (targetKelvin != rawKelvin) + dlog( + "clamped " + std::to_string(rawKelvin) + "K to " + + std::to_string(targetKelvin) + "K"); + + g_transition.fromGain = g_currentGain; + g_transition.toGain = kelvinToGain(targetKelvin); + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + g_active = true; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = targetKelvin; + + postStateEvent(true, true); + + lua_pushinteger(L, targetKelvin); + return 1; +} + +static int luaNightlightDisable(lua_State* L) { + if (!g_active) return 0; + + const float durationSec = + lua_gettop(L) >= 1 ? static_cast(luaL_checknumber(L, 1)) : 0.4f; + + g_transition.fromGain = g_currentGain; + g_transition.toGain = {1.f, 1.f, 1.f}; + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = 6500; + + postStateEvent(false, true); + + return 0; +} + +static int luaNightlightState(lua_State* L) { + lua_pushstring( + L, + std::format( + "{{\"enabled\":{},\"animating\":{},\"last\":{},\"current\":{}}}", + g_active ? "true" : "false", + g_transition.active ? "true" : "false", + g_lastKelvin, + g_currentKelvin) + .c_str()); + return 1; +} + +static void postStateEvent(bool enabled, bool animating) { + if (!g_pEventManager) return; + g_pEventManager->postEvent( + SHyprIPCEvent{ + .event = "zshell-nightlight", + .data = std::format( + "{},{},{},{}", + enabled ? 1 : 0, + animating ? 1 : 0, + g_lastKelvin, + g_currentKelvin)}); +} + +static const SFunctionMatch* findExactMethod( + const std::vector& methods, + const std::string& classAndMethod) { + for (const auto& m : methods) { + if (m.signature.find(classAndMethod) != std::string::npos) return &m; + } + return nullptr; +} + +static std::string manglePrefix( + const std::string& className, const std::string& methodName) { + return std::to_string(className.size()) + className + + std::to_string(methodName.size()) + methodName; +} + +APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) { + PHANDLE = handle; + initEnvPaths(); + dlog("=== PLUGIN_INIT, build timestamp " __DATE__ " " __TIME__ " ==="); + + const std::string serverHash = __hyprland_api_get_hash(); + const std::string clientHash = __hyprland_api_get_client_hash(); + + if (serverHash != clientHash) { + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] Mismatched headers! Can't proceed.", + CHyprColor{1.0f, 0.2f, 0.2f, 1.0f}, + 5000); + throw std::runtime_error("[zshell-nightlight] version mismatch"); + } + + static const auto SAVEMIRROR_METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror"); + const SFunctionMatch* saveMirrorTarget = findExactMethod( + SAVEMIRROR_METHODS, + manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror")); + + if (saveMirrorTarget) { + g_pSaveMirrorHook = HyprlandAPI::createFunctionHook( + PHANDLE, + saveMirrorTarget->address, + reinterpret_cast(&hkSaveBufferForMirror)); + if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook()) + dlog( + "saveBufferForMirror hook installed: " + + saveMirrorTarget->signature); + } + + if (!g_pSaveMirrorHook) + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — " + "captures may still show the nightlight", + CHyprColor{1.f, 0.6f, 0.2f, 1.f}, + 8000); + + static const auto METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "begin"); + const SFunctionMatch* target = + findExactMethod(METHODS, manglePrefix("CHyprOpenGLImpl", "begin")); + if (!target) + throw std::runtime_error( + "[zshell-nightlight] couldn't uniquely resolve " + "CHyprOpenGLImpl::begin"); + + g_pBeginHook = HyprlandAPI::createFunctionHook( + PHANDLE, target->address, reinterpret_cast(&hkBegin)); + if (!g_pBeginHook || !g_pBeginHook->hook()) + throw std::runtime_error("[zshell-nightlight] failed to hook begin()"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlSet", luaNightlightSet)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlDisable", luaNightlightDisable)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlState", luaNightlightState)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + return { + "zshell-nightlight", + "Animated nightlight, excluded from screencopy", + "ZShell", + "1.2"}; +} + + +APICALL EXPORT std::string PLUGIN_API_VERSION() { + return HYPRLAND_API_VERSION; +} + + +APICALL EXPORT void PLUGIN_EXIT() {} diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 7d1d2ff..2dfe707 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -9,7 +9,7 @@ qml_module(ZShell-services cavaprovider.hpp cavaprovider.cpp desktopmodel.hpp desktopmodel.cpp desktopstatemanager.hpp desktopstatemanager.cpp - hyprsunsetmanager.hpp hyprsunsetmanager.cpp + nightlightmanager.hpp nightlightmanager.cpp tickingservice.hpp tickingservice.cpp sensorslib.hpp sensorslib.cpp usagefmt.hpp usagefmt.cpp @@ -25,4 +25,4 @@ qml_module(ZShell-services PkgConfig::Aubio PkgConfig::Cava Sensors::Sensors -) + ) diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.cpp b/Plugins/ZShell/Services/hyprsunsetmanager.cpp deleted file mode 100644 index 0b33f6f..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.cpp +++ /dev/null @@ -1,148 +0,0 @@ -#include "hyprsunsetmanager.hpp" -#include -#include -#include -#include - -namespace ZShell::services { - -HyprsunsetManager::HyprsunsetManager(QObject* parent) : QObject(parent) { - connect(&m_timer, &QTimer::timeout, this, &HyprsunsetManager::apply); - connect(&m_manualTimer, &QTimer::timeout, this, [this] { - m_manualToggle = false; - emit manualToggleChanged(); - apply(); - }); - connect(&m_startCooldown, &QTimer::timeout, this, [this] { - m_startAllowed = true; - apply(); - }); - - m_startCooldown.start(2000); - m_manualTimer.setSingleShot(true); - m_timer.start(60000); - - m_process.setStandardInputFile(QProcess::nullDevice()); - m_process.setStandardOutputFile(QProcess::nullDevice()); -} - -int HyprsunsetManager::startTime() const { - return m_startTime; -} - -int HyprsunsetManager::endTime() const { - return m_endTime; -} - -bool HyprsunsetManager::manualToggle() const { - return m_manualToggle; -} - -bool HyprsunsetManager::enabled() const { - return m_enabled; -} - -bool HyprsunsetManager::activeAuto() const { - return m_activeAuto; -} - -int HyprsunsetManager::temp() const { - return m_temp; -} - -void HyprsunsetManager::setActiveAuto(bool activeAuto) { - if (activeAuto == m_activeAuto) return; - - m_activeAuto = activeAuto; - emit activeAutoChanged(); -} - -void HyprsunsetManager::setManualToggle(bool toggle) { - if (toggle == m_manualToggle) return; - - m_manualToggle = toggle; - emit manualToggleChanged(); - - m_manualTimer.start(60 * 60 * 1000); -} - -void HyprsunsetManager::setEndTime(const int& time) { - if (time == m_endTime) return; - - m_endTime = time; - emit endTimeChanged(); - apply(); -} - -void HyprsunsetManager::setStartTime(const int& time) { - if (time == m_startTime) return; - - m_startTime = time; - emit startTimeChanged(); - apply(); -} - -void HyprsunsetManager::setTemp(const int& temp) { - if (temp == m_temp) return; - - m_temp = temp; - emit tempChanged(); - apply(); -} - -void HyprsunsetManager::toggle() { - if (m_enabled) { - end(); - } else { - start(); - } -} - -void HyprsunsetManager::start() { - if (m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = true; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments( - {"hyprsunset", "temperature", QString::number(m_temp)}); - m_process.startDetached(); -} - -void HyprsunsetManager::end() { - if (!m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = false; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments({"hyprsunset", "identity"}); - m_process.startDetached(); -} - -void HyprsunsetManager::apply() { - if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; - - const auto current = QTime::currentTime(); - const auto currentMin = current.hour() * 60 + current.minute(); - bool isDarkTime = false; - - if (m_startTime <= m_endTime) { - isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); - } else { - isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); - } - - if (isDarkTime) { - start(); - } else { - end(); - } -} - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.hpp b/Plugins/ZShell/Services/hyprsunsetmanager.hpp deleted file mode 100644 index 7241c0d..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.hpp +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace ZShell::services { - -class HyprsunsetManager : public QObject { - Q_OBJECT - QML_ELEMENT - Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) - Q_PROPERTY( - int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) - Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) - Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) - Q_PROPERTY( - bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY - activeAutoChanged) - Q_PROPERTY( - bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY - manualToggleChanged) - - public: - explicit HyprsunsetManager(QObject* parent = nullptr); - - [[nodiscard]] int startTime() const; - [[nodiscard]] int endTime() const; - [[nodiscard]] bool enabled() const; - [[nodiscard]] int temp() const; - [[nodiscard]] bool activeAuto() const; - [[nodiscard]] bool manualToggle() const; - - Q_INVOKABLE void toggle(); - Q_INVOKABLE void apply(); - - void setStartTime(const int& time); - void setEndTime(const int& time); - void setTemp(const int& temp); - void setActiveAuto(bool activeAuto); - void setManualToggle(bool toggle); - - signals: - void enabledChanged(); - void startTimeChanged(); - void activeAutoChanged(); - void endTimeChanged(); - void tempChanged(); - void manualToggleChanged(); - - private: - int m_startTime; - int m_endTime; - bool m_enabled = false; - bool m_manualToggle = false; - bool m_activeAuto; - bool m_startAllowed = false; - bool m_initialized = false; - QTimer m_startCooldown; - int m_temp; - QProcess m_process; - QTimer m_timer; - QTimer m_manualTimer; - void start(); - void end(); -}; - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.cpp b/Plugins/ZShell/Services/nightlightmanager.cpp new file mode 100644 index 0000000..78658d4 --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.cpp @@ -0,0 +1,444 @@ +#include "nightlightmanager.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::services { + +namespace { +constexpr const char* NL_EVENT = "zshell-nightlight"; +constexpr int NEUTRAL_KELVIN = 6500; +constexpr int RECONNECT_MS = 2000; +} // namespace + +NightlightManager::NightlightManager(QObject* parent) : QObject(parent) { + connect(&m_timer, &QTimer::timeout, this, &NightlightManager::apply); + connect(&m_manualTimer, &QTimer::timeout, this, [this] { + m_manualToggle = false; + emit manualToggleChanged(); + apply(); + }); + connect(&m_startCooldown, &QTimer::timeout, this, [this] { + m_startAllowed = true; + apply(); + }); + connect(&m_reconnectTimer, &QTimer::timeout, this, [this] { + if (!m_useNativeNightlight) return; + + connectEventSocket(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + bootstrapState(); + }); + + m_startCooldown.start(2000); + m_manualTimer.setSingleShot(true); + m_reconnectTimer.setSingleShot(true); + m_timer.start(60000); + + m_process.setStandardInputFile(QProcess::nullDevice()); + m_process.setStandardOutputFile(QProcess::nullDevice()); +} + +int NightlightManager::startTime() const { + return m_startTime; +} + +int NightlightManager::endTime() const { + return m_endTime; +} + +bool NightlightManager::manualToggle() const { + return m_manualToggle; +} + +bool NightlightManager::enabled() const { + return m_enabled; +} + +bool NightlightManager::activeAuto() const { + return m_activeAuto; +} + +int NightlightManager::temp() const { + return m_temp; +} + +double NightlightManager::fadeDuration() const { + return m_fadeDuration; +} + +bool NightlightManager::animating() const { + return m_animating; +} + +int NightlightManager::lastTemp() const { + return m_lastTemp; +} + +int NightlightManager::currentTemp() const { + return m_currentTemp; +} + +void NightlightManager::setActiveAuto(bool activeAuto) { + if (activeAuto == m_activeAuto) return; + + m_activeAuto = activeAuto; + emit activeAutoChanged(); +} + +void NightlightManager::setManualToggle(bool toggle) { + if (toggle == m_manualToggle) return; + + m_manualToggle = toggle; + emit manualToggleChanged(); + + m_manualTimer.start(60 * 60 * 1000); +} + +void NightlightManager::setEndTime(const int& time) { + if (time == m_endTime) return; + + m_endTime = time; + emit endTimeChanged(); + apply(); +} + +void NightlightManager::setStartTime(const int& time) { + if (time == m_startTime) return; + + m_startTime = time; + emit startTimeChanged(); + apply(); +} + +void NightlightManager::setTemp(const int& temp) { + if (temp == m_temp) return; + + m_temp = temp; + emit tempChanged(); + apply(); +} + +void NightlightManager::setFadeDuration(double duration) { + if (duration < 0.0) duration = 0.0; + + m_fadeDuration = duration; + emit fadeDurationChanged(); +} + +void NightlightManager::toggle() { + if (m_enabled) { + end(); + } else { + start(); + } +} + +void NightlightManager::start() { + if (m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = true; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlSet(%1, %2)") + .arg(m_temp) + .arg(m_fadeDuration)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"hyprsunset", "temperature", QString::number(m_temp)}); + m_process.startDetached(); + } +} + +void NightlightManager::end() { + if (!m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = false; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlDisable(%1)").arg(m_fadeDuration)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments({"hyprsunset", "identity"}); + m_process.startDetached(); + } +} + +void NightlightManager::apply() { + if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; + + const auto current = QTime::currentTime(); + const auto currentMin = current.hour() * 60 + current.minute(); + bool isDarkTime = false; + + if (m_startTime <= m_endTime) { + isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); + } else { + isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); + } + + if (isDarkTime) { + start(); + } else { + end(); + } +} + +bool NightlightManager::useNativeNightlight() const { + return m_useNativeNightlight; +} + +QString NightlightManager::pluginPath() const { + return m_pluginPath; +} + +void NightlightManager::setPluginPath(const QString& path) { + if (path == m_pluginPath) return; + + m_pluginPath = path; + emit pluginPathChanged(); +} + +void NightlightManager::setUseNativeNightlight(bool native) { + if (native == m_useNativeNightlight) return; + + m_useNativeNightlight = native; + emit useNativeNightlightChanged(); + + if (native) { + disconnect(&m_eventSocket, nullptr, this, nullptr); + connect( + &m_eventSocket, + &QLocalSocket::readyRead, + this, + &NightlightManager::handleEventLines); + connect( + &m_eventSocket, + &QLocalSocket::errorOccurred, + this, + &NightlightManager::scheduleReconnect); + connectEventSocket(); + bootstrapState(); + } else { + m_reconnectTimer.stop(); + disconnect(&m_eventSocket, nullptr, this, nullptr); + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) + m_eventSocket.disconnectFromServer(); + m_socketBuffer.clear(); + m_animating = false; + emit animatingChanged(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + } +} + +QString NightlightManager::hyprlandDir() const { + const auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + if (his.isEmpty()) return QString(); + + auto runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + auto dir = runtimeDir + "/hypr/" + his; + if (!QFileInfo(dir).isDir()) dir = "/tmp/hypr/" + his; + + if (!QFileInfo(dir).isDir()) return QString(); + + return dir; +} + +QString NightlightManager::eventSocketPath() const { + const auto dir = hyprlandDir(); + return dir.isEmpty() ? QString() : dir + "/.socket2.sock"; +} + +QString NightlightManager::resolvePluginPath() const { + if (!m_pluginPath.isEmpty()) return m_pluginPath; + + const QString name = QStringLiteral("libzshell-nightlight.so"); + const QStringList dirs = { + "/usr/lib/ZShell/plugins", + "/usr/local/lib/ZShell/plugins", + }; + for (const auto& dir : dirs) { + const QString candidate = dir + "/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + const QString home = qEnvironmentVariable("HOME"); + if (!home.isEmpty()) { + const QString candidate = home + "/.local/lib/ZShell/plugins/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + return QString(); +} + +void NightlightManager::tryLoadPlugin() { + if (m_pluginLoadAttempted) return; + + const auto path = resolvePluginPath(); + if (path.isEmpty()) { + qWarning() << "NightlightManager: plugin not found; searched " + "pluginPath and /usr/lib/ZShell/plugins"; + m_pluginLoadAttempted = true; + return; + } + + m_pluginLoadAttempted = true; + m_pluginLoadedByUs = true; + qInfo() << "NightlightManager: plugin not loaded, loading" << path; + QProcess::startDetached("hyprctl", {"plugin", "load", path}); + + QTimer::singleShot(1500, this, [this] { bootstrapState(); }); +} + +void NightlightManager::bootstrapState() { + if (!m_useNativeNightlight) return; + + const auto dir = hyprlandDir(); + if (dir.isEmpty()) return; + + auto* sock = new QLocalSocket(this); + auto* watchdog = new QTimer(this); + watchdog->setSingleShot(true); + + const auto finish = [sock, watchdog]() { + watchdog->stop(); + sock->abort(); + sock->deleteLater(); + }; + + connect(sock, &QLocalSocket::connected, sock, [sock] { + sock->write("repl return hl.plugin.zshell.nlState()"); + sock->flush(); + }); + connect(sock, &QLocalSocket::readyRead, sock, [this, sock, finish]() { + const auto raw = sock->readAll(); + const auto doc = QJsonDocument::fromJson(raw); + finish(); + if (!doc.isObject()) { + if (m_pluginLoadAttempted) + qWarning() << "NightlightManager: plugin load failed, state " + << "query still answered:" << raw; + + tryLoadPlugin(); + return; + } + + if (m_pluginLoadedByUs) { + qInfo() << "NightlightManager: plugin was not loaded, manager " + << "loaded it; state:" << raw; + m_pluginLoadedByUs = false; + } else { + qInfo() << "NightlightManager: plugin already loaded, state:" + << raw; + } + + const auto obj = doc.object(); + updateState( + obj.value("enabled").toBool(), + obj.value("animating").toBool(), + obj.value("last").toInt(), + obj.value("current").toInt()); + }); + connect(sock, &QLocalSocket::errorOccurred, sock, finish); + connect(watchdog, &QTimer::timeout, sock, finish); + watchdog->start(2500); + + sock->connectToServer(dir + "/.socket.sock"); +} + +void NightlightManager::connectEventSocket() { + if (!m_useNativeNightlight) return; + + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) return; + + const auto path = eventSocketPath(); + if (path.isEmpty()) { + scheduleReconnect(); + return; + } + + m_socketBuffer.clear(); + m_eventSocket.connectToServer(path, QLocalSocket::ReadOnly); +} + +void NightlightManager::scheduleReconnect() { + if (!m_useNativeNightlight) return; + + m_reconnectTimer.start(RECONNECT_MS); +} + +void NightlightManager::handleEventLines() { + m_socketBuffer.append(m_eventSocket.readAll()); + + qsizetype idx = 0; + while ((idx = m_socketBuffer.indexOf('\n')) >= 0) { + const auto line = m_socketBuffer.left(idx); + m_socketBuffer.remove(0, idx + 1); + handleEventLine(line); + } +} + +void NightlightManager::handleEventLine(const QByteArray& line) { + const qsizetype sep = line.indexOf(">>"); + if (sep <= 0) return; + + if (line.left(sep) != NL_EVENT) return; + + // data = enabled,animating,last,current + const auto fields = line.mid(sep + 2).split(','); + if (fields.size() < 4) return; + + bool ok = false; + const auto enabled = fields.at(0).toInt(&ok); + if (!ok) return; + const auto animating = fields.at(1).toInt(&ok); + if (!ok) return; + const auto last = fields.at(2).toInt(&ok); + if (!ok) return; + const auto current = fields.at(3).toInt(&ok); + if (!ok) return; + + updateState(enabled == 1, animating == 1, last, current); +} + +void NightlightManager::updateState( + bool enabled, bool animating, int last, int current) { + if (enabled != m_enabled) { + m_enabled = enabled; + emit enabledChanged(); + } + if (animating != m_animating) { + m_animating = animating; + emit animatingChanged(); + } + if (last != m_lastTemp) { + m_lastTemp = last; + emit lastTempChanged(); + } + if (current != m_currentTemp) { + m_currentTemp = current; + emit currentTempChanged(); + } +} + +}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.hpp b/Plugins/ZShell/Services/nightlightmanager.hpp new file mode 100644 index 0000000..43b341d --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +class NightlightManager : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) + Q_PROPERTY( + int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) + Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) + Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) + Q_PROPERTY( + double fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY + fadeDurationChanged) + Q_PROPERTY( + bool useNativeNightlight READ useNativeNightlight WRITE + setUseNativeNightlight NOTIFY useNativeNightlightChanged) + Q_PROPERTY( + QString pluginPath READ pluginPath WRITE setPluginPath NOTIFY + pluginPathChanged) + Q_PROPERTY( + bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY + activeAutoChanged) + Q_PROPERTY( + bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY + manualToggleChanged) + Q_PROPERTY(bool animating READ animating NOTIFY animatingChanged) + Q_PROPERTY(int lastTemp READ lastTemp NOTIFY lastTempChanged) + Q_PROPERTY(int currentTemp READ currentTemp NOTIFY currentTempChanged) + + public: + explicit NightlightManager(QObject* parent = nullptr); + + [[nodiscard]] int startTime() const; + [[nodiscard]] int endTime() const; + [[nodiscard]] bool enabled() const; + [[nodiscard]] int temp() const; + [[nodiscard]] double fadeDuration() const; + [[nodiscard]] bool useNativeNightlight() const; + [[nodiscard]] QString pluginPath() const; + [[nodiscard]] bool activeAuto() const; + [[nodiscard]] bool manualToggle() const; + [[nodiscard]] bool animating() const; + [[nodiscard]] int lastTemp() const; + [[nodiscard]] int currentTemp() const; + + Q_INVOKABLE void toggle(); + Q_INVOKABLE void apply(); + + void setStartTime(const int& time); + void setEndTime(const int& time); + void setTemp(const int& temp); + void setFadeDuration(double duration); + void setUseNativeNightlight(bool native); + void setPluginPath(const QString& path); + void setActiveAuto(bool activeAuto); + void setManualToggle(bool toggle); + + signals: + void enabledChanged(); + void startTimeChanged(); + void activeAutoChanged(); + void endTimeChanged(); + void tempChanged(); + void fadeDurationChanged(); + void useNativeNightlightChanged(); + void pluginPathChanged(); + void manualToggleChanged(); + void animatingChanged(); + void lastTempChanged(); + void currentTempChanged(); + + private: + int m_startTime; + int m_endTime; + bool m_enabled = false; + bool m_manualToggle = false; + bool m_activeAuto; + bool m_startAllowed = false; + bool m_initialized = false; + QTimer m_startCooldown; + int m_temp; + double m_fadeDuration = 0.4; + bool m_useNativeNightlight = false; + QString m_pluginPath; + bool m_pluginLoadAttempted = false; + bool m_pluginLoadedByUs = false; + bool m_animating = false; + int m_lastTemp = 6500; + int m_currentTemp = 6500; + QProcess m_process; + QTimer m_timer; + QTimer m_manualTimer; + QLocalSocket m_eventSocket; + QByteArray m_socketBuffer; + QTimer m_reconnectTimer; + + void start(); + void end(); + [[nodiscard]] QString hyprlandDir() const; + [[nodiscard]] QString eventSocketPath() const; + [[nodiscard]] QString resolvePluginPath() const; + void tryLoadPlugin(); + void bootstrapState(); + void connectEventSocket(); + void scheduleReconnect(); + void handleEventLines(); + void handleEventLine(const QByteArray& line); + void updateState(bool enabled, bool animating, int last, int current); +}; + +}; // namespace ZShell::services From d259c6ffbeab737d4815f03fbbca579e818d6dcc Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 14:09:54 +0200 Subject: [PATCH 03/21] fix: shader fade animation linear curve --- Plugins/ZShell/HyprPlugins/colortemp.hpp | 5 ----- Plugins/ZShell/HyprPlugins/main.cpp | 6 ++---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Plugins/ZShell/HyprPlugins/colortemp.hpp b/Plugins/ZShell/HyprPlugins/colortemp.hpp index 25f9c05..0da6f88 100644 --- a/Plugins/ZShell/HyprPlugins/colortemp.hpp +++ b/Plugins/ZShell/HyprPlugins/colortemp.hpp @@ -29,9 +29,4 @@ inline std::array kelvinToGain(int kelvin) { return {static_cast(r), static_cast(g), static_cast(b)}; } -inline float easeInOutCubic(float x) { - return x < 0.5f ? 4.f * x * x * x - : 1.f - std::pow(-2.f * x + 2.f, 3.f) / 2.f; -} - } // namespace ZShell::services diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp index ad2080e..39953e9 100644 --- a/Plugins/ZShell/HyprPlugins/main.cpp +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -25,7 +25,6 @@ extern "C" { #include "colortemp.hpp" -using ZShell::services::easeInOutCubic; using ZShell::services::kelvinToGain; using Render::IFramebuffer; @@ -241,17 +240,16 @@ static void hkBegin( .count(); const float t = std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f); - const float e = easeInOutCubic(t); for (size_t i = 0; i < 3; ++i) g_currentGain[i] = g_transition.fromGain[i] + - (g_transition.toGain[i] - g_transition.fromGain[i]) * e; + (g_transition.toGain[i] - g_transition.fromGain[i]) * t; if (g_uniformMode) { setUniformGain(g_currentGain); } else { - const int step = static_cast(e * BAKED_STEPS); + const int step = static_cast(t * BAKED_STEPS); if (step != g_transition.lastBakedStep) { g_transition.lastBakedStep = step; ensureShaderFile( From 2c90339c77a311b841b2157fb95785b000497779 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 20 Sep 2026 12:59:53 +0200 Subject: [PATCH 04/21] feat: dashboard show on hover, media and weather tabs added --- Components/ColorArcPicker.qml | 10 +- Components/CustomFlickable.qml | 4 +- Components/CustomListView.qml | 4 +- Components/CustomScrollBar.qml | 10 +- Components/CustomTextField.qml | 44 +- Components/LineGraph.qml | 467 +++++++ Components/ScreenState.qml | 37 + Components/StateLayer.qml | 4 +- Daemons/NotifServer.qml | 3 +- Drawers/Drawing.qml | 15 +- Drawers/Interactions.qml | 130 +- Drawers/Panels.qml | 35 +- Drawers/Windows.qml | 89 +- Greeter/WeatherInfo.qml | 3 +- Helpers/Strings.qml | 9 + Helpers/Visibilities.qml | 16 - Helpers/Weather.qml | 35 +- Modules/Bar/Bar.qml | 11 +- Modules/Bar/BarLoader.qml | 6 +- Modules/Bar/Border.qml | 2 +- Modules/Bar/Components/Clock.qml | 10 +- Modules/Bar/Components/NotifBell.qml | 12 +- Modules/Bar/Components/Resources.qml | 24 +- Modules/Bar/Components/Tray/TrayItem.qml | 8 +- Modules/Clipboard/Content.qml | 4 +- Modules/Clipboard/Wrapper.qml | 6 +- Modules/Dashboard/Content.qml | 175 ++- Modules/Dashboard/Dash.qml | 26 +- Modules/Dashboard/Dash/Calendar.qml | 18 +- Modules/Dashboard/Dash/Media.qml | 329 ----- Modules/Dashboard/Dash/User.qml | 2 +- Modules/Dashboard/Media.qml | 152 +++ .../{Dash => }/Media/BackgroundShapes.qml | 0 .../Dashboard/{Dash => }/Media/CoverArt.qml | 0 .../{Dash => }/Media/CoverVisualizer.qml | 4 +- Modules/Dashboard/Media/Details.qml | 194 +++ Modules/Dashboard/Media/LyricList.qml | 317 +++++ Modules/Dashboard/Media/LyricsAndSelector.qml | 79 ++ Modules/Dashboard/Media/LyricsInfo.qml | 212 +++ Modules/Dashboard/Tabs.qml | 234 ++-- Modules/Dashboard/Weather/Graphs.qml | 145 ++ Modules/Dashboard/WeatherTab.qml | 259 ++++ Modules/Dashboard/Wrapper.qml | 29 +- .../DesktopIcons/BackgroundContextMenu.qml | 5 +- Modules/Dock/Content.qml | 6 +- Modules/Dock/Parts/DockAppButton.qml | 6 +- Modules/Dock/Wrapper.qml | 6 +- Modules/Drawing/Content.qml | 18 +- Modules/Drawing/Wrapper.qml | 6 +- Modules/Launcher/AppList.qml | 10 +- Modules/Launcher/CarouselView.qml | 6 +- Modules/Launcher/Content.qml | 14 +- Modules/Launcher/ContentList.qml | 10 +- Modules/Launcher/Items/AppItem.qml | 4 +- Modules/Launcher/Items/CalcItem.qml | 4 +- Modules/Launcher/Items/FilesItem.qml | 141 ++ Modules/Launcher/Items/WallpaperTile.qml | 2 +- Modules/Launcher/Services/Actions.qml | 4 +- Modules/Launcher/Services/SchemeVariants.qml | 2 +- Modules/Launcher/Wrapper.qml | 8 +- Modules/Lock/Weather/Forecast.qml | 4 +- Modules/Notifications/Content.qml | 8 +- Modules/Notifications/Sidebar/Content.qml | 6 +- Modules/Notifications/Sidebar/Notif.qml | 5 +- Modules/Notifications/Sidebar/NotifDock.qml | 6 +- .../Notifications/Sidebar/NotifDockList.qml | 6 +- Modules/Notifications/Sidebar/NotifGroup.qml | 14 +- .../Notifications/Sidebar/NotifGroupList.qml | 6 +- Modules/Notifications/Sidebar/Props.qml | 7 - .../Sidebar/Utils/Cards/Record.qml | 11 +- .../Sidebar/Utils/Cards/RecordingList.qml | 37 +- .../Sidebar/Utils/Cards/Toggles.qml | 8 +- .../Notifications/Sidebar/Utils/Content.qml | 12 +- .../Notifications/Sidebar/Utils/Wrapper.qml | 16 +- Modules/Notifications/Sidebar/Wrapper.qml | 10 +- Modules/Notifications/Wrapper.qml | 4 +- Modules/Osd/Content.qml | 4 +- Modules/Osd/Wrapper.qml | 10 +- Modules/Resources/Wrapper.qml | 4 +- Modules/Settings/Common/TextFieldRow.qml | 85 ++ .../Settings/Pages/Panels/DashboardPanel.qml | 28 + Modules/Settings/Wrapper.qml | 4 +- Modules/Shortcuts.qml | 25 +- Plugins/ZShell/Config/CMakeLists.txt | 2 + Plugins/ZShell/Config/dashboard.hpp | 2 + Plugins/ZShell/Config/enums.hpp | 29 + Plugins/ZShell/Config/general.hpp | 5 + Plugins/ZShell/Config/services.hpp | 2 + Plugins/ZShell/Internal/sparklineitem.cpp | 117 +- Plugins/ZShell/Internal/sparklineitem.hpp | 23 + Plugins/ZShell/Services/CMakeLists.txt | 7 +- Plugins/ZShell/Services/lyriccandidate.cpp | 55 + Plugins/ZShell/Services/lyriccandidate.hpp | 53 + Plugins/ZShell/Services/lyrics.cpp | 1178 +++++++++++++++++ Plugins/ZShell/Services/lyrics.hpp | 162 +++ Plugins/cmake/qml-module.cmake | 55 + Services/ShellState.qml | 35 + 97 files changed, 4480 insertions(+), 990 deletions(-) create mode 100644 Components/LineGraph.qml create mode 100644 Components/ScreenState.qml delete mode 100644 Helpers/Visibilities.qml delete mode 100644 Modules/Dashboard/Dash/Media.qml create mode 100644 Modules/Dashboard/Media.qml rename Modules/Dashboard/{Dash => }/Media/BackgroundShapes.qml (100%) rename Modules/Dashboard/{Dash => }/Media/CoverArt.qml (100%) rename Modules/Dashboard/{Dash => }/Media/CoverVisualizer.qml (63%) create mode 100644 Modules/Dashboard/Media/Details.qml create mode 100644 Modules/Dashboard/Media/LyricList.qml create mode 100644 Modules/Dashboard/Media/LyricsAndSelector.qml create mode 100644 Modules/Dashboard/Media/LyricsInfo.qml create mode 100644 Modules/Dashboard/Weather/Graphs.qml create mode 100644 Modules/Dashboard/WeatherTab.qml create mode 100644 Modules/Launcher/Items/FilesItem.qml delete mode 100644 Modules/Notifications/Sidebar/Props.qml create mode 100644 Modules/Settings/Common/TextFieldRow.qml create mode 100644 Plugins/ZShell/Config/enums.hpp create mode 100644 Plugins/ZShell/Services/lyriccandidate.cpp create mode 100644 Plugins/ZShell/Services/lyriccandidate.hpp create mode 100644 Plugins/ZShell/Services/lyrics.cpp create mode 100644 Plugins/ZShell/Services/lyrics.hpp create mode 100644 Plugins/cmake/qml-module.cmake create mode 100644 Services/ShellState.qml diff --git a/Components/ColorArcPicker.qml b/Components/ColorArcPicker.qml index 0a14f32..43b9c34 100644 --- a/Components/ColorArcPicker.qml +++ b/Components/ColorArcPicker.qml @@ -46,10 +46,10 @@ Item { } function syncFromPenColor() { - if (!drawing.drawingState) + if (!drawing.screenState) return; - const c = drawing.drawingState.penColor; + const c = drawing.screenState.penColor; if (c.hsvSaturation > 0) { currentHue = c.hsvHue; @@ -86,7 +86,7 @@ Item { currentHue = relative / arcSweep; lastChromaticHue = currentHue; - drawing.drawingState.penColor = Qt.hsva(currentHue, drawing.drawingState.penColor.hsvSaturation, drawing.drawingState.penColor.hsvValue, drawing.drawingState.penColor.a); + drawing.screenState.penColor = Qt.hsva(currentHue, drawing.screenState.penColor.hsvSaturation, drawing.screenState.penColor.hsvValue, drawing.screenState.penColor.a); } implicitHeight: 180 @@ -112,7 +112,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing.drawingState + target: root.drawing.screenState } Canvas { @@ -152,7 +152,7 @@ Item { CustomRect { anchors.centerIn: parent - color: root.drawing?.drawingState.penColor + color: root.drawing?.screenState.penColor implicitHeight: implicitWidth implicitWidth: canvas.height - root.handleSize - Tokens.padding.extraLarge * 2 radius: Tokens.rounding.full diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index 249f093..a1267f2 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -1,12 +1,12 @@ import QtQuick -import qs.Helpers +import qs.Services Flickable { id: root property bool doneFakeFlick - interactive: !Visibilities.getForActive().isDrawing + interactive: !ShellState.forActive().isDrawing maximumFlickVelocity: 3000 rebound: Transition { diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml index 0b8d618..2b02e96 100644 --- a/Components/CustomListView.qml +++ b/Components/CustomListView.qml @@ -1,12 +1,12 @@ import QtQuick -import qs.Helpers +import qs.Services ListView { id: root property bool doneFakeFlick - interactive: !Visibilities.getForActive()?.isDrawing + interactive: !ShellState.forActive()?.isDrawing maximumFlickVelocity: 3000 rebound: Transition { diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 0159df0..dedd428 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Templates -import qs.Helpers +import qs.Services import ZShell.Config import qs.Services @@ -17,8 +17,12 @@ ScrollBar { property bool shouldBeActive readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 - enabled: !Visibilities.getForActive().isDrawing - implicitWidth: Tokens.padding.extraSmall * 2 + property bool shouldBeActive + property bool inverted + + enabled: !ShellState.forActive().isDrawing + implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 + implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 contentItem: Item { } diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index 8ddc120..66141b8 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -15,28 +15,33 @@ TextFieldBase { readonly property int clampedRadius: Math.min(horizontalPadding, Math.min(width, height) / 2, radius) readonly property string effectiveSupportingText: isError && errorText ? errorText : supportingText + readonly property int filledOffset: type === CustomTextField.Filled ? Tokens.spacing.small : 0 + readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 + readonly property real smallFontScale: smallFontSize / font.pointSize + readonly property int supportingTextOffset: effectiveSupportingText ? supportingTextLoader.height + Tokens.spacing.extraSmall : 0 + readonly property int trailingOffset: trailingIcon ? trailingIconLoader.width + trailingIconLoader.anchors.rightMargin : 0 + readonly property bool valid: !validate || (!text && emptyIsValid) || (validate instanceof RegExp ? validate.test(text) : !!validate(text)) + + property int horizontalPadding: Tokens.padding.large + property int verticalPadding: Tokens.padding.large + + property int radius: Tokens.rounding.small + + property string supportingText + property string trailingIcon + property int smallFontSize: Tokens.font.size.small + + property int type: CustomTextField.Outlined + property var validate // Regex or function property bool emptyIsValid: true property string errorText - readonly property int filledOffset: type === CustomTextField.Filled ? Tokens.spacing.small : 0 - readonly property int horizontalPadding: Tokens.padding.large property bool isError property string leadingIcon - readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 - property int radius: Tokens.rounding.small - readonly property real smallFontScale: smallFontSize / font.pointSize - property int smallFontSize: Tokens.font.size.small - property string supportingText - readonly property int supportingTextOffset: effectiveSupportingText ? supportingTextLoader.height + Tokens.spacing.extraSmall : 0 - property string trailingIcon - readonly property int trailingOffset: trailingIcon ? trailingIconLoader.width + trailingIconLoader.anchors.rightMargin : 0 - property int type: CustomTextField.Outlined - readonly property bool valid: !validate || (!text && emptyIsValid) || (validate instanceof RegExp ? validate.test(text) : !!validate(text)) - property var validate // Regex or function - bottomPadding: Tokens.padding.large + supportingTextOffset - filledOffset + bottomPadding: verticalPadding + supportingTextOffset - filledOffset leftPadding: horizontalPadding + leadingOffset rightPadding: horizontalPadding + trailingOffset - topPadding: Tokens.padding.large + filledOffset + topPadding: verticalPadding + filledOffset background: Loader { anchors.bottomMargin: root.supportingTextOffset @@ -212,12 +217,10 @@ TextFieldBase { } } Behavior on strokeColor { - CAnim { - } + CAnim {} } Behavior on strokeWidth { - Anim { - } + Anim {} } PathLine { @@ -292,8 +295,7 @@ TextFieldBase { implicitHeight: root.activeFocus ? 2 : 1 Behavior on implicitHeight { - Anim { - } + Anim {} } } } diff --git a/Components/LineGraph.qml b/Components/LineGraph.qml new file mode 100644 index 0000000..c16a1a6 --- /dev/null +++ b/Components/LineGraph.qml @@ -0,0 +1,467 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +Item { + id: root + + required property list values + + property int xSteps: 24 + property real yMin: NaN + property real yMax: NaN + property real yPaddingTop: 0 + property real yPaddingBottom: 0 + + property int verticalLineStep: 0 + property int horizontalLineStep: 0 + property bool firstXLine: true + property bool lastXLine: false + property int minHorizontalLines: 0 + + property var xLabel: null + property var yLabel: null + + property real smoothing: 0 + property bool curveSmoothing: true + property real markerX: -1 + property real markerGap: 2 // px gap between line and marker dot edge, each side + + property color lineColor: "white" + property color gridColor: "white" + property color markerColor: "white" + property color labelColor: "white" + property real gridAlpha: 0.12 + property real labelAlpha: 0.6 + property real fillAlpha: 0.15 + property real lineWidth: 2 + property real markerDotRadius: 4 + property real labelFontSize: 10 + + Canvas { + id: canvas + + anchors.fill: parent + renderStrategy: Canvas.Immediate + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + function strokeCurve(ctx, pts) { + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + + if (root.curveSmoothing && pts.length > 2) { + const k = 1 / 6; + const n = pts.length; + + for (let i = 0; i < n - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, n - 1)]; + + let c1x = p1.x + (p2.x - p0.x) * k; + let c1y = p1.y + (p2.y - p0.y) * k; + let c2x = p2.x - (p3.x - p1.x) * k; + let c2y = p2.y - (p3.y - p1.y) * k; + + const minY = Math.min(p1.y, p2.y); + const maxY = Math.max(p1.y, p2.y); + c1y = Math.max(minY, Math.min(maxY, c1y)); + c2y = Math.max(minY, Math.min(maxY, c2y)); + + ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y); + } + } else { + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i]; + const p1 = pts[i + 1]; + const cx = (p0.x + p1.x) * 0.5; + ctx.bezierCurveTo(cx, p0.y, cx, p1.y, p1.x, p1.y); + } + } + } + + // Control points for segment i (pts[i] -> pts[i+1]), matching strokeCurve + function segmentControls(pts, i) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, pts.length - 1)]; + + if (root.curveSmoothing && pts.length > 2) { + const k = 1 / 6; + let c1x = p1.x + (p2.x - p0.x) * k; + let c1y = p1.y + (p2.y - p0.y) * k; + let c2x = p2.x - (p3.x - p1.x) * k; + let c2y = p2.y - (p3.y - p1.y) * k; + + const minY = Math.min(p1.y, p2.y); + const maxY = Math.max(p1.y, p2.y); + c1y = Math.max(minY, Math.min(maxY, c1y)); + c2y = Math.max(minY, Math.min(maxY, c2y)); + + return [c1x, c1y, c2x, c2y]; + } + + const cx = (p1.x + p2.x) * 0.5; + return [cx, p1.y, cx, p2.y]; + } + + // Point on the curve at a given x (x is monotonic per segment) + function pointAtX(pts, targetX) { + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const p1 = pts[j]; + const p2 = pts[j + 1]; + const [c1x, c1y, c2x, c2y] = segmentControls(pts, j); + + let tlo = 0; + let thi = 1; + for (let it = 0; it < 24; it++) { + const tm = (tlo + thi) / 2; + const u = 1 - tm; + const x = u * u * u * p1.x + 3 * u * u * tm * c1x + 3 * u * tm * tm * c2x + tm * tm * tm * p2.x; + if (x < targetX) + tlo = tm; + else + thi = tm; + } + + const t = (tlo + thi) / 2; + const u = 1 - t; + const y = u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y; + return { x: targetX, y }; + } + + // Part of the curve left/right of a cut x, including the cut point + function cutLeft(pts, targetX) { + if (targetX <= pts[0].x) + return []; + if (targetX >= pts[pts.length - 1].x) + return pts.slice(); + + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const left = pts.slice(0, j + 1); + if (targetX > pts[j].x) + left.push(pointAtX(pts, targetX)); + return left; + } + + function cutRight(pts, targetX) { + if (targetX >= pts[pts.length - 1].x) + return []; + if (targetX <= pts[0].x) + return pts.slice(); + + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const right = pts.slice(j + 1); + if (targetX < pts[j + 1].x) + right.unshift(pointAtX(pts, targetX)); + return right; + } + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + + const W = width; + const H = height; + if (W < 2 || H < 2) + return; + + const padLeft = root.labelFontSize * 3.4; + const padRight = 2; + const padTop = 2; + const padBottom = root.labelFontSize + 8; + const plot = { + x: padLeft, + y: padTop, + w: W - padLeft - padRight, + h: H - padTop - padBottom + }; + if (plot.w < 2 || plot.h < 2) + return; + + // Auto-fit: NaN yMin/yMax derive from the data, snapped to the + // horizontal step grid, honoring minHorizontalLines + let lo = root.yMin; + let hi = root.yMax; + const loAuto = Number.isNaN(lo); + const hiAuto = Number.isNaN(hi); + + if (loAuto || hiAuto) { + let dmin = Infinity; + let dmax = -Infinity; + for (const v of root.values) { + if (v == null) + continue; + if (v < dmin) + dmin = v; + if (v > dmax) + dmax = v; + } + if (dmin === Infinity) { + dmin = 0; + dmax = 1; + } + + const step = root.horizontalLineStep; + const minSteps = root.minHorizontalLines > 1 ? root.minHorizontalLines - 1 : 0; + + if (step > 0) { + if (hiAuto) { + const baseLo = loAuto ? Math.floor(dmin / step) * step : lo; + const span = Math.max(Math.ceil((dmax - baseLo) / step), minSteps, 1); + lo = baseLo; + hi = baseLo + span * step; + } else { + const span = Math.max(Math.ceil((hi - dmin) / step), minSteps, 1); + lo = hi - span * step; + } + } else { + if (loAuto) + lo = dmin; + if (hiAuto) + hi = dmax; + if (hi <= lo) + hi = lo + 1; + } + } + + const scaleBottom = lo - root.yPaddingBottom; + const scaleTop = hi + root.yPaddingTop; + const range = scaleTop - scaleBottom; + if (range <= 0 || root.xSteps < 1) + return; + + let series = root.values; + if (root.smoothing > 0 && series.length > 0) { + const radius = Math.round(root.smoothing); + const raw = series; + series = raw.map((v, i) => { + if (v == null) + return null; + + let sum = 0; + let n = 0; + for (let j = -radius; j <= radius; j++) { + const u = raw[i + j]; + if (u != null) { + sum += u; + n++; + } + } + return n > 0 ? sum / n : null; + }); + } + + const xAt = i => plot.x + (i / root.xSteps) * plot.w; + const yAt = v => plot.y + (1 - (v - scaleBottom) / range) * plot.h; + const crisp = v => Math.floor(v) + 0.5; + + // 1. Grid lines + ctx.strokeStyle = Qt.alpha(root.gridColor, root.gridAlpha); + ctx.lineWidth = 1; + + if (root.verticalLineStep > 0) { + for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { + if (i === 0 && !root.firstXLine) + continue; + if (i === root.xSteps && !root.lastXLine) + continue; + + const x = crisp(xAt(i)); + ctx.beginPath(); + ctx.moveTo(x, plot.y); + ctx.lineTo(x, H); + ctx.stroke(); + } + } + + // Lines extend down into yPaddingBottom, snapped to the grid + const vStart = lo - (root.yPaddingBottom > 0 ? Math.floor(root.yPaddingBottom / root.horizontalLineStep) * root.horizontalLineStep : 0); + + if (root.horizontalLineStep > 0) { + for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { + const y = crisp(yAt(v)); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(plot.x + plot.w, y); + ctx.stroke(); + } + } + + // 2. Labels + ctx.fillStyle = Qt.alpha(root.labelColor, root.labelAlpha); + ctx.font = root.labelFontSize + "px sans-serif"; + + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + if (root.verticalLineStep > 0 && root.xLabel) { + for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { + if (i === 0 && !root.firstXLine) + continue; + if (i === root.xSteps && !root.lastXLine) + continue; + + const text = root.xLabel(i); + if (text) + ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 4); + } + } + + ctx.textBaseline = "bottom"; + if (root.horizontalLineStep > 0 && root.yLabel) { + for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { + const text = root.yLabel(v); + if (text) + ctx.fillText(text, 2, yAt(v) - 2); + } + } + + // 3. Curve, split into segments at null gaps + const segments = []; + let current = null; + for (let i = 0; i < series.length; i++) { + if (series[i] == null) { + if (current) + segments.push(current); + current = null; + continue; + } + const pt = { + x: xAt(i), + y: yAt(series[i]) + }; + if (!current) + current = []; + current.push(pt); + } + if (current) + segments.push(current); + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + const hasMarker = root.markerX >= 0 && root.markerX <= root.xSteps; + const markerPx = hasMarker ? xAt(root.markerX) : 0; + let gapPx = 0; + if (hasMarker) { + // markerGap is measured from the dot edge, perpendicular to the + // curve; project onto x via the local curve slope + let slope = 0; + const seg = segments.find(s => s[0].x <= markerPx && markerPx <= s[s.length - 1].x); + if (seg && seg.length > 2 && markerPx - 1 >= seg[0].x && markerPx + 1 <= seg[seg.length - 1].x) + slope = (pointAtX(seg, markerPx + 1).y - pointAtX(seg, markerPx - 1).y) / 2; + gapPx = (root.markerDotRadius + root.markerGap) / Math.sqrt(1 + slope * slope); + } + + for (const seg of segments) { + if (seg.length < 2) + continue; + + // Fill stays continuous under the marker + ctx.fillStyle = Qt.alpha(root.lineColor, root.fillAlpha); + strokeCurve(ctx, seg); + ctx.lineTo(seg[seg.length - 1].x, plot.y + plot.h); + ctx.lineTo(seg[0].x, plot.y + plot.h); + ctx.closePath(); + ctx.fill(); + + // Stroke has a gap around the marker + const pieces = hasMarker ? [cutLeft(seg, markerPx - gapPx), cutRight(seg, markerPx + gapPx)] : [seg]; + + for (const pts of pieces) { + if (pts.length < 2) + continue; + + ctx.strokeStyle = root.lineColor; + ctx.lineWidth = root.lineWidth; + strokeCurve(ctx, pts); + ctx.stroke(); + } + } + + // 4. Marker: dashed vertical + dot + if (root.markerX >= 0 && root.markerX <= root.xSteps) { + const x = markerPx; + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = Qt.alpha(root.markerColor, 0.6); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(crisp(x), plot.y); + ctx.lineTo(crisp(x), H); + ctx.stroke(); + ctx.setLineDash([]); + + const i0 = Math.floor(root.markerX); + const v0 = series[i0]; + const v1 = series[i0 + 1]; + let y = null; + + if (v0 != null && v1 != null) + y = yAt(v0 + (v1 - v0) * (root.markerX - i0)); + else if (v0 != null) + y = yAt(v0); + + if (y != null) { + ctx.fillStyle = root.markerColor; + ctx.beginPath(); + ctx.arc(x, y, root.markerDotRadius, 0, Math.PI * 2); + ctx.fill(); + } + } + } + } + + onValuesChanged: canvas.requestPaint() + onXStepsChanged: canvas.requestPaint() + onYMinChanged: canvas.requestPaint() + onYMaxChanged: canvas.requestPaint() + onYPaddingTopChanged: canvas.requestPaint() + onYPaddingBottomChanged: canvas.requestPaint() + onVerticalLineStepChanged: canvas.requestPaint() + onHorizontalLineStepChanged: canvas.requestPaint() + onFirstXLineChanged: canvas.requestPaint() + onLastXLineChanged: canvas.requestPaint() + onMinHorizontalLinesChanged: canvas.requestPaint() + onXLabelChanged: canvas.requestPaint() + onYLabelChanged: canvas.requestPaint() + onSmoothingChanged: canvas.requestPaint() + onCurveSmoothingChanged: canvas.requestPaint() + onMarkerXChanged: canvas.requestPaint() + onMarkerGapChanged: canvas.requestPaint() + onLineColorChanged: canvas.requestPaint() + onGridColorChanged: canvas.requestPaint() + onMarkerColorChanged: canvas.requestPaint() + onLabelColorChanged: canvas.requestPaint() + onGridAlphaChanged: canvas.requestPaint() + onLabelAlphaChanged: canvas.requestPaint() + onFillAlphaChanged: canvas.requestPaint() + onLineWidthChanged: canvas.requestPaint() + onMarkerDotRadiusChanged: canvas.requestPaint() + onLabelFontSizeChanged: canvas.requestPaint() +} diff --git a/Components/ScreenState.qml b/Components/ScreenState.qml new file mode 100644 index 0000000..663dfe1 --- /dev/null +++ b/Components/ScreenState.qml @@ -0,0 +1,37 @@ +import QtQuick +import Quickshell + +PersistentProperties { + required property ShellScreen modelData + + reloadableId: "screenState" + + // Drawer visibilities + property bool bar + property bool clipboard + property bool dashboard + property bool dock + property bool isDrawing + property bool launcher + property bool osd + property bool resources + property bool settings + property bool sidebar + + // Dashboard state + property int dashboardTab + property date dashboardDate: new Date() + + // Sidebar state + property int sidebarTab + property list expandedNotifs: [] + + // Utilities state + property string recordingConfirmDelete + property bool recordingListExpanded + property string recordingMode + + // Drawing state + property color penColor: "white" + property int penWidth: 4 +} diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 88d5f0b..523f6e0 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -2,7 +2,7 @@ import QtQuick import QtQuick.Shapes import ZShell import ZShell.Config -import qs.Helpers +import qs.Services import qs.Services MouseArea { @@ -52,7 +52,7 @@ MouseArea { anchors.fill: parent cursorShape: !enabled ? undefined : Qt.PointingHandCursor - enabled: parent.enabled && !Visibilities.getForActive()?.isDrawing + enabled: parent.enabled && !ShellState.forActive()?.isDrawing hoverEnabled: true Behavior on stateOpacity { diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 0f8f305..3360c8b 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -10,6 +10,7 @@ import ZShell import qs.Components import qs.Modules import qs.Helpers +import qs.Services import qs.Paths import ZShell.Config @@ -25,7 +26,7 @@ Singleton { property alias server: server function shouldThrottle(appName: string): bool { - if (props.dnd || [...Visibilities.screens.values()].some(v => v.sidebar)) + if (props.dnd || ShellState.anySidebarOpen()) return false; const key = (appName || "unknown").trim().toLowerCase(); diff --git a/Drawers/Drawing.qml b/Drawers/Drawing.qml index 9b45c31..ac27301 100644 --- a/Drawers/Drawing.qml +++ b/Drawers/Drawing.qml @@ -3,28 +3,23 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import ZShell.Internal +import qs.Components Item { id: root readonly property alias content: contentLoader.item - readonly property PersistentProperties drawingState: PersistentProperties { - property color penColor: "white" - property int penWidth: 4 - - reloadableId: "drawingState" - } - required property PersistentProperties visibilities + required property ScreenState screenState Loader { id: contentLoader - active: root.visibilities.isDrawing + active: root.screenState.isDrawing anchors.fill: parent sourceComponent: StrokeCanvas { - penColor: root.drawingState.penColor - penWidth: root.drawingState.penWidth + penColor: root.screenState.penColor + penWidth: root.screenState.penWidth } } } diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 85dd0da..a8a1bbc 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -19,7 +19,7 @@ Item { required property ShellScreen screen property bool singleGestureTriggered: false property bool utilitiesShortcutActive - required property PersistentProperties visibilities + required property ScreenState screenState function inBottomPanel(panel: Item, x: real, y: real): bool { const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); @@ -63,7 +63,7 @@ Item { cursorShape: (active && centroid.pressPosition.y < root.bar.implicitHeight) ? Qt.ClosedHandCursor : undefined dragThreshold: 0 - enabled: !root.visibilities.isDrawing + enabled: !root.screenState.isDrawing grabPermissions: PointerHandler.CanTakeOverFromHandlersOfSameType | PointerHandler.ApprovesTakeOverByAnything maximumPointCount: 1 minimumPointCount: 1 @@ -76,48 +76,60 @@ Item { onCentroidChanged: { const x = centroid.position.x; const y = centroid.position.y; - const dragX = x - centroid.pressPosition.x; - const dragY = y - centroid.pressPosition.y; + const pressX = centroid.pressPosition.x; + const pressY = centroid.pressPosition.y; + const dragX = x - pressX; + const dragY = y - pressY; - if (centroid.pressPosition.y >= root.screen.height - Config.bar.border && centroid.pressPosition.x > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) - root.visibilities.launcher = true; + if (pressY >= root.screen.height - Config.bar.border && pressX > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) + root.screenState.launcher = true; if (root.singleGestureTriggered) return; - if (root.geometry.insetTop(root.borderThickness) > centroid.pressPosition.y) { - if (dragY > 20) { - root.visibilities.settings = true; - root.singleGestureTriggered = true; - } else if (dragY < -20) { - root.visibilities.settings = false; - root.singleGestureTriggered = true; - } - } + // if (root.geometry.insetTop(root.borderThickness) > pressY) { + // if (dragY > 20) { + // root.screenState.settings = true; + // root.singleGestureTriggered = true; + // } else if (dragY < -20) { + // root.screenState.settings = false; + // root.singleGestureTriggered = true; + // } + // } - if (centroid.pressPosition.y > root.screen.height - root.geometry.insetBottom(root.borderThickness) && centroid.pressPosition.x < root.screen.width / 5 && dragY < -50) - root.visibilities.clipboard = true; + if (pressY > root.screen.height - root.geometry.insetBottom(root.borderThickness) && pressX < root.screen.width / 5 && dragY < -50) + root.screenState.clipboard = true; - if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.geometry.insetTop(root.borderThickness) && centroid.pressPosition.x > root.screen.width / 5 && !root.visibilities.launcher) + if (!Config.dock.hoverToReveal && pressY > root.screen.height - root.geometry.insetTop(root.borderThickness) && pressX > root.screen.width / 5 && !root.screenState.launcher) if (dragY < -10) { - root.visibilities.dock = true; + root.screenState.dock = true; root.singleGestureTriggered = true; } - if (centroid.pressPosition.x > root.screen.width - Config.bar.border && centroid.pressPosition.y < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { - root.visibilities.sidebar = true; + if (pressX > root.screen.width - Config.bar.border && pressY < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { + root.screenState.sidebar = true; root.singleGestureTriggered = true; } - if (centroid.pressPosition.x >= root.screen.width - Config.bar.border && centroid.pressPosition.y > (root.screen.height / 2) && dragX < -20) { + if (pressX >= root.screen.width - Config.bar.border && pressY > (root.screen.height / 2) && dragX < -20) { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); root.singleGestureTriggered = true; } - if (centroid.pressPosition.x <= Config.bar.border && dragX > 20) { + if (pressX <= Config.bar.border && dragX > 20) { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); root.singleGestureTriggered = true; } + + if (root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY) && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { + if (dragY > Config.dashboard.dragThreshold) { + root.screenState.dashboard = true; + root.singleGestureTriggered = true; + } else if (dragY < -Config.dashboard.dragThreshold) { + root.screenState.dashboard = false; + root.singleGestureTriggered = true; + } + } } } @@ -127,7 +139,7 @@ Item { property bool setInitialPoint: false acceptedButtons: Qt.LeftButton | Qt.RightButton - enabled: root.visibilities.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) + enabled: root.screenState.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) onActiveChanged: { if (!active) { @@ -178,12 +190,12 @@ Item { HoverHandler { id: hoverHandler - cursorShape: root.visibilities.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined + cursorShape: root.screenState.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined onHoveredChanged: { if (!hovered) { if (!root.osdShortcutActive) { - root.visibilities.osd = false; + root.screenState.osd = false; root.panels.osd.hovered = false; } @@ -193,13 +205,16 @@ Item { if (Config.bar.autoHide || root.bar.fullscreen) root.bar.isHovered = false; + + if (root.screenState.dashboard && Config.dashboard.showOnHover) + root.screenState.dashboard = false; } } onPointChanged: { const x = point.position.x; const y = point.position.y; - if (root.visibilities.isDrawing) { + if (root.screenState.isDrawing) { if (root.inLeftPanel(root.panels.drawing, x, y) && !(drawingHandler.point.pressedButtons & Qt.LeftButton)) { root.panels.drawing.expand(); root.drawing.content.hideHover(); @@ -210,7 +225,7 @@ Item { return; } - if (!root.visibilities.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) + if (!root.screenState.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) root.bar.isHovered = true; if (root.bar.fullscreen && !root.bar.isHovered) @@ -228,7 +243,7 @@ Item { const showOsd = outOfSidebar && root.inRightPanel(root.panels.osdWrapper, x, y); if (!root.osdShortcutActive) { - root.visibilities.osd = showOsd; + root.screenState.osd = showOsd; root.panels.osd.hovered = showOsd; } else if (showOsd) { root.osdShortcutActive = false; @@ -236,8 +251,13 @@ Item { } } - if (Config.dock.enable && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) - root.visibilities.dock = true; + if (Config.dock.enable && Config.dock.hoverToReveal && !root.screenState.dock && !root.screenState.launcher && root.inBottomPanel(root.panels.dock, x, y)) + root.screenState.dock = true; + + const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, x, y); + + if (Config.dashboard.showOnHover) + root.screenState.dashboard = inDashboard; if (root.geometry.barContains(x, y)) root.bar.checkPopout(root.geometry.axisPos(x, y)); @@ -246,16 +266,16 @@ Item { Connections { function onDashboardChanged() { - if (root.visibilities.dashboard) { + if (root.screenState.dashboard) { const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); if (!inDashboardArea) { root.dashboardShortcutActive = true; } if (root.panels.launcher.x + root.panels.launcher.width > root.panels.dashboardWrapper.x) - root.visibilities.launcher = false; + root.screenState.launcher = false; - root.visibilities.settings = false; - root.visibilities.sidebar = false; + root.screenState.settings = false; + root.screenState.sidebar = false; root.popouts.hasCurrent = false; } else { root.dashboardShortcutActive = false; @@ -263,12 +283,12 @@ Item { } function onIsDrawingChanged() { - if (!root.visibilities.isDrawing) + if (!root.screenState.isDrawing) root.drawing.clear(); } function onLauncherChanged() { - if (!root.visibilities.launcher) { + if (!root.screenState.launcher) { root.dashboardShortcutActive = false; root.osdShortcutActive = false; root.utilitiesShortcutActive = false; @@ -276,23 +296,23 @@ Item { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { - root.visibilities.osd = false; + root.screenState.osd = false; root.panels.osd.hovered = false; } } - if (root.visibilities.launcher) { + if (root.screenState.launcher) { if (root.panels.dashboardWrapper.x < root.panels.launcher.x + root.panels.launcher.width) { - root.visibilities.dashboard = false; + root.screenState.dashboard = false; } - root.visibilities.dock = false; - root.visibilities.settings = false; + root.screenState.dock = false; + root.screenState.settings = false; } } function onOsdChanged() { - if (root.visibilities.osd) { + if (root.screenState.osd) { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { root.osdShortcutActive = true; @@ -303,32 +323,32 @@ Item { } function onResourcesChanged() { - if (root.visibilities.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { + if (root.screenState.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { root.popouts.hasCurrent = false; } - if (root.visibilities.resources) - root.visibilities.settings = false; + if (root.screenState.resources) + root.screenState.settings = false; } function onSettingsChanged() { - if (root.visibilities.settings) { - root.visibilities.resources = false; - root.visibilities.dashboard = false; - root.visibilities.sidebar = false; + if (root.screenState.settings) { + root.screenState.resources = false; + root.screenState.dashboard = false; + root.screenState.sidebar = false; root.panels.popouts.hasCurrent = false; - root.visibilities.launcher = false; + root.screenState.launcher = false; } } function onSidebarChanged() { - if (root.visibilities.sidebar) { - root.visibilities.dashboard = false; - root.visibilities.settings = false; + if (root.screenState.sidebar) { + root.screenState.dashboard = false; + root.screenState.settings = false; root.popouts.hasCurrent = false; } } - target: root.visibilities + target: root.screenState } } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 96ca9f8..18ecd64 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -42,7 +42,7 @@ Item { readonly property alias sidebar: sidebar readonly property alias toasts: toasts readonly property alias utilities: utilities - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottomMargin: geometry.insetBottom(borderThickness) anchors.fill: parent @@ -64,7 +64,7 @@ Item { id: resources position: root.geometry.position - visibilities: root.visibilities + screenState: root.screenState } } @@ -85,7 +85,7 @@ Item { anchors.verticalCenter: parent.verticalCenter screen: root.screen sidebarOrSessionVisible: sidebar.visible - visibilities: root.visibilities + screenState: root.screenState } } @@ -96,7 +96,7 @@ Item { anchors.verticalCenter: parent.verticalCenter drawing: root.drawingItem screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Popouts.ClipWrapper { @@ -122,7 +122,7 @@ Item { anchors.top: parent.top panels: root sidebarPanel: sidebar - visibilities: root.visibilities + screenState: root.screenState } Launcher.Wrapper { @@ -132,7 +132,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Utils.Wrapper { @@ -142,7 +142,7 @@ Item { anchors.right: parent.right popouts: popouts sidebar: sidebar - visibilities: root.visibilities + screenState: root.screenState } Item { @@ -150,13 +150,11 @@ Item { property real offsetScale: dashboard.shouldBeActive ? 0 : 1 - anchors.bottom: root.geometry.barOnLeft || root.geometry.barOnBottom ? parent.bottom : undefined - anchors.left: root.geometry.barOnLeft ? parent.left : undefined - anchors.right: root.geometry.barOnLeft ? undefined : parent.right - anchors.top: root.geometry.barOnBottom || root.geometry.barOnLeft ? undefined : parent.top + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter clip: true - implicitHeight: root.geometry.horizontal ? dashboard.implicitHeight * (1 - offsetScale) : dashboard.implicitHeight - implicitWidth: root.geometry.horizontal ? dashboard.implicitWidth : dashboard.implicitWidth * (1 - offsetScale) + implicitHeight: dashboard.implicitHeight * (1 - offsetScale) + implicitWidth: dashboard.implicitWidth Behavior on offsetScale { Anim { @@ -169,8 +167,7 @@ Item { id: dashboard offsetScale: dashboardWrapper.offsetScale - position: root.geometry.position - visibilities: root.visibilities + screenState: root.screenState } } @@ -181,7 +178,7 @@ Item { anchors.right: parent.right anchors.top: notifications.bottom panels: root - visibilities: root.visibilities + screenState: root.screenState } Item { @@ -197,7 +194,7 @@ Item { anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } @@ -208,7 +205,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Clipboard.Wrapper { @@ -217,6 +214,6 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index bac1fbc..161027a 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -50,16 +50,17 @@ CustomWindow { readonly property alias menuRegion: regions.menuPopoutRegion readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) property var root: Quickshell.shellDir + readonly property ScreenState screenState: ShellState.forScreen(screen) readonly property real sdfBorderOffset: 2 * fsTransitionProg readonly property real shadowOpacity: 0.7 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" contentItem.focus: true - mask: visibilities.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) + mask: screenState.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) name: "Bar" Behavior on fsTransitionProg { @@ -73,26 +74,26 @@ CustomWindow { contentItem.Keys.onEscapePressed: { if (Config.bar.autoHide) - visibilities.bar = false; - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.dock = false; - visibilities.clipboard = false; + screenState.bar = false; + screenState.launcher = false; + screenState.sidebar = false; + screenState.dashboard = false; + screenState.osd = false; + screenState.settings = false; + screenState.resources = false; + screenState.dock = false; + screenState.clipboard = false; panels.popouts.hasCurrent = false; } onHasFullscreenChanged: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.clipboard = false; - visibilities.dock = false; + screenState.launcher = false; + screenState.sidebar = false; + screenState.dashboard = false; + screenState.osd = false; + screenState.settings = false; + screenState.resources = false; + screenState.clipboard = false; + screenState.dock = false; panels.popouts.hasCurrent = false; } @@ -144,44 +145,26 @@ CustomWindow { HyprlandFocusGrab { id: focusGrab - active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) + active: root.screenState.dock || root.screenState.resources || root.screenState.launcher || root.screenState.sidebar || (!Config.dashboard.showOnHover && root.screenState.dashboard) || root.screenState.settings || root.screenState.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) windows: [root] onCleared: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.clipboard = false; - visibilities.dock = false; + root.screenState.launcher = false; + root.screenState.sidebar = false; + root.screenState.dashboard = false; + root.screenState.osd = false; + root.screenState.settings = false; + root.screenState.resources = false; + root.screenState.clipboard = false; + root.screenState.dock = false; panels.popouts.hasCurrent = false; } } - PersistentProperties { - id: visibilities - - property bool bar - property bool clipboard - property bool dashboard - property bool dock - property bool isDrawing - property bool launcher - property bool notif: NotifServer.popups.length > 0 - property bool osd - property bool resources - property bool settings - property bool sidebar - - Component.onCompleted: Visibilities.load(root.screen, this) - } - Binding { property: "bar" - target: visibilities - value: visibilities.sidebar || visibilities.dashboard || visibilities.osd || (!Config.bar.hideWhenNotif && visibilities.notif) || visibilities.resources || visibilities.settings || bar.isHovered + target: root.screenState + value: root.screenState.sidebar || root.screenState.dashboard || root.screenState.osd || (!Config.bar.hideWhenNotif && NotifServer.popups.length > 0) || root.screenState.resources || root.screenState.settings || bar.isHovered when: Config.bar.autoHide } @@ -348,7 +331,7 @@ CustomWindow { anchors.fill: parent layer.enabled: true - visibilities: visibilities + screenState: root.screenState z: 2 layer.effect: MultiEffect { @@ -389,7 +372,7 @@ CustomWindow { panels: panels popouts: panels.popouts screen: root.screen - visibilities: visibilities + screenState: root.screenState Panels { id: panels @@ -399,7 +382,7 @@ CustomWindow { drawingItem: drawing geometry: geometry screen: root.screen - visibilities: visibilities + screenState: root.screenState clipboard.transform: Matrix4x4 { matrix: clipboardBg.deformMatrix @@ -442,14 +425,14 @@ CustomWindow { anchors.bottom: geometry.barOnBottom ? parent.bottom : undefined anchors.left: parent.left anchors.top: geometry.barOnBottom ? undefined : parent.top - enabled: !visibilities.isDrawing + enabled: !screenState.isDrawing fullscreen: root.hasFullscreen height: geometry.horizontal ? implicitHeight : parent.height popouts: panels.popouts popoutsWrapper: panels.popoutsWrapper position: geometry.position screen: root.screen - visibilities: visibilities + screenState: root.screenState width: geometry.horizontal ? parent.width : implicitWidth } } diff --git a/Greeter/WeatherInfo.qml b/Greeter/WeatherInfo.qml index d7f0f1c..60cda3a 100644 --- a/Greeter/WeatherInfo.qml +++ b/Greeter/WeatherInfo.qml @@ -119,7 +119,8 @@ ColumnLayout { length: count }, () => null); - return forecast.slice(0, count); + const start = Math.max(0, Weather.currentHourIndex); + return forecast.slice(start, start + count); } ColumnLayout { diff --git a/Helpers/Strings.qml b/Helpers/Strings.qml index f1980b5..0e9061b 100644 --- a/Helpers/Strings.qml +++ b/Helpers/Strings.qml @@ -5,6 +5,15 @@ import Quickshell Singleton { property var _regexCache: ({}) + function percent(value: int): string { + // TRANSLATORS: %1 = a number + return qsTr("%1%").arg(value); + } + + function percentOne(value: real): string { + return percent(Math.round(value * 100)); + } + function testRegexList(filterList: list, target: string): bool { const regexChecker = /^\^.*\$$/; for (const filter of filterList) { diff --git a/Helpers/Visibilities.qml b/Helpers/Visibilities.qml deleted file mode 100644 index fe22047..0000000 --- a/Helpers/Visibilities.qml +++ /dev/null @@ -1,16 +0,0 @@ -pragma Singleton - -import Quickshell - -Singleton { - property var bars: new Map() - property var screens: new Map() - - function getForActive(): PersistentProperties { - return screens.get(Hypr.focusedMonitor); - } - - function load(screen: ShellScreen, visibilities: var): void { - screens.set(Hypr.monitorFor(screen), visibilities); - } -} diff --git a/Helpers/Weather.qml b/Helpers/Weather.qml index ecf5e80..0b9b535 100644 --- a/Helpers/Weather.qml +++ b/Helpers/Weather.qml @@ -3,25 +3,36 @@ pragma Singleton import QtQuick import Quickshell import ZShell +import ZShell.Internal import ZShell.Config Singleton { id: root readonly property var cachedCities: new Map() - property var cc - property string city readonly property string description: cc?.weatherDesc ?? qsTr("No weather") readonly property string feelsLike: formatTemp(cc?.feelsLikeC) - property list forecast - property list hourlyForecast readonly property int humidity: cc?.humidity ?? 0 readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" - property string loc readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string temp: formatTemp(cc?.tempC) readonly property real windSpeed: cc?.windSpeed ?? 0 + readonly property CircularBuffer precipitationForecast: precipitationBuffer + + property var cc + property string city + property list forecast + property list hourlyForecast + readonly property int currentHourIndex: { + const now = new Date(); + for (let i = 0; i < hourlyForecast.length; i++) { + if (new Date(hourlyForecast[i].timestamp.replace("T", " ")) >= now) + return i; + } + return -1; + } + property string loc function fetchCityFromCoords(coords: string): void { if (cachedCities.has(coords)) { @@ -112,11 +123,12 @@ Singleton { forecast = forecastList; const hourlyList = []; - const now = new Date(); + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); for (let i = 0; i < json.hourly.time.length; i++) { const time = new Date(json.hourly.time[i].replace("T", " ")); - if (time < now) + if (time < startOfToday) continue; hourlyList.push({ @@ -129,6 +141,10 @@ Singleton { }); } hourlyForecast = hourlyList; + precipitationBuffer.clear(); + + for (const hour of hourlyList) + precipitationBuffer.push(hour.precipChance); }); } @@ -258,6 +274,11 @@ Singleton { onLocChanged: fetchWeatherData() + CircularBuffer { + id: precipitationBuffer + capacity: 24 + } + Connections { function onWeatherLocationChanged(): void { root.reload(); diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index fe33739..f0f5fb0 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config +import qs.Components import qs.Modules.Bar.Components import qs.Modules.Bar.Popouts @@ -17,7 +18,7 @@ GridLayout { required property ClipWrapper popoutsWrapper required property ShellScreen screen readonly property int vPadding: 6 - required property PersistentProperties visibilities + required property ScreenState screenState function axisCenterOf(item: Item): real { const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); @@ -34,7 +35,7 @@ GridLayout { return; } - if (visibilities.sidebar || visibilities.dashboard || visibilities.resources || visibilities.settings) + if (screenState.sidebar || screenState.dashboard || screenState.resources || screenState.settings) return; if (id === "statusIcons" && Config.bar.popouts.statusIcons) { @@ -149,7 +150,7 @@ GridLayout { delegate: EntryWrapper { Resources { horizontal: root.horizontal - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } @@ -173,7 +174,7 @@ GridLayout { NotifBell { horizontal: root.horizontal popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } @@ -187,7 +188,7 @@ GridLayout { horizontal: root.horizontal loader: root popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 5fd3a70..41e52fb 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -21,9 +21,9 @@ Item { required property ClipWrapper popoutsWrapper required property string position required property ShellScreen screen - readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || visibilities.bar)) || isHovered + readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || screenState.bar)) || isHovered readonly property int vPadding: 6 - required property PersistentProperties visibilities + required property ScreenState screenState function checkPopout(pos: real): void { (content.item as Bar)?.checkPopout(pos); @@ -85,7 +85,7 @@ Item { popouts: root.popouts popoutsWrapper: root.popoutsWrapper screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Bar/Border.qml b/Modules/Bar/Border.qml index c67fa45..95503f6 100644 --- a/Modules/Bar/Border.qml +++ b/Modules/Bar/Border.qml @@ -11,7 +11,7 @@ Item { id: root required property Item bar - required property PersistentProperties visibilities + required property ScreenState screenState anchors.fill: parent diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index e7de27d..7940c1d 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -13,7 +13,7 @@ import qs.Services WidgetBase { id: root - readonly property color contentColor: visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + readonly property color contentColor: screenState.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface readonly property string effectiveFormat: { let fmt = Config.general.dateFormat; if (!root.horizontal) @@ -44,10 +44,10 @@ WidgetBase { } required property GridLayout loader required property Wrapper popouts - readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.small * 2 : verticalColumn.implicitHeight + Tokens.padding.small * 2 - required property PersistentProperties visibilities + readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 + required property ScreenState screenState - color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : size implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full @@ -151,7 +151,7 @@ WidgetBase { color: root.contentColor onClicked: { - root.visibilities.dashboard = !root.visibilities.dashboard; + root.screenState.dashboard = !root.screenState.dashboard; } } } diff --git a/Modules/Bar/Components/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml index c255deb..34b4c68 100644 --- a/Modules/Bar/Components/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -11,17 +11,17 @@ WidgetBase { id: root required property Wrapper popouts - required property PersistentProperties visibilities + required property ScreenState screenState - color: visibilities.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer radius: Tokens.rounding.full MaterialIcon { id: notificationCenterIcon anchors.centerIn: parent - color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - fill: root.visibilities.sidebar ? 1 : 0 + color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + fill: root.screenState.sidebar ? 1 : 0 font.pointSize: Tokens.font.size.larger text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" @@ -36,10 +36,10 @@ WidgetBase { } StateLayer { - color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface onClicked: { - root.visibilities.sidebar = !root.visibilities.sidebar; + root.screenState.sidebar = !root.screenState.sidebar; } } } diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index 56cb3d9..32bd1f2 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -12,18 +12,18 @@ import qs.Components WidgetBase { id: root - required property PersistentProperties visibilities + required property ScreenState screenState clip: true - color: visibilities.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : gridLayout.implicitHeight + Tokens.padding.medium * 2 implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.medium * 2 : baseSize radius: Tokens.rounding.full StateLayer { - color: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - onClicked: root.visibilities.resources = !root.visibilities.resources + onClicked: root.screenState.resources = !root.screenState.resources } GridLayout { @@ -53,8 +53,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "memory" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary percentage: Cpu.percentage warningThreshold: 95 } @@ -64,8 +64,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "memory_alt" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary percentage: Memory.percentage warningThreshold: 80 } @@ -75,8 +75,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "gamepad" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary percentage: Gpu.percentage } @@ -85,8 +85,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "developer_board" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary percentage: Gpu.memoryUsed / Gpu.memoryTotal } } diff --git a/Modules/Bar/Components/Tray/TrayItem.qml b/Modules/Bar/Components/Tray/TrayItem.qml index d7b8fc1..9a1e228 100644 --- a/Modules/Bar/Components/Tray/TrayItem.qml +++ b/Modules/Bar/Components/Tray/TrayItem.qml @@ -62,10 +62,10 @@ Item { root.popouts.currentName = `traymenu${root.ind}`; root.popouts.currentCenter = Qt.binding(() => root.mapToItem(root.loader, root.implicitWidth / 2, 0).x); root.popouts.hasCurrent = true; - if (visibilities.sidebar || visibilities.dashboard || visibilities.settings) { - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.settings = false; + if (screenState.sidebar || screenState.dashboard || screenState.settings) { + screenState.sidebar = false; + screenState.dashboard = false; + screenState.settings = false; } } } diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index 4be55ea..dd5d6a3 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -14,7 +14,7 @@ Item { readonly property int itemHeight: Config.clipboard.sizes.itemHeight required property ShellScreen screen - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: search.implicitHeight + entries.anchors.topMargin + ((view.spacing + itemHeight) * Config.clipboard.maxEntriesShown) - view.spacing implicitWidth: Config.clipboard.sizes.width + preview.width + preview.anchors.leftMargin @@ -49,7 +49,7 @@ Item { Keys.onUpPressed: view.decrementCurrentIndex() onAccepted: { ClipHistory.copy(view.currentItem.modelData); - root.visibilities.clipboard = false; + root.screenState.clipboard = false; } } } diff --git a/Modules/Clipboard/Wrapper.qml b/Modules/Clipboard/Wrapper.qml index 1a0a8f7..cfd60a3 100644 --- a/Modules/Clipboard/Wrapper.qml +++ b/Modules/Clipboard/Wrapper.qml @@ -11,8 +11,8 @@ Item { property int contentHeight property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.clipboard && Config.clipboard.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.clipboard && Config.clipboard.enabled + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight + Tokens.padding.small * 2 @@ -35,7 +35,7 @@ Item { sourceComponent: Content { screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 13e8e49..20c418a 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -5,78 +5,177 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell import qs.Components Item { id: root - required property PersistentProperties dashState - readonly property real nonAnimHeight: view.implicitHeight + viewWrapper.anchors.margins * 2 readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 - required property PersistentProperties visibilities + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2 + + required property ScreenState screenState + + readonly property var dashboardTabs: { + const allTabs = [ + { + component: dashComponent, + iconName: "dashboard", + text: qsTr("Dashboard"), + enabled: true + }, + { + component: weatherComponent, + iconName: "cloud", + text: qsTr("Weather"), + enabled: true + }, + { + component: mediaComponent, + iconName: "queue_music", + text: qsTr("Media"), + enabled: true + }, + // { + // component: testingComponent, + // iconName: "speed", + // text: qsTr("Testing"), + // enabled: true + // }, + ]; + return allTabs.filter(tab => tab.enabled); + } - implicitHeight: nonAnimHeight implicitWidth: nonAnimWidth + implicitHeight: nonAnimHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Behavior on implicitWidth { - Anim { - } + Anim {} + } + + Tabs { + id: tabs + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) + anchors.margins: Tokens.padding.large + + nonAnimWidth: root.nonAnimWidth - anchors.margins * 2 + screenState: root.screenState + tabs: root.dashboardTabs } ClippingRectangle { id: viewWrapper - anchors.bottom: parent.bottom + anchors.top: tabs.bottom anchors.left: parent.left - anchors.margins: Tokens.padding.small anchors.right: parent.right - anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.small color: "transparent" radius: Tokens.rounding.extraLarge - anchors.margins - Item { + Flickable { id: view - readonly property int currentIndex: root.dashState?.currentTab - readonly property Item currentItem: row.children[currentIndex] + readonly property int currentIndex: root.screenState.dashboardTab + readonly property Item currentItem: { + repeater.count; + return repeater.itemAt(currentIndex); + } anchors.fill: parent - implicitHeight: currentItem.implicitHeight - implicitWidth: currentItem.implicitWidth + + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 + + contentX: currentItem?.x ?? 0 + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight + + interactive: false + + onContentXChanged: { + if (!moving || !currentItem) + return; + + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); + } RowLayout { id: row - Pane { - index: 0 + Repeater { + id: repeater - sourceComponent: Dash { - dashState: root.dashState - visibilities: root.visibilities + model: ScriptModel { + values: root.dashboardTabs } + + delegate: Loader { + id: paneLoader + + required property int index + required property var modelData + + Layout.alignment: Qt.AlignTop + + sourceComponent: modelData.component + + Component.onCompleted: active = Qt.binding(() => { + if (index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) + } + } + } + + Component { + id: dashComponent + + Dash { + screenState: root.screenState + } + } + + Component { + id: weatherComponent + + WeatherTab {} + } + + Component { + id: mediaComponent + + Media { + screenState: root.screenState + } + } + + // Component { + // id: testingComponent + // + // WeatherTest {} + // } + + Behavior on contentX { + Anim { + type: Anim.DefaultEffects } } } } - - component Pane: Loader { - id: pane - - required property int index - - Layout.alignment: Qt.AlignTop - - Component.onCompleted: active = Qt.binding(() => { - // Always keep current tab loaded - if (pane.index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) - } } diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index bf7bc2a..54d6e51 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -11,10 +11,9 @@ import qs.Services GridLayout { id: root - required property PersistentProperties dashState - readonly property bool dashboardVisible: visibilities.dashboard + readonly property bool dashboardVisible: screenState.dashboard property int radius: Tokens.rounding.medium - required property PersistentProperties visibilities + required property ScreenState screenState columnSpacing: Tokens.spacing.small rowSpacing: Tokens.spacing.small @@ -61,7 +60,7 @@ GridLayout { User { id: user - dashState: root.dashState + screenState: root.screenState } } @@ -72,8 +71,7 @@ GridLayout { Layout.row: 0 radius: root.radius - Weather { - } + Weather {} } Rect { @@ -87,7 +85,7 @@ GridLayout { Calendar { id: calendar - dashState: root.dashState + screenState: root.screenState } } @@ -104,20 +102,6 @@ GridLayout { } } - CustomClippingRect { - Layout.column: 0 - Layout.columnSpan: 5 - Layout.fillWidth: true - Layout.preferredHeight: media.implicitHeight - Layout.row: 2 - color: Colors.tPalette.m3surfaceContainer - radius: root.radius - - Media { - id: media - } - } - component Rect: CustomRect { color: Colors.tPalette.m3surfaceContainer } diff --git a/Modules/Dashboard/Dash/Calendar.qml b/Modules/Dashboard/Dash/Calendar.qml index 8161957..9d198ef 100644 --- a/Modules/Dashboard/Dash/Calendar.qml +++ b/Modules/Dashboard/Dash/Calendar.qml @@ -12,12 +12,12 @@ CustomMouseArea { id: root property int activeGrid: 1 - required property PersistentProperties dashState + required property ScreenState screenState property bool initialized: false property int month1 property int month2 - readonly property int realCurrMonth: dashState.currentDate.getMonth() - readonly property int realCurrYear: dashState.currentDate.getFullYear() + readonly property int realCurrMonth: screenState.dashboardDate.getMonth() + readonly property int realCurrYear: screenState.dashboardDate.getFullYear() property int year1 property int year2 @@ -43,9 +43,9 @@ CustomMouseArea { function onWheel(event: WheelEvent): void { if (event.angleDelta.y > 0) - root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); + root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); else if (event.angleDelta.y < 0) - root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); + root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); } acceptedButtons: Qt.MiddleButton @@ -61,7 +61,7 @@ CustomMouseArea { initialized = true; } - onClicked: root.dashState.currentDate = new Date() + onClicked: root.screenState.dashboardDate = new Date() onRealCurrMonthChanged: handleDateChange() onRealCurrYearChanged: handleDateChange() @@ -135,7 +135,7 @@ CustomMouseArea { padding: Tokens.padding.extraSmall type: IconButton.Text - onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) + onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) } Item { @@ -158,7 +158,7 @@ CustomMouseArea { } } - onClicked: root.dashState.currentDate = new Date() + onClicked: root.screenState.dashboardDate = new Date() } Item { @@ -206,7 +206,7 @@ CustomMouseArea { padding: Tokens.padding.extraSmall type: IconButton.Text - onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) + onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) } } diff --git a/Modules/Dashboard/Dash/Media.qml b/Modules/Dashboard/Dash/Media.qml deleted file mode 100644 index 53cda5a..0000000 --- a/Modules/Dashboard/Dash/Media.qml +++ /dev/null @@ -1,329 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Services.Mpris -import QtQuick -import QtQuick.Layouts -import QtQuick.Shapes -import QtQuick.Effects -import ZShell.Components -import ZShell.Services -import ZShell.Config -import qs.Modules.Dashboard.Dash.Media -import qs.Daemons -import qs.Components -import qs.Helpers -import qs.Services - -Item { - id: root - readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 - property real playerProgress: { - const active = Players.active; - return active?.length ? (active.position % active.length) / active.length : 0; - } - property int rowHeight: Tokens.padding.large + Config.dashboard.sizes.mediaProgressThickness + Tokens.spacing.small - - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - function lengthStr(length: int): string { - if (length < 0) - return "-1:-1"; - - const hours = Math.floor(length / 3600); - const mins = Math.floor((length % 3600) / 60); - const secs = Math.floor(length % 60).toString().padStart(2, "0"); - - if (hours > 0) - return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; - return `${mins}:${secs}`; - } - - Behavior on playerProgress { - Anim { - duration: Tokens.anim.durations.large - } - } - - Timer { - running: Players.active?.isPlaying ?? false - interval: Config.dashboard.mediaUpdateInterval - triggeredOnStart: true - repeat: true - onTriggered: Players.active?.positionChanged() - } - - ServiceRef { - service: Audio.cava - } - - BackgroundShapes { - anchors.fill: parent - } - - Shape { - id: visualizer - - readonly property int bars: Config.services.visualizerBars - property color color: Qt.alpha(Colors.palette.m3primary, 0.5) - property color fillColor: Qt.alpha(color, 0.1) - - anchors.fill: parent - anchors.leftMargin: -(shape.strokeWidth / 2) - layer.enabled: true - asynchronous: true - preferredRendererType: Shape.CurveRenderer - - ShapePath { - id: shape - strokeColor: visualizer.color - fillColor: visualizer.fillColor - strokeWidth: 2 - - PathSvg { - path: shape.curvePath - } - - property string curvePath: { - const values = Audio.cava.values; - const n = values.length; - - if (n < 2) - return ""; - - const h = root.height + shape.strokeWidth / 2; - const w = root.width + shape.strokeWidth; - - function x(i) { - return i * w / (n - 1); - } - - function y(i) { - return h - (values[i] * Config.dashboard.sizes.mediaVisualizerSize) / 2; - } - - let d = `M 0 ${h} `; - d += `L ${x(0)} ${y(0)} `; - - for (let i = 0; i < n - 1; ++i) { - const x0 = x(i); - const y0 = y(i); - - const x1 = x(i + 1); - const y1 = y(i + 1); - - const prev = Math.max(0, i - 1); - const next = Math.min(n - 1, i + 2); - - const c1x = x0 + (x1 - x(prev)) / 6; - const c1y = y0 + (y1 - y(prev)) / 6; - - const c2x = x1 - (x(next) - x0) / 6; - const c2y = y1 - (y(next) - y0) / 6; - - d += `C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x1} ${y1} `; - } - - d += `L ${w} ${h} `; - d += `L 0 ${h} Z`; - - return d; - } - } - } - - CustomRect { - anchors.fill: visualizer - - color: Qt.alpha(Colors.palette.m3shadow, 0.2) - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: visualizer - } - } - - RowLayout { - id: layout - - anchors.fill: parent - anchors.margins: Tokens.padding.large - anchors.leftMargin: Tokens.padding.extraLarge - - CoverVisualizer { - Layout.fillHeight: true - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize - } - - ColumnLayout { - Layout.fillWidth: true - Layout.leftMargin: Tokens.spacing.largeIncreased - Layout.rightMargin: Tokens.spacing.largeIncreased - - spacing: Tokens.spacing.small - - MarqueeText { - id: title - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.normal - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - MarqueeText { - id: album - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - MarqueeText { - id: artist - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3secondary - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - RowLayout { - id: positionSliderLayout - Layout.topMargin: Tokens.spacing.large - Layout.fillWidth: true - spacing: Tokens.spacing.small - - TextMetrics { - id: timeMetrics - - text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" - } - - CustomText { - id: positionLabel - - Layout.preferredWidth: timeMetrics.width - text: root.lengthStr(Players.active?.position ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - - CustomSlider { - id: positionSlider - - Layout.fillWidth: true - value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 - enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength - wavy: true - animateWave: Players.active?.isPlaying ?? false - waveFrequency: 5 - waveDuration: 2000 - interactionOnMove: false - onInteraction: value => { - const active = Players.active; - if (active?.canSeek && active?.positionSupported) - active.position = value * active.length; - } - Binding { - target: positionLabel - property: "text" - value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) - when: positionSlider.dragging - } - } - - CustomText { - Layout.preferredWidth: timeMetrics.width - text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - } - ButtonRow { - Layout.topMargin: Tokens.spacing.largeIncreased - Layout.fillWidth: true - spacing: Tokens.spacing.extraSmall - Layout.leftMargin: positionSliderLayout.implicitWidth / 4 - Layout.rightMargin: positionSliderLayout.implicitWidth / 4 - - IconButton { - type: IconButton.Tonal - icon: "shuffle" - isRound: true - shapeMorph: true - checked: Players.active?.shuffle ?? false - enabled: Players.active?.shuffleSupported - onClicked: Players.active.shuffle = !Players.active?.shuffle - } - - IconButton { - id: previousBtn - - type: IconButton.Tonal - icon: "skip_previous" - isRound: true - shapeMorph: true - enabled: Players.active?.canGoPrevious - onClicked: Players.active?.previous() - font.pointSize: Tokens.font.size.large - } - - IconButton { - id: playPauseBtn - - icon: Players.active?.isPlaying ? "pause" : "play_arrow" - isRound: true - shapeMorph: true - fillWidth: true - checked: Players.active?.isPlaying ?? false - enabled: Players.active?.canTogglePlaying - onClicked: Players.active?.togglePlaying() - font.pointSize: Tokens.font.size.large - } - - IconButton { - id: nextBtn - - type: IconButton.Tonal - icon: "skip_next" - isRound: true - shapeMorph: true - enabled: Players.active?.canGoNext - onClicked: Players.active?.next() - font.pointSize: Tokens.font.size.large - } - - IconButton { - type: IconButton.Tonal - icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" - isRound: true - shapeMorph: true - checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist - enabled: Players.active?.loopSupported - onClicked: { - const state = Players.active.loopState; - if (state === MprisLoopState.None) - Players.active.loopState = MprisLoopState.Track; - else if (state === MprisLoopState.Track) - Players.active.loopState = MprisLoopState.Playlist; - else - Players.active.loopState = MprisLoopState.None; - } - } - } - } - } -} diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 0c6e0c8..1d37230 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -9,7 +9,7 @@ import qs.Helpers Row { id: root - required property PersistentProperties dashState + required property ScreenState screenState padding: 20 spacing: 12 diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml new file mode 100644 index 0000000..f1f98ee --- /dev/null +++ b/Modules/Dashboard/Media.qml @@ -0,0 +1,152 @@ +import QtQuick +import QtQuick.Layouts +import M3Shapes +import ZShell.Config +import qs.Modules.Dashboard.Media +import qs.Components +import qs.Services +import qs.Helpers + +Item { + id: root + + property ScreenState screenState + + implicitWidth: 1000 + implicitHeight: 320 + + BackgroundShapes { + anchors.fill: parent + } + + RowLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.extraLarge + + CoverVisualizer { + Layout.fillHeight: true + implicitWidth: 250 + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + state: Players.active ? "" : "noMedia" + + states: State { + name: "noMedia" + + PropertyChanges { + noMedia.opacity: 1 + content.opacity: 0 + } + } + + transitions: [ + Transition { + from: "" + + SequentialAnimation { + Anim { + target: content + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + target: noMedia + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + to: "" + + SequentialAnimation { + Anim { + target: noMedia + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + target: content + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Loader { + id: noMedia + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -Tokens.padding.extraLarge * 2 + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialShape { + Layout.topMargin: (pathBounds().height - implicitSize) / 2 + Layout.bottomMargin: (pathBounds().height - implicitSize) / 2 + Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter + color: Colors.palette.m3primaryContainer + implicitSize: icon.implicitHeight + Tokens.padding.extraLarge * 2 + shape: MaterialShape.ClamShell + + Behavior on color { + CAnim {} + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "queue_music" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3onPrimaryContainer + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Nothing playing") + font.pointSize: Tokens.font.size.normal + } + + CustomText { + text: qsTr("Play something for it to show up here!") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large + } + } + } + + Loader { + id: content + + anchors.fill: parent + asynchronous: true + active: opacity > 0 + + sourceComponent: RowLayout { + spacing: Tokens.spacing.extraLarge + + Details { + Layout.fillWidth: true + } + + LyricsAndSelector { + Layout.fillHeight: true + implicitWidth: 300 + } + } + } + } + } +} diff --git a/Modules/Dashboard/Dash/Media/BackgroundShapes.qml b/Modules/Dashboard/Media/BackgroundShapes.qml similarity index 100% rename from Modules/Dashboard/Dash/Media/BackgroundShapes.qml rename to Modules/Dashboard/Media/BackgroundShapes.qml diff --git a/Modules/Dashboard/Dash/Media/CoverArt.qml b/Modules/Dashboard/Media/CoverArt.qml similarity index 100% rename from Modules/Dashboard/Dash/Media/CoverArt.qml rename to Modules/Dashboard/Media/CoverArt.qml diff --git a/Modules/Dashboard/Dash/Media/CoverVisualizer.qml b/Modules/Dashboard/Media/CoverVisualizer.qml similarity index 63% rename from Modules/Dashboard/Dash/Media/CoverVisualizer.qml rename to Modules/Dashboard/Media/CoverVisualizer.qml index b7476a0..171db11 100644 --- a/Modules/Dashboard/Dash/Media/CoverVisualizer.qml +++ b/Modules/Dashboard/Media/CoverVisualizer.qml @@ -11,8 +11,8 @@ Item { id: cover anchors.centerIn: parent - implicitHeight: Config.dashboard.sizes.mediaCoverArtSize - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize + implicitHeight: 200 + implicitWidth: 200 shape.shape: MaterialShape.Cookie9Sided } } diff --git a/Modules/Dashboard/Media/Details.qml b/Modules/Dashboard/Media/Details.qml new file mode 100644 index 0000000..39ba295 --- /dev/null +++ b/Modules/Dashboard/Media/Details.qml @@ -0,0 +1,194 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris +import ZShell.Components +import ZShell.Config +import qs.Components +import qs.Services +import qs.Helpers + +ColumnLayout { + id: root + + readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 + + function lengthStr(length: int): string { + if (length < 0) + return "-1:-1"; + + const hours = Math.floor(length / 3600); + const mins = Math.floor((length % 3600) / 60); + const secs = Math.floor(length % 60).toString().padStart(2, "0"); + + if (hours > 0) + return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + return `${mins}:${secs}`; + } + + spacing: Tokens.spacing.extraSmall + + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackTitle ?? "" + font.pointSize: Tokens.font.size.large + elide: Text.ElideRight + animate: true + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackArtist || qsTr("Unknown artist") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + elide: Text.ElideRight + animate: true + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackAlbum || qsTr("Unknown album") + color: Colors.palette.m3secondary + font.pointSize: Tokens.font.size.normal + elide: Text.ElideRight + animate: true + } + + RowLayout { + Layout.topMargin: Tokens.spacing.extraLargeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.small + + TextMetrics { + id: timeMetrics + + text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" + font.pointSize: Tokens.font.size.normal + } + + CustomText { + id: positionLabel + + Layout.preferredWidth: timeMetrics.width + text: root.lengthStr(Players.active?.position ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + + CustomSlider { + id: positionSlider + + Layout.fillWidth: true + value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 + enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength + wavy: true + animateWave: Players.active?.isPlaying ?? false + waveFrequency: 5 + waveDuration: 2000 + interactionOnMove: false + onInteraction: value => { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + + Binding { + target: positionLabel + property: "text" + value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) + when: positionSlider.dragging + } + } + + CustomText { + Layout.preferredWidth: timeMetrics.width + text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + } + + ButtonRow { + Layout.topMargin: Tokens.spacing.largeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + IconButton { + type: IconButton.Tonal + icon: "shuffle" + isRound: true + shapeMorph: true + checked: Players.active?.shuffle ?? false + font.pointSize: Tokens.font.size.large + enabled: Players.active?.shuffleSupported + onClicked: Players.active.shuffle = !Players.active?.shuffle + implicitWidth: Math.round(implicitHeight * 0.9) + } + + IconButton { + id: previousBtn + + type: IconButton.Tonal + icon: "skip_previous" + isRound: true + shapeMorph: true + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canGoPrevious + onClicked: Players.active?.previous() + } + + IconButton { + id: playPauseBtn + + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + isRound: true + shapeMorph: true + fillWidth: true + checked: Players.active?.isPlaying ?? false + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() + } + + IconButton { + id: nextBtn + + type: IconButton.Tonal + icon: "skip_next" + isRound: true + shapeMorph: true + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canGoNext + onClicked: Players.active?.next() + } + + IconButton { + type: IconButton.Tonal + icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" + isRound: true + shapeMorph: true + checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist + font.pointSize: Tokens.font.size.large + enabled: Players.active?.loopSupported + onClicked: { + const state = Players.active.loopState; + if (state === MprisLoopState.None) + Players.active.loopState = MprisLoopState.Track; + else if (state === MprisLoopState.Track) + Players.active.loopState = MprisLoopState.Playlist; + else + Players.active.loopState = MprisLoopState.None; + } + implicitWidth: Math.round(implicitHeight * 0.9) + } + } +} diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml new file mode 100644 index 0000000..a90aaa2 --- /dev/null +++ b/Modules/Dashboard/Media/LyricList.qml @@ -0,0 +1,317 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Effects +import qs.Services +import qs.Helpers + +Item { + id: root + + // Funny binding hack to make lyrics update + readonly property var _: { + const p = Players.active; + if (p) + Lyrics.setTrack(p.trackArtist, p.trackTitle, p.trackAlbum, p.length); + else + Lyrics.clearTrack(); + } + + readonly property real fadeAmount: 0.1 + property list lyricList: Lyrics.lyrics + + layer.enabled: true + layer.effect: Mask { + maskSource: mask + + Rectangle { + id: mask + + layer.enabled: true + visible: false + implicitWidth: root.width + implicitHeight: root.height + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + color: Qt.alpha("black", 0) + position: 0 + } + GradientStop { + color: Qt.alpha("black", 1) + position: root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 1) + position: 1 - root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 0) + position: 1 + } + } + } + } + + state: { + if (Lyrics.hasLyrics) + return "hasLyrics"; + if (Lyrics.loading) + return "loading"; + return "noLyrics"; + } + + states: [ + State { + name: "loading" + + PropertyChanges { + loadingIndicator.opacity: 1 + lyrics.opacity: 0 + noLyrics.opacity: 0 + } + }, + State { + name: "hasLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 1 + noLyrics.opacity: 0 + } + }, + State { + name: "noLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 0 + noLyrics.opacity: 1 + } + } + ] + + transitions: [ + Transition { + from: "loading" + + SequentialAnimation { + Anim { + target: loadingIndicator + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [lyrics, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "noLyrics" + + SequentialAnimation { + Anim { + target: noLyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, lyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Loader { + id: loadingIndicator + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.large + + CustomRect { + Layout.alignment: Qt.AlignHCenter + implicitWidth: shape.implicitSize + Tokens.padding.medium * 2 + implicitHeight: shape.implicitSize + Tokens.padding.medium * 2 + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: shape + + anchors.centerIn: parent + implicitSize: Math.round(300 / 5) + containsIcon: true // This removes the pentagon, which is not centered + } + } + + CustomText { + text: qsTr("Loading lyrics...") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Loader { + id: noLyrics + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "sentiment_sad" + font.pointSize: Tokens.font.size.extraLarge + color: Colors.palette.m3outline + } + + CustomText { + text: qsTr("No lyrics found") + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.normal + } + } + } + + CustomListView { + id: lyrics + + anchors.fill: parent + anchors.topMargin: parent.height * root.fadeAmount / 2 + anchors.bottomMargin: parent.height * root.fadeAmount / 2 + + displayMarginBeginning: anchors.topMargin + displayMarginEnd: anchors.bottomMargin + + model: root.lyricList + Component.onCompleted: { + currentIndex = Qt.binding(() => { + model; // Force update when lyrics change + return Lyrics.indexForTime(Players.active?.position ?? 0); + }); + positionViewAtIndex(currentIndex, ListView.Center); + } + onModelChanged: Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center)) + + highlightRangeMode: ListView.ApplyRange + highlightMoveDuration: Tokens.anim.durations.large + highlightMoveVelocity: -1 + preferredHighlightBegin: (height - (currentItem?.implicitHeight ?? 0)) / 2 + preferredHighlightEnd: (height + (currentItem?.implicitHeight ?? 0)) / 2 + + spacing: Tokens.spacing.small + opacity: 0 + enabled: opacity > 0 + + delegate: CustomText { + id: lyric + + required property string modelData + required property int index + property real effectScale: ListView.isCurrentItem ? 1 : 0 + + anchors.left: lyrics.contentItem.left + anchors.right: lyrics.contentItem.right + + text: modelData || ". . ." + color: ListView.isCurrentItem ? Colors.palette.m3primary : mouse.containsMouse ? Colors.palette.m3onSurface : Colors.palette.m3outline + font.pointSize: Tokens.font.size.medium + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + layer.enabled: effectScale > 0 + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: Colors.palette.m3primary + shadowOpacity: 0.5 * lyric.effectScale + shadowBlur: 0.6 * lyric.effectScale + blur: 0.4 * lyric.effectScale + } + + Behavior on effectScale { + Anim { + type: Anim.SlowEffects + } + } + + MouseArea { + id: mouse + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + const p = Players.active; + if (p) + p.position = Lyrics.timeForIndex(lyric.index); + } + } + } + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + + Behavior on lyricList { + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + PropertyAction {} + Anim { + target: lyrics + property: "opacity" + to: 1 + type: Anim.SlowEffects + } + } + } +} diff --git a/Modules/Dashboard/Media/LyricsAndSelector.qml b/Modules/Dashboard/Media/LyricsAndSelector.qml new file mode 100644 index 0000000..453ed2c --- /dev/null +++ b/Modules/Dashboard/Media/LyricsAndSelector.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Services + +Item { + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium + + RowLayout { + Layout.bottomMargin: -Tokens.spacing.medium + spacing: Tokens.spacing.medium + z: 1 + + MaterialIcon { + Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) + text: "lyrics" + font.pointSize: Tokens.font.size.large + } + + CustomText { + Layout.fillWidth: true + text: qsTr("Lyrics") + font.pointSize: Tokens.font.size.large + } + + LyricsInfo {} + } + + LyricList { + Layout.fillWidth: true + Layout.fillHeight: true + } + + SplitButton { + Layout.alignment: Qt.AlignHCenter + + type: SplitButton.Tonal + enabled: Players.list.length + active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData + + menuItems: playerList.instances + fallbackIcon: "music_off" + fallbackText: qsTr("No players", "no media players active") + + minLeftWidth: layout.width - expandBtn.implicitWidth - spacing + label.Layout.maximumWidth: minLeftWidth - iconLabel.implicitWidth - textRow.spacing - textRow.anchors.horizontalCenterOffset / 2 - horizontalPadding * 2 + label.elide: Text.ElideRight + + stateLayer.enabled: true + menuOnTop: true + + Variants { + id: playerList + + model: Players.list + + PlayerItem {} + } + } + } + + component PlayerItem: MenuItem { + required property MprisPlayer modelData + + icon: modelData === Players.active ? "check" : "" + text: Players.getIdentity(modelData) + activeIcon: "animated_images" + } +} diff --git a/Modules/Dashboard/Media/LyricsInfo.qml b/Modules/Dashboard/Media/LyricsInfo.qml new file mode 100644 index 0000000..2e4437c --- /dev/null +++ b/Modules/Dashboard/Media/LyricsInfo.qml @@ -0,0 +1,212 @@ +import QtQuick +import QtQuick.Layouts +import ZShell +import ZShell.Blobs +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +Item { + id: root + + property bool open + readonly property real padding: Tokens.padding.large + + implicitWidth: btn.implicitWidth * 0.9 + implicitHeight: btn.implicitHeight * 0.9 + + BlobGroup { + id: blobGroup + + color: Colors.palette.m3surfaceContainerHighest + smoothing: Tokens.rounding.medium + cornerFill: false + + Behavior on color { + CAnim {} + } + } + + BlobRect { + id: btnRect + + anchors.fill: parent + anchors.margins: !btn.pressed && btn.containsMouse ? -Tokens.padding.extraSmall : 0 + group: blobGroup + radius: Tokens.rounding.medium + + Behavior on anchors.margins { + Anim {} + } + } + + BlobRect { + id: rect + + anchors.right: parent.right + anchors.top: parent.top + + implicitWidth: parent.width + implicitHeight: parent.height + + group: blobGroup + radius: Tokens.rounding.medium + deformScale: 0.00001 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + rect.anchors.rightMargin: root.width - Tokens.spacing.small + rect.anchors.topMargin: -Tokens.padding.medium + rect.implicitWidth: Math.max(layout.implicitWidth, placeholder.implicitWidth) + root.padding * 2 + rect.implicitHeight: Math.max(layout.implicitHeight, placeholder.implicitHeight) + root.padding * 2 + content.opacity: 1 + } + } + + transitions: Transition { + Anim { + properties: "rightMargin,implicitWidth" + } + Anim { + properties: "topMargin,implicitHeight" + easing: Tokens.anim.expressiveFastSpatial + } + Anim { + property: "opacity" + type: Anim.DefaultEffects + } + } + + Behavior on implicitWidth { + Anim {} + } + + Behavior on implicitHeight { + Anim {} + } + + Item { + id: content + + anchors.fill: parent + clip: true + opacity: 0 + state: Lyrics.loading || !Lyrics.hasLyrics ? "" : "hasLyrics" + + states: State { + name: "hasLyrics" + + PropertyChanges { + layout.opacity: 1 + placeholder.opacity: 0 + } + } + + transitions: [ + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: layout + property: "opacity" + type: Anim.FastEffects + } + Anim { + target: placeholder + property: "opacity" + type: Anim.DefaultEffects + } + } + }, + Transition { + to: "hasLyrics" + + SequentialAnimation { + Anim { + target: placeholder + property: "opacity" + type: Anim.FastEffects + } + Anim { + target: layout + property: "opacity" + type: Anim.DefaultEffects + } + } + } + ] + + ColumnLayout { + id: layout + + anchors.centerIn: parent + spacing: Tokens.spacing.extraSmall + opacity: 0 + + CustomText { + // TRANSLATORS: %1 = lyrics backend name, e.g. LRCLIB + text: qsTr("Backend: %1").arg(ZUtils.enumToString(Lyrics, "backend")) + color: Colors.palette.m3onSurfaceVariant + animate: true + } + + CustomText { + Layout.maximumWidth: 1000 / 2 + // TRANSLATORS: %1/%2/%3 = matched track title, artist and album + text: qsTr("Selected candidate: %1 | %2 | %3").arg(Lyrics.selectedCandidate.title).arg(Lyrics.selectedCandidate.artist).arg(Lyrics.selectedCandidate.album) + color: Colors.palette.m3onSurfaceVariant + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + animate: true + } + + CustomText { + // TRANSLATORS: %1 = lyric timing offset; ms is the millisecond unit, leave untranslated + text: qsTr("Offset: %1 ms").arg(Lyrics.offset) + color: Colors.palette.m3onSurfaceVariant + animate: true + } + } + + Item { + id: placeholder + + anchors.centerIn: parent + implicitWidth: placeholderText.implicitWidth + implicitHeight: placeholderText.implicitHeight + + CustomText { + id: placeholderText + + text: Lyrics.loading ? qsTr("Loading...") : qsTr("No lyrics found") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + animate: true + } + } + } + } + + MouseArea { + id: btn + + anchors.centerIn: parent + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Tokens.padding.extraSmall * 2 + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: root.open = !root.open + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "more_vert" + font.pointSize: Tokens.font.size.large + } + } +} diff --git a/Modules/Dashboard/Tabs.qml b/Modules/Dashboard/Tabs.qml index 8a0bccf..ce04b93 100644 --- a/Modules/Dashboard/Tabs.qml +++ b/Modules/Dashboard/Tabs.qml @@ -1,23 +1,24 @@ pragma ComponentBehavior: Bound -import qs.Components -import ZShell.Config -import qs.Helpers -import qs.Modules -import Quickshell -import Quickshell.Widgets import QtQuick -import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Templates +import Quickshell +import ZShell.Config +import qs.Components import qs.Services Item { id: root - readonly property alias count: bar.count required property real nonAnimWidth - required property PersistentProperties state + required property ScreenState screenState + required property var tabs - implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + readonly property alias count: bar.count + readonly property int tabIndicatorSpacing: Tokens.spacing.small + + implicitHeight: bar.implicitHeight + bar.anchors.topMargin + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight TabBar { id: bar @@ -25,85 +26,99 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top + anchors.topMargin: root.tabIndicatorSpacing + + currentIndex: root.screenState.dashboardTab + onCurrentIndexChanged: root.screenState.dashboardTab = currentIndex + + implicitHeight: contentHeight background: null - currentIndex: root.state.currentTab + contentItem: RowLayout { + spacing: 0 - onCurrentIndexChanged: root.state.currentTab = currentIndex - - Tab { - iconName: "dashboard" - text: qsTr("Dashboard") + Repeater { + model: bar.contentModel + } } - Tab { - iconName: "queue_music" - text: qsTr("Media") - } + Repeater { + model: ScriptModel { + values: root.tabs + } - Tab { - iconName: "speed" - text: qsTr("Performance") - } + delegate: Tab { + required property var modelData - Tab { - iconName: "cloud" - text: qsTr("Weather") + iconName: modelData.iconName + text: modelData.text + } } - - // Tab { - // iconName: "workspaces" - // text: qsTr("Workspaces") - // } } Item { id: indicator anchors.top: bar.bottom - clip: true - implicitHeight: 40 - implicitWidth: bar.currentItem.implicitWidth + anchors.topMargin: root.tabIndicatorSpacing + + implicitWidth: { + const tab = bar.currentItem; + if (tab) + return tab.implicitWidth; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width; + } + implicitHeight: 3 + x: { const tab = bar.currentItem; const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; - return width * tab.TabBar.index + (width - tab.implicitWidth) / 2; + const tabWidth = tab?.implicitWidth ?? width; + return width * bar.currentIndex + (width - tabWidth) / 2; + } + + clip: true + + CustomRect { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight * 2 + + color: Colors.palette.m3primary + radius: Tokens.rounding.full + } + + Behavior on x { + Anim {} } Behavior on implicitWidth { - Anim { - } - } - Behavior on x { - Anim { - } - } - - CustomRect { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - color: Colors.palette.m3primary - implicitHeight: parent.implicitHeight * 2 - radius: Tokens.rounding.full + Anim {} } } CustomRect { id: separator + anchors.top: indicator.bottom anchors.left: parent.left anchors.right: parent.right - anchors.top: indicator.bottom - color: Colors.palette.m3outlineVariant + implicitHeight: 1 + color: Colors.palette.m3outlineVariant } component Tab: TabButton { id: tab - readonly property bool current: TabBar.tabBar.currentItem === this required property string iconName + readonly property bool current: TabBar.tabBar.currentItem === this + Layout.fillWidth: true + Layout.preferredWidth: 1 // Uniform width across all tabs + implicitWidth: implicitContentWidth + implicitHeight: implicitContentHeight background: null contentItem: CustomMouseArea { @@ -111,117 +126,47 @@ Item { function onWheel(event: WheelEvent): void { if (event.angleDelta.y < 0) - root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1); + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, bar.count - 1); else if (event.angleDelta.y > 0) - root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); } - cursorShape: Qt.PointingHandCursor - implicitHeight: icon.height + label.height implicitWidth: Math.max(icon.width, label.width) + implicitHeight: icon.height + label.height - onPressed: event => { - root.state.currentTab = tab.TabBar.index; + hoverEnabled: true + cursorShape: Qt.PointingHandCursor - const stateY = stateWrapper.y; - rippleAnim.x = event.x; - rippleAnim.y = event.y - stateY; + onPressed: root.screenState.dashboardTab = tab.TabBar.index - const dist = (ox, oy) => ox * ox + oy * oy; - rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), dist(event.x, stateWrapper.height - event.y), dist(width - event.x, event.y + stateY), dist(width - event.x, stateWrapper.height - event.y))); - - rippleAnim.restart(); - } - - SequentialAnimation { - id: rippleAnim - - property real radius - property real x - property real y - - PropertyAction { - property: "x" - target: ripple - value: rippleAnim.x - } - - PropertyAction { - property: "y" - target: ripple - value: rippleAnim.y - } - - PropertyAction { - property: "opacity" - target: ripple - value: 0.08 - } - - Anim { - from: 0 - properties: "implicitWidth,implicitHeight" - target: ripple - to: rippleAnim.radius * 2 - } - - Anim { - easing.type: Easing.BezierSpline - property: "opacity" - target: ripple - to: 0 - } - } - - ClippingRectangle { - id: stateWrapper + StateLayer { + id: stateLayer + anchors.fill: undefined anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - color: "transparent" - implicitHeight: parent.height + 8 * 2 - radius: Tokens.rounding.small + implicitHeight: parent.height + root.tabIndicatorSpacing * 2 - CustomRect { - id: stateLayer - - anchors.fill: parent - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0 - - Behavior on opacity { - Anim { - } - } - } - - CustomRect { - id: ripple - - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - opacity: 0 - radius: Tokens.rounding.full - - transform: Translate { - x: -ripple.width / 2 - y: -ripple.height / 2 - } - } + radius: Tokens.rounding.medium + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface + onClicked: root.screenState.dashboardTab = tab.TabBar.index } MaterialIcon { id: icon - anchors.bottom: label.top anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: label.top + + text: tab.iconName color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant fill: tab.current ? 1 : 0 - font.pointSize: 18 - text: tab.iconName + font.pointSize: Tokens.font.size.large Behavior on fill { Anim { + type: Anim.DefaultEffects } } } @@ -229,10 +174,11 @@ Item { CustomText { id: label - anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + anchors.bottom: parent.bottom + text: tab.text + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant } } } diff --git a/Modules/Dashboard/Weather/Graphs.qml b/Modules/Dashboard/Weather/Graphs.qml new file mode 100644 index 0000000..ff0e0df --- /dev/null +++ b/Modules/Dashboard/Weather/Graphs.qml @@ -0,0 +1,145 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services +import qs.Helpers + +RowLayout { + id: root + + spacing: Tokens.spacing.largeIncreased + + readonly property list forecastToday: Weather.hourlyForecast.slice(0, 25) + + property var now: new Date() + property bool useTwelveHourClock: Config.services.useTwelveHourClock + property bool curveSmoothing: true + property real smoothing: 2 + property real markerGap: Math.min(Math.max(Tokens.spacing.medium / 2, 0), 6) + + readonly property real markerX: { + const d = root.now; + return d.getHours() + d.getMinutes() / 60; + } + + function formatHour(h) { + if (root.useTwelveHourClock) { + const hr = h % 12 === 0 ? 12 : h % 12; + return hr + (h < 12 ? " AM" : " PM"); + } + return h; + } + + Timer { + interval: 60000 + repeat: true + running: true + + onTriggered: root.now = new Date() + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.width / 2 + + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "device_thermostat" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3secondary + } + + CustomText { + text: qsTr("Temperature") + } + } + + Item { + id: temperature + + implicitHeight: 150 + Layout.fillWidth: true + + LineGraph { + id: tempGraph + + anchors.fill: parent + values: root.forecastToday.map(e => e.tempC) + verticalLineStep: 3 + xSteps: 24 + horizontalLineStep: 10 + minHorizontalLines: 4 + yPaddingTop: horizontalLineStep + yPaddingBottom: horizontalLineStep + xLabel: i => root.formatHour(i) + yLabel: v => Math.round(v) + "°" + + markerX: root.markerX + smoothing: root.smoothing + curveSmoothing: root.curveSmoothing + markerGap: root.markerGap + + lineColor: Colors.palette.m3secondary + gridColor: Colors.palette.m3onSurface + markerColor: Colors.palette.m3secondary + labelColor: Colors.palette.m3onSurfaceVariant + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.width / 2 + + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "water_drop" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3tertiary + } + + CustomText { + text: qsTr("Chance of precipitation") + } + } + + Item { + id: precipitation + + Layout.fillWidth: true + implicitHeight: 150 + + LineGraph { + id: graph + + anchors.fill: parent + + values: root.forecastToday.map(e => e?.precipChance ?? null) + xSteps: 24 + yMin: 0 + yMax: 100 + yPaddingTop: 20 + verticalLineStep: 3 + lastXLine: false + horizontalLineStep: 20 + xLabel: i => root.formatHour(i) + yLabel: v => v + "%" + + markerX: root.markerX + smoothing: root.smoothing + curveSmoothing: root.curveSmoothing + markerGap: root.markerGap + + lineColor: Colors.palette.m3tertiary + gridColor: Colors.palette.m3onSurface + markerColor: Colors.palette.m3tertiary + labelColor: Colors.palette.m3onSurfaceVariant + } + } + } +} diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml new file mode 100644 index 0000000..fbd44bc --- /dev/null +++ b/Modules/Dashboard/WeatherTab.qml @@ -0,0 +1,259 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Modules.Dashboard.Weather +import qs.Services +import qs.Helpers + +Item { + id: root + + readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null + + implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 + implicitHeight: layout.implicitHeight + Component.onCompleted: Weather.reload() + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Tokens.spacing.medium + + RowLayout { + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large + Layout.fillWidth: true + + Column { + spacing: Tokens.spacing.extraSmall + + CustomText { + text: Weather.city || qsTr("Loading...") + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3onSurface + } + + CustomText { + text: new Date().toLocaleDateString(Qt.locale("en_US"), "dddd, MMMM d") + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + } + } + + Item { + Layout.fillWidth: true + } + + Row { + spacing: Tokens.spacing.largeIncreased + + WeatherStat { + icon: "wb_twilight" + label: qsTr("Sunrise") + value: Weather.sunrise + colour: Colors.palette.m3tertiary + } + + WeatherStat { + icon: "bedtime" + label: qsTr("Sunset") + value: Weather.sunset + colour: Colors.palette.m3tertiary + } + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: bigInfoRow.implicitHeight + bigInfoRow.anchors.margins * 2 + + radius: Tokens.rounding.large + color: Colors.tPalette.m3surfaceContainer + + Graphs { + id: bigInfoRow + anchors.fill: parent + anchors.margins: Tokens.padding.medium + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + DetailCard { + icon: Weather.icon + label: qsTr("Temperature") + value: Weather.temp + colour: Colors.palette.m3primary + } + DetailCard { + icon: "thermostat" + label: qsTr("Feels like", "apparent temperature") + value: Weather.feelsLike + colour: Colors.palette.m3primary + } + DetailCard { + icon: "water_drop" + label: qsTr("Humidity") + value: Strings.percent(Weather.humidity) + colour: Colors.palette.m3secondary + } + DetailCard { + icon: "air" + label: qsTr("Wind") + value: Weather.windSpeed ? qsTr("%1 km/h").arg(Weather.windSpeed) : "--" + colour: Colors.palette.m3tertiary + } + } + + CustomText { + Layout.topMargin: Tokens.spacing.medium + Layout.leftMargin: Tokens.padding.medium + visible: forecastRepeater.count > 0 + text: qsTr("7-day forecast") + font.pointSize: Tokens.font.size.normal + color: Colors.palette.m3onSurface + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + Repeater { + id: forecastRepeater + + model: Weather.forecast + + CustomRect { + id: forecastItem + + required property int index + required property var modelData + + Layout.fillWidth: true + implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.medium * 2 + + radius: Tokens.rounding.large + color: Colors.tPalette.m3surfaceContainer + + ColumnLayout { + id: forecastItemColumn + + anchors.centerIn: parent + spacing: Tokens.spacing.small + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.index === 0 ? qsTr("Today", "forecast column") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "ddd") + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3primary + } + + CustomText { + Layout.topMargin: -Tokens.spacing.extraSmall + Layout.alignment: Qt.AlignHCenter + text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "MMM d") + font.pointSize: Tokens.font.size.small + opacity: 0.7 + color: Colors.palette.m3onSurfaceVariant + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.modelData.icon + font.pointSize: Tokens.font.size.extraLarge + color: Colors.palette.m3secondary + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: { + const min = Weather.formatTemp(forecastItem.modelData.minTempC, true); + const max = Weather.formatTemp(forecastItem.modelData.maxTempC, true); + return qsTr("%1 / %2", "min/max temperature").arg(min).arg(max); + } + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3tertiary + } + } + } + } + } + } + + component DetailCard: CustomRect { + id: detailRoot + + property string icon + property string label + property string value + property color colour + + Layout.fillWidth: true + Layout.preferredHeight: 60 + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + Row { + anchors.centerIn: parent + spacing: Tokens.spacing.medium + + MaterialIcon { + text: detailRoot.icon + color: detailRoot.colour + font.pointSize: Tokens.font.size.large + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + text: detailRoot.label + font.pointSize: Tokens.font.size.small + opacity: 0.7 + horizontalAlignment: Text.AlignLeft + } + CustomText { + text: detailRoot.value + font.pointSize: Tokens.font.size.small + horizontalAlignment: Text.AlignLeft + } + } + } + } + + component WeatherStat: Row { + id: weatherStat + + property string icon + property string label + property string value + property color colour + + spacing: Tokens.spacing.small + + MaterialIcon { + text: weatherStat.icon + font.pointSize: Tokens.font.size.extraLarge + color: weatherStat.colour + } + + Column { + CustomText { + text: weatherStat.label + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + } + CustomText { + text: weatherStat.value + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurface + } + } + } +} diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index c78a97f..b2e0f4b 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -8,24 +8,13 @@ import ZShell.Config Item { id: root - readonly property PersistentProperties dashState: PersistentProperties { - property date currentDate: new Date() - property int currentTab - - reloadableId: "dashboardState" - } readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 required property real offsetScale - required property string position - readonly property bool shouldBeActive: root.visibilities.dashboard && Config.dashboard.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: root.screenState.dashboard && Config.dashboard.enabled + required property ScreenState screenState - anchors.bottom: position === "bottom" ? parent.bottom : undefined - anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 - anchors.left: position === "left" ? parent.left : undefined - anchors.leftMargin: position === "left" ? (-implicitWidth - 5) * offsetScale : 0 - anchors.top: position === "top" ? parent.top : undefined - anchors.topMargin: position === "top" ? (-implicitHeight - 5) * offsetScale : 0 + anchors.top: parent.top + anchors.topMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 854 opacity: 1 - offsetScale @@ -35,15 +24,11 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.bottom: root.position === "top" ? parent.bottom : undefined - anchors.horizontalCenter: root.position !== "left" ? parent.horizontalCenter : undefined - anchors.right: root.position !== "left" ? undefined : parent.right - anchors.top: root.position === "bottom" ? parent.top : undefined - anchors.verticalCenter: root.position === "left" ? undefined : parent.verticalCenter + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter sourceComponent: Content { - dashState: root.dashState - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml index 88e9bc0..24f0217 100644 --- a/Modules/DesktopIcons/BackgroundContextMenu.qml +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -8,6 +8,7 @@ import ZShell.Config import qs.Paths import qs.Helpers import qs.Services +import qs.Services Item { id: root @@ -97,8 +98,8 @@ Item { anchors.fill: parent onClicked: { - const visibilities = Visibilities.getForActive(); - visibilities.settings = true; + const state = ShellState.forActive(); + state.settings = true; root.close() } } diff --git a/Modules/Dock/Content.qml b/Modules/Dock/Content.qml index 5fda10a..1cb257c 100644 --- a/Modules/Dock/Content.qml +++ b/Modules/Dock/Content.qml @@ -27,7 +27,7 @@ Item { property var pendingCommitIds: [] readonly property int rounding: Tokens.rounding.largeIncreased required property ShellScreen screen - required property PersistentProperties visibilities + required property ScreenState screenState property var visualIds: [] function beginVisualDrag(appId, modelData, item) { @@ -198,7 +198,7 @@ Item { anchors.centerIn: parent appListRoot: root appToplevel: modelData - visibilities: root.visibilities + screenState: root.screenState visible: root.draggedAppId !== slot.appId } @@ -314,7 +314,7 @@ Item { appListRoot: root appToplevel: root.draggedModelData enabled: false - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Dock/Parts/DockAppButton.qml b/Modules/Dock/Parts/DockAppButton.qml index ab9164b..d6ca2f0 100644 --- a/Modules/Dock/Parts/DockAppButton.qml +++ b/Modules/Dock/Parts/DockAppButton.qml @@ -19,7 +19,7 @@ CustomRect { property real iconSize: implicitHeight - 20 readonly property bool isSeparator: appToplevel?.appId === "__dock_separator__" property int lastFocused: -1 - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: Config.dock.height implicitWidth: isSeparator ? 1 : implicitHeight @@ -62,12 +62,12 @@ CustomRect { onClicked: { if (appToplevel?.toplevels.length === 0) { root.desktopEntry?.execute(); - root.visibilities.dock = false; + root.screenState.dock = false; return; } lastFocused = (lastFocused + 1) % appToplevel?.toplevels.length; appToplevel?.toplevels[lastFocused].activate(); - root.visibilities.dock = false; + root.screenState.dock = false; } } diff --git a/Modules/Dock/Wrapper.qml b/Modules/Dock/Wrapper.qml index a9897a5..df41f2e 100644 --- a/Modules/Dock/Wrapper.qml +++ b/Modules/Dock/Wrapper.qml @@ -12,8 +12,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.dock && Config.dock.enable - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.dock && Config.dock.enable + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight @@ -39,7 +39,7 @@ Item { sourceComponent: Content { panels: root.panels screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Drawing/Content.qml b/Modules/Drawing/Content.qml index 5fe6eea..c689836 100644 --- a/Modules/Drawing/Content.qml +++ b/Modules/Drawing/Content.qml @@ -12,7 +12,7 @@ Item { readonly property var colors1: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4"] readonly property var colors2: ["#3b82f6", "#a855f7", "#ec4899", "#ffffff", "#000000"] required property var drawing - required property var visibilities + required property ScreenState screenState required property Wrapper wrapper function syncFromPenColor() { @@ -20,17 +20,17 @@ Item { return; if (!saturationSlider.pressed) - saturationSlider.value = drawing.drawingState.penColor.hsvSaturation; + saturationSlider.value = drawing.screenState.penColor.hsvSaturation; if (!brightnessSlider.pressed) - brightnessSlider.value = drawing.drawingState.penColor.hsvValue; + brightnessSlider.value = drawing.screenState.penColor.hsvValue; } function updatePenColorFromHsv() { if (!drawing) return; - drawing.drawingState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.drawingState.penColor.a); + drawing.screenState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.screenState.penColor.a); } implicitHeight: column.height + Tokens.padding.small * 2 @@ -43,7 +43,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing.drawingState + target: root.drawing.screenState } Column { @@ -133,9 +133,9 @@ Item { multiplier: 1 orientation: Qt.Horizontal to: 45 - value: root.drawing.drawingState.penWidth + value: root.drawing.screenState.penWidth - onMoved: root.drawing.drawingState.penWidth = value + onMoved: root.drawing.screenState.penWidth = value } ButtonRow { @@ -153,7 +153,7 @@ Item { shapeMorph: true text: "Exit" - onClicked: root.visibilities.isDrawing = false + onClicked: root.screenState.isDrawing = false } IconTextButton { @@ -203,6 +203,6 @@ Item { shapeMorph: true shapeMorphExpansion: pressed ? 12 : 0 - onClicked: root.drawing.drawingState.penColor = modelData + onClicked: root.drawing.screenState.penColor = modelData } } diff --git a/Modules/Drawing/Wrapper.qml b/Modules/Drawing/Wrapper.qml index 4389b06..e7835d1 100644 --- a/Modules/Drawing/Wrapper.qml +++ b/Modules/Drawing/Wrapper.qml @@ -15,8 +15,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 property bool pinned: false required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.isDrawing - required property var visibilities + readonly property bool shouldBeActive: screenState.isDrawing + required property ScreenState screenState function collapse(): void { if (pinned) @@ -98,7 +98,7 @@ Item { } sourceComponent: Content { drawing: root.drawing - visibilities: root.visibilities + screenState: root.screenState wrapper: root } } diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 4a0e862..717edf6 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -15,7 +15,7 @@ CustomListView { property string displayText readonly property string requestedState: stateForText(search.text) required property SearchBar search - required property PersistentProperties visibilities + required property ScreenState screenState function resultsForText(text: string): var { switch (stateForText(text)) { @@ -44,7 +44,7 @@ CustomListView { } function syncDisplayText(): void { - if (visibilities.launcher && requestedState === displayState) + if (screenState.launcher && requestedState === displayState) displayText = search.text; } @@ -55,7 +55,7 @@ CustomListView { preferredHighlightBegin: 0 preferredHighlightEnd: height spacing: Tokens.spacing.small - state: visibilities.launcher ? requestedState : displayState + state: screenState.launcher ? requestedState : displayState verticalLayoutDirection: ListView.BottomToTop CustomScrollBar.vertical: CustomScrollBar { @@ -236,7 +236,7 @@ CustomListView { id: appItem AppItem { - visibilities: root.visibilities + screenState: root.screenState } } @@ -277,6 +277,6 @@ CustomListView { root.syncDisplayText(); } - target: root.visibilities + target: root.screenState } } diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index e1aeb80..3db4299 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -13,14 +13,14 @@ CarouselView { readonly property var screen: QsWindow.window?.screen required property SearchBar search readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) - required property var visibilities + required property ScreenState screenState currentIndex: { const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); return idx >= 0 ? idx : 0; } delegateProperties: ({ - visibilities: root.visibilities + screenState: root.screenState }) focalWidth: Config.launcher.sizes.wallpaperWidth glideDuration: 250 @@ -32,7 +32,7 @@ CarouselView { return 0; const barMargins = Config.bar.border * 2; let margins = Math.round(screen.width / 6); - if (visibilities.utilities || visibilities.sidebar) + if (screenState.utilities || screenState.sidebar) margins = Config.sidebar.sizes.width; return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; } diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 9bb4338..4bfaf49 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -15,7 +15,7 @@ Item { readonly property int padding: Tokens.padding.largeIncreased / 4 required property var panels readonly property int rounding: Tokens.rounding.largeIncreased - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: search.height + listWrapper.height + padding * 2 implicitWidth: listWrapper.width + padding * 2 @@ -38,7 +38,7 @@ Item { panels: root.panels rounding: root.rounding search: search - visibilities: root.visibilities + screenState: root.screenState } } @@ -55,7 +55,7 @@ Item { Component.onCompleted: forceActiveFocus() Keys.onDownPressed: list.currentList?.decrementCurrentIndex() - Keys.onEscapePressed: root.visibilities.launcher = false + Keys.onEscapePressed: root.screenState.launcher = false Keys.onPressed: event => { if (event.modifiers & Qt.ControlModifier) { if (event.key === Qt.Key_J) { @@ -81,7 +81,7 @@ Item { if (Colors.scheme === "dynamic" && currentItem.modelData.path !== Wallpapers.actualCurrent) Wallpapers.previewColorLock = true; Wallpapers.setWallpaper(currentItem.modelData.path); - root.visibilities.launcher = false; + root.screenState.launcher = false; } else if (text.startsWith(Config.launcher.actionPrefix)) { if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) currentItem.onClicked(); @@ -89,18 +89,18 @@ Item { currentItem.modelData.onClicked(list.currentList); } else { Apps.launch(currentItem.modelData); - root.visibilities.launcher = false; + root.screenState.launcher = false; } } } Connections { function onLauncherChanged(): void { - if (!root.visibilities.launcher) + if (!root.screenState.launcher) search.text = ""; } - target: root.visibilities + target: root.screenState } } } diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 0081914..76bfeed 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -20,7 +20,7 @@ Item { required property int rounding required property SearchBar search readonly property bool showWallpapers: search.text.startsWith(`${Config.launcher.actionPrefix}wallpaper `) - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter @@ -28,7 +28,7 @@ Item { state: showWallpapers ? "wallpapers" : "apps" Behavior on implicitHeight { - enabled: root.visibilities.launcher + enabled: root.screenState.launcher Anim { duration: Tokens.anim.durations.small @@ -36,7 +36,7 @@ Item { } } Behavior on implicitWidth { - enabled: root.visibilities.launcher + enabled: root.screenState.launcher Anim { duration: Tokens.anim.durations.small @@ -99,7 +99,7 @@ Item { sourceComponent: AppList { search: root.search - visibilities: root.visibilities + screenState: root.screenState } } @@ -115,7 +115,7 @@ Item { sourceComponent: CarouselView { panels: root.panels search: root.search - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 24d46ba..1a2604a 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -12,7 +12,7 @@ Item { id: root required property DesktopEntry modelData - required property PersistentProperties visibilities + required property ScreenState screenState anchors.left: parent?.left anchors.right: parent?.right @@ -23,7 +23,7 @@ Item { onClicked: { Apps.launch(root.modelData); - root.visibilities.launcher = false; + root.screenState.launcher = false; } } diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 59da218..5a81aab 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -16,7 +16,7 @@ Item { function onClicked(): void { Quickshell.execDetached(["wl-copy", Qalculator.eval(math, false)]); - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; } anchors.left: parent?.left @@ -81,7 +81,7 @@ Item { onClicked: { Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]); - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml new file mode 100644 index 0000000..ab3b3ba --- /dev/null +++ b/Modules/Launcher/Items/FilesItem.qml @@ -0,0 +1,141 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Modules.Launcher.Services +import qs.Services + +Item { + id: root + + required property var list + required property var modelData + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: Config.launcher.sizes.itemHeight + + function onClicked(): void { + root.list.screenState.launcher = false; + if (root.modelData?.path) + Files.launch(["xdg-open", root.modelData.path]); + } + + StateLayer { + id: sLayer + + radius: Tokens.rounding.medium + manualHoverOverride: openLocation.hovered + + onClicked: { + root.onClicked(); + } + } + + IconButton { + id: openLocation + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + icon: "open_in_new" + isRound: true + + scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4 + opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on scale { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: { + root.list.screenState.launcher = false; + if (root.modelData?.path) + Files.showInFolder(root.modelData.path); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small + anchors.margins: Tokens.padding.small + anchors.rightMargin: Tokens.padding.small + + IconImage { + id: icon + + anchors.verticalCenter: parent.verticalCenter + implicitSize: Tokens.font.size.extraLarge + source: Quickshell.iconPath(root.modelData?.icon) ?? "" + visible: !root.modelData?.isImage + } + + Loader { + active: opacity > 0 + anchors.verticalCenter: parent.verticalCenter + asynchronous: true + opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + sourceComponent: LoadingIndicator { + color: Colors.palette.m3primaryContainer + implicitSize: Tokens.font.size.extraLarge + } + } + + FadeImage { + id: image + + property int implicitSize: Tokens.font.size.extraLarge + + width: implicitSize + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + source: root.modelData?.isImage ? root.modelData?.path ?? "" : "" + visible: root.modelData?.isImage + } + + Item { + anchors.left: icon.right + anchors.leftMargin: Tokens.spacing.medium + anchors.verticalCenter: icon.verticalCenter + implicitHeight: name.implicitHeight + desc.implicitHeight + implicitWidth: parent.width - icon.width + + CustomText { + id: name + + font.pointSize: Tokens.font.size.normal + text: root.modelData?.name ?? "" + elide: Text.ElideRight + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + + CustomText { + id: desc + + anchors.top: name.bottom + color: Colors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + text: root.modelData?.path ?? "" + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + } + } +} diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 806f89e..b4a1091 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -12,7 +12,7 @@ Item { required property var modelData // FileSystemEntry-like {path, relativePath} required property real tileHeight required property real tileWidth - required property PersistentProperties visibilities + required property ScreenState screenState signal requestActivate diff --git a/Modules/Launcher/Services/Actions.qml b/Modules/Launcher/Services/Actions.qml index 80df3fc..135dbc3 100644 --- a/Modules/Launcher/Services/Actions.qml +++ b/Modules/Launcher/Services/Actions.qml @@ -42,10 +42,10 @@ Searcher { if (command[0] === "autocomplete" && command.length > 1) { list.search.text = `${Config.launcher.actionPrefix}${command[1]} `; } else if (command[0] === "setMode" && command.length > 1) { - list.visibilities.launcher = false; + list.screenState.launcher = false; Colors.setMode(command[1]); } else { - list.visibilities.launcher = false; + list.screenState.launcher = false; Quickshell.execDetached(command); } } diff --git a/Modules/Launcher/Services/SchemeVariants.qml b/Modules/Launcher/Services/SchemeVariants.qml index 08a754f..5488706 100644 --- a/Modules/Launcher/Services/SchemeVariants.qml +++ b/Modules/Launcher/Services/SchemeVariants.qml @@ -79,7 +79,7 @@ Searcher { required property string variant function onClicked(list: AppList): void { - list.visibilities.launcher = false; + list.screenState.launcher = false; Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--scheme", variant]); Config.colors.schemeType = variant; } diff --git a/Modules/Launcher/Wrapper.qml b/Modules/Launcher/Wrapper.qml index e5ae86c..1fa0c24 100644 --- a/Modules/Launcher/Wrapper.qml +++ b/Modules/Launcher/Wrapper.qml @@ -12,7 +12,7 @@ Item { property int contentHeight readonly property real maxHeight: { let max = screen.height - Tokens.spacing.largeIncreased * 2; - if (visibilities.resources && panels.resourcesWrapper.x + panels.resourcesWrapper.width > root.x) + if (screenState.resources && panels.resourcesWrapper.x + panels.resourcesWrapper.width > root.x) max -= panels.resources.nonAnimHeight; if (panels.popouts.hasCurrent) if (panels.popouts.current?.x + panels.popouts.current?.width > root.x && panels.popouts.current?.x < root.x + root.width) @@ -22,8 +22,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.launcher - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.launcher + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight @@ -57,7 +57,7 @@ Item { sourceComponent: Content { maxHeight: root.maxHeight panels: root.panels - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index 07632b3..467879b 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -60,7 +60,7 @@ CustomRect { MaterialShape { Layout.alignment: Qt.AlignHCenter - color: Qt.alpha(Colors.palette.m3primary, hour.index === 0 ? 1 : 0) + color: Qt.alpha(Colors.palette.m3primary, hour.index === Weather.currentHourIndex ? 1 : 0) implicitSize: temp.implicitHeight + Tokens.padding.small * 2 shape: MaterialShape.Cookie4Sided @@ -73,7 +73,7 @@ CustomRect { id: temp anchors.centerIn: parent - color: hour.index === 0 ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: hour.index === Weather.currentHourIndex ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface font.pointSize: Tokens.font.size.normal text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F } diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index 46477a9..bad138a 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -13,7 +13,7 @@ Item { readonly property int padding: Tokens.padding.smaller required property Item panels - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottom: parent.bottom anchors.right: parent.right @@ -27,16 +27,16 @@ Item { for (let i = 0; i < count; i++) height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; - if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || visibilities.dashboard) + if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || screenState.dashboard) return 0; - if (visibilities.osd) { + if (screenState.osd) { const h = panels.osd.y - Tokens.spacing.small * 2 - padding * 2; if (height > h) height = h; } - if (visibilities.session) { + if (screenState.session) { const h = panels.session.y - Tokens.spacing.small * 2 - padding * 2; if (height > h) height = h; diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 06f0c5d..143c6a5 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -7,8 +7,7 @@ import qs.Services Item { id: root - required property Props props - required property var visibilities + required property ScreenState screenState ColumnLayout { id: layout @@ -23,8 +22,7 @@ Item { radius: Tokens.rounding.small NotifDock { - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Notifications/Sidebar/Notif.qml b/Modules/Notifications/Sidebar/Notif.qml index 4f3c13c..ae7a016 100644 --- a/Modules/Notifications/Sidebar/Notif.qml +++ b/Modules/Notifications/Sidebar/Notif.qml @@ -15,8 +15,7 @@ CustomRect { required property bool expanded required property NotifServer.Notif modelData readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState color: { const c = root.modelData?.urgency === "critical" ? Colors.palette.m3secondaryContainer : Colors.layer(Colors.palette.m3surfaceContainerHigh, 2); @@ -137,7 +136,7 @@ CustomRect { onLinkActivated: link => { Qt.openUrlExternally(link); - root.visibilities.sidebar = false; + root.screenState.sidebar = false; } } diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml index 5cd56cc..6792807 100644 --- a/Modules/Notifications/Sidebar/NotifDock.qml +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -14,8 +14,7 @@ Item { id: root readonly property int notifCount: NotifServer.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0) - required property Props props - required property var visibilities + required property ScreenState screenState anchors.fill: parent anchors.margins: Tokens.padding.small @@ -120,8 +119,7 @@ Item { id: notifList container: view - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/NotifDockList.qml b/Modules/Notifications/Sidebar/NotifDockList.qml index 597a7ba..759e6d9 100644 --- a/Modules/Notifications/Sidebar/NotifDockList.qml +++ b/Modules/Notifications/Sidebar/NotifDockList.qml @@ -12,8 +12,7 @@ LazyListView { id: root required property Flickable container - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState anchors.left: parent?.left anchors.right: parent?.right @@ -118,8 +117,7 @@ LazyListView { container: root.container modelData: notif.modelData - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/NotifGroup.qml b/Modules/Notifications/Sidebar/NotifGroup.qml index bf6a376..faa67eb 100644 --- a/Modules/Notifications/Sidebar/NotifGroup.qml +++ b/Modules/Notifications/Sidebar/NotifGroup.qml @@ -16,7 +16,7 @@ CustomRect { readonly property list activeNotifs: notifs.filter(n => !n.closed) readonly property string appIcon: activeNotifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" required property Flickable container - readonly property bool expanded: props.expandedNotifs.includes(modelData) + readonly property bool expanded: screenState.expandedNotifs.includes(modelData) readonly property string image: activeNotifs.find(n => n.image.length > 0)?.image ?? "" required property string modelData readonly property int nonAnimHeight: { @@ -26,7 +26,7 @@ CustomRect { } readonly property int notifCount: activeNotifs.length readonly property list notifs: NotifServer.list.filter(n => n.appName === modelData) - required property Props props + required property ScreenState screenState readonly property int urgency: { if (activeNotifs.find(n => n.urgency === NotificationUrgency.Critical)) return NotificationUrgency.Critical; @@ -34,14 +34,13 @@ CustomRect { return NotificationUrgency.Normal; return NotificationUrgency.Low; } - required property PersistentProperties visibilities function toggleExpand(expand: bool): void { if (expand) { if (!expanded) - props.expandedNotifs.push(modelData); + screenState.expandedNotifs.push(modelData); } else if (expanded) { - props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } } @@ -59,7 +58,7 @@ CustomRect { Component.onDestruction: { if (notifCount === 0 && expanded) - props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } RowLayout { @@ -233,8 +232,7 @@ CustomRect { container: root.container expanded: root.expanded notifs: root.notifs - props: root.props - visibilities: root.visibilities + screenState: root.screenState onRequestToggleExpand: expand => root.toggleExpand(expand) } diff --git a/Modules/Notifications/Sidebar/NotifGroupList.qml b/Modules/Notifications/Sidebar/NotifGroupList.qml index 904a787..b047878 100644 --- a/Modules/Notifications/Sidebar/NotifGroupList.qml +++ b/Modules/Notifications/Sidebar/NotifGroupList.qml @@ -14,8 +14,7 @@ LazyListView { required property Flickable container required property bool expanded required property list notifs - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState signal requestToggleExpand(expand: bool) @@ -122,8 +121,7 @@ LazyListView { anchors.fill: parent expanded: root.expanded modelData: notif.modelData - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml deleted file mode 100644 index 21a2541..0000000 --- a/Modules/Notifications/Sidebar/Props.qml +++ /dev/null @@ -1,7 +0,0 @@ -import Quickshell - -PersistentProperties { - property list expandedNotifs: [] - - reloadableId: "sidebar" -} diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml index 6731cbe..0f90322 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml @@ -11,8 +11,8 @@ import qs.Services CustomRect { id: root - required property var props - required property PersistentProperties visibilities + required property ScreenState screenState + Layout.fillWidth: true color: Colors.tPalette.m3surfaceContainer @@ -72,7 +72,7 @@ CustomRect { } SplitButton { - active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] + active: menuItems.find(m => root.screenState.recordingMode === m.icon + m.text) ?? menuItems[0] enabled: !Recorder.running menuOnTop: true @@ -107,7 +107,7 @@ CustomRect { } ] - menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text + menu.onItemSelected: item => root.screenState.recordingMode = item.icon + item.text } } @@ -190,8 +190,7 @@ CustomRect { id: recordingList RecordingList { - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index c113f97..876c9d4 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -14,8 +14,7 @@ import qs.Services ColumnLayout { id: root - required property var props - required property PersistentProperties visibilities + required property ScreenState screenState spacing: 0 @@ -23,7 +22,7 @@ ColumnLayout { Layout.fillWidth: true cursorShape: Qt.PointingHandCursor - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded RowLayout { spacing: Tokens.spacing.small @@ -42,11 +41,11 @@ ColumnLayout { } IconButton { - icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more" + icon: root.screenState.recordingListExpanded ? "unfold_less" : "unfold_more" label.animate: true type: IconButton.Text - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded } } } @@ -57,7 +56,7 @@ ColumnLayout { Layout.fillWidth: true Layout.rightMargin: -Tokens.spacing.small clip: true - implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.props.recordingListExpanded ? 10 : 3) + implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.screenState.recordingListExpanded ? 10 : 3) CustomScrollBar.vertical: CustomScrollBar { flickable: list @@ -109,8 +108,11 @@ ColumnLayout { type: IconButton.Text onClicked: { - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + root.screenState.sidebar = false; + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]); } } @@ -119,8 +121,11 @@ ColumnLayout { type: IconButton.Text onClicked: { - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + root.screenState.sidebar = false; + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]); } } } @@ -170,11 +175,11 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignHCenter - Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0 + Layout.preferredHeight: root.screenState.recordingListExpanded ? implicitHeight : 0 color: Colors.palette.m3outline font.pointSize: Tokens.font.size.extraLarge - opacity: root.props.recordingListExpanded ? 1 : 0 - scale: root.props.recordingListExpanded ? 1 : 0 + opacity: root.screenState.recordingListExpanded ? 1 : 0 + scale: root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" Behavior on Layout.preferredHeight { @@ -196,10 +201,10 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0 + Layout.preferredWidth: !root.screenState.recordingListExpanded ? implicitWidth : 0 color: Colors.palette.m3outline - opacity: !root.props.recordingListExpanded ? 1 : 0 - scale: !root.props.recordingListExpanded ? 1 : 0 + opacity: !root.screenState.recordingListExpanded ? 1 : 0 + scale: !root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" Behavior on Layout.preferredWidth { diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index f32e6b1..a84f644 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -12,13 +12,11 @@ import qs.Services import qs.Helpers import qs.Daemons import qs.Modules.Settings -import qs.Modules.Bar.Popouts as BarPopouts CustomRect { id: root readonly property bool needExtraRow: quickToggles.length > 6 - required property BarPopouts.Wrapper popouts readonly property var quickToggles: { const seenIds = new Set(); @@ -40,8 +38,8 @@ CustomRect { return true; }); } + required property ScreenState screenState readonly property int splitIndex: Math.ceil(quickToggles.length / 2) - required property PersistentProperties visibilities color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + Tokens.padding.extraLargeIncreased @@ -168,8 +166,8 @@ CustomRect { isToggle: false onClicked: { - root.visibilities.sidebar = false; - root.visibilities.settings = true; + root.screenState.sidebar = false; + root.screenState.settings = true; } } } diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index 922eff9..26e5343 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,16 +1,14 @@ import Quickshell import QtQuick import QtQuick.Layouts -import qs.Modules.Bar.Popouts as BarPopouts +import qs.Components import qs.Modules.Notifications.Sidebar.Utils.Cards import ZShell.Config Item { id: root - required property BarPopouts.Wrapper popouts - required property PersistentProperties props - required property var visibilities + required property ScreenState screenState implicitHeight: layout.implicitHeight implicitWidth: layout.implicitWidth @@ -25,15 +23,13 @@ Item { } Record { - props: root.props - visibilities: root.visibilities + screenState: root.screenState z: 1 } Toggles { Layout.fillWidth: true - popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 63a01b6..189ad55 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -10,17 +10,9 @@ Item { id: root property real offsetScale: shouldBeActive ? 0 : 1 - required property BarPopouts.Wrapper popouts - readonly property PersistentProperties props: PersistentProperties { - property string recordingConfirmDelete - property bool recordingListExpanded: false - property string recordingMode - - reloadableId: "utilities" - } - readonly property bool shouldBeActive: visibilities.sidebar + required property ScreenState screenState + readonly property bool shouldBeActive: screenState.sidebar && !sidebar.chatActive required property Item sidebar - required property var visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight + 8 * 2 @@ -45,9 +37,7 @@ Item { sourceComponent: Content { implicitWidth: root.implicitWidth - 8 * 2 - popouts: root.popouts - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index 245247f..6f493da 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -7,12 +7,11 @@ import QtQuick Item { id: root + readonly property bool chatActive: screenState.sidebarTab === 1 property real offsetScale: shouldBeActive ? 0 : 1 required property var panels - readonly property Props props: Props { - } - readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled - required property var visibilities + required property ScreenState screenState + readonly property bool shouldBeActive: root.screenState.sidebar && Config.sidebar.enabled anchors.rightMargin: (-implicitWidth - 5) * offsetScale implicitWidth: Config.sidebar.sizes.width @@ -38,8 +37,7 @@ Item { sourceComponent: Content { implicitWidth: Config.sidebar.sizes.width - Tokens.padding.small * 2 - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Wrapper.qml b/Modules/Notifications/Wrapper.qml index 03131f8..e010b09 100644 --- a/Modules/Notifications/Wrapper.qml +++ b/Modules/Notifications/Wrapper.qml @@ -7,7 +7,7 @@ Item { required property Item panels required property Item sidebarPanel - required property var visibilities + required property ScreenState screenState implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth @@ -17,6 +17,6 @@ Item { id: content panels: root.panels - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Osd/Content.qml b/Modules/Osd/Content.qml index d1b8b35..e1ac3d9 100644 --- a/Modules/Osd/Content.qml +++ b/Modules/Osd/Content.qml @@ -16,7 +16,7 @@ Item { required property bool muted required property bool sourceMuted required property real sourceVolume - required property var visibilities + required property ScreenState screenState required property real volume implicitHeight: layout.implicitHeight + Tokens.padding.smaller * 2 @@ -53,7 +53,7 @@ Item { // Microphone volume WrappedLoader { - shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.visibilities.session) + shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.screenState.session) sourceComponent: CustomMouseArea { function onWheel(event: WheelEvent) { diff --git a/Modules/Osd/Wrapper.qml b/Modules/Osd/Wrapper.qml index e879a4c..e388beb 100644 --- a/Modules/Osd/Wrapper.qml +++ b/Modules/Osd/Wrapper.qml @@ -16,16 +16,16 @@ Item { property bool muted property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.osd && Config.osd.enabled && !(visibilities.utilities && Config.utilities.enabled) + readonly property bool shouldBeActive: screenState.osd && Config.osd.enabled && !(screenState.utilities && Config.utilities.enabled) property real sidebarOffset: sidebarOrSessionVisible ? 12 : 0 required property bool sidebarOrSessionVisible property bool sourceMuted property real sourceVolume - required property var visibilities + required property ScreenState screenState property real volume function show(): void { - visibilities.osd = true; + screenState.osd = true; timer.restart(); } @@ -90,7 +90,7 @@ Item { onTriggered: { if (!root.hovered) - root.visibilities.osd = false; + root.screenState.osd = false; } } @@ -107,7 +107,7 @@ Item { muted: root.muted sourceMuted: root.sourceMuted sourceVolume: root.sourceVolume - visibilities: root.visibilities + screenState: root.screenState volume: root.volume } } diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 9004694..99adbf0 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -11,8 +11,8 @@ Item { readonly property real nonAnimHeight: content.item?.nonAnimHeight ?? 0 property real offsetScale: shouldBeActive ? 0 : 1 required property string position - readonly property bool shouldBeActive: root.visibilities.resources - required property PersistentProperties visibilities + readonly property bool shouldBeActive: root.screenState.resources + required property ScreenState screenState anchors.bottom: position === "bottom" ? parent.bottom : undefined anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 diff --git a/Modules/Settings/Common/TextFieldRow.qml b/Modules/Settings/Common/TextFieldRow.qml new file mode 100644 index 0000000..38dd9a8 --- /dev/null +++ b/Modules/Settings/Common/TextFieldRow.qml @@ -0,0 +1,85 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +ConnectedRect { + id: root + + property alias label: label.text + property string subtext + property string value + property alias placeholderText: input.placeholderText + property string errorText + property alias maximumLength: input.maximumLength + property alias validate: input.validate + property alias validator: input.validator + property bool smallField + readonly property alias field: input + + signal valueEdited(value: string) + signal editingFinished(value: string) + + function clear(): void { + input.clear(); + } + + Component.onDestruction: { + if (value !== input.text) + editingFinished(input.text); + } + + Layout.fillWidth: true + implicitHeight: rowLayout.implicitHeight + Tokens.padding.medium + Math.max(0, Tokens.padding.large - input.verticalPadding) * 2 + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + CustomText { + Layout.fillWidth: true + visible: root.subtext + text: input.isError && root.errorText ? root.errorText : root.subtext + color: input.isError && root.errorText ? Colors.palette.m3error : Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + elide: Text.ElideRight + animate: root.errorText + } + } + + CustomTextField { + id: input + + Layout.preferredWidth: root.smallField ? Tokens.sizes.nexus.smallTextFieldWidth : Tokens.sizes.nexus.textFieldWidth + Layout.maximumWidth: root.width / 2 + Layout.alignment: Qt.AlignVCenter + verticalPadding: Tokens.padding.small + + text: root.value + + onTextEdited: root.valueEdited(text) + onEditingFinished: root.editingFinished(text) + } + } +} diff --git a/Modules/Settings/Pages/Panels/DashboardPanel.qml b/Modules/Settings/Pages/Panels/DashboardPanel.qml index 7657761..af8beb8 100644 --- a/Modules/Settings/Pages/Panels/DashboardPanel.qml +++ b/Modules/Settings/Pages/Panels/DashboardPanel.qml @@ -33,5 +33,33 @@ PageBase { onToggled: Config.dashboard.enabled = checked } + + // Behavior + SectionHeader { + text: qsTr("Behavior") + } + + ToggleRow { + checked: Config.dashboard.showOnHover + first: true + settingAnchor: "dashboard-showonhover" + text: qsTr("Show on hover") + subtext: qsTr("Dashboard %1 be shown when hovering the middle-top part of the shell").arg(checked ? "will" : "will not") + + onToggled: Config.dashboard.showOnHover = checked + } + + SpinRow { + from: 30 + settingAnchor: "dashboard-drag-threshold" + stepSize: 20 + enabled: !Config.dashboard.showOnHover + subtext: qsTr("How far you need to drag before the panel opens") + text: qsTr("Drag threshold") + to: 100 + value: Config.dashboard.dragThreshold + + onMoved: v => Config.dashboard.dragThreshold = v + } } } diff --git a/Modules/Settings/Wrapper.qml b/Modules/Settings/Wrapper.qml index 6de8131..a096857 100644 --- a/Modules/Settings/Wrapper.qml +++ b/Modules/Settings/Wrapper.qml @@ -12,8 +12,8 @@ Item { required property var panels property string queuedMode: "wallpaper" required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.settings - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.settings + required property ScreenState screenState implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth diff --git a/Modules/Shortcuts.qml b/Modules/Shortcuts.qml index 68c6662..ca227ca 100644 --- a/Modules/Shortcuts.qml +++ b/Modules/Shortcuts.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import qs.Components import qs.Helpers +import qs.Services Scope { id: root @@ -17,8 +18,8 @@ Scope { onPressed: { root.launcherInterrupted = false; if (!root.launcherInterrupted && !root.hasFullscreen) { - const visibilities = Visibilities.getForActive(); - visibilities.launcher = !visibilities.launcher; + const state = ShellState.forActive(); + state.launcher = !state.launcher; } root.launcherInterrupted = false; } @@ -28,8 +29,8 @@ Scope { name: "toggle-drawing" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.isDrawing = !visibilities.isDrawing; + const state = ShellState.forActive(); + state.isDrawing = !state.isDrawing; } } @@ -37,8 +38,8 @@ Scope { name: "toggle-nc" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.sidebar = !visibilities.sidebar; + const state = ShellState.forActive(); + state.sidebar = !state.sidebar; } } @@ -46,8 +47,8 @@ Scope { name: "show-osd" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.osd = !visibilities.osd; + const state = ShellState.forActive(); + state.osd = !state.osd; } } @@ -55,8 +56,8 @@ Scope { name: "toggle-settings" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.settings = !visibilities.settings; + const state = ShellState.forActive(); + state.settings = !state.settings; } } @@ -64,8 +65,8 @@ Scope { name: "toggle-clipboard" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.clipboard = !visibilities.clipboard; + const state = ShellState.forActive(); + state.clipboard = !state.clipboard; } } } diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index bee1215..339a024 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -1,5 +1,6 @@ qml_module(ZShell-config URI ZShell.Config + INCLUDE_PREFIX config SOURCES confignode.hpp confignode.cpp configobject.hpp configobject.cpp @@ -14,6 +15,7 @@ qml_module(ZShell-config colors.hpp dashboard.hpp dock.hpp + enums.hpp general.hpp launcher.hpp lock.hpp diff --git a/Plugins/ZShell/Config/dashboard.hpp b/Plugins/ZShell/Config/dashboard.hpp index 97471b8..271c6f5 100644 --- a/Plugins/ZShell/Config/dashboard.hpp +++ b/Plugins/ZShell/Config/dashboard.hpp @@ -48,6 +48,8 @@ class Dashboard : public ConfigObject { QML_ANONYMOUS CFG_PROPERTY(bool, enabled, true) + CFG_PROPERTY(bool, showOnHover, true) + CFG_PROPERTY(int, dragThreshold, 50) CFG_PROPERTY(int, mediaUpdateInterval, 2000) CONFIG_SUBOBJECT(Performance, performance) CFG_PROPERTY(int, resourceUpdateInterval, 1000) diff --git a/Plugins/ZShell/Config/enums.hpp b/Plugins/ZShell/Config/enums.hpp new file mode 100644 index 0000000..9ba61a0 --- /dev/null +++ b/Plugins/ZShell/Config/enums.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace ZShell::config { + +#define ENUM(Name, ...) \ + namespace Name { \ + \ + Q_NAMESPACE \ + QML_ELEMENT \ + \ + enum Enum : quint8 { __VA_ARGS__ }; \ + Q_ENUM_NS(Enum) \ + }; + +ENUM(BarWorkspaceDisplay, Shapes, Text, Icons) +ENUM(BarWorkspaceCapitalisation, Preserve, Upper, Lower) +ENUM(LyricsBackend, Auto, Local, LRCLIB, NetEase) +ENUM(GpuType, Auto, Nvidia, Generic, None) +ENUM(NotifsFullscreen, On, Off) +ENUM(TemperatureUnit, Auto, Celsius, Fahrenheit, Kelvin) +ENUM(DataUnit, Binary, Decimal) +ENUM(ClockFormat, Auto, TwelveHour, TwentyFourHour) + +#undef ENUM + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 9f6a70f..1195e6f 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -1,6 +1,7 @@ #pragma once #include "configlist.hpp" #include "configobject.hpp" +#include #include namespace ZShell::config { @@ -141,6 +142,10 @@ class General : public ConfigObject { CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) CFG_PROPERTY(bool, desktopIcons, true) CONFIG_SUBOBJECT(Idle, idle) + CFG_PROPERTY( + QString, + lyricsDir, + QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + QStringLiteral("/Lyrics/")) CFG_PROPERTY(QString, logo, QString()) CFG_PROPERTY(bool, showOverFullscreen, true) CFG_PROPERTY( diff --git a/Plugins/ZShell/Config/services.hpp b/Plugins/ZShell/Config/services.hpp index 45d2c2e..00e0ea5 100644 --- a/Plugins/ZShell/Config/services.hpp +++ b/Plugins/ZShell/Config/services.hpp @@ -1,6 +1,7 @@ #pragma once #include "configlist.hpp" #include "configobject.hpp" +#include "enums.hpp" namespace ZShell::config { @@ -31,6 +32,7 @@ class Services : public ConfigObject { CFG_PROPERTY(bool, ddcutilService, true) CFG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) CFG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) + CFG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) CFG_PROPERTY(qreal, maxVolume, 1) CFG_PROPERTY(qreal, minBrightness, 0.01) CONFIG_LIST( diff --git a/Plugins/ZShell/Internal/sparklineitem.cpp b/Plugins/ZShell/Internal/sparklineitem.cpp index c4f2314..7c0b2f2 100644 --- a/Plugins/ZShell/Internal/sparklineitem.cpp +++ b/Plugins/ZShell/Internal/sparklineitem.cpp @@ -1,9 +1,11 @@ #include "sparklineitem.hpp" #include +#include #include #include #include +#include namespace ZShell::internal { @@ -35,9 +37,19 @@ void SparklineItem::drawLine( const int len = buffer->count(); if (len < 2 || m_maxValue <= 0.0) return; - const qreal stepX = w / static_cast(m_historyLength - 1); - const qreal startX = - w - (len - 1) * stepX - stepX * m_slideProgress + stepX; + qreal stepX = 0.0; + + if (m_sliding) + stepX = w / static_cast(m_historyLength - 1); + else + stepX = w / static_cast(len - 1); + + qreal startX = 0.0; + + if (m_sliding) + startX = w - (len - 1) * stepX - stepX * m_slideProgress + stepX; + else + startX = 0; const qreal strokePad = qCeil(m_lineWidth / 2); const qreal curvePad = 3.0; @@ -52,24 +64,75 @@ void SparklineItem::drawLine( points.reserve(len); for (int i = 0; i < len; ++i) { + qreal value = 0.0; + + if (m_smoothing > 0.0) { + const int radius = qRound(m_smoothing); + qreal sum = 0.0; + int count = 0; + + for (int j = -radius; j <= radius; ++j) { + const int index = i + j; + + if (index < 0 || index >= len) continue; + + sum += buffer->at(index); + count++; + } + + value = sum / static_cast(count); + } else { + value = buffer->at(i); + } + + value = qBound(0.0, value, m_maxValue); + const qreal x = startX + i * stepX; - const qreal value = qBound(0.0, buffer->at(i), m_maxValue); const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); } QPainterPath linePath; linePath.moveTo(points[0]); - for (int i = 0; i < points.size() - 1; ++i) { - const QPointF& p0 = points[i]; - const QPointF& p1 = points[i + 1]; + if (m_curveSmoothing && points.size() > 2) { + // 1.0 = standard uniform Catmull-Rom. Lower (e.g. 0.5) = tighter, less overshoot. + const qreal tension = 1.0; + const qreal k = + tension / 6.0; // Catmull-Rom -> Bezier factor (1/6 for uniform) - const qreal ctrlX = (p0.x() + p1.x()) * 0.5; - const QPointF c1(ctrlX, p0.y()); - const QPointF c2(ctrlX, p1.y()); + const int n = points.size(); - linePath.cubicTo(c1, c2, p1); + for (int i = 0; i < n - 1; ++i) { + // Duplicate endpoints so the first/last segments are well-defined + const QPointF& p0 = points[qMax(i - 1, 0)]; + const QPointF& p1 = points[i]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + // Keep control points inside the vertical range of the segment's + // endpoints so we never overshoot (no dips below zero, no + // bulges above a peak). Remember: smaller y = higher value. + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + linePath.cubicTo(c1, c2, p2); + } + } else { + // Existing horizontal-tangent (smoothstep-style) curve + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } } QPen pen(color, m_lineWidth); @@ -224,4 +287,36 @@ void SparklineItem::setLineWidth(qreal width) { update(); } +void SparklineItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + emit slidingChanged(); +} + +bool SparklineItem::sliding() const { + return m_sliding; +}; + +void SparklineItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + emit smoothingChanged(); +} + +qreal SparklineItem::smoothing() const { + return m_smoothing; +}; + +void SparklineItem::setCurveSmoothing(bool sliding) { + if (m_curveSmoothing == sliding) return; + m_curveSmoothing = sliding; + emit curveSmoothingChanged(); +} + +bool SparklineItem::curveSmoothing() const { + return m_curveSmoothing; +}; + } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/sparklineitem.hpp b/Plugins/ZShell/Internal/sparklineitem.hpp index 3cea1ed..a79be15 100644 --- a/Plugins/ZShell/Internal/sparklineitem.hpp +++ b/Plugins/ZShell/Internal/sparklineitem.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "circularbuffer.hpp" @@ -40,6 +41,13 @@ class SparklineItem : public QQuickPaintedItem { Q_PROPERTY( qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY lineWidthChanged) + Q_PROPERTY(bool sliding READ sliding WRITE setSliding NOTIFY slidingChanged) + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY( + bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY + curveSmoothingChanged) public: explicit SparklineItem(QQuickItem* parent = nullptr); @@ -76,6 +84,15 @@ class SparklineItem : public QQuickPaintedItem { [[nodiscard]] qreal lineWidth() const; void setLineWidth(qreal width); + [[nodiscard]] bool sliding() const; + void setSliding(bool sliding); + + [[nodiscard]] qreal smoothing() const; + void setSmoothing(qreal smoothing); + + [[nodiscard]] bool curveSmoothing() const; + void setCurveSmoothing(bool curveSmoothing); + signals: void line1Changed(); void line2Changed(); @@ -87,6 +104,9 @@ class SparklineItem : public QQuickPaintedItem { void slideProgressChanged(); void historyLengthChanged(); void lineWidthChanged(); + void slidingChanged(); + void smoothingChanged(); + void curveSmoothingChanged(); private: void drawLine( @@ -106,6 +126,9 @@ class SparklineItem : public QQuickPaintedItem { qreal m_slideProgress = 0.0; int m_historyLength = 30; qreal m_lineWidth = 2.0; + bool m_sliding = true; + qreal m_smoothing = 0.0; + bool m_curveSmoothing = false; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 2dfe707..4dcae3c 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -18,6 +18,8 @@ qml_module(ZShell-services memory.hpp memory.cpp diskinfo.hpp diskinfo.cpp storage.hpp storage.cpp + lyriccandidate.hpp lyriccandidate.cpp + lyrics.hpp lyrics.cpp LIBRARIES Qt6::Core Qt6::Qml @@ -25,4 +27,7 @@ qml_module(ZShell-services PkgConfig::Aubio PkgConfig::Cava Sensors::Sensors - ) + ZShell-config + DEPENDENCIES + ZShell.Config +) diff --git a/Plugins/ZShell/Services/lyriccandidate.cpp b/Plugins/ZShell/Services/lyriccandidate.cpp new file mode 100644 index 0000000..8678769 --- /dev/null +++ b/Plugins/ZShell/Services/lyriccandidate.cpp @@ -0,0 +1,55 @@ +#include "lyriccandidate.hpp" + +namespace ZShell::services { + +LyricCandidate::LyricCandidate( + LyricsBackend backend, + QString id, + QString title, + QString artist, + QString album, + qreal duration) + : m_backend(backend) + , m_id(std::move(id)) + , m_title(std::move(title)) + , m_artist(std::move(artist)) + , m_album(std::move(album)) + , m_duration(duration) {} + +LyricsBackend LyricCandidate::backend() const { + return m_backend; +} + +QString LyricCandidate::id() const { + return m_id; +} + +QString LyricCandidate::title() const { + return m_title; +} + +QString LyricCandidate::artist() const { + return m_artist; +} + +QString LyricCandidate::album() const { + return m_album; +} + +qreal LyricCandidate::duration() const { + return m_duration; +} + +bool LyricCandidate::isValid() const { + return !m_id.isEmpty(); +} + +bool LyricCandidate::operator==(const LyricCandidate& o) const noexcept { + return m_backend == o.m_backend && m_id == o.m_id; +} + +bool LyricCandidate::operator!=(const LyricCandidate& o) const noexcept { + return !(*this == o); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyriccandidate.hpp b/Plugins/ZShell/Services/lyriccandidate.hpp new file mode 100644 index 0000000..f3f0700 --- /dev/null +++ b/Plugins/ZShell/Services/lyriccandidate.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include "config/enums.hpp" + +namespace ZShell::services { + +using LyricsBackend = config::LyricsBackend::Enum; + +class LyricCandidate { + Q_GADGET + QML_VALUE_TYPE(lyricCandidate) + + Q_PROPERTY(ZShell::config::LyricsBackend::Enum backend READ backend) + Q_PROPERTY(QString id READ id) + Q_PROPERTY(QString title READ title) + Q_PROPERTY(QString artist READ artist) + Q_PROPERTY(QString album READ album) + Q_PROPERTY(qreal duration READ duration) + + public: + LyricCandidate() = default; + LyricCandidate( + LyricsBackend backend, + QString id, + QString title, + QString artist, + QString album = {}, + qreal duration = 0.0); + + [[nodiscard]] LyricsBackend backend() const; + [[nodiscard]] QString id() const; + [[nodiscard]] QString title() const; + [[nodiscard]] QString artist() const; + [[nodiscard]] QString album() const; + [[nodiscard]] qreal duration() const; + + [[nodiscard]] bool isValid() const; + bool operator==(const LyricCandidate& o) const noexcept; + bool operator!=(const LyricCandidate& o) const noexcept; + + private: + LyricsBackend m_backend = LyricsBackend::Auto; + QString m_id; + QString m_title; + QString m_artist; + QString m_album; + qreal m_duration = 0.0; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyrics.cpp b/Plugins/ZShell/Services/lyrics.cpp new file mode 100644 index 0000000..6a85e75 --- /dev/null +++ b/Plugins/ZShell/Services/lyrics.cpp @@ -0,0 +1,1178 @@ +#include "lyrics.hpp" + +#include +#include +#include +#include +#include +#include + +#include "config/config.hpp" +#include "config/general.hpp" +#include "config/services.hpp" + +namespace { + +Q_LOGGING_CATEGORY(lcLyrics, "ZShell.lyrics", QtInfoMsg) + +} // namespace + +namespace ZShell::services { + +using Qt::StringLiterals::operator""_s; +using Qt::StringLiterals::operator""_ba; + +namespace { + +constexpr int k_loadDebounceMs = 50; +constexpr qreal k_indexFudge = 0.1; + +[[nodiscard]] const QHash& netEaseHeaders() { + static const QHash k_h = { + {"User-Agent"_ba, + "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"_ba}, + {"Referer"_ba, "https://music.163.com/"_ba}, + }; + return k_h; +} + +[[nodiscard]] const QHash& lrclibHeaders() { + static const QHash k_h = { + {"User-Agent"_ba, + "ZShell-shell (https://github.com/ZShell-dots/shell)"_ba}, + }; + return k_h; +} + +[[nodiscard]] QString joinArtists(const QString& s) { + return s.trimmed(); +} + +[[nodiscard]] QString sanitizeFilenamePart(const QString& s) { + QString out; + out.reserve(s.size()); + for (const QChar c : s) { + if (c == u'/' || c == u'\0') { + out.append(u'_'); + } else { + out.append(c); + } + } + return out; +} + +[[nodiscard]] bool containsCi(const QString& haystack, const QString& needle) { + return haystack.contains(needle, Qt::CaseInsensitive); +} + +} // namespace + +Lyrics::Lyrics(QObject* parent) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) + , m_loadDebounce(new QTimer(this)) { + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(k_loadDebounceMs); + QObject::connect(m_loadDebounce, &QTimer::timeout, this, &Lyrics::doLoad); + + const auto* cfg = config::Config::instance(); + const auto* svcCfg = cfg->services(); + const auto* genCfg = cfg->general(); + + m_preferredBackend = svcCfg->lyricsBackend(); + + QObject::connect( + svcCfg, + &config::Services::lyricsBackendChanged, + this, + &Lyrics::onPreferredBackendConfigChanged); + QObject::connect( + genCfg, + &config::General::lyricsDirChanged, + this, + &Lyrics::onLyricsDirChanged); + + loadLyricsMap(); +} + +QStringList Lyrics::lyrics() const { + return m_lyrics; +} + +LyricsBackend Lyrics::backend() const { + return m_backend; +} + +LyricsBackend Lyrics::preferredBackend() const { + return m_preferredBackend; +} + +void Lyrics::setPreferredBackend(LyricsBackend value) { + if (m_preferredBackend == value) { + return; + } + m_preferredBackend = value; + emit preferredBackendChanged(); + + config::Config::instance()->services()->set_lyricsBackend(value); + + scheduleLoad(); +} + +QList Lyrics::lyricCandidates() const { + return m_candidates; +} + +LyricCandidate Lyrics::selectedCandidate() const { + return m_selected; +} + +void Lyrics::setSelectedCandidate(const LyricCandidate& value) { + if (m_selected == value) { + return; + } + m_selected = value; + emit selectedCandidateChanged(); + + if (!value.isValid()) { + return; + } + + const auto b = value.backend(); + setBackend(b); + setLoading(true); + + cancelInFlight(); + const int reqId = newRequestId(); + + if (b == LyricsBackend::LRCLIB || b == LyricsBackend::NetEase) { + const QString cached = readCachedLrc(b, value.id()); + if (!cached.isEmpty()) { + const auto lines = parseLrc(cached); + if (!lines.isEmpty()) { + setLines(lines, b); + setLoading(false); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + return; + } + } + } + + if (b == LyricsBackend::LRCLIB) { + fetchLrclibById(value.id(), reqId); + } else if (b == LyricsBackend::NetEase) { + fetchNetEaseLyricsById(value.id(), reqId); + } else if (b == LyricsBackend::Local) { + // For local, the id is the file path. Read directly. + QFile f(value.id()); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + setLines(parseLrc(text), LyricsBackend::Local); + setLoading(false); + } else { + qCWarning(lcLyrics) + << "selectedCandidate: cannot open local file" << value.id(); + setLoading(false); + } + } + + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } +} + +bool Lyrics::loading() const { + return m_loading; +} + +bool Lyrics::hasLyrics() const { + return m_hasLyrics; +} + +qreal Lyrics::offset() const { + return m_offset; +} + +void Lyrics::setOffset(qreal value) { + if (qFuzzyCompare(m_offset, value)) { + return; + } + m_offset = value; + emit offsetChanged(); + + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } +} + +QString Lyrics::trackArtist() const { + return m_artist; +} + +QString Lyrics::trackTitle() const { + return m_title; +} + +int Lyrics::indexForTime(qreal time) const { + if (m_lines.isEmpty()) { + return -1; + } + const qreal target = time - m_offset + k_indexFudge; + qsizetype lo = 0; + qsizetype hi = m_lines.size(); + while (lo < hi) { + const qsizetype mid = lo + (hi - lo) / 2; + if (m_lines.at(mid).time <= target) { + lo = mid + 1; + } else { + hi = mid; + } + } + return static_cast(lo - 1); +} + +qreal Lyrics::timeForIndex(int index) const { + if (index < 0 || index >= m_lines.size()) { + return -1.0; + } + return m_lines.at(index).time + m_offset; +} + +void Lyrics::setTrack( + const QString& artist, + const QString& title, + const QString& album, + qreal duration) { + const QString a = artist.trimmed(); + const QString t = title.trimmed(); + + if (a == m_artist && t == m_title && album == m_album && + qFuzzyCompare(duration + 1.0, m_duration + 1.0)) { + return; + } + + m_artist = a; + m_title = t; + m_album = album; + m_duration = duration; + emit trackChanged(); + + scheduleLoad(); +} + +void Lyrics::clearTrack() { + cancelInFlight(); + m_artist.clear(); + m_title.clear(); + m_album.clear(); + m_duration = 0.0; + emit trackChanged(); + + clearCandidates(); + clearLines(); + setLoading(false); +} + +void Lyrics::refresh() { + scheduleLoad(); +} + +void Lyrics::setBackend(LyricsBackend value) { + if (m_backend == value) { + return; + } + m_backend = value; + emit backendChanged(); +} + +void Lyrics::setLoading(bool value) { + if (m_loading == value) { + return; + } + m_loading = value; + emit loadingChanged(); +} + +void Lyrics::setLines(QVector lines, LyricsBackend source) { + std::ranges::sort(lines, [](const LyricLine& a, const LyricLine& b) { + return a.time < b.time; + }); + + m_lines = std::move(lines); + QStringList list; + list.reserve(m_lines.size()); + for (const auto& l : std::as_const(m_lines)) { + list.append(l.text); + } + m_lyrics = std::move(list); + + setBackend(source); + emit lyricsChanged(); + + const auto hasLyrics = !m_lines.isEmpty(); + if (hasLyrics != m_hasLyrics) { + m_hasLyrics = hasLyrics; + emit hasLyricsChanged(); + } +} + +void Lyrics::clearLines() { + if (!m_hasLyrics) { + return; + } + + // Doesn't actually clear lines, set a flag instead so anims can run + m_hasLyrics = false; + emit hasLyricsChanged(); +} + +void Lyrics::appendCandidates(const QList& add) { + if (add.isEmpty()) { + return; + } + bool changed = false; + for (const auto& c : add) { + if (!m_candidates.contains(c)) { + m_candidates.append(c); + changed = true; + } + } + if (changed) { + emit lyricCandidatesChanged(); + } +} + +void Lyrics::clearCandidates() { + if (m_candidates.isEmpty()) { + return; + } + m_candidates.clear(); + emit lyricCandidatesChanged(); +} + +void Lyrics::scheduleLoad() { + m_loadDebounce->start(); +} + +int Lyrics::newRequestId() { + return ++m_currentRequestId; +} + +void Lyrics::cancelInFlight() { + for (auto it = m_pendingReplies.begin(); it != m_pendingReplies.end(); + ++it) { + for (auto& ptr : it.value()) { + if (auto* reply = ptr.data()) { + reply->abort(); + reply->deleteLater(); + } + } + } + m_pendingReplies.clear(); +} + +void Lyrics::trackReply(int reqId, QNetworkReply* reply) { + if (!reply) { + return; + } + m_pendingReplies[reqId].append(QPointer(reply)); +} + +void Lyrics::doLoad() { + if (m_artist.isEmpty() && m_title.isEmpty()) { + clearLines(); + clearCandidates(); + setLoading(false); + return; + } + + cancelInFlight(); + const int reqId = newRequestId(); + + setLoading(true); + clearLines(); + clearCandidates(); + + // Restore per-track prefs (offset, last-selected backend/id) + m_settingFromPrefs = true; + const QJsonObject saved = m_lyricsMap.value(trackKey()).toObject(); + setOffset(saved.value(u"offset"_s).toDouble(0.0)); + LyricCandidate restored; + const QString savedBackendKey = saved.value(u"backend"_s).toString(); + const QString savedId = saved.value(u"id"_s).toString(); + if (!savedBackendKey.isEmpty() && !savedId.isEmpty()) { + restored = LyricCandidate( + backendFromKey(savedBackendKey), + savedId, + m_title, + m_artist, + m_album, + m_duration); + } + m_settingFromPrefs = false; + + // Always populate online candidates for the picker, regardless of preferred backend + searchLrclibCandidates(reqId); + searchNetEaseCandidates(reqId); + + if (restored.isValid()) { + // Honor saved selection for this track + m_settingFromPrefs = true; + setSelectedCandidate(restored); + m_settingFromPrefs = false; + return; + } + + // Primary attempt by preferred backend + switch (m_preferredBackend) { + case LyricsBackend::Local: + tryLocal(reqId); + break; + case LyricsBackend::LRCLIB: + tryLrclib(reqId); + break; + case LyricsBackend::NetEase: + tryNetEase(reqId); + break; + case LyricsBackend::Auto: + default: + tryLocal(reqId); + break; + } +} + +void Lyrics::chainNext(LyricsBackend justFailed, int reqId) { + if (m_preferredBackend != LyricsBackend::Auto) { + // Non-auto modes don't chain + setLoading(false); + return; + } + switch (justFailed) { + case LyricsBackend::Local: + tryLrclib(reqId); + return; + case LyricsBackend::LRCLIB: + tryNetEase(reqId); + return; + case LyricsBackend::NetEase: + default: + setLoading(false); + return; + } +} + +void Lyrics::tryLocal(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::Local); + + const QString dir = lyricsDir(); + if (dir.isEmpty()) { + chainNext(LyricsBackend::Local, reqId); + return; + } + + const QString direct = tryReadLocalLrc(dir, m_artist, m_title); + if (!direct.isEmpty()) { + QFile f(direct); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + const auto lines = parseLrc(text); + if (!lines.isEmpty()) { + setLines(lines, LyricsBackend::Local); + appendCandidates({LyricCandidate( + LyricsBackend::Local, + direct, + m_title, + m_artist, + m_album, + m_duration)}); + m_selected = LyricCandidate( + LyricsBackend::Local, + direct, + m_title, + m_artist, + m_album, + m_duration); + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + return; + } + } + } + + const QString recursive = findLocalLrcRecursive(dir, m_artist, m_title); + if (!recursive.isEmpty()) { + QFile f(recursive); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + const auto lines = parseLrc(text); + if (!lines.isEmpty()) { + setLines(lines, LyricsBackend::Local); + appendCandidates({LyricCandidate( + LyricsBackend::Local, + recursive, + m_title, + m_artist, + m_album, + m_duration)}); + m_selected = LyricCandidate( + LyricsBackend::Local, + recursive, + m_title, + m_artist, + m_album, + m_duration); + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + return; + } + } + } + + qCDebug(lcLyrics) << "no local lrc for" << m_artist << "-" << m_title; + chainNext(LyricsBackend::Local, reqId); +} + +void Lyrics::tryLrclib(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::LRCLIB); + + QUrl url(u"https://lrclib.net/api/get"_s); + QUrlQuery q; + q.addQueryItem(u"track_name"_s, m_title); + q.addQueryItem(u"artist_name"_s, m_artist); + if (!m_album.isEmpty()) { + q.addQueryItem(u"album_name"_s, m_album); + } + + constexpr qreal k_maxDurationSecs = std::numeric_limits::max(); + if (m_duration > 0 && qIsFinite(m_duration) && + m_duration < k_maxDurationSecs) { + q.addQueryItem(u"duration"_s, QString::number(qRound(m_duration))); + } + url.setQuery(q); + + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) << "lrclib /get error:" << reply->errorString(); + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonObject obj = doc.object(); + const QString synced = obj.value(u"syncedLyrics"_s).toString(); + const qint64 id = static_cast(obj.value(u"id"_s).toDouble()); + + if (synced.isEmpty()) { + qCDebug(lcLyrics) + << "lrclib: no syncedLyrics for" << m_artist << "-" << m_title; + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + + const auto lines = parseLrc(synced); + if (lines.isEmpty()) { + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + + writeCachedLrc(LyricsBackend::LRCLIB, QString::number(id), synced); + setLines(lines, LyricsBackend::LRCLIB); + const LyricCandidate cand( + LyricsBackend::LRCLIB, + QString::number(id), + obj.value(u"trackName"_s).toString(), + obj.value(u"artistName"_s).toString(), + obj.value(u"albumName"_s).toString(), + obj.value(u"duration"_s).toDouble()); + appendCandidates({cand}); + m_selected = cand; + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + }); +} + +void Lyrics::tryNetEase(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::NetEase); + + // Reset cookies (LyricsBackend::NetEase rejects requests with stale cookies sometimes) + m_nam->setCookieJar(new QNetworkCookieJar(m_nam)); + + QUrl url(u"https://music.163.com/api/search/get"_s); + QUrlQuery q; + q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist)); + q.addQueryItem(u"type"_s, u"1"_s); + q.addQueryItem(u"limit"_s, u"5"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "netease /search error:" << reply->errorString(); + chainNext(LyricsBackend::NetEase, reqId); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray songs = doc.object() + .value(u"result"_s) + .toObject() + .value(u"songs"_s) + .toArray(); + + // Find best match by artist substring + qint64 bestId = -1; + for (const auto& v : songs) { + const QJsonObject s = v.toObject(); + const QJsonArray artists = s.value(u"artists"_s).toArray(); + if (artists.isEmpty()) { + continue; + } + const QString sArtist = + artists.first().toObject().value(u"name"_s).toString(); + if (containsCi(m_artist, sArtist) || + containsCi(sArtist, m_artist)) { + bestId = static_cast(s.value(u"id"_s).toDouble()); + break; + } + } + + if (bestId < 0) { + qCDebug(lcLyrics) << "netease: no artist match for" << m_artist + << "-" << m_title; + chainNext(LyricsBackend::NetEase, reqId); + return; + } + + fetchNetEaseLyricsById(QString::number(bestId), reqId); + }); +} + +void Lyrics::searchLrclibCandidates(int reqId) { + QUrl url(u"https://lrclib.net/api/search"_s); + QUrlQuery q; + q.addQueryItem(u"track_name"_s, m_title); + q.addQueryItem(u"artist_name"_s, m_artist); + url.setQuery(q); + + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "lrclib /search error:" << reply->errorString(); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray arr = doc.array(); + + QList add; + add.reserve(arr.size()); + for (const auto& v : arr) { + const QJsonObject o = v.toObject(); + if (o.value(u"syncedLyrics"_s).isNull() && + o.value(u"plainLyrics"_s).isNull()) { + continue; + } + add.append(LyricCandidate( + LyricsBackend::LRCLIB, + QString::number( + static_cast(o.value(u"id"_s).toDouble())), + o.value(u"trackName"_s).toString(), + o.value(u"artistName"_s).toString(), + o.value(u"albumName"_s).toString(), + o.value(u"duration"_s).toDouble())); + } + appendCandidates(add); + }); +} + +void Lyrics::searchNetEaseCandidates(int reqId) { + m_nam->setCookieJar(new QNetworkCookieJar(m_nam)); + + QUrl url(u"https://music.163.com/api/search/get"_s); + QUrlQuery q; + q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist)); + q.addQueryItem(u"type"_s, u"1"_s); + q.addQueryItem(u"limit"_s, u"5"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "netease candidates error:" << reply->errorString(); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray songs = doc.object() + .value(u"result"_s) + .toObject() + .value(u"songs"_s) + .toArray(); + + QList add; + add.reserve(songs.size()); + for (const auto& v : songs) { + const QJsonObject s = v.toObject(); + QStringList artistNames; + const QJsonArray artists = s.value(u"artists"_s).toArray(); + artistNames.reserve(artists.size()); + for (const auto& a : artists) { + artistNames.append(a.toObject().value(u"name"_s).toString()); + } + add.append(LyricCandidate( + LyricsBackend::NetEase, + QString::number( + static_cast(s.value(u"id"_s).toDouble())), + s.value(u"name"_s).toString(), + artistNames.join(u", "_s))); + } + appendCandidates(add); + }); +} + +void Lyrics::fetchLrclibById(const QString& id, int reqId) { + const QUrl url(u"https://lrclib.net/api/get/"_s + id); + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId, id] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcLyrics) + << "lrclib /get/{id} error:" << reply->errorString(); + setLoading(false); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QString synced = + doc.object().value(u"syncedLyrics"_s).toString(); + if (synced.isEmpty()) { + qCDebug(lcLyrics) << "lrclib /get/{id}: no syncedLyrics"; + setLoading(false); + return; + } + writeCachedLrc(LyricsBackend::LRCLIB, id, synced); + setLines(parseLrc(synced), LyricsBackend::LRCLIB); + setLoading(false); + }); +} + +void Lyrics::fetchNetEaseLyricsById(const QString& id, int reqId) { + QUrl url(u"https://music.163.com/api/song/lyric"_s); + QUrlQuery q; + q.addQueryItem(u"id"_s, id); + q.addQueryItem(u"lv"_s, u"1"_s); + q.addQueryItem(u"kv"_s, u"1"_s); + q.addQueryItem(u"tv"_s, u"-1"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId, id] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcLyrics) + << "netease /lyric error:" << reply->errorString(); + setLoading(false); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QString lrc = doc.object() + .value(u"lrc"_s) + .toObject() + .value(u"lyric"_s) + .toString(); + if (lrc.isEmpty()) { + qCDebug(lcLyrics) << "netease /lyric: empty for id" << id; + setLoading(false); + return; + } + writeCachedLrc(LyricsBackend::NetEase, id, lrc); + setLines(parseLrc(lrc), LyricsBackend::NetEase); + setLoading(false); + }); +} + +QNetworkReply* Lyrics::getJson( + const QUrl& url, const QHash& headers) { + QNetworkRequest req(url); + req.setAttribute( + QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + req.setRawHeader("Cache-Control"_ba, "no-cache, no-store"_ba); + req.setRawHeader("Pragma"_ba, "no-cache"_ba); + req.setRawHeader("Connection"_ba, "close"_ba); + req.setRawHeader("Accept"_ba, "application/json"_ba); + for (auto it = headers.constBegin(); it != headers.constEnd(); ++it) { + req.setRawHeader(it.key(), it.value()); + } + return m_nam->get(req); +} + +void Lyrics::onPreferredBackendConfigChanged() { + const LyricsBackend desired = + config::Config::instance()->services()->lyricsBackend(); + if (desired == m_preferredBackend) { + return; + } + m_preferredBackend = desired; + emit preferredBackendChanged(); + scheduleLoad(); +} + +void Lyrics::onLyricsDirChanged() { + scheduleLoad(); +} + +void Lyrics::loadLyricsMap() { + m_lyricsMap = {}; + m_lyricsMapLoaded = false; + + QFile f(lyricsMapPath()); + if (!f.open(QIODevice::ReadOnly)) { + m_lyricsMapLoaded = true; + return; + } + const QByteArray bytes = f.readAll(); + f.close(); + + QJsonParseError err{}; + const QJsonDocument doc = QJsonDocument::fromJson(bytes, &err); + if (err.error != QJsonParseError::NoError) { + qCWarning(lcLyrics) + << "lyrics_map.json parse error:" << err.errorString(); + m_lyricsMapLoaded = true; + return; + } + m_lyricsMap = doc.object(); + m_lyricsMapLoaded = true; +} + +void Lyrics::persistTrackPrefs() { + if (!m_lyricsMapLoaded || trackKey().isEmpty()) { + return; + } + const QString key = trackKey(); + QJsonObject entry = m_lyricsMap.value(key).toObject(); + entry.insert(u"offset"_s, m_offset); + if (m_selected.isValid()) { + entry.insert(u"backend"_s, backendKey(m_selected.backend())); + entry.insert(u"id"_s, m_selected.id()); + } + m_lyricsMap.insert(key, entry); + + QDir().mkpath(stateDir()); + + QSaveFile out(lyricsMapPath()); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcLyrics) << "cannot open" << lyricsMapPath() + << "for write:" << out.errorString(); + return; + } + const QByteArray bytes = + QJsonDocument(m_lyricsMap).toJson(QJsonDocument::Compact); + if (out.write(bytes) != bytes.size()) { + qCWarning(lcLyrics) << "short write to" << lyricsMapPath(); + out.cancelWriting(); + return; + } + if (!out.commit()) { + qCWarning(lcLyrics) << "commit failed for" << lyricsMapPath() << ":" + << out.errorString(); + } +} + +QString Lyrics::lyricsDir() { + QString dir = config::Config::instance()->general()->lyricsDir(); + if (dir.isEmpty()) { + return {}; + } + if (dir == u"~"_s) { + dir = QDir::homePath(); + } else if (dir.startsWith(u"~/"_s)) { + dir.replace(0, 1, QDir::homePath()); + } + while (dir.endsWith(u'/') && dir.size() > 1) { + dir.chop(1); + } + return dir; +} + +QString Lyrics::lyricsMapPath() { + return stateDir() + u"/lyrics_map.json"_s; +} + +QString Lyrics::trackKey() const { + if (m_artist.isEmpty() && m_title.isEmpty()) { + return {}; + } + return u"%1 - %2"_s.arg(joinArtists(m_artist), m_title); +} + +QString Lyrics::backendKey(LyricsBackend value) { + switch (value) { + case LyricsBackend::Local: + return u"Local"_s; + case LyricsBackend::LRCLIB: + return u"LRCLIB"_s; + case LyricsBackend::NetEase: + return u"NetEase"_s; + case LyricsBackend::Auto: + default: + return u"Auto"_s; + } +} + +LyricsBackend Lyrics::backendFromKey(const QString& key) { + if (key.compare(u"Local"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::Local; + } + if (key.compare(u"LRCLIB"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::LRCLIB; + } + if (key.compare(u"NetEase"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::NetEase; + } + return LyricsBackend::Auto; +} + +const QString& Lyrics::stateDir() { + static const QString k_dir = [] { + QString state = qEnvironmentVariable("XDG_STATE_HOME"); + if (state.isEmpty()) { + state = QDir::homePath() + u"/.local/state"_s; + } + return state + u"/ZShell/lyrics"_s; + }(); + return k_dir; +} + +const QString& Lyrics::cacheDir() { + static const QString k_dir = [] { + QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); + if (cache.isEmpty()) { + cache = QDir::homePath() + u"/.cache"_s; + } + return cache + u"/ZShell/lyrics"_s; + }(); + return k_dir; +} + +QString Lyrics::cachePathFor(LyricsBackend backend, const QString& id) { + if (id.isEmpty() || backend == LyricsBackend::Auto || + backend == LyricsBackend::Local) { + return {}; + } + return u"%1/%2/%3.lrc"_s.arg( + cacheDir(), backendKey(backend), sanitizeFilenamePart(id)); +} + +QString Lyrics::readCachedLrc(LyricsBackend backend, const QString& id) { + const QString path = cachePathFor(backend, id); + if (path.isEmpty()) { + return {}; + } + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + return QString::fromUtf8(f.readAll()); +} + +void Lyrics::writeCachedLrc( + LyricsBackend backend, const QString& id, const QString& text) { + if (text.isEmpty()) { + return; + } + const QString path = cachePathFor(backend, id); + if (path.isEmpty()) { + return; + } + QDir().mkpath(QFileInfo(path).absolutePath()); + + QSaveFile out(path); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcLyrics) + << "cannot open" << path << "for write:" << out.errorString(); + return; + } + const QByteArray bytes = text.toUtf8(); + if (out.write(bytes) != bytes.size()) { + qCWarning(lcLyrics) << "short write to" << path; + out.cancelWriting(); + return; + } + if (!out.commit()) { + qCWarning(lcLyrics) + << "commit failed for" << path << ":" << out.errorString(); + } +} + +QString Lyrics::tryReadLocalLrc( + const QString& dir, const QString& artist, const QString& title) { + if (artist.isEmpty() && title.isEmpty()) { + return {}; + } + const QString flat = u"%1/%2 - %3.lrc"_s.arg( + dir, sanitizeFilenamePart(artist), sanitizeFilenamePart(title)); + return QFile::exists(flat) ? flat : QString(); +} + +QString Lyrics::findLocalLrcRecursive( + const QString& dir, const QString& artist, const QString& title) { + if (dir.isEmpty()) { + return {}; + } + if (artist.isEmpty() && title.isEmpty()) { + return {}; + } + + QDirIterator it( + dir, + QStringList{u"*.lrc"_s}, + QDir::Files | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + + while (it.hasNext()) { + const QString path = it.next(); + const QString name = it.fileName(); + if ((artist.isEmpty() || containsCi(name, artist)) && + (title.isEmpty() || containsCi(name, title))) { + return path; + } + } + return {}; +} + +QVector Lyrics::parseLrc(const QString& text) { + QVector result; + if (text.isEmpty()) { + return result; + } + + static const QRegularExpression k_timeRegex( + u"\\[(\\d+):(\\d+(?:\\.\\d+)?)\\]"_s); + static const QStringList k_creditKeywords = { + u"作词"_s, + u"作曲"_s, + u"编曲"_s, + u"制作"_s, + u"收录"_s, + u"演奏"_s, + u"词:"_s, + u"曲:"_s, + u"Lyricist"_s, + u"Composer"_s, + u"Arranger"_s, + u"Producer"_s, + u"Mixing"_s, + u"Mastering"_s, + }; + + const QStringList lines = text.split(u'\n'); + for (const QString& line : lines) { + QList matches; + auto it = k_timeRegex.globalMatch(line); + while (it.hasNext()) { + matches.append(it.next()); + } + if (matches.isEmpty()) { + continue; + } + + QString lyric = line; + lyric.replace(k_timeRegex, QString()); + lyric = lyric.trimmed(); + + const qreal firstTime = matches.first().captured(1).toInt() * 60.0 + + matches.first().captured(2).toDouble(); + + if (firstTime < 20.0) { + bool isCredit = false; + for (const QString& k : k_creditKeywords) { + if (lyric.contains(k, Qt::CaseInsensitive)) { + isCredit = true; + break; + } + } + if (isCredit && + (lyric.contains(u':') || lyric.contains(QChar(0xFF1A)) || + lyric.size() < 25)) { + continue; + } + } + + for (const auto& m : matches) { + const qreal t = + m.captured(1).toInt() * 60.0 + m.captured(2).toDouble(); + result.append(LyricLine{.time = t, .text = lyric}); + } + } + + std::ranges::sort(result, [](const LyricLine& a, const LyricLine& b) { + return a.time < b.time; + }); + + return result; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyrics.hpp b/Plugins/ZShell/Services/lyrics.hpp new file mode 100644 index 0000000..864af88 --- /dev/null +++ b/Plugins/ZShell/Services/lyrics.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "lyriccandidate.hpp" + +namespace ZShell::services { + +struct LyricLine { + qreal time = 0.0; + QString text; +}; + +class Lyrics : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QStringList lyrics READ lyrics NOTIFY lyricsChanged) + Q_PROPERTY( + ZShell::config::LyricsBackend::Enum backend READ backend NOTIFY + backendChanged) + Q_PROPERTY( + ZShell::config::LyricsBackend::Enum preferredBackend READ + preferredBackend WRITE setPreferredBackend NOTIFY + preferredBackendChanged) + Q_PROPERTY( + QList lyricCandidates READ + lyricCandidates NOTIFY lyricCandidatesChanged) + Q_PROPERTY( + ZShell::services::LyricCandidate selectedCandidate READ selectedCandidate + WRITE setSelectedCandidate NOTIFY selectedCandidateChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(bool hasLyrics READ hasLyrics NOTIFY hasLyricsChanged) + Q_PROPERTY(qreal offset READ offset WRITE setOffset NOTIFY offsetChanged) + Q_PROPERTY(QString trackArtist READ trackArtist NOTIFY trackChanged) + Q_PROPERTY(QString trackTitle READ trackTitle NOTIFY trackChanged) + + public: + explicit Lyrics(QObject* parent = nullptr); + + [[nodiscard]] QStringList lyrics() const; + [[nodiscard]] LyricsBackend backend() const; + [[nodiscard]] LyricsBackend preferredBackend() const; + void setPreferredBackend(LyricsBackend value); + [[nodiscard]] QList lyricCandidates() const; + [[nodiscard]] LyricCandidate selectedCandidate() const; + void setSelectedCandidate(const LyricCandidate& value); + [[nodiscard]] bool loading() const; + [[nodiscard]] bool hasLyrics() const; + [[nodiscard]] qreal offset() const; + void setOffset(qreal value); + [[nodiscard]] QString trackArtist() const; + [[nodiscard]] QString trackTitle() const; + + [[nodiscard]] Q_INVOKABLE int indexForTime(qreal time) const; + [[nodiscard]] Q_INVOKABLE qreal timeForIndex(int index) const; + Q_INVOKABLE void setTrack( + const QString& artist, + const QString& title, + const QString& album = {}, + qreal duration = 0.0); + Q_INVOKABLE void clearTrack(); + Q_INVOKABLE void refresh(); + + signals: + void lyricsChanged(); + void backendChanged(); + void preferredBackendChanged(); + void lyricCandidatesChanged(); + void selectedCandidateChanged(); + void loadingChanged(); + void hasLyricsChanged(); + void offsetChanged(); + void trackChanged(); + + private: + void setBackend(LyricsBackend value); + void setLoading(bool value); + void setLines(QVector lines, LyricsBackend source); + void clearLines(); + void appendCandidates(const QList& add); + void clearCandidates(); + + void scheduleLoad(); + void doLoad(); + void cancelInFlight(); + int newRequestId(); + + void tryLocal(int reqId); + void tryLrclib(int reqId); + void tryNetEase(int reqId); + void chainNext(LyricsBackend justFailed, int reqId); + + void searchLrclibCandidates(int reqId); + void searchNetEaseCandidates(int reqId); + + void fetchLrclibById(const QString& id, int reqId); + void fetchNetEaseLyricsById(const QString& id, int reqId); + + QNetworkReply* getJson( + const QUrl& url, const QHash& headers = {}); + void trackReply(int reqId, QNetworkReply* reply); + + void onPreferredBackendConfigChanged(); + void onLyricsDirChanged(); + + void loadLyricsMap(); + void persistTrackPrefs(); + + [[nodiscard]] static QString lyricsDir(); + [[nodiscard]] static QString lyricsMapPath(); + [[nodiscard]] QString trackKey() const; + [[nodiscard]] static QString backendKey(LyricsBackend value); + [[nodiscard]] static LyricsBackend backendFromKey(const QString& key); + + [[nodiscard]] static const QString& stateDir(); + [[nodiscard]] static const QString& cacheDir(); + [[nodiscard]] static QString cachePathFor( + LyricsBackend backend, const QString& id); + [[nodiscard]] static QString readCachedLrc( + LyricsBackend backend, const QString& id); + static void writeCachedLrc( + LyricsBackend backend, const QString& id, const QString& text); + + [[nodiscard]] static QVector parseLrc(const QString& text); + [[nodiscard]] static QString tryReadLocalLrc( + const QString& dir, const QString& artist, const QString& title); + [[nodiscard]] static QString findLocalLrcRecursive( + const QString& dir, const QString& artist, const QString& title); + + QNetworkAccessManager* m_nam; + QTimer* m_loadDebounce; + + QVector m_lines; + QStringList m_lyrics; + LyricsBackend m_backend = LyricsBackend::Auto; + LyricsBackend m_preferredBackend = LyricsBackend::Auto; + QList m_candidates; + LyricCandidate m_selected; + bool m_loading = false; + bool m_hasLyrics = false; + qreal m_offset = 0.0; + + QString m_artist; + QString m_title; + QString m_album; + qreal m_duration = 0.0; + + int m_currentRequestId = 0; + QHash>> m_pendingReplies; + + QJsonObject m_lyricsMap; + bool m_lyricsMapLoaded = false; + bool m_settingFromPrefs = false; +}; + +} // namespace ZShell::services diff --git a/Plugins/cmake/qml-module.cmake b/Plugins/cmake/qml-module.cmake new file mode 100644 index 0000000..0b8ac52 --- /dev/null +++ b/Plugins/cmake/qml-module.cmake @@ -0,0 +1,55 @@ +message(STATUS "QML install dir: ${CMAKE_INSTALL_PREFIX}/${INSTALL_QMLDIR}") + +function(qml_module arg_TARGET) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI;INCLUDE_PREFIX" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES") + + set_source_files_properties(${arg_QML_SINGLETONS} PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + + qt_add_qml_module(${arg_TARGET} + URI ${arg_URI} + SOURCES ${arg_SOURCES} + QML_FILES ${arg_QML_FILES} ${arg_QML_SINGLETONS} + DEPENDENCIES ${arg_DEPENDENCIES} + IMPORTS ${arg_IMPORTS} + OPTIONAL_IMPORTS ${arg_OPTIONAL_IMPORTS} + DEFAULT_IMPORTS ${arg_DEFAULT_IMPORTS} + ) + + qt_query_qml_module(${arg_TARGET} + URI module_uri + PLUGIN_TARGET module_plugin_target + TARGET_PATH module_target_path + QMLDIR module_qmldir + TYPEINFO module_typeinfo + ) + + message(STATUS "Created QML module: ${module_uri}") + + string(REPLACE "/" ";" uri_parts "${module_target_path}") + list(GET uri_parts 0 top_level) + set(backing_lib_dir "${INSTALL_QMLDIR}/${top_level}/lib") + set(module_dir "${INSTALL_QMLDIR}/${module_target_path}") + + install(TARGETS ${arg_TARGET} + LIBRARY DESTINATION "${backing_lib_dir}" + RUNTIME DESTINATION "${backing_lib_dir}" + ) + install(TARGETS "${module_plugin_target}" + LIBRARY DESTINATION "${module_dir}" + RUNTIME DESTINATION "${module_dir}" + ) + install(FILES "${module_qmldir}" DESTINATION "${module_dir}") + install(FILES "${module_typeinfo}" DESTINATION "${module_dir}") + + target_link_libraries(${arg_TARGET} PRIVATE zshell-pch Qt::Core Qt::Qml ${arg_LIBRARIES}) + + if(arg_INCLUDE_PREFIX) + set(include_dir "${CMAKE_CURRENT_BINARY_DIR}/include") + file(MAKE_DIRECTORY "${include_dir}") + file(CREATE_LINK "${CMAKE_CURRENT_SOURCE_DIR}" "${include_dir}/${arg_INCLUDE_PREFIX}" SYMBOLIC) + target_include_directories(${arg_TARGET} PUBLIC "${include_dir}") + endif() + + file(RELATIVE_PATH plugin_to_lib "/${module_target_path}" "/${top_level}/lib") + set_property(TARGET ${module_plugin_target} APPEND PROPERTY INSTALL_RPATH "$ORIGIN/${plugin_to_lib}") +endfunction() diff --git a/Services/ShellState.qml b/Services/ShellState.qml new file mode 100644 index 0000000..b806f4d --- /dev/null +++ b/Services/ShellState.qml @@ -0,0 +1,35 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Components +import qs.Helpers + +Singleton { + function anySidebarOpen(): bool { + return states.instances.some(s => s.sidebar); + } + + function forActive(): ScreenState { + const mon = Hypr.focusedMonitor; + for (const s of states.instances) + if (Hypr.monitorFor(s.modelData) === mon) + return s; + return null; + } + + function forScreen(screen: ShellScreen): ScreenState { + for (const s of states.instances) + if (s.modelData === screen) + return s; + return null; + } + + Variants { + id: states + + model: Quickshell.screens + + ScreenState {} + } +} From f825e4d53b90cdf1e9870d7522feb4fb7eaa5f1a Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 21 Sep 2026 15:37:45 +0200 Subject: [PATCH 05/21] feat: fontbuilder to streamline font styling --- Components/BaseStyledSlider.qml | 63 ++-- Components/ButtonBase.qml | 6 +- Components/CustomRadioButton.qml | 7 +- Components/CustomSlider.qml | 16 +- Components/CustomText.qml | 9 +- Components/CustomTextField.qml | 6 +- Components/GeminiGlow.qml | 42 +++ Components/IconButton.qml | 3 +- Components/IconTextButton.qml | 3 +- Components/MaterialIcon.qml | 20 +- Components/SearchBar.qml | 2 +- Components/TextFieldBase.qml | 11 +- Components/Toast/ToastItem.qml | 7 +- Helpers/Picker.qml | 2 +- Helpers/Picker/AnnotationToolbar.qml | 14 +- Modules/Bar/Components/Clock.qml | 9 +- Modules/Bar/Components/Common/BatteryIcon.qml | 6 +- Modules/Bar/Components/HyprsunsetWidget.qml | 5 +- Modules/Bar/Components/MediaWidget.qml | 12 +- Modules/Bar/Components/NotifBell.qml | 8 +- Modules/Bar/Components/Resource.qml | 5 +- .../Components/StatusIcons/AudioWidget.qml | 11 +- .../Bar/Components/StatusIcons/MicWidget.qml | 11 +- .../Components/StatusIcons/UPowerWidget.qml | 14 +- Modules/Bar/Components/UpdatesWidget.qml | 3 +- Modules/Bar/Components/WindowTitle.qml | 6 +- Modules/Bar/Popouts/UPowerPopout.qml | 5 +- Modules/Bar/Popouts/UpdatesPopout.qml | 12 +- Modules/Clipboard/Content.qml | 6 +- Modules/Dashboard/Dash/DateTime.qml | 51 --- Modules/Dashboard/Dash/User.qml | 7 +- Modules/Dashboard/Dash/Weather.qml | 5 +- Modules/Dashboard/Media.qml | 6 +- Modules/Dashboard/Media/Details.qml | 18 +- Modules/Dashboard/Media/LyricList.qml | 8 +- Modules/Dashboard/Media/LyricsAndSelector.qml | 4 +- Modules/Dashboard/Media/LyricsInfo.qml | 4 +- Modules/Dashboard/Tabs.qml | 2 +- Modules/Dashboard/Weather/Graphs.qml | 4 +- Modules/Dashboard/WeatherTab.qml | 29 +- .../DesktopIcons/BackgroundContextMenu.qml | 302 ++++++++------- .../DesktopIcons/DesktopIconContextMenu.qml | 13 +- Modules/Drawing/Content.qml | 4 +- Modules/Drawing/Wrapper.qml | 8 +- Modules/Launcher/ContentList.qml | 16 +- Modules/Launcher/Items/ActionItem.qml | 5 +- Modules/Launcher/Items/AppItem.qml | 3 +- Modules/Launcher/Items/CalcItem.qml | 6 +- Modules/Launcher/Items/FilesItem.qml | 4 +- Modules/Launcher/Items/VariantItem.qml | 7 +- Modules/Launcher/Items/WallpaperTile.qml | 3 +- Modules/Lock/Center.qml | 37 +- Modules/Lock/InputField.qml | 15 +- Modules/Lock/LockSurface.qml | 3 +- Modules/Lock/Media.qml | 3 +- Modules/Lock/NotifDock.qml | 7 +- Modules/Lock/NotifGroup.qml | 20 +- Modules/Lock/Resources.qml | 5 +- Modules/Lock/Weather/BriefInfo.qml | 8 +- Modules/Lock/Weather/Forecast.qml | 13 +- Modules/Notifications/Notification.qml | 31 +- Modules/Notifications/Sidebar/NotifDock.qml | 32 +- .../Sidebar/Utils/Cards/Record.qml | 26 +- .../Sidebar/Utils/Cards/RecordingList.qml | 6 +- .../Sidebar/Utils/Cards/Toggles.qml | 2 +- .../Sidebar/Utils/IdleInhibit.qml | 20 +- Modules/Polkit/Polkit.qml | 12 +- Modules/Resources/Cards/HeroCard.qml | 13 +- Modules/Resources/Cards/MemoryCard.qml | 5 +- Modules/Resources/Cards/StorageCard.qml | 10 +- Modules/Resources/Cards/VramCard.qml | 5 +- Modules/Settings/Common/AudioDeviceList.qml | 9 +- Modules/Settings/Common/BlobPopup.qml | 8 +- Modules/Settings/Common/DialogRowButton.qml | 2 +- .../Settings/Common/DialogSelectButton.qml | 3 +- Modules/Settings/Common/InfoRow.qml | 8 +- Modules/Settings/Common/ItemList.qml | 7 +- Modules/Settings/Common/ListEditor.qml | 13 +- Modules/Settings/Common/NavRow.qml | 69 +--- Modules/Settings/Common/PageBase.qml | 3 +- Modules/Settings/Common/PopupRow.qml | 6 +- Modules/Settings/Common/RowButton.qml | 7 +- Modules/Settings/Common/SearchPopup.qml | 9 +- Modules/Settings/Common/SectionHeader.qml | 2 +- Modules/Settings/Common/SelectRow.qml | 4 +- Modules/Settings/Common/SliderRow.qml | 4 +- Modules/Settings/Common/SpinRow.qml | 4 +- Modules/Settings/Common/TextFieldRow.qml | 1 - Modules/Settings/Common/TimeDialogRow.qml | 2 +- Modules/Settings/Common/TimeDialogToggle.qml | 4 +- Modules/Settings/Common/TimeInput.qml | 4 +- Modules/Settings/Common/ToggleRow.qml | 5 +- Modules/Settings/NavPane.qml | 9 +- Modules/Settings/NavPane/NavLocations.qml | 19 +- Modules/Settings/PageCompRegistry.qml | 2 +- Modules/Settings/Pages/AboutPage.qml | 4 +- Modules/Settings/Pages/Apps/AllApps.qml | 8 +- Modules/Settings/Pages/Apps/AppInfo.qml | 7 +- Modules/Settings/Pages/AppsPage.qml | 8 +- Modules/Settings/Pages/Audio/AppVolumes.qml | 1 - Modules/Settings/Pages/AudioPage.qml | 8 +- Modules/Settings/Pages/Panels/BarPanel.qml | 6 +- Modules/Settings/Pages/PanelsPage.qml | 10 +- Modules/Settings/Pages/ServicesPage.qml | 2 +- .../Pages/Wallpaper/WallpaperSelect.qml | 2 +- Plugins/ZShell/Config/CMakeLists.txt | 4 + Plugins/ZShell/Config/appearance.hpp | 351 +++++++++++++++++- Plugins/ZShell/Config/configobject.cpp | 16 +- Plugins/ZShell/Config/font.cpp | 290 +++++++++++++++ Plugins/ZShell/Config/font.hpp | 200 ++++++++++ Plugins/ZShell/Config/fontbuilder.cpp | 105 ++++++ Plugins/ZShell/Config/fontbuilder.hpp | 41 ++ Plugins/ZShell/Config/tokens.hpp | 137 ++----- assets/shaders/geminiglow.frag | 93 +++++ assets/shaders/geminiglow.frag.qsb | Bin 0 -> 3721 bytes 115 files changed, 1687 insertions(+), 916 deletions(-) create mode 100755 Components/GeminiGlow.qml delete mode 100644 Modules/Dashboard/Dash/DateTime.qml create mode 100644 Plugins/ZShell/Config/font.cpp create mode 100644 Plugins/ZShell/Config/font.hpp create mode 100644 Plugins/ZShell/Config/fontbuilder.cpp create mode 100644 Plugins/ZShell/Config/fontbuilder.hpp create mode 100755 assets/shaders/geminiglow.frag create mode 100644 assets/shaders/geminiglow.frag.qsb diff --git a/Components/BaseStyledSlider.qml b/Components/BaseStyledSlider.qml index 9d3b654..0ebb4ad 100644 --- a/Components/BaseStyledSlider.qml +++ b/Components/BaseStyledSlider.qml @@ -80,49 +80,29 @@ Slider { property bool moving - function update(): void { - animate = !moving; - binding.when = moving; - font.pointSize = moving ? Tokens.font.size.small : Tokens.font.size.larger; - font.family = moving ? Config.appearance.font.family.sans : Config.appearance.font.family.material; - } - anchors.centerIn: parent + anchors.verticalCenterOffset: 1 + text: moving ? Math.round(root.value * 100) : root.icon color: Colors.palette.m3inverseOnSurface - text: root.icon + font: moving ? Tokens.font.body.small : Tokens.font.icon.medium - onMovingChanged: anim.restart() - - Binding { - id: binding - - property: "text" - target: icon - value: Math.round(root.value * root.multiplier) - when: false - } - - SequentialAnimation { - id: anim - - Anim { - duration: Tokens.anim.durations.normal / 2 - easing: Tokens.anim.standardAccel - property: "scale" - target: icon - to: 0 - } - - ScriptAction { - script: icon.update() - } - - Anim { - duration: Tokens.anim.durations.normal / 2 - easing: Tokens.anim.standardDecel - property: "scale" - target: icon - to: 1 + Behavior on moving { + SequentialAnimation { + Anim { + target: icon + property: "scale" + to: 0.3 + duration: Tokens.anim.durations.small / 2 + easing: Tokens.anim.standardAccel + } + PropertyAction {} + Anim { + target: icon + property: "scale" + to: 1 + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardDecel + } } } } @@ -130,7 +110,7 @@ Slider { } Behavior on value { Anim { - duration: Tokens.anim.durations.large + type: Anim.StandardLarge } } @@ -138,7 +118,6 @@ Slider { onValueChanged: { if (!initialized) { initialized = true; - oldValue = value; return; } diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml index ec11a7d..9b9e7bd 100644 --- a/Components/ButtonBase.qml +++ b/Components/ButtonBase.qml @@ -20,11 +20,7 @@ CustomRect { property color disabledColor: Qt.alpha(Colors.palette.m3onSurface, 0.1) property color disabledOnColor: Qt.alpha(Colors.palette.m3onSurface, 0.38) property bool fillWidth - property font font: ({ - family: Config.appearance.font.family.sans, - pointSize: Tokens.font.size.larger, - bold: false - }) + property font font: Tokens.font.body.small property real horizontalPadding: padding readonly property alias hovered: stateLayer.containsMouse required implicitHeight diff --git a/Components/CustomRadioButton.qml b/Components/CustomRadioButton.qml index aca7899..aca0f4c 100644 --- a/Components/CustomRadioButton.qml +++ b/Components/CustomRadioButton.qml @@ -6,7 +6,7 @@ import qs.Services RadioButton { id: root - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight) implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin @@ -14,7 +14,7 @@ RadioButton { anchors.left: outerCircle.right anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter - font.pointSize: root.font.pointSize + font: root.font text: root.text } indicator: Rectangle { @@ -29,8 +29,7 @@ RadioButton { radius: Tokens.rounding.full Behavior on border.color { - CAnim { - } + CAnim {} } StateLayer { diff --git a/Components/CustomSlider.qml b/Components/CustomSlider.qml index 39c5339..5acd92b 100644 --- a/Components/CustomSlider.qml +++ b/Components/CustomSlider.qml @@ -100,15 +100,19 @@ Slider { anchors.bottom: parent.bottom anchors.top: parent.top color: root.insetColor - font.pixelSize: Tokens.font.size.smaller * 2 + fontStyle: { + if (root.height < 96) + return Tokens.font.icon.pixelSize(24).build(); + else + return Tokens.font.icon.pixelSize(32).build(); + } text: root.insetIcon verticalAlignment: Text.AlignVCenter visible: !root.wavy && root.insetIcon !== "" x: Tokens.spacing.extraSmall + dockT * (handle.x + Tokens.spacing.small) Behavior on dockT { - Anim { - } + Anim {} } } @@ -138,8 +142,7 @@ Slider { startX: x Behavior on color { - CAnim { - } + CAnim {} } Anim on waveProgress { duration: root.waveDuration @@ -156,8 +159,7 @@ Slider { Behavior on filledWidth { id: widthBehavior - Anim { - } + Anim {} } Component.onCompleted: { diff --git a/Components/CustomText.qml b/Components/CustomText.qml index 08ae9ee..144665e 100644 --- a/Components/CustomText.qml +++ b/Components/CustomText.qml @@ -14,15 +14,13 @@ Text { property real animateTo: 1 color: Colors.palette.m3onSurface - font.family: Config.appearance.font.family.sans - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.small linkColor: Colors.palette.m3onPrimaryFixedVariant renderType: Text.NativeRendering textFormat: Text.PlainText Behavior on color { - CAnim { - } + CAnim {} } Behavior on text { enabled: root.animate @@ -34,8 +32,7 @@ Text { type: Anim.FastEffects } - PropertyAction { - } + PropertyAction {} TAnim { target: root diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index 66141b8..ab2ff06 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -29,7 +29,7 @@ TextFieldBase { property string supportingText property string trailingIcon - property int smallFontSize: Tokens.font.size.small + property int smallFontSize: Tokens.font.label.small.pointSize property int type: CustomTextField.Outlined property var validate // Regex or function @@ -151,6 +151,7 @@ TextFieldBase { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant text: root.leadingIcon + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() } } @@ -165,6 +166,7 @@ TextFieldBase { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant text: root.trailingIcon + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() } } } @@ -179,7 +181,7 @@ TextFieldBase { sourceComponent: CustomText { color: root.isError ? Colors.palette.m3error : Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.effectiveSupportingText } } diff --git a/Components/GeminiGlow.qml b/Components/GeminiGlow.qml new file mode 100755 index 0000000..443ef07 --- /dev/null +++ b/Components/GeminiGlow.qml @@ -0,0 +1,42 @@ +import QtQuick +import Quickshell + +ShaderEffect { + id: root + + property Item target: parent + + property real glowSize: 40 + property real innerGlow: 0 + property real lineWidth: 2 + property real intensity: 1.0 + property real speed: 1.0 + property int period: 6000 + property bool running: true + + property color color1: "#4285F4" + property color color2: "#9B72CB" + property color color3: "#D96570" + property color color4: "#F2A54A" + + readonly property size itemSize: Qt.size(width, height) + readonly property size rectSize: target ? Qt.size(target.width, target.height) : Qt.size(0, 0) + readonly property real radius: (target as Rectangle).radius ?? 0 + property real phase: 0 + + anchors.fill: target + anchors.margins: -glowSize + z: -1 + + enabled: false + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/geminiglow.frag.qsb`) + + FrameAnimation { + running: root.running && root.visible + onTriggered: { + const dt = frameTime * 1000; + root.phase = (root.phase + dt * root.speed / root.period) % 1.0; + } + } +} diff --git a/Components/IconButton.qml b/Components/IconButton.qml index 0210520..3bdfc48 100644 --- a/Components/IconButton.qml +++ b/Components/IconButton.qml @@ -8,6 +8,7 @@ ButtonBase { property alias icon: label.text readonly property alias label: label + font: Tokens.font.icon.medium activeColor: type === IconButton.Filled ? Colors.palette.m3primary : Colors.palette.m3secondary activeOnColor: type === IconButton.Filled ? Colors.palette.m3onPrimary : type === IconButton.Tonal ? Colors.palette.m3onSecondary : Colors.palette.m3primary implicitHeight: { @@ -36,7 +37,7 @@ ButtonBase { anchors.verticalCenterOffset: 1 color: root.onColor fill: !root.isToggle || root.internalChecked ? 1 : 0 - font.pointSize: root.font.pointSize + fontStyle: root.font Behavior on fill { Anim { diff --git a/Components/IconTextButton.qml b/Components/IconTextButton.qml index 6190121..fb568f6 100644 --- a/Components/IconTextButton.qml +++ b/Components/IconTextButton.qml @@ -46,10 +46,9 @@ ButtonBase { Layout.alignment: Qt.AlignVCenter color: root.onColor fill: root.internalChecked ? 1 : 0 - font: { + fontStyle: { const f = Qt.font(root.font); f.pointSize = Math.round(root.font.pointSize * 1.2); - f.family = "Material Symbols Rounded"; return f; } diff --git a/Components/MaterialIcon.qml b/Components/MaterialIcon.qml index 0e81d10..b93e38c 100644 --- a/Components/MaterialIcon.qml +++ b/Components/MaterialIcon.qml @@ -1,27 +1,13 @@ +import QtQuick import ZShell.Config import qs.Services CustomText { id: root - readonly property list allowed: [20, 24, 40, 48] property real fill: 0 property int grade: Colors.light ? 0 : -25 - readonly property int opsz: clampOpsz(optical) - property int optical: fontInfo.pixelSize + property font fontStyle: Tokens.font.icon.small - function clampOpsz(value): int { - return allowed.reduce((closest, current) => { - return Math.abs(current - value) < Math.abs(closest - value) ? current : closest; - }); - } - - font.family: "Material Symbols Rounded" - font.pointSize: Tokens.font.size.larger - font.variableAxes: ({ - FILL: fill, - GRAD: grade, - opsz: opsz, - wght: fontInfo.weight - }) + font: Tokens.font.icon.size(fontStyle.pointSize).weight(fontStyle.weight).vaxes(fontStyle.variableAxes).fill(fill.toFixed(1)).grade(grade).build() } diff --git a/Components/SearchBar.qml b/Components/SearchBar.qml index f14b51e..5642d73 100644 --- a/Components/SearchBar.qml +++ b/Components/SearchBar.qml @@ -62,7 +62,7 @@ TextFieldBase { anchors.leftMargin: Tokens.padding.large anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() text: "search" } diff --git a/Components/TextFieldBase.qml b/Components/TextFieldBase.qml index b68318b..0edd409 100644 --- a/Components/TextFieldBase.qml +++ b/Components/TextFieldBase.qml @@ -8,7 +8,7 @@ TextField { color: Colors.palette.m3onSurface cursorVisible: !readOnly - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small implicitHeight: contentHeight + topPadding + bottomPadding implicitWidth: contentWidth + leftPadding + rightPadding placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom @@ -18,14 +18,11 @@ TextField { verticalAlignment: TextInput.AlignVCenter Behavior on color { - CAnim { - } - } - cursorDelegate: Item { + CAnim {} } + cursorDelegate: Item {} Behavior on selectionColor { - CAnim { - } + CAnim {} } CustomRect { diff --git a/Components/Toast/ToastItem.qml b/Components/Toast/ToastItem.qml index b37ead8..254e20c 100644 --- a/Components/Toast/ToastItem.qml +++ b/Components/Toast/ToastItem.qml @@ -36,8 +36,7 @@ CustomRect { radius: Tokens.rounding.medium Behavior on border.color { - CAnim { - } + CAnim {} } Elevation { @@ -84,7 +83,7 @@ CustomRect { return Colors.palette.m3onError; return Colors.palette.m3onSurfaceVariant; } - font.pointSize: Math.round(Tokens.font.size.large * 1.2) + fontStyle: Tokens.font.icon.builders.large.scale(1.2).build() text: root.modelData.icon } } @@ -107,7 +106,7 @@ CustomRect { return Colors.palette.m3onSurface; } elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.small text: root.modelData.title } diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index 05b4146..710dd99 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -42,7 +42,7 @@ Item { readonly property real scaleRatio: Hypr.monitorFor(screen).scale required property ShellScreen screen property real sh: Math.abs(sy - ey) - readonly property color shadowColor: Hypr.options.decoration.shadow.color + readonly property color shadowColor: Hypr.options.decoration.shadow?.color ?? "black" readonly property bool shadowEnabled: Hypr.options.decoration.shadow.enabled readonly property var shadowOffset: Hypr.options.decoration.shadow.offset readonly property int shadowRange: Hypr.options.decoration.shadow.range diff --git a/Helpers/Picker/AnnotationToolbar.qml b/Helpers/Picker/AnnotationToolbar.qml index d95eb37..fb6b673 100755 --- a/Helpers/Picker/AnnotationToolbar.qml +++ b/Helpers/Picker/AnnotationToolbar.qml @@ -73,7 +73,7 @@ CustomRect { required property var modelData color: root.tool === modelData.id ? "#3584e4" : "transparent" - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: tool.modelData.glyph inactiveColor: root.tool === modelData.id ? Colors.palette.m3primary : "transparent" inactiveOnColor: root.tool === modelData.id ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface @@ -103,7 +103,7 @@ CustomRect { required property color modelData fillWidth: false - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "" implicitHeight: buttonSize implicitWidth: buttonSize @@ -128,7 +128,7 @@ CustomRect { spacing: Tokens.spacing.extraSmall IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "undo" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -140,7 +140,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete_history" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -152,7 +152,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "content_copy" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -164,7 +164,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete_forever" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -176,7 +176,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "check" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index 7940c1d..4791c73 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -13,6 +13,7 @@ import qs.Services WidgetBase { id: root + readonly property font font: Tokens.font.clock.family(Config.appearance.font.family.clock).build() readonly property color contentColor: screenState.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface readonly property string effectiveFormat: { let fmt = Config.general.dateFormat; @@ -57,7 +58,7 @@ WidgetBase { anchors.centerIn: parent color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font height: implicitHeight text: Time.dateStr visible: root.horizontal @@ -84,7 +85,7 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: Qt.formatDateTime(Time.date, modelData) Behavior on color { @@ -114,7 +115,7 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: { if (modelData.includes("h")) return Time.hourStr; @@ -135,7 +136,7 @@ WidgetBase { CustomText { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: Qt.formatDateTime(Time.date, "AP") visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0 diff --git a/Modules/Bar/Components/Common/BatteryIcon.qml b/Modules/Bar/Components/Common/BatteryIcon.qml index 2f13bc9..6a78dfa 100644 --- a/Modules/Bar/Components/Common/BatteryIcon.qml +++ b/Modules/Bar/Components/Common/BatteryIcon.qml @@ -28,8 +28,7 @@ Row { CustomText { color: Battery.getColors(root.percentage, root.devState).fg - font.pointSize: Tokens.font.size.larger / 1.5 - font.weight: 800 + font: Tokens.font.body.builders.small.size(11).weight(Font.Bold).build() height: track.height horizontalAlignment: Text.AlignHCenter text: Math.round(root.percentage * 100) @@ -60,8 +59,7 @@ Row { clip: true color: Battery.getColors(root.percentage, root.devState).bg - font.pointSize: Tokens.font.size.larger / 1.5 - font.weight: 800 + font: Tokens.font.body.builders.small.size(11).weight(Font.Bold).build() height: track.height horizontalAlignment: Text.AlignHCenter text: Math.round(root.percentage * 100) diff --git a/Modules/Bar/Components/HyprsunsetWidget.qml b/Modules/Bar/Components/HyprsunsetWidget.qml index 8afd545..f409a29 100644 --- a/Modules/Bar/Components/HyprsunsetWidget.qml +++ b/Modules/Bar/Components/HyprsunsetWidget.qml @@ -25,12 +25,11 @@ WidgetBase { animate: true color: root.tempEnabled ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.tempEnabled ? 1 : 0 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: root.tempEnabled ? "lightbulb" : "light_off" Behavior on fill { - Anim { - } + Anim {} } } } diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index 32fb007..7d2297f 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -19,14 +19,12 @@ WidgetBase { Behavior on implicitHeight { enabled: !root.horizontal - Anim { - } + Anim {} } Behavior on implicitWidth { enabled: root.horizontal - Anim { - } + Anim {} } TextMetrics { @@ -47,15 +45,14 @@ WidgetBase { columns: root.horizontal ? -1 : 1 Behavior on implicitWidth { - Anim { - } + Anim {} } MaterialIcon { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter animate: true color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Players.active?.isPlaying ? "music_note" : "music_off" } @@ -65,7 +62,6 @@ WidgetBase { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter animate: true color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter implicitHeight: root.horizontal ? root.implicitHeight : root.textWidth implicitWidth: root.horizontal ? root.textWidth : root.implicitWidth diff --git a/Modules/Bar/Components/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml index 34b4c68..dfeb760 100644 --- a/Modules/Bar/Components/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -22,16 +22,14 @@ WidgetBase { anchors.centerIn: parent color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.screenState.sidebar ? 1 : 0 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.size(16).build() text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" Behavior on color { - CAnim { - } + CAnim {} } Behavior on fill { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/Resource.qml b/Modules/Bar/Components/Resource.qml index 4314ee7..cce4285 100644 --- a/Modules/Bar/Components/Resource.qml +++ b/Modules/Bar/Components/Resource.qml @@ -27,8 +27,7 @@ GridLayout { percentage: 0 Behavior on animatedPercentage { - Anim { - } + Anim {} } Component.onCompleted: animatedPercentage = percentage @@ -44,7 +43,7 @@ GridLayout { Layout.preferredWidth: 16 color: root.iconColor - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: root.icon } diff --git a/Modules/Bar/Components/StatusIcons/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml index 93c4752..a6fab2a 100644 --- a/Modules/Bar/Components/StatusIcons/AudioWidget.qml +++ b/Modules/Bar/Components/StatusIcons/AudioWidget.qml @@ -13,19 +13,16 @@ MaterialIcon { animate: true color: Audio.muted ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Audio.muted ? "volume_off" : "volume_up" Behavior on Layout.maximumWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/StatusIcons/MicWidget.qml b/Modules/Bar/Components/StatusIcons/MicWidget.qml index 8f58616..cb61bee 100644 --- a/Modules/Bar/Components/StatusIcons/MicWidget.qml +++ b/Modules/Bar/Components/StatusIcons/MicWidget.qml @@ -13,19 +13,16 @@ MaterialIcon { animate: true color: (Audio.sourceMuted ?? false) ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Audio.sourceMuted ? "mic_off" : "mic" Behavior on Layout.maximumWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml index 8cdc2a7..48c1b71 100644 --- a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml +++ b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml @@ -15,20 +15,16 @@ Item { implicitWidth: currentItem?.implicitWidth ?? 0 Behavior on Layout.preferredHeight { - Anim { - } + Anim {} } Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } Component.onCompleted: Battery.startDaemon() @@ -67,7 +63,7 @@ Item { Layout.alignment: Qt.AlignVCenter animate: true fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: { if (PowerProfiles.profile === PowerProfile.PowerSaver) return "energy_savings_leaf"; diff --git a/Modules/Bar/Components/UpdatesWidget.qml b/Modules/Bar/Components/UpdatesWidget.qml index a525040..a44c0de 100644 --- a/Modules/Bar/Components/UpdatesWidget.qml +++ b/Modules/Bar/Components/UpdatesWidget.qml @@ -27,14 +27,13 @@ WidgetBase { MaterialIcon { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter - font.pointSize: root.horizontal ? Tokens.font.size.larger : Tokens.font.size.large + fontStyle: Tokens.font.icon.small text: "package_2" } CustomText { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter color: root.textColor - font.pointSize: Tokens.font.size.normal text: root.countUpdates } } diff --git a/Modules/Bar/Components/WindowTitle.qml b/Modules/Bar/Components/WindowTitle.qml index c213072..7515093 100644 --- a/Modules/Bar/Components/WindowTitle.qml +++ b/Modules/Bar/Components/WindowTitle.qml @@ -52,8 +52,7 @@ Item { elide: Qt.ElideRight elideWidth: root.maxSize - (root.horizontal ? root.current.anchors.leftMargin : 0) - font.family: "Rubik" - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.small text: Hypr.activeToplevel?.title ?? qsTr("Desktop") onElideWidthChanged: root.current.text = elidedText @@ -81,8 +80,7 @@ Item { width: root.horizontal ? implicitWidth : implicitHeight Behavior on opacity { - Anim { - } + Anim {} } transform: [ Translate { diff --git a/Modules/Bar/Popouts/UPowerPopout.qml b/Modules/Bar/Popouts/UPowerPopout.qml index b702dd7..09c94e1 100644 --- a/Modules/Bar/Popouts/UPowerPopout.qml +++ b/Modules/Bar/Popouts/UPowerPopout.qml @@ -181,8 +181,7 @@ Column { } ] transitions: Transition { - AnchorAnim { - } + AnchorAnim {} } } @@ -246,7 +245,7 @@ Column { anchors.centerIn: parent color: profiles.current === text ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: profiles.current === text ? 1 : 0 - font.pointSize: Tokens.font.size.large * 2 + fontStyle: Tokens.font.icon.extraLarge grade: Colors.light ? 0 : (text === "balance" ? -25 : 0) text: parent.icon diff --git a/Modules/Bar/Popouts/UpdatesPopout.qml b/Modules/Bar/Popouts/UpdatesPopout.qml index 8a143f3..6de1f39 100644 --- a/Modules/Bar/Popouts/UpdatesPopout.qml +++ b/Modules/Bar/Popouts/UpdatesPopout.qml @@ -39,7 +39,7 @@ CustomClippingRect { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top color: Colors.tPalette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.extraLarge * 3 + fontStyle: Tokens.font.icon.builders.large.size(48).weight(Font.DemiBold).build() horizontalAlignment: Text.AlignHCenter text: "check" } @@ -89,7 +89,7 @@ CustomClippingRect { anchors.rightMargin: Tokens.padding.small MaterialIcon { - font.pointSize: Tokens.font.size.large * 2 + fontStyle: Tokens.font.icon.extraLarge text: "package_2" } @@ -100,7 +100,7 @@ CustomClippingRect { Layout.fillWidth: true Layout.preferredHeight: 25 elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium text: update.sections[0] } @@ -122,7 +122,7 @@ CustomClippingRect { Layout.preferredWidth: 125 animate: true color: Colors.palette.m3tertiary - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium horizontalAlignment: Text.AlignHCenter marqueeEnabled: true pauseMs: 4000 @@ -133,7 +133,7 @@ CustomClippingRect { MaterialIcon { Layout.fillHeight: true color: Colors.palette.m3secondary - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large horizontalAlignment: Text.AlignHCenter text: "arrow_right_alt" verticalAlignment: Text.AlignVCenter @@ -146,7 +146,7 @@ CustomClippingRect { Layout.preferredWidth: 120 animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium horizontalAlignment: Text.AlignHCenter marqueeEnabled: true pauseMs: 4000 diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index dd5d6a3..f4683c6 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -66,8 +66,7 @@ Item { radius: 25 Behavior on implicitWidth { - Anim { - } + Anim {} } Column { @@ -104,7 +103,6 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small text: lineRow.modelData.line } } @@ -252,7 +250,7 @@ Item { anchors.left: parent.left anchors.margins: Tokens.padding.small anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: clipItem.isImage ? "image" : "text_fields" } diff --git a/Modules/Dashboard/Dash/DateTime.qml b/Modules/Dashboard/Dash/DateTime.qml deleted file mode 100644 index 3a76d5c..0000000 --- a/Modules/Dashboard/Dash/DateTime.qml +++ /dev/null @@ -1,51 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import qs.Components -import ZShell.Config -import qs.Helpers -import qs.Services - -Item { - id: root - - anchors.bottom: parent.bottom - anchors.top: parent.top - implicitWidth: 110 - - ColumnLayout { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 0 - - CustomText { - Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: -(font.pointSize * 0.4) - color: Colors.palette.m3secondary - font.family: "Rubik" - font.pointSize: 18 - font.weight: 600 - text: Time.hourStr - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.palette.m3primary - font.family: "Rubik" - font.pointSize: 18 * 0.9 - text: "•••" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: -(font.pointSize * 0.4) - color: Colors.palette.m3secondary - font.family: "Rubik" - font.pointSize: 18 - font.weight: 600 - text: Time.minuteStr - } - } -} diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 1d37230..264ae5f 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -23,8 +23,7 @@ Row { MaterialIcon { anchors.centerIn: parent fill: 1 - font.pointSize: Math.floor(info.implicitHeight / 2) || 1 - grade: 200 + fontStyle: Tokens.font.icon.large text: "person" } @@ -65,7 +64,6 @@ Row { anchors.leftMargin: icon.anchors.leftMargin anchors.verticalCenter: icon.verticalCenter elide: Text.ElideRight - font.pointSize: 13 text: `: ${SystemInfo.osPrettyName || SystemInfo.osName}` width: Config.dashboard.sizes.infoWidth } @@ -103,7 +101,7 @@ Row { anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 color: line.colour fill: 1 - font.pointSize: 13 + fontStyle: Tokens.font.icon.small text: line.icon } @@ -114,7 +112,6 @@ Row { anchors.leftMargin: icon.anchors.leftMargin anchors.verticalCenter: icon.verticalCenter elide: Text.ElideNone - font.pointSize: 13 text: `: ${line.text}` width: Config.dashboard.sizes.infoWidth } diff --git a/Modules/Dashboard/Dash/Weather.qml b/Modules/Dashboard/Dash/Weather.qml index c951fb7..906ac93 100644 --- a/Modules/Dashboard/Dash/Weather.qml +++ b/Modules/Dashboard/Dash/Weather.qml @@ -19,7 +19,7 @@ Item { anchors.verticalCenter: parent.verticalCenter animate: true color: Colors.palette.m3secondary - font.pointSize: 54 + fontStyle: Tokens.font.icon.extraLarge text: Weather.icon } @@ -35,8 +35,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.extraLarge - font.weight: 500 + font: Tokens.font.body.large text: Weather.temp } diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml index f1f98ee..297e996 100644 --- a/Modules/Dashboard/Media.qml +++ b/Modules/Dashboard/Media.qml @@ -108,7 +108,7 @@ Item { anchors.centerIn: parent text: "queue_music" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large color: Colors.palette.m3onPrimaryContainer } } @@ -116,13 +116,13 @@ Item { CustomText { Layout.alignment: Qt.AlignHCenter text: qsTr("Nothing playing") - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium } CustomText { text: qsTr("Play something for it to show up here!") color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large } } } diff --git a/Modules/Dashboard/Media/Details.qml b/Modules/Dashboard/Media/Details.qml index 39ba295..2d6c6c9 100644 --- a/Modules/Dashboard/Media/Details.qml +++ b/Modules/Dashboard/Media/Details.qml @@ -38,7 +38,7 @@ ColumnLayout { CustomText { Layout.fillWidth: true text: Players.active?.trackTitle ?? "" - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.large elide: Text.ElideRight animate: true } @@ -47,7 +47,7 @@ ColumnLayout { Layout.fillWidth: true text: Players.active?.trackArtist || qsTr("Unknown artist") color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.medium elide: Text.ElideRight animate: true } @@ -56,7 +56,7 @@ ColumnLayout { Layout.fillWidth: true text: Players.active?.trackAlbum || qsTr("Unknown album") color: Colors.palette.m3secondary - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.medium elide: Text.ElideRight animate: true } @@ -70,7 +70,7 @@ ColumnLayout { id: timeMetrics text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" - font.pointSize: Tokens.font.size.normal + font: Tokens.font.label.medium } CustomText { @@ -128,7 +128,7 @@ ColumnLayout { isRound: true shapeMorph: true checked: Players.active?.shuffle ?? false - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() enabled: Players.active?.shuffleSupported onClicked: Players.active.shuffle = !Players.active?.shuffle implicitWidth: Math.round(implicitHeight * 0.9) @@ -141,7 +141,7 @@ ColumnLayout { icon: "skip_previous" isRound: true shapeMorph: true - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.icon.large enabled: Players.active?.canGoPrevious onClicked: Players.active?.previous() } @@ -154,7 +154,7 @@ ColumnLayout { shapeMorph: true fillWidth: true checked: Players.active?.isPlaying ?? false - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.icon.large enabled: Players.active?.canTogglePlaying onClicked: Players.active?.togglePlaying() } @@ -166,7 +166,7 @@ ColumnLayout { icon: "skip_next" isRound: true shapeMorph: true - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.icon.large enabled: Players.active?.canGoNext onClicked: Players.active?.next() } @@ -177,7 +177,7 @@ ColumnLayout { isRound: true shapeMorph: true checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() enabled: Players.active?.loopSupported onClicked: { const state = Players.active.loopState; diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml index a90aaa2..69ce845 100644 --- a/Modules/Dashboard/Media/LyricList.qml +++ b/Modules/Dashboard/Media/LyricList.qml @@ -179,7 +179,7 @@ Item { CustomText { text: qsTr("Loading lyrics...") color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.normal } } @@ -204,14 +204,14 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignHCenter text: "sentiment_sad" - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge color: Colors.palette.m3outline } CustomText { text: qsTr("No lyrics found") color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium } } } @@ -258,7 +258,7 @@ Item { text: modelData || ". . ." color: ListView.isCurrentItem ? Colors.palette.m3primary : mouse.containsMouse ? Colors.palette.m3onSurface : Colors.palette.m3outline - font.pointSize: Tokens.font.size.medium + font: Tokens.font.body.medium wrapMode: Text.WrapAtWordBoundaryOrAnywhere layer.enabled: effectScale > 0 diff --git a/Modules/Dashboard/Media/LyricsAndSelector.qml b/Modules/Dashboard/Media/LyricsAndSelector.qml index 453ed2c..328cc5b 100644 --- a/Modules/Dashboard/Media/LyricsAndSelector.qml +++ b/Modules/Dashboard/Media/LyricsAndSelector.qml @@ -23,13 +23,13 @@ Item { MaterialIcon { Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) text: "lyrics" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large } CustomText { Layout.fillWidth: true text: qsTr("Lyrics") - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large } LyricsInfo {} diff --git a/Modules/Dashboard/Media/LyricsInfo.qml b/Modules/Dashboard/Media/LyricsInfo.qml index 2e4437c..cc15f41 100644 --- a/Modules/Dashboard/Media/LyricsInfo.qml +++ b/Modules/Dashboard/Media/LyricsInfo.qml @@ -184,7 +184,7 @@ Item { text: Lyrics.loading ? qsTr("Loading...") : qsTr("No lyrics found") color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium animate: true } } @@ -206,7 +206,7 @@ Item { anchors.centerIn: parent text: "more_vert" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium } } } diff --git a/Modules/Dashboard/Tabs.qml b/Modules/Dashboard/Tabs.qml index ce04b93..56234fa 100644 --- a/Modules/Dashboard/Tabs.qml +++ b/Modules/Dashboard/Tabs.qml @@ -162,7 +162,7 @@ Item { text: tab.iconName color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant fill: tab.current ? 1 : 0 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium Behavior on fill { Anim { diff --git a/Modules/Dashboard/Weather/Graphs.qml b/Modules/Dashboard/Weather/Graphs.qml index ff0e0df..f0ac43d 100644 --- a/Modules/Dashboard/Weather/Graphs.qml +++ b/Modules/Dashboard/Weather/Graphs.qml @@ -48,7 +48,7 @@ RowLayout { MaterialIcon { text: "device_thermostat" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium color: Colors.palette.m3secondary } @@ -99,7 +99,7 @@ RowLayout { MaterialIcon { text: "water_drop" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium color: Colors.palette.m3tertiary } diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml index fbd44bc..24538a2 100644 --- a/Modules/Dashboard/WeatherTab.qml +++ b/Modules/Dashboard/WeatherTab.qml @@ -31,13 +31,13 @@ Item { CustomText { text: Weather.city || qsTr("Loading...") - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.builders.large.size(24).weight(Font.DemiBold).build() color: Colors.palette.m3onSurface } CustomText { text: new Date().toLocaleDateString(Qt.locale("en_US"), "dddd, MMMM d") - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small color: Colors.palette.m3onSurfaceVariant } } @@ -114,7 +114,7 @@ Item { Layout.leftMargin: Tokens.padding.medium visible: forecastRepeater.count > 0 text: qsTr("7-day forecast") - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.builders.medium.weight(Font.DemiBold).build() color: Colors.palette.m3onSurface } @@ -148,7 +148,7 @@ Item { CustomText { Layout.alignment: Qt.AlignHCenter text: forecastItem.index === 0 ? qsTr("Today", "forecast column") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "ddd") - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.builders.medium.weight(Font.DemiBold).build() color: Colors.palette.m3primary } @@ -156,7 +156,7 @@ Item { Layout.topMargin: -Tokens.spacing.extraSmall Layout.alignment: Qt.AlignHCenter text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "MMM d") - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small opacity: 0.7 color: Colors.palette.m3onSurfaceVariant } @@ -164,7 +164,7 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignHCenter text: forecastItem.modelData.icon - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge color: Colors.palette.m3secondary } @@ -175,7 +175,7 @@ Item { const max = Weather.formatTemp(forecastItem.modelData.maxTempC, true); return qsTr("%1 / %2", "min/max temperature").arg(min).arg(max); } - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() color: Colors.palette.m3tertiary } } @@ -204,7 +204,7 @@ Item { MaterialIcon { text: detailRoot.icon color: detailRoot.colour - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large anchors.verticalCenter: parent.verticalCenter } @@ -214,20 +214,20 @@ Item { CustomText { text: detailRoot.label - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small opacity: 0.7 horizontalAlignment: Text.AlignLeft } CustomText { text: detailRoot.value - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() horizontalAlignment: Text.AlignLeft } } } } - component WeatherStat: Row { + component WeatherStat: RowLayout { id: weatherStat property string icon @@ -239,19 +239,20 @@ Item { MaterialIcon { text: weatherStat.icon - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge color: weatherStat.colour } Column { + Layout.alignment: Qt.AlignVCenter CustomText { text: weatherStat.label - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small color: Colors.palette.m3onSurfaceVariant } CustomText { text: weatherStat.value - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() color: Colors.palette.m3onSurface } } diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml index 24f0217..98050f3 100644 --- a/Modules/DesktopIcons/BackgroundContextMenu.qml +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -11,175 +11,195 @@ import qs.Services import qs.Services Item { - id: root + id: root - anchors.fill: parent - z: 998 - visible: false + anchors.fill: parent + z: 998 + visible: false - property real menuX: 0 - property real menuY: 0 + property real menuX: 0 + property real menuY: 0 - MouseArea { - anchors.fill: parent - onClicked: root.close() - } + MouseArea { + anchors.fill: parent + onClicked: root.close() + } - CustomClippingRect { - id: popupBackground - readonly property real padding: 4 + CustomClippingRect { + id: popupBackground + readonly property real padding: 4 - x: root.menuX - y: root.menuY + x: root.menuX + y: root.menuY - color: Colors.tPalette.m3surface - radius: Tokens.rounding.medium + color: Colors.tPalette.m3surface + radius: Tokens.rounding.medium - implicitWidth: menuLayout.implicitWidth + padding * 2 - implicitHeight: menuLayout.implicitHeight + padding * 2 + implicitWidth: menuLayout.implicitWidth + padding * 2 + implicitHeight: menuLayout.implicitHeight + padding * 2 - Behavior on opacity { Anim {} } - opacity: root.visible ? 1 : 0 + Behavior on opacity { + Anim {} + } + opacity: root.visible ? 1 : 0 - ColumnLayout { - id: menuLayout - anchors.centerIn: parent - spacing: 0 + ColumnLayout { + id: menuLayout + anchors.centerIn: parent + spacing: 0 - CustomRect { - Layout.preferredWidth: 200 - radius: popupBackground.radius - popupBackground.padding - implicitHeight: openTerminalRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.preferredWidth: 200 + radius: popupBackground.radius - popupBackground.padding + implicitHeight: openTerminalRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: openTerminalRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: openTerminalRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "terminal"; font.pointSize: 20 } - CustomText { text: "Open terminal"; Layout.fillWidth: true } - } + MaterialIcon { + text: "terminal" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "Open terminal" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", FileUtils.trimFileProtocol(Paths.desktop)]) - root.close() - } - } - } + onClicked: { + Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", FileUtils.trimFileProtocol(Paths.desktop)]); + root.close(); + } + } + } - CustomRect { - Layout.fillWidth: true - implicitHeight: 1 - color: Colors.palette.m3outlineVariant - Layout.topMargin: 4 - Layout.bottomMargin: 4 - } + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: Colors.palette.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: settingsRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.fillWidth: true + radius: popupBackground.radius - popupBackground.padding + implicitHeight: settingsRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: settingsRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: settingsRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "settings"; font.pointSize: 20 } - CustomText { text: "ZShell settings"; Layout.fillWidth: true } - } + MaterialIcon { + text: "settings" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "ZShell settings" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - const state = ShellState.forActive(); + onClicked: { + const state = ShellState.forActive(); state.settings = true; - root.close() - } - } - } + root.close(); + } + } + } - CustomRect { - Layout.fillWidth: true - implicitHeight: 1 - color: Colors.palette.m3outlineVariant - Layout.topMargin: 4 - Layout.bottomMargin: 4 - } + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: Colors.palette.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: logoutRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.fillWidth: true + radius: popupBackground.radius - popupBackground.padding + implicitHeight: logoutRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: logoutRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: logoutRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "logout"; font.pointSize: 20 } - CustomText { text: "Logout"; Layout.fillWidth: true } - } + MaterialIcon { + text: "logout" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "Logout" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - Hyprland.dispatch("global quickshell:sessionOpen") - root.close() - } - } - } + onClicked: { + Hyprland.dispatch("global quickshell:sessionOpen"); + root.close(); + } + } + } - // CustomRect { - // Layout.fillWidth: true - // implicitHeight: 1 - // color: Colors.palette.m3outlineVariant - // Layout.topMargin: 4 - // Layout.bottomMargin: 4 - // } - // - // CustomRect { - // Layout.fillWidth: true - // radius: popupBackground.radius - popupBackground.padding - // implicitHeight: desktopIconsRow.implicitHeight + Tokens.padding.extraSmall * 2 - // - // RowLayout { - // id: desktopIconsRow - // spacing: 8 - // anchors.fill: parent - // anchors.leftMargin: Tokens.padding.small - // - // MaterialIcon { text: Config.options.background.showDesktopIcons ? "visibility_off" : "visibility"; font.pointSize: 20 } - // CustomText { text: Config.options.background.showDesktopIcons ? "Hide icons" : "Show icons"; Layout.fillWidth: true } - // } - // - // StateLayer { - // anchors.fill: parent - // - // onClicked: { - // Config.options.background.showDesktopIcons = !Config.options.background.showDesktopIcons - // root.close() - // } - // } - // } - } - } + // CustomRect { + // Layout.fillWidth: true + // implicitHeight: 1 + // color: Colors.palette.m3outlineVariant + // Layout.topMargin: 4 + // Layout.bottomMargin: 4 + // } + // + // CustomRect { + // Layout.fillWidth: true + // radius: popupBackground.radius - popupBackground.padding + // implicitHeight: desktopIconsRow.implicitHeight + Tokens.padding.extraSmall * 2 + // + // RowLayout { + // id: desktopIconsRow + // spacing: 8 + // anchors.fill: parent + // anchors.leftMargin: Tokens.padding.small + // + // MaterialIcon { text: Config.options.background.showDesktopIcons ? "visibility_off" : "visibility"; font.pointSize: 20 } + // CustomText { text: Config.options.background.showDesktopIcons ? "Hide icons" : "Show icons"; Layout.fillWidth: true } + // } + // + // StateLayer { + // anchors.fill: parent + // + // onClicked: { + // Config.options.background.showDesktopIcons = !Config.options.background.showDesktopIcons + // root.close() + // } + // } + // } + } + } - function openAt(mouseX, mouseY, parentW, parentH) { - menuX = Math.floor(Math.min(mouseX, parentW - popupBackground.implicitWidth)) - menuY = Math.floor(Math.min(mouseY, parentH - popupBackground.implicitHeight)) - visible = true - } + function openAt(mouseX, mouseY, parentW, parentH) { + menuX = Math.floor(Math.min(mouseX, parentW - popupBackground.implicitWidth)); + menuY = Math.floor(Math.min(mouseY, parentH - popupBackground.implicitHeight)); + visible = true; + } - function close() { - visible = false - } + function close() { + visible = false; + } } diff --git a/Modules/DesktopIcons/DesktopIconContextMenu.qml b/Modules/DesktopIcons/DesktopIconContextMenu.qml index 4b9b570..349d0cb 100644 --- a/Modules/DesktopIcons/DesktopIconContextMenu.qml +++ b/Modules/DesktopIcons/DesktopIconContextMenu.qml @@ -54,8 +54,7 @@ Item { y: contextMenu.menuY Behavior on opacity { - Anim { - } + Anim {} } ColumnLayout { @@ -77,7 +76,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "open_in_new" } @@ -120,7 +119,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: contextMenu.targetIsDir ? "terminal" : "apps" } @@ -165,7 +164,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "content_copy" } @@ -199,7 +198,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "edit" } @@ -241,7 +240,7 @@ Item { MaterialIcon { color: deleteButton.hovered ? Colors.palette.m3onError : Colors.palette.m3error - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "delete" } diff --git a/Modules/Drawing/Content.qml b/Modules/Drawing/Content.qml index c689836..a580337 100644 --- a/Modules/Drawing/Content.qml +++ b/Modules/Drawing/Content.qml @@ -145,7 +145,6 @@ Item { IconTextButton { fillWidth: true - font.pointSize: Tokens.font.size.normal icon: "close" inactiveColor: Colors.palette.m3error inactiveOnColor: Colors.palette.m3onError @@ -158,7 +157,6 @@ Item { IconTextButton { fillWidth: true - font.pointSize: Tokens.font.size.normal icon: "ink_eraser" inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: Colors.palette.m3onSurfaceVariant @@ -193,7 +191,7 @@ Item { required property ButtonRow row fillWidth: false - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium icon: "" implicitHeight: buttonSize implicitWidth: buttonSize diff --git a/Modules/Drawing/Wrapper.qml b/Modules/Drawing/Wrapper.qml index e7835d1..654950e 100644 --- a/Modules/Drawing/Wrapper.qml +++ b/Modules/Drawing/Wrapper.qml @@ -74,11 +74,10 @@ Item { opacity: root.expanded ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: MaterialIcon { - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.medium text: "arrow_forward_ios" } } @@ -93,8 +92,7 @@ Item { opacity: root.expanded ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: Content { drawing: root.drawing diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 76bfeed..54093de 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -53,8 +53,7 @@ Item { to: 0 } - PropertyAction { - } + PropertyAction {} Anim { duration: Tokens.anim.durations.small @@ -130,18 +129,16 @@ Item { spacing: Tokens.spacing.medium Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } MaterialIcon { anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: root.state === "wallpapers" ? "wallpaper_slideshow" : "manage_search" } @@ -150,14 +147,13 @@ Item { CustomText { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.larger - font.weight: 500 + font: Tokens.font.body.builders.large.weight(Font.Medium).build() text: root.state === "wallpapers" ? qsTr("No wallpapers found") : qsTr("No results") } CustomText { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.state === "wallpapers" && Wallpapers.list.length === 0 ? qsTr("Try putting some wallpapers in %1").arg(Paths.shortenHome(Paths.wallsdir)) : qsTr("Try searching for something else") } } diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index b6ee987..3f8218a 100644 --- a/Modules/Launcher/Items/ActionItem.qml +++ b/Modules/Launcher/Items/ActionItem.qml @@ -33,7 +33,7 @@ Item { id: icon anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: root.modelData?.icon ?? "" } @@ -47,7 +47,6 @@ Item { CustomText { id: name - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -57,7 +56,7 @@ Item { anchors.top: name.bottom color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData?.desc ?? "" width: root.width - icon.width - Tokens.rounding.medium * 2 } diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 1a2604a..6303b0f 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -51,7 +51,6 @@ Item { CustomText { id: name - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -61,7 +60,7 @@ Item { anchors.top: name.bottom color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (root.modelData?.comment || root.modelData?.genericName || root.modelData?.name) ?? "" width: root.width - icon.width - Tokens.rounding.medium - Tokens.padding.medium * 2 } diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 5a81aab..e526b94 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -40,7 +40,7 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignVCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: "function" } @@ -92,7 +92,7 @@ Item { anchors.rightMargin: Tokens.spacing.small anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiary - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium opacity: stateLayer.containsMouse ? 1 : 0 text: qsTr("Open in calculator") @@ -109,7 +109,7 @@ Item { anchors.rightMargin: Tokens.padding.small anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiary - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium text: "open_in_new" } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml index ab3b3ba..b878e6e 100644 --- a/Modules/Launcher/Items/FilesItem.qml +++ b/Modules/Launcher/Items/FilesItem.qml @@ -120,7 +120,7 @@ Item { CustomText { id: name - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.modelData?.name ?? "" elide: Text.ElideRight width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 @@ -132,7 +132,7 @@ Item { anchors.top: name.bottom color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData?.path ?? "" width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 } diff --git a/Modules/Launcher/Items/VariantItem.qml b/Modules/Launcher/Items/VariantItem.qml index ca5d6cd..25caa4f 100644 --- a/Modules/Launcher/Items/VariantItem.qml +++ b/Modules/Launcher/Items/VariantItem.qml @@ -32,7 +32,7 @@ Item { id: icon anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: root.modelData?.icon ?? "" } @@ -44,7 +44,6 @@ Item { width: parent.width - icon.width - anchors.leftMargin - (current.active ? current.width + Tokens.spacing.medium : 0) CustomText { - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -53,7 +52,7 @@ Item { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData?.description ?? "" } } @@ -67,7 +66,7 @@ Item { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "check" } } diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index b4a1091..293c48c 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -32,8 +32,7 @@ Item { MaterialIcon { anchors.centerIn: parent color: Colors.tPalette.m3outline - font.pointSize: Tokens.font.size.extraLarge * 2 - font.weight: 600 + fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build() text: "image" } diff --git a/Modules/Lock/Center.qml b/Modules/Lock/Center.qml index 06ad24b..481d757 100644 --- a/Modules/Lock/Center.qml +++ b/Modules/Lock/Center.qml @@ -28,27 +28,21 @@ ColumnLayout { CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3secondary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: Time.hourStr } CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3primary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: ":" } CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3secondary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: Time.minuteStr } } @@ -57,9 +51,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter Layout.topMargin: -Tokens.padding.large * 2 color: Colors.palette.m3tertiary - font.bold: true - font.family: Config.appearance.font.family.mono - font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale) + font: Tokens.font.title.builders.medium.weight(Font.DemiBold).build() text: Time.format("dddd, d MMMM yyyy") } @@ -74,7 +66,7 @@ ColumnLayout { MaterialIcon { anchors.centerIn: parent color: Colors.palette.m3onSurfaceVariant - font.pointSize: Math.floor(root.centerWidth / 4) + fontStyle: Tokens.font.icon.size(root.centerWidth / 4).build() text: "person" } @@ -144,8 +136,7 @@ ColumnLayout { } Behavior on opacity { - Anim { - } + Anim {} } } @@ -159,6 +150,7 @@ ColumnLayout { id: inputField pam: root.lock.pam + centerScale: root.centerScale } CustomRect { @@ -180,7 +172,6 @@ ColumnLayout { anchors.centerIn: parent color: root.lock.pam.buffer ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - font.weight: 500 text: "arrow_forward" } } @@ -193,8 +184,7 @@ ColumnLayout { implicitHeight: Math.max(message.implicitHeight, stateMessage.implicitHeight) Behavior on implicitHeight { - Anim { - } + Anim {} } CustomText { @@ -226,7 +216,7 @@ ColumnLayout { anchors.right: parent.right animateProp: "opacity" color: Colors.palette.m3onSurfaceVariant - font.family: Config.appearance.font.family.mono + font: Tokens.font.body.small horizontalAlignment: Qt.AlignHCenter lineHeight: 1.2 opacity: shouldBeVisible && !message.msg ? 1 : 0 @@ -234,12 +224,10 @@ ColumnLayout { wrapMode: Text.WrapAtWordBoundaryOrAnywhere Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } onMsgChanged: { @@ -295,8 +283,7 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right color: Colors.palette.m3error - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Qt.AlignHCenter opacity: 0 scale: 0.7 diff --git a/Modules/Lock/InputField.qml b/Modules/Lock/InputField.qml index 67511c3..b2ca56c 100644 --- a/Modules/Lock/InputField.qml +++ b/Modules/Lock/InputField.qml @@ -14,6 +14,7 @@ Item { property string buffer required property Pam pam + required property real centerScale readonly property alias placeholder: placeholder Layout.fillHeight: true @@ -41,8 +42,7 @@ Item { anchors.centerIn: parent animate: true color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.builders.medium.scale(root.centerScale).width(110).build() opacity: root.buffer ? 0 : 1 text: { if (root.pam.passwd.active) @@ -53,8 +53,7 @@ Item { } Behavior on opacity { - Anim { - } + Anim {} } } @@ -84,12 +83,11 @@ Item { implicitHeight: charList.implicitHeight implicitWidth: implicitHeight opacity: 0 - radius: Tokens.rounding.small / 2 + radius: Tokens.rounding.full scale: 0 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { Anim { @@ -137,8 +135,7 @@ Item { Behavior on implicitWidth { id: imWidthBehavior - Anim { - } + Anim {} } model: ScriptModel { values: root.buffer.split("") diff --git a/Modules/Lock/LockSurface.qml b/Modules/Lock/LockSurface.qml index 6086d68..540e704 100644 --- a/Modules/Lock/LockSurface.qml +++ b/Modules/Lock/LockSurface.qml @@ -236,8 +236,7 @@ WlSessionLockSurface { id: lockIcon anchors.centerIn: parent - font.bold: true - font.pointSize: Tokens.font.size.extraLarge * 4 + fontStyle: Tokens.font.icon.builders.extraLarge.scale(4).weight(Font.Bold).build() text: "lock" } diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml index d292965..326f5ba 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -57,7 +57,7 @@ CustomClippingRect { animate: true color: Colors.palette.m3primary elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.large horizontalAlignment: Text.AlignHCenter text: (Players.active?.trackTitle ?? qsTr("Nothing playing")) || qsTr("Unknown track") } @@ -67,7 +67,6 @@ CustomClippingRect { animate: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small horizontalAlignment: Text.AlignHCenter text: (Players.active?.trackArtist ?? qsTr("Try playing some music!")) || qsTr("Unknown artist") } diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index 3b468a9..c441ff9 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -66,9 +66,7 @@ ColumnLayout { CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.large - font.weight: 500 + font: Tokens.font.headline.builders.small.width(90).build() text: qsTr("No Notifications") } } @@ -94,8 +92,7 @@ ColumnLayout { to: 1 } } - delegate: NotifGroup { - } + delegate: NotifGroup {} displaced: Transition { Anim { properties: "opacity,scale" diff --git a/Modules/Lock/NotifGroup.qml b/Modules/Lock/NotifGroup.qml index 09bf5d5..0d6d6ad 100644 --- a/Modules/Lock/NotifGroup.qml +++ b/Modules/Lock/NotifGroup.qml @@ -80,7 +80,7 @@ CustomRect { MaterialIcon { color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) } } @@ -133,14 +133,14 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData } CustomText { animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.notifs[0]?.timeStr ?? "" } @@ -153,12 +153,10 @@ CustomRect { radius: Tokens.rounding.full Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } StateLayer { @@ -181,7 +179,7 @@ CustomRect { Layout.leftMargin: Tokens.padding.extraSmall / 2 animate: true color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.notifs.length } @@ -261,8 +259,7 @@ CustomRect { opacity: root.expanded ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { Repeater { @@ -270,8 +267,7 @@ CustomRect { values: root.notifs.slice(Config.notifs.groupPreviewNum) } - NotifLine { - } + NotifLine {} } } } diff --git a/Modules/Lock/Resources.qml b/Modules/Lock/Resources.qml index e06fff7..7ce6c90 100644 --- a/Modules/Lock/Resources.qml +++ b/Modules/Lock/Resources.qml @@ -70,14 +70,13 @@ CustomRect { implicitSize: width Behavior on clampedVal { - Anim { - } + Anim {} } MaterialIcon { anchors.centerIn: parent color: res.fgColor - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: res.icon } } diff --git a/Modules/Lock/Weather/BriefInfo.qml b/Modules/Lock/Weather/BriefInfo.qml index fc6a820..cd92521 100644 --- a/Modules/Lock/Weather/BriefInfo.qml +++ b/Modules/Lock/Weather/BriefInfo.qml @@ -16,7 +16,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.large text: Weather.description } @@ -29,7 +29,7 @@ ColumnLayout { animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Weather.temp } @@ -44,7 +44,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: qsTr("Feels like %1").arg(Weather.temp) visible: root.rootHeight > 550 } @@ -53,7 +53,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.normal text: { const today = Weather.forecast[0]; return qsTr("High %1 • Low %2").arg(Weather.formatTemp(today?.maxTempC)).arg(Weather.formatTemp(today?.minTempC)); diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index 467879b..dd8cf82 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -24,14 +24,14 @@ CustomRect { MaterialIcon { Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "schedule" } CustomText { id: title - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.medium text: qsTr("Hourly forecast") } } @@ -65,8 +65,7 @@ CustomRect { shape: MaterialShape.Cookie4Sided Behavior on color { - CAnim { - } + CAnim {} } CustomText { @@ -74,7 +73,7 @@ CustomRect { anchors.centerIn: parent color: hour.index === Weather.currentHourIndex ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F } } @@ -82,7 +81,7 @@ CustomRect { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3secondary - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: hour.cond.icon } @@ -96,7 +95,7 @@ CustomRect { Layout.alignment: Qt.AlignHCenter Layout.topMargin: Tokens.spacing.extraSmall color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: hour.index === 0 ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00") } } diff --git a/Modules/Notifications/Notification.qml b/Modules/Notifications/Notification.qml index 46e2bc4..0cf7d74 100644 --- a/Modules/Notifications/Notification.qml +++ b/Modules/Notifications/Notification.qml @@ -94,8 +94,7 @@ CustomRect { implicitHeight: root.nonAnimHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Loader { @@ -171,6 +170,7 @@ CustomRect { sourceComponent: MaterialIcon { color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) + fontStyle: Tokens.font.icon.medium } } } @@ -222,6 +222,7 @@ CustomRect { maximumLineCount: 1 opacity: root.expanded ? 1 : 0 text: appNameMetrics.elidedText + font: Tokens.font.label.medium Behavior on opacity { Anim { @@ -246,14 +247,12 @@ CustomRect { anchors.leftMargin: Tokens.spacing.small anchors.top: parent.top animate: true - font.pointSize: Tokens.font.size.small height: implicitHeight maximumLineCount: 1 text: summaryMetrics.elidedText Behavior on height { - Anim { - } + Anim {} } states: State { name: "expanded" @@ -281,8 +280,7 @@ CustomRect { property: "topMargin" } - AnchorAnim { - } + AnchorAnim {} } } @@ -303,6 +301,7 @@ CustomRect { anchors.top: parent.top color: Colors.palette.m3onSurfaceVariant text: "•" + font: Tokens.font.body.small states: State { name: "expanded" @@ -314,8 +313,7 @@ CustomRect { } } transitions: Transition { - AnchorAnim { - } + AnchorAnim {} } } @@ -327,7 +325,7 @@ CustomRect { anchors.top: parent.top animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Text.AlignLeft text: root.modelData.timeStr } @@ -355,14 +353,13 @@ CustomRect { anchors.verticalCenterOffset: root.expanded ? -1 : 1 rotation: root.expanded ? 180 : 0 text: "expand_more" + fontStyle: Tokens.font.icon.medium Behavior on anchors.verticalCenterOffset { - Anim { - } + Anim {} } Behavior on rotation { - Anim { - } + Anim {} } } } @@ -376,7 +373,7 @@ CustomRect { anchors.top: summary.bottom animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small opacity: root.expanded ? 0 : 1 text: bodyPreviewMetrics.elidedText textFormat: Text.MarkdownText @@ -407,6 +404,7 @@ CustomRect { animate: true color: Colors.palette.m3onSurfaceVariant height: text ? implicitHeight : 0 + font: Tokens.font.body.medium opacity: root.expanded ? 1 : 0 text: root.modelData.body textFormat: Text.MarkdownText @@ -447,6 +445,7 @@ CustomRect { enabled: root.expanded fillWidth: root.modelData.actions.length === 0 icon: "close" + font: Tokens.font.icon.builders.small.size(14).build() inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant isRound: true @@ -464,7 +463,6 @@ CustomRect { enabled: root.expanded fillWidth: true - font.pointSize: Tokens.font.size.small implicitWidth: label.implicitWidth inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant @@ -493,6 +491,7 @@ CustomRect { label.animate: true padding: Tokens.padding.extraSmall shapeMorph: true + font: Tokens.font.icon.builders.small.size(14).build() onClicked: { Quickshell.clipboardText = root.modelData.body; diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml index 6792807..e0cbdf0 100644 --- a/Modules/Notifications/Sidebar/NotifDock.qml +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -37,19 +37,15 @@ Item { anchors.leftMargin: root.notifCount > 0 ? 0 : -width - titleText.anchors.leftMargin anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3outline - font.family: "CaskaydiaCove NF" - font.pointSize: 13 - font.weight: 500 + font: Tokens.font.label.large opacity: root.notifCount > 0 ? 1 : 0 text: root.notifCount Behavior on anchors.leftMargin { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } } @@ -62,9 +58,7 @@ Item { anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3outline elide: Text.ElideRight - font.family: "CaskaydiaCove NF" - font.pointSize: 13 - font.weight: 500 + font: Tokens.font.label.large text: root.notifCount > 0 ? qsTr("notification%1").arg(root.notifCount === 1 ? "" : "s") : qsTr("Notifications") } } @@ -86,8 +80,7 @@ Item { opacity: root.notifCount > 0 ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { spacing: 20 @@ -95,9 +88,7 @@ Item { CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.family: "CaskaydiaCove NF" - font.pointSize: 18 - font.weight: 500 + font: Tokens.font.headline.builders.small.width(90).build() text: qsTr("No Notifications") } } @@ -160,20 +151,17 @@ Item { scale: root.notifCount > 0 ? 1 : 0.5 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } sourceComponent: IconButton { id: clearBtn - font.pointSize: Math.round(18 * 1.2) + font: Tokens.font.icon.large icon: "clear_all" - padding: 8 - radius: Tokens.rounding.full + isRound: true onClicked: clearTimer.start() diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml index 0f90322..5094d86 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml @@ -13,7 +13,6 @@ CustomRect { required property ScreenState screenState - Layout.fillWidth: true color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 @@ -46,7 +45,7 @@ CustomRect { anchors.horizontalCenterOffset: -0.5 anchors.verticalCenterOffset: 1.5 color: Recorder.running ? Colors.palette.m3onSecondary : Colors.palette.m3onSecondaryContainer - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "screen_record" } } @@ -58,7 +57,7 @@ CustomRect { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Screen Recorder") } @@ -66,7 +65,7 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off") } } @@ -126,8 +125,7 @@ CustomRect { enabled: false - Anim { - } + Anim {} } Behavior on running { SequentialAnimation { @@ -155,8 +153,7 @@ CustomRect { value: true } - PropertyAction { - } + PropertyAction {} PropertyAction { property: "enabled" @@ -207,8 +204,7 @@ CustomRect { radius: Tokens.rounding.full Behavior on implicitWidth { - Anim { - } + Anim {} } SequentialAnimation on opacity { alwaysRunToEnd: true @@ -236,13 +232,13 @@ CustomRect { anchors.centerIn: parent animate: true color: Recorder.paused ? Colors.palette.m3onTertiary : Colors.palette.m3onError - font.family: Config.appearance.font.family.mono + font: Tokens.font.mono.small text: Recorder.paused ? "PAUSED" : "REC" } } CustomText { - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: { const elapsed = Recorder.elapsed; @@ -266,8 +262,9 @@ CustomRect { IconButton { checked: Recorder.paused - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: Recorder.paused ? "play_arrow" : "pause" + isRound: true isToggle: true label.animate: true type: IconButton.Tonal @@ -279,8 +276,9 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "stop" + isRound: true inactiveColor: Colors.palette.m3error inactiveOnColor: Colors.palette.m3onError diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index 876c9d4..5c28149 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -29,14 +29,14 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignVCenter - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "list" } CustomText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Recordings") } @@ -177,7 +177,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter Layout.preferredHeight: root.screenState.recordingListExpanded ? implicitHeight : 0 color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge opacity: root.screenState.recordingListExpanded ? 1 : 0 scale: root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index a84f644..e0128f9 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -53,7 +53,7 @@ CustomRect { spacing: Tokens.spacing.medium CustomText { - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Quick Toggles") } diff --git a/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml b/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml index ae190e1..7cb4b6e 100644 --- a/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml +++ b/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml @@ -15,8 +15,7 @@ CustomRect { radius: Tokens.rounding.small Behavior on implicitHeight { - Anim { - } + Anim {} } RowLayout { @@ -39,7 +38,7 @@ CustomRect { anchors.centerIn: parent color: IdleInhibitor.enabled ? Colors.palette.m3onSecondary : Colors.palette.m3onSecondaryContainer - font.pointSize: 18 + fontStyle: Tokens.font.icon.large text: "coffee" } } @@ -51,7 +50,7 @@ CustomRect { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: 13 + font: Tokens.font.body.medium text: qsTr("Keep Awake") } @@ -59,7 +58,7 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: 11 + font: Tokens.font.body.small text: IdleInhibitor.enabled ? qsTr("Preventing sleep mode") : qsTr("Normal power management") } } @@ -83,16 +82,13 @@ CustomRect { scale: IdleInhibitor.enabled ? 1 : 0.5 Behavior on anchors.bottomMargin { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } sourceComponent: CustomRect { color: Colors.palette.m3primary @@ -105,7 +101,7 @@ CustomRect { anchors.centerIn: parent color: Colors.palette.m3onPrimary - font.pointSize: Math.round(11 * 0.9) + font: Tokens.font.body.builders.small.size(Math.round(Tokens.font.body.small.pointSize * 0.9)).build() text: qsTr("Active since %1").arg(Qt.formatTime(IdleInhibitor.enabledSince, Config.services.useTwelveHourClock ? "hh:mm a" : "hh:mm")) } } diff --git a/Modules/Polkit/Polkit.qml b/Modules/Polkit/Polkit.qml index 7bff94e..c598cd5 100644 --- a/Modules/Polkit/Polkit.qml +++ b/Modules/Polkit/Polkit.qml @@ -112,7 +112,7 @@ Scope { MaterialIcon { anchors.fill: parent - font.pointSize: 64 + fontStyle: Tokens.font.icon.size(64).build() horizontalAlignment: Text.AlignHCenter text: "security" verticalAlignment: Text.AlignVCenter @@ -129,8 +129,7 @@ Scope { CustomText { Layout.alignment: Qt.AlignLeft Layout.preferredWidth: Math.min(600, contentWidth) - font.bold: true - font.pointSize: 16 + font: Tokens.font.title.builders.medium.weight(Font.Bold).build() text: polkitAgent.flow?.message ?? "" wrapMode: Text.WordWrap } @@ -139,8 +138,7 @@ Scope { Layout.alignment: Qt.AlignLeft Layout.preferredWidth: Math.min(600, contentWidth) color: Colors.tPalette.m3onSurfaceVariant - font.bold: true - font.pointSize: 12 + font: Tokens.font.title.builders.small.weight(Font.Bold).build() text: polkitAgent.flow?.supplementaryMessage || "No Additional Information" wrapMode: Text.WordWrap } @@ -155,6 +153,7 @@ Scope { placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password" placeholderTextColor: polkitAgent.flow?.failed ? Colors.palette.m3onError : Colors.tPalette.m3onSurfaceVariant selectByMouse: true + font: Tokens.font.body.small background: CustomRect { color: (polkitAgent.flow?.failed && passInput.text === "") ? Colors.palette.m3error : Colors.tPalette.m3surfaceVariant @@ -342,8 +341,7 @@ Scope { visible: panelWindow.visible Behavior on color { - CAnim { - } + CAnim {} } anchors { diff --git a/Modules/Resources/Cards/HeroCard.qml b/Modules/Resources/Cards/HeroCard.qml index 668a329..2b488de 100644 --- a/Modules/Resources/Cards/HeroCard.qml +++ b/Modules/Resources/Cards/HeroCard.qml @@ -27,8 +27,7 @@ CustomClippingRect { implicitWidth: parent.width * root.usage Behavior on implicitWidth { - Anim { - } + Anim {} } } @@ -45,8 +44,7 @@ CustomClippingRect { value: root.usage Behavior on clampedVal { - Anim { - } + Anim {} } MaterialIcon { @@ -54,7 +52,7 @@ CustomClippingRect { anchors.centerIn: parent color: root.accent - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: root.icon } } @@ -125,15 +123,14 @@ CustomClippingRect { anchors.right: parent.right color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Usage") } CustomText { anchors.right: parent.right color: root.accent - font.pointSize: Tokens.font.size.extraLarge - font.weight: Font.Medium + font: Tokens.font.headline.builders.small.width(50).build() text: isNaN(root.usage) ? "...%" : Math.round(root.usage * 100) + "%" } } diff --git a/Modules/Resources/Cards/MemoryCard.qml b/Modules/Resources/Cards/MemoryCard.qml index 168663d..bb56af0 100644 --- a/Modules/Resources/Cards/MemoryCard.qml +++ b/Modules/Resources/Cards/MemoryCard.qml @@ -51,8 +51,7 @@ CustomRect { value: Memory.percentage Behavior on clampedVal { - Anim { - } + Anim {} } ColumnLayout { @@ -65,7 +64,7 @@ CustomRect { CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(Memory.percentage * 100) + "%" } diff --git a/Modules/Resources/Cards/StorageCard.qml b/Modules/Resources/Cards/StorageCard.qml index 238bed7..83b4ecc 100644 --- a/Modules/Resources/Cards/StorageCard.qml +++ b/Modules/Resources/Cards/StorageCard.qml @@ -43,8 +43,7 @@ CustomRect { value: root.percentage Behavior on clampedVal { - Anim { - } + Anim {} } ColumnLayout { @@ -56,14 +55,14 @@ CustomRect { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "hard_drive" } CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(root.percentage * 100) + "%" } @@ -112,8 +111,7 @@ CustomRect { model: Storage.disks - DiskItem { - } + DiskItem {} } } } diff --git a/Modules/Resources/Cards/VramCard.qml b/Modules/Resources/Cards/VramCard.qml index 789bc39..034f00f 100644 --- a/Modules/Resources/Cards/VramCard.qml +++ b/Modules/Resources/Cards/VramCard.qml @@ -53,8 +53,7 @@ CustomRect { value: Gpu.memoryUsed / Gpu.memoryTotal Behavior on clampedVal { - Anim { - } + Anim {} } ColumnLayout { @@ -67,7 +66,7 @@ CustomRect { CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(circularIndicator.value * 100) + "%" } diff --git a/Modules/Settings/Common/AudioDeviceList.qml b/Modules/Settings/Common/AudioDeviceList.qml index febdd2a..d0d21bb 100644 --- a/Modules/Settings/Common/AudioDeviceList.qml +++ b/Modules/Settings/Common/AudioDeviceList.qml @@ -60,12 +60,11 @@ ItemList { anchors.centerIn: parent color: device.active ? Colors.palette.m3onPrimary : Colors.palette.m3onSecondaryContainer fill: device.active ? 1 : 0 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium text: root.iconName Behavior on fill { - Anim { - } + Anim {} } } } @@ -73,13 +72,13 @@ ItemList { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small text: device.modelData?.description || device.modelData?.name || qsTr("Unknown") } MaterialIcon { color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium opacity: device.active ? 1 : 0 text: "check" diff --git a/Modules/Settings/Common/BlobPopup.qml b/Modules/Settings/Common/BlobPopup.qml index 1978d10..bad56b1 100644 --- a/Modules/Settings/Common/BlobPopup.qml +++ b/Modules/Settings/Common/BlobPopup.qml @@ -35,8 +35,7 @@ Item { smoothing: Tokens.rounding.largeIncreased Behavior on color { - CAnim { - } + CAnim {} } } @@ -49,8 +48,7 @@ Item { radius: root.open ? Tokens.rounding.largeIncreased : Tokens.rounding.largeIncreased Behavior on anchors.margins { - Anim { - } + Anim {} } Behavior on radius { Anim { @@ -122,7 +120,7 @@ Item { anchors.centerIn: parent color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "view_apps" } } diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index 80cc881..ee9d4cb 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -185,7 +185,7 @@ Item { spacing: 0 CustomText { - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.builders.large.weight(Font.Normal).build() text: root.header } diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index a3a9c74..5866d6b 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -65,7 +65,6 @@ DialogRowButton { anchors.verticalCenter: parent.verticalCenter color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller text: root.labelFor(item.modelData) } @@ -76,7 +75,7 @@ DialogRowButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiaryContainer - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium opacity: item.selected ? 1 : 0 text: "check" diff --git a/Modules/Settings/Common/InfoRow.qml b/Modules/Settings/Common/InfoRow.qml index 0cec60b..63f510d 100644 --- a/Modules/Settings/Common/InfoRow.qml +++ b/Modules/Settings/Common/InfoRow.qml @@ -24,7 +24,7 @@ ConnectedRect { MaterialIcon { color: root.iconColor - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: root.icon } } @@ -53,14 +53,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } @@ -72,7 +72,7 @@ ConnectedRect { Layout.maximumWidth: root.width / 2 color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Text.AlignRight } } diff --git a/Modules/Settings/Common/ItemList.qml b/Modules/Settings/Common/ItemList.qml index b5fcd41..d32f678 100644 --- a/Modules/Settings/Common/ItemList.qml +++ b/Modules/Settings/Common/ItemList.qml @@ -23,8 +23,7 @@ ConnectedRect { implicitHeight: (showList && list.count > 0 ? list.contentHeight : placeholder.implicitHeight + Tokens.padding.extraLarge * 2) + extraHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Loader { @@ -46,7 +45,7 @@ ConnectedRect { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: root.placeholderIcon } @@ -54,7 +53,7 @@ ConnectedRect { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: root.placeholderText } } diff --git a/Modules/Settings/Common/ListEditor.qml b/Modules/Settings/Common/ListEditor.qml index e9e7705..9685068 100644 --- a/Modules/Settings/Common/ListEditor.qml +++ b/Modules/Settings/Common/ListEditor.qml @@ -54,14 +54,12 @@ ListView { } } Behavior on implicitHeight { - Anim { - } + Anim {} } model: DelegateModel { id: visualModel - delegate: ListRow { - } + delegate: ListRow {} model: ScriptModel { id: valuesModel } @@ -280,7 +278,7 @@ ListView { id: dragIcon color: Qt.alpha(Colors.palette.m3onSurfaceVariant, enabledSwitch.checked ? 1 : 0.5) - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "drag_indicator" } @@ -290,7 +288,6 @@ ListView { Layout.fillWidth: true color: Qt.alpha(Colors.palette.m3onSurface, enabledSwitch.checked ? 1 : 0.5) elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller text: root.labelFor(item.modelData) } @@ -298,13 +295,13 @@ ListView { id: enabledSwitch checked: root.toggledFor(item.modelData) - font.pointSize: Tokens.font.size.normal + font: Tokens.font.icon.medium onToggled: root.itemToggled(item.DelegateModel.itemsIndex, checked) } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete" inactiveOnColor: Colors.palette.m3error isRound: true diff --git a/Modules/Settings/Common/NavRow.qml b/Modules/Settings/Common/NavRow.qml index a496c53..7d0ca3a 100644 --- a/Modules/Settings/Common/NavRow.qml +++ b/Modules/Settings/Common/NavRow.qml @@ -1,71 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Layouts -import qs.Components -import ZShell.Config -import qs.Services -ConnectedRect { - id: root - - property alias icon: icon.text - property alias status: status.text - property alias text: label.text - - signal clicked - - Layout.fillWidth: true - implicitHeight: navLayout.implicitHeight + navLayout.anchors.margins * 2 - - StateLayer { - onClicked: root.clicked() - } - - RowLayout { - id: navLayout - - anchors.fill: parent - anchors.leftMargin: Tokens.padding.largeIncreased - anchors.margins: Tokens.padding.small - anchors.rightMargin: Tokens.padding.largeIncreased - spacing: Tokens.spacing.medium - - MaterialIcon { - id: icon - - color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - CustomText { - id: label - - Layout.fillWidth: true - elide: Text.ElideRight - font.pointSize: Tokens.font.size.small - } - - CustomText { - id: status - - Layout.fillWidth: true - animate: true - color: Colors.palette.m3outline - elide: Text.ElideRight - font.pointSize: Tokens.font.size.small - visible: text - } - } - - MaterialIcon { - color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - text: "chevron_right" - } - } +RowButton { + trailingIcon: "chevron_right" + subLabel.animate: true } diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index 5b4200c..de02ceb 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -134,6 +134,7 @@ ColumnLayout { inactiveOnColor: Colors.palette.m3onSurfaceVariant isRound: true type: IconButton.Tonal + font: Tokens.font.icon.medium onClicked: root.sState.closeSubPage() } @@ -142,7 +143,7 @@ ColumnLayout { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.large text: root.title } } diff --git a/Modules/Settings/Common/PopupRow.qml b/Modules/Settings/Common/PopupRow.qml index 3e7394f..0b3a22a 100644 --- a/Modules/Settings/Common/PopupRow.qml +++ b/Modules/Settings/Common/PopupRow.qml @@ -40,7 +40,7 @@ ConnectedRect { id: icon color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.medium } ColumnLayout { @@ -52,7 +52,7 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { @@ -62,7 +62,7 @@ ConnectedRect { animate: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small visible: text } } diff --git a/Modules/Settings/Common/RowButton.qml b/Modules/Settings/Common/RowButton.qml index 50d1c3d..419fae8 100644 --- a/Modules/Settings/Common/RowButton.qml +++ b/Modules/Settings/Common/RowButton.qml @@ -51,7 +51,7 @@ ConnectedRect { visible: root.icon !== "" fill: 1 text: root.icon - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium } Column { @@ -66,7 +66,6 @@ ConnectedRect { anchors.left: parent.left anchors.right: parent.right elide: Text.ElideRight - font.pointSize: Tokens.font.size.small } CustomText { @@ -76,7 +75,7 @@ ConnectedRect { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small visible: text } } @@ -108,7 +107,7 @@ ConnectedRect { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: root.trailingIcon } } diff --git a/Modules/Settings/Common/SearchPopup.qml b/Modules/Settings/Common/SearchPopup.qml index 193fd1a..4bfc086 100644 --- a/Modules/Settings/Common/SearchPopup.qml +++ b/Modules/Settings/Common/SearchPopup.qml @@ -70,7 +70,6 @@ DialogRowButton { anchors.verticalCenter: parent.verticalCenter color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller text: item.modelData } @@ -81,7 +80,7 @@ DialogRowButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiaryContainer - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium opacity: item.selected ? 1 : 0 text: "check" @@ -100,12 +99,12 @@ DialogRowButton { Layout.fillWidth: true bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest - clearIcon.font.pointSize: Tokens.font.size.large + clearIcon.font: Tokens.font.icon.medium clearIcon.padding: Tokens.padding.extraSmall - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small placeholderText: qsTr("Search...") searchIcon.anchors.leftMargin: Tokens.padding.large - searchIcon.font.pointSize: Tokens.font.size.large + searchIcon.fontStyle: Tokens.font.icon.medium Behavior on bg.border.color { CAnim {} diff --git a/Modules/Settings/Common/SectionHeader.qml b/Modules/Settings/Common/SectionHeader.qml index 0433f65..b826399 100644 --- a/Modules/Settings/Common/SectionHeader.qml +++ b/Modules/Settings/Common/SectionHeader.qml @@ -13,5 +13,5 @@ CustomText { Layout.topMargin: first ? 0 : Tokens.spacing.largeIncreased - ((parent as ColumnLayout).spacing ?? 0) color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.label.medium } diff --git a/Modules/Settings/Common/SelectRow.qml b/Modules/Settings/Common/SelectRow.qml index 0689182..ce97cad 100644 --- a/Modules/Settings/Common/SelectRow.qml +++ b/Modules/Settings/Common/SelectRow.qml @@ -40,14 +40,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } diff --git a/Modules/Settings/Common/SliderRow.qml b/Modules/Settings/Common/SliderRow.qml index d4669ea..10c69b1 100644 --- a/Modules/Settings/Common/SliderRow.qml +++ b/Modules/Settings/Common/SliderRow.qml @@ -40,14 +40,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { id: valueLabel color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } } diff --git a/Modules/Settings/Common/SpinRow.qml b/Modules/Settings/Common/SpinRow.qml index 93c9d13..429d4d1 100644 --- a/Modules/Settings/Common/SpinRow.qml +++ b/Modules/Settings/Common/SpinRow.qml @@ -39,14 +39,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } diff --git a/Modules/Settings/Common/TextFieldRow.qml b/Modules/Settings/Common/TextFieldRow.qml index 38dd9a8..32ec8a4 100644 --- a/Modules/Settings/Common/TextFieldRow.qml +++ b/Modules/Settings/Common/TextFieldRow.qml @@ -62,7 +62,6 @@ ConnectedRect { visible: root.subtext text: input.isError && root.errorText ? root.errorText : root.subtext color: input.isError && root.errorText ? Colors.palette.m3error : Colors.palette.m3outline - font.pointSize: Tokens.font.size.small elide: Text.ElideRight animate: root.errorText } diff --git a/Modules/Settings/Common/TimeDialogRow.qml b/Modules/Settings/Common/TimeDialogRow.qml index 25bc7cb..701b2ed 100644 --- a/Modules/Settings/Common/TimeDialogRow.qml +++ b/Modules/Settings/Common/TimeDialogRow.qml @@ -193,7 +193,7 @@ Item { spacing: 0 CustomText { - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.builders.large.weight(Font.Normal).build() text: root.header } diff --git a/Modules/Settings/Common/TimeDialogToggle.qml b/Modules/Settings/Common/TimeDialogToggle.qml index 0b540fa..9d75041 100644 --- a/Modules/Settings/Common/TimeDialogToggle.qml +++ b/Modules/Settings/Common/TimeDialogToggle.qml @@ -34,14 +34,14 @@ ConnectedRect { CustomText { id: text - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { id: subtext color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small wrapMode: Text.WordWrap } } diff --git a/Modules/Settings/Common/TimeInput.qml b/Modules/Settings/Common/TimeInput.qml index c8bfd2b..f01100f 100644 --- a/Modules/Settings/Common/TimeInput.qml +++ b/Modules/Settings/Common/TimeInput.qml @@ -137,7 +137,7 @@ ColumnLayout { CustomText { id: startSeparator - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.body.builders.large.size(28).weight(Font.DemiBold).build() text: ":" } @@ -360,7 +360,7 @@ ColumnLayout { CustomText { id: endSeparator - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.body.builders.large.size(28).weight(Font.DemiBold).build() text: ":" } diff --git a/Modules/Settings/Common/ToggleRow.qml b/Modules/Settings/Common/ToggleRow.qml index 36fb583..2eea3f5 100644 --- a/Modules/Settings/Common/ToggleRow.qml +++ b/Modules/Settings/Common/ToggleRow.qml @@ -20,6 +20,7 @@ CustomSwitch { Layout.fillWidth: true cLayer: 2 + font: Tokens.font.body.small horizontalPadding: Tokens.padding.largeIncreased implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 @@ -59,7 +60,7 @@ CustomSwitch { anchors.left: parent.left anchors.right: parent.right elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: root.font opacity: root.enabled ? 1 : 0.5 text: root.text @@ -75,7 +76,7 @@ CustomSwitch { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small opacity: root.enabled ? 1 : 0.5 text: root.subtext visible: root.subtext diff --git a/Modules/Settings/NavPane.qml b/Modules/Settings/NavPane.qml index e802071..820e3e3 100644 --- a/Modules/Settings/NavPane.qml +++ b/Modules/Settings/NavPane.qml @@ -18,16 +18,15 @@ ColumnLayout { Layout.fillWidth: true bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest - clearIcon.font.pointSize: Tokens.font.size.large + clearIcon.font: Tokens.font.icon.medium clearIcon.padding: Tokens.padding.extraSmall - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.large placeholderText: qsTr("Search settings") searchIcon.anchors.leftMargin: Tokens.padding.largeIncreased - searchIcon.font.pointSize: Tokens.font.size.large + searchIcon.fontStyle: Tokens.font.icon.medium Behavior on bg.border.color { - CAnim { - } + CAnim {} } Binding { diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index 5ccf726..bbd096b 100644 --- a/Modules/Settings/NavPane/NavLocations.qml +++ b/Modules/Settings/NavPane/NavLocations.qml @@ -107,7 +107,7 @@ VerticalFadeFlickable { anchors.fill: parent anchors.margins: Tokens.padding.large - spacing: Tokens.spacing.small + spacing: Tokens.spacing.medium CustomRect { Layout.bottomMargin: -1 @@ -122,7 +122,7 @@ VerticalFadeFlickable { anchors.verticalCenterOffset: 1 color: item.isCurrentPage ? Colors.palette.m3onPrimary : Colors.palette.m3onSecondaryContainer fill: item.modelData.noFill ? 0 : 1 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.weight(Font.Medium).build() grade: 25 text: item.modelData.icon } @@ -135,6 +135,7 @@ VerticalFadeFlickable { CustomText { Layout.fillWidth: true elide: Text.ElideRight + font: Tokens.font.body.medium text: item.modelData.name } @@ -142,6 +143,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight + font: Tokens.font.label.small text: item.modelData.description } } @@ -198,7 +200,7 @@ VerticalFadeFlickable { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.weight(Font.Medium).build() text: group.modelData.icon } @@ -206,7 +208,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3secondary elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: group.modelData.page } } @@ -283,7 +285,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: { const labels = result.modelData.crumbLabels.slice(1); const section = result.modelData.section; @@ -297,7 +299,6 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurface elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal text: SettingsSearcher.highlight(result.modelData.title, root.search, Colors.palette.m3primary) textFormat: text.includes(" 0 @@ -319,7 +320,8 @@ VerticalFadeFlickable { z: 1 onClicked: { - root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor); + const target = result.modelData.targetSubPath; + root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor); } } @@ -349,7 +351,6 @@ VerticalFadeFlickable { Layout.fillWidth: true Layout.topMargin: Tokens.padding.large color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter text: qsTr("No matching settings") visible: root.searching && root.results.length === 0 diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 444fb1e..46a2249 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -182,7 +182,7 @@ QtObject { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: "handyman" } diff --git a/Modules/Settings/Pages/AboutPage.qml b/Modules/Settings/Pages/AboutPage.qml index 1fae077..53bc19e 100644 --- a/Modules/Settings/Pages/AboutPage.qml +++ b/Modules/Settings/Pages/AboutPage.qml @@ -56,14 +56,14 @@ PageBase { CustomText { Layout.alignment: Qt.AlignHCenter Layout.topMargin: Tokens.spacing.small - font.pointSize: Tokens.font.size.large + font: Tokens.font.headline.large text: "ZShell" } CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: ZUtils.version ? `v${ZUtils.version}` : "…" } } diff --git a/Modules/Settings/Pages/Apps/AllApps.qml b/Modules/Settings/Pages/Apps/AllApps.qml index dc05f73..0dde289 100644 --- a/Modules/Settings/Pages/Apps/AllApps.qml +++ b/Modules/Settings/Pages/Apps/AllApps.qml @@ -67,7 +67,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: appItem.modelData.name } @@ -75,7 +75,7 @@ PageBase { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: (appItem.modelData.comment || appItem.modelData.genericName) ?? "" visible: text } @@ -84,14 +84,14 @@ PageBase { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: "favorite" visible: Strings.testRegexList(Config.launcher.favoriteApps, appItem.modelData.id) } MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "chevron_right" } } diff --git a/Modules/Settings/Pages/Apps/AppInfo.qml b/Modules/Settings/Pages/Apps/AppInfo.qml index b3273f4..333a880 100644 --- a/Modules/Settings/Pages/Apps/AppInfo.qml +++ b/Modules/Settings/Pages/Apps/AppInfo.qml @@ -57,7 +57,7 @@ PageBase { CustomText { Layout.fillWidth: true - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.app?.name ?? "" wrapMode: Text.WordWrap } @@ -65,7 +65,7 @@ PageBase { CustomText { Layout.fillWidth: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (root.app?.comment || root.app?.genericName) ?? "" visible: text wrapMode: Text.WordWrap @@ -152,7 +152,7 @@ PageBase { id: label Layout.alignment: Qt.AlignTop - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small } Item { @@ -165,7 +165,6 @@ PageBase { Layout.fillWidth: true Layout.maximumWidth: implicitWidth + 1 // Whyyyyyyyyy color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small wrapMode: Text.WrapAtWordBoundaryOrAnywhere } } diff --git a/Modules/Settings/Pages/AppsPage.qml b/Modules/Settings/Pages/AppsPage.qml index a9ff2f7..43cf99f 100644 --- a/Modules/Settings/Pages/AppsPage.qml +++ b/Modules/Settings/Pages/AppsPage.qml @@ -76,7 +76,7 @@ PageBase { icon: "apps" last: true settingAnchor: "apps-all-apps" - status: qsTr("Browse installed apps, set favorites and hidden") + subtext: qsTr("Browse installed apps, set favorites and hidden") text: qsTr("All apps") onClicked: root.sState.openSubPage(1) @@ -154,7 +154,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: appItem.modelData.name } @@ -162,7 +162,7 @@ PageBase { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (appItem.modelData.comment || appItem.modelData.genericName) ?? "" visible: text } @@ -171,7 +171,7 @@ PageBase { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: "favorite" visible: Strings.testRegexList(Config.launcher.favoriteApps, appItem.modelData.id) } diff --git a/Modules/Settings/Pages/Audio/AppVolumes.qml b/Modules/Settings/Pages/Audio/AppVolumes.qml index 59d634b..9d59da6 100644 --- a/Modules/Settings/Pages/Audio/AppVolumes.qml +++ b/Modules/Settings/Pages/Audio/AppVolumes.qml @@ -28,7 +28,6 @@ PageBase { Layout.fillWidth: true Layout.leftMargin: Tokens.padding.extraSmall color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small text: qsTr("Adjust the volume of individual apps currently playing audio.") wrapMode: Text.WordWrap } diff --git a/Modules/Settings/Pages/AudioPage.qml b/Modules/Settings/Pages/AudioPage.qml index fa8f225..7c6e0f7 100644 --- a/Modules/Settings/Pages/AudioPage.qml +++ b/Modules/Settings/Pages/AudioPage.qml @@ -103,7 +103,7 @@ PageBase { spacing: Tokens.spacing.small MaterialIcon { - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "tune" } @@ -114,7 +114,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: qsTr("App volumes") } @@ -123,14 +123,14 @@ PageBase { animate: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: Audio.streams.length === 0 ? qsTr("No apps playing audio") : Audio.streams.length === 1 ? qsTr("1 app playing audio") : qsTr("%1 apps playing audio").arg(Audio.streams.length) } } MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "chevron_right" } } diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index c14f3bd..8f6ddb6 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -91,7 +91,7 @@ PageBase { first: true icon: "widgets" settingAnchor: "bar-tray" - status: qsTr("System tray icons") + subtext: qsTr("System tray icons") text: qsTr("Tray") onClicked: root.sState.openSubPage(6) @@ -100,7 +100,7 @@ PageBase { NavRow { icon: "signal_cellular_alt" settingAnchor: "bar-statusicons" - status: qsTr("Visible indicators") + subtext: qsTr("Visible indicators") text: qsTr("Status icons") onClicked: root.sState.openSubPage(7) @@ -110,7 +110,7 @@ PageBase { icon: "schedule" last: true settingAnchor: "bar-clock" - status: qsTr("Date, icon, background") + subtext: qsTr("Date, icon, background") text: qsTr("Clock") onClicked: root.sState.openSubPage(8) diff --git a/Modules/Settings/Pages/PanelsPage.qml b/Modules/Settings/Pages/PanelsPage.qml index eeb33ba..6a16a5b 100644 --- a/Modules/Settings/Pages/PanelsPage.qml +++ b/Modules/Settings/Pages/PanelsPage.qml @@ -17,7 +17,7 @@ PageBase { first: true icon: "dock_to_bottom" settingAnchor: "panels-bar" - status: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover") + subtext: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover") text: qsTr("Bar") onClicked: root.sState.openSubPage(1) @@ -26,7 +26,7 @@ PageBase { NavRow { icon: "dashboard" settingAnchor: "panels-dashboard" - status: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Dashboard") onClicked: root.sState.openSubPage(2) @@ -35,7 +35,7 @@ PageBase { NavRow { icon: "insert_chart" settingAnchor: "panels-resources" - status: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Resources") onClicked: root.sState.openSubPage(3) @@ -44,7 +44,7 @@ PageBase { NavRow { icon: "apps" settingAnchor: "panels-launcher" - status: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Launcher") onClicked: root.sState.openSubPage(4) @@ -54,7 +54,7 @@ PageBase { icon: "dock_to_right" last: true settingAnchor: "panels-sidebar" - status: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Sidebar") onClicked: root.sState.openSubPage(5) diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index e179635..11b655e 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -72,7 +72,7 @@ PageBase { icon: "notifications" last: true settingAnchor: "services-notifications" - status: qsTr("Notifications, toasts, timeouts") + subtext: qsTr("Notifications, toasts, timeouts") text: qsTr("Notifications") onClicked: root.sState.openSubPage(1) diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index 18016d2..a4d4906 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -25,7 +25,7 @@ PageBase { CustomText { Layout.topMargin: Tokens.spacing.largeIncreased - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.small text: qsTr("Wallpapers") } diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index 339a024..fdb1ea2 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -7,6 +7,8 @@ qml_module(ZShell-config configlist.hpp configlist.cpp config.hpp config.cpp anim.hpp anim.cpp + fontbuilder.hpp fontbuilder.cpp + font.hpp font.cpp tokens.hpp appearance.hpp background.hpp @@ -25,4 +27,6 @@ qml_module(ZShell-config services.hpp sidebar.hpp utilities.hpp + LIBRARIES + Qt::Gui ) diff --git a/Plugins/ZShell/Config/appearance.hpp b/Plugins/ZShell/Config/appearance.hpp index b4b0e5a..5181423 100644 --- a/Plugins/ZShell/Config/appearance.hpp +++ b/Plugins/ZShell/Config/appearance.hpp @@ -1,19 +1,78 @@ #pragma once #include "configobject.hpp" +#include +#include +#include #include namespace ZShell::config { +class AnimDurations : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY( + int expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY + scaleChanged) + Q_PROPERTY( + int expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY + scaleChanged) + Q_PROPERTY( + int expressiveFastEffects READ expressiveFastEffects NOTIFY scaleChanged) + Q_PROPERTY( + int expressiveFastSpatial READ expressiveFastSpatial NOTIFY scaleChanged) + Q_PROPERTY( + int expressiveSlowEffects READ expressiveSlowEffects NOTIFY scaleChanged) + Q_PROPERTY( + int expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY scaleChanged) + Q_PROPERTY(int large READ large NOTIFY scaleChanged) + Q_PROPERTY(int normal READ normal NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY scaleChanged) + + public: + explicit AnimDurations(QObject* parent = nullptr) : ConfigObject(parent) {} + + [[nodiscard]] int small() const { return static_cast(200 * scale()); } + [[nodiscard]] int normal() const { return static_cast(400 * scale()); } + [[nodiscard]] int large() const { return static_cast(600 * scale()); } + [[nodiscard]] int extraLarge() const { + return static_cast(1000 * scale()); + } + [[nodiscard]] int expressiveFastSpatial() const { + return static_cast(350 * scale()); + } + [[nodiscard]] int expressiveDefaultSpatial() const { + return static_cast(500 * scale()); + } + [[nodiscard]] int expressiveSlowSpatial() const { + return static_cast(650 * scale()); + } + [[nodiscard]] int expressiveFastEffects() const { + return static_cast(150 * scale()); + } + [[nodiscard]] int expressiveDefaultEffects() const { + return static_cast(200 * scale()); + } + [[nodiscard]] int expressiveSlowEffects() const { + return static_cast(300 * scale()); + } +}; + class Anim : public ConfigObject { Q_OBJECT QML_ANONYMOUS CFG_PROPERTY(int, mediaGifSpeedAdjustment, 300) CFG_PROPERTY(qreal, sessionGifSpeed, 0.7) + CONFIG_SUBOBJECT(AnimDurations, durations) public: - explicit Anim(QObject* parent = nullptr) : ConfigObject(parent) {} + explicit Anim(QObject* parent = nullptr) + : ConfigObject(parent), m_durations(new AnimDurations(this)) {} }; class Deform : public ConfigObject { @@ -39,15 +98,161 @@ class FontFamily : public ConfigObject { explicit FontFamily(QObject* parent = nullptr) : ConfigObject(parent) {} }; -class Font : public ConfigObject { +namespace detail { + +struct FontConfig { + QString family = {}; + int size; + int weight = QFont::Normal; + bool italic = false; + QVariantMap vaxes = {{QStringLiteral("ROND"), 25}}; +}; + +} // namespace detail + +class FontSizeNode : public ConfigObject { + public: + explicit FontSizeNode(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + +class FontStyleNode : public ConfigObject { + public: + explicit FontStyleNode(QObject* parent = nullptr) : ConfigObject(parent) {} + + virtual FontSizeNode* largeNode() const = 0; + virtual FontSizeNode* mediumNode() const = 0; + virtual FontSizeNode* smallNode() const = 0; + virtual FontSizeNode* extraLargeNode() const { return nullptr; } +}; + +#define ARG(...) __VA_ARGS__ +#define FONT(...) ZShell::config::detail::FontConfig __VA_ARGS__ +#define FONT_CONFIG(Style, Size, props) \ + class FontConfig##Style##Size : public FontSizeNode { \ + Q_OBJECT QML_ANONYMOUS \ + \ + CFG_PROPERTY(QString, family, ARG(props).family) \ + CFG_PROPERTY(int, size, ARG(props).size) \ + CFG_PROPERTY(int, weight, ARG(props).weight) \ + CFG_PROPERTY(bool, italic, ARG(props).italic) \ + CFG_PROPERTY(QVariantMap, vaxes, ARG(props).vaxes) \ + \ + public \ + : explicit FontConfig##Style##Size(QObject* parent = nullptr) \ + : FontSizeNode(parent) {} \ + }; +#define FONT_STYLE(Style, family_, large_, medium_, small_) \ + FONT_CONFIG(Style, Large, ARG(large_)) \ + FONT_CONFIG(Style, Medium, ARG(medium_)) \ + FONT_CONFIG(Style, Small, ARG(small_)) \ + \ + class FontStyle##Style : public FontStyleNode { \ + Q_OBJECT QML_ANONYMOUS \ + \ + CFG_PROPERTY(QString, family, family_) \ + CONFIG_SUBOBJECT(FontConfig##Style##Large, large) \ + CONFIG_SUBOBJECT(FontConfig##Style##Medium, medium) \ + CONFIG_SUBOBJECT(FontConfig##Style##Small, small) \ + \ + public \ + : explicit FontStyle##Style(QObject* parent = nullptr) \ + : FontStyleNode(parent) \ + , m_large(new FontConfig##Style##Large(this)) \ + , m_medium(new FontConfig##Style##Medium(this)) \ + , m_small(new FontConfig##Style##Small(this)) {} \ + \ + FontSizeNode* largeNode() const override { return m_large; } \ + FontSizeNode* mediumNode() const override { return m_medium; } \ + FontSizeNode* smallNode() const override { return m_small; } \ + }; + +// clang-format off +FONT_STYLE(Headline, QStringLiteral("GoogleSansFlex"), + FONT({ .size = 32, .weight = QFont::Medium }), + FONT({ .size = 28, .weight = QFont::Medium }), + FONT({ .size = 24, .weight = QFont::Medium }) +) +FONT_STYLE(Title, QStringLiteral("GoogleSansFlex"), + FONT({ .size = 22, .weight = QFont::Medium }), + FONT({ .size = 16, .weight = QFont::Medium }), + FONT({ .size = 14, .weight = QFont::Medium }) +) +FONT_STYLE(Body, QStringLiteral("GoogleSansFlex"), + FONT({ .size = 16 }), + FONT({ .size = 14 }), + FONT({ .size = 12 }) +) +FONT_STYLE(Label, QStringLiteral("GoogleSansFlex"), + FONT({ .size = 14, .weight = QFont::Medium }), + FONT({ .size = 12, .weight = QFont::Medium }), + FONT({ .size = 11 }) +) +FONT_STYLE(Mono, QStringLiteral("CaskaydiaCove NF"), + FONT({ .size = 16, .vaxes = {} }), + FONT({ .size = 14, .vaxes = {} }), + FONT({ .size = 12, .vaxes = {} }) +) +// clang-format on + +FONT_CONFIG(Icon, ExtraLarge, FONT({.size = static_cast(48 / 1.33)})) +FONT_CONFIG(Icon, Large, FONT({.size = static_cast(32 / 1.33)})) +FONT_CONFIG(Icon, Medium, FONT({.size = static_cast(24 / 1.33)})) +FONT_CONFIG(Icon, Small, FONT({.size = static_cast(20 / 1.33)})) + +class FontStyleIcon : public FontStyleNode { Q_OBJECT QML_ANONYMOUS - CONFIG_SUBOBJECT(FontFamily, family) + CFG_PROPERTY(QString, family, QStringLiteral("Material Symbols Rounded")) + CONFIG_SUBOBJECT(FontConfigIconExtraLarge, extraLarge) + CONFIG_SUBOBJECT(FontConfigIconLarge, large) + CONFIG_SUBOBJECT(FontConfigIconMedium, medium) + CONFIG_SUBOBJECT(FontConfigIconSmall, small) public: - explicit Font(QObject* parent = nullptr) - : ConfigObject(parent), m_family(new FontFamily(this)) {} + explicit FontStyleIcon(QObject* parent = nullptr) + : FontStyleNode(parent) + , m_extraLarge(new FontConfigIconExtraLarge(this)) + , m_large(new FontConfigIconLarge(this)) + , m_medium(new FontConfigIconMedium(this)) + , m_small(new FontConfigIconSmall(this)) {} + + FontSizeNode* extraLargeNode() const override { return m_extraLarge; } + FontSizeNode* largeNode() const override { return m_large; } + FontSizeNode* mediumNode() const override { return m_medium; } + FontSizeNode* smallNode() const override { return m_small; } +}; + +#undef ARG +#undef FONT +#undef FONT_CONFIG +#undef FONT_STYLE + +class AppearanceFont : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + CONFIG_SUBOBJECT(FontFamily, family) + CONFIG_SUBOBJECT(FontStyleHeadline, headline) + CONFIG_SUBOBJECT(FontStyleTitle, title) + CONFIG_SUBOBJECT(FontStyleBody, body) + CONFIG_SUBOBJECT(FontStyleLabel, label) + CONFIG_SUBOBJECT(FontStyleMono, mono) + CONFIG_SUBOBJECT(FontStyleIcon, icon) + CFG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + CFG_PROPERTY(QString, workspaces, QStringLiteral("Rubik")) + + public: + explicit AppearanceFont(QObject* parent = nullptr) + : ConfigObject(parent) + , m_family(new FontFamily(this)) + , m_headline(new FontStyleHeadline(this)) + , m_title(new FontStyleTitle(this)) + , m_body(new FontStyleBody(this)) + , m_label(new FontStyleLabel(this)) + , m_mono(new FontStyleMono(this)) + , m_icon(new FontStyleIcon(this)) {} }; class Transparency : public ConfigObject { @@ -62,13 +267,140 @@ class Transparency : public ConfigObject { explicit Transparency(QObject* parent = nullptr) : ConfigObject(parent) {} }; +class AppearanceRounding : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY scaleChanged) + Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) + Q_PROPERTY(int large READ large NOTIFY scaleChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) + Q_PROPERTY(int full READ full CONSTANT) + + public: + explicit AppearanceRounding(QObject* parent = nullptr) + : ConfigObject(parent) {} + + [[nodiscard]] int extraSmall() const { + return static_cast(4 * scale()); + } + [[nodiscard]] int small() const { return static_cast(8 * scale()); } + [[nodiscard]] int medium() const { return static_cast(12 * scale()); } + [[nodiscard]] int large() const { return static_cast(16 * scale()); } + [[nodiscard]] int largeIncreased() const { + return static_cast(20 * scale()); + } + [[nodiscard]] int extraLarge() const { + return static_cast(28 * scale()); + } + [[nodiscard]] int extraLargeIncreased() const { + return static_cast(32 * scale()); + } + [[nodiscard]] int extraExtraLarge() const { + return static_cast(48 * scale()); + } + [[nodiscard]] int full() const { return std::numeric_limits::max(); } +}; + +class AppearanceSpacing : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY scaleChanged) + Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) + Q_PROPERTY(int large READ large NOTIFY scaleChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) + + public: + explicit AppearanceSpacing(QObject* parent = nullptr) + : ConfigObject(parent) {} + + [[nodiscard]] int extraSmall() const { + return static_cast(4 * scale()); + } + [[nodiscard]] int small() const { return static_cast(8 * scale()); } + [[nodiscard]] int medium() const { return static_cast(12 * scale()); } + [[nodiscard]] int large() const { return static_cast(16 * scale()); } + [[nodiscard]] int largeIncreased() const { + return static_cast(20 * scale()); + } + [[nodiscard]] int extraLarge() const { + return static_cast(28 * scale()); + } + [[nodiscard]] int extraLargeIncreased() const { + return static_cast(32 * scale()); + } + [[nodiscard]] int extraExtraLarge() const { + return static_cast(48 * scale()); + } +}; + +class AppearancePadding : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) + Q_PROPERTY(int smaller READ smaller NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY scaleChanged) + Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) + Q_PROPERTY(int large READ large NOTIFY scaleChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) + + public: + explicit AppearancePadding(QObject* parent = nullptr) + : ConfigObject(parent) {} + + [[nodiscard]] int extraSmall() const { + return static_cast(4 * scale()); + } + [[nodiscard]] int smaller() const { return static_cast(5 * scale()); } + [[nodiscard]] int small() const { return static_cast(8 * scale()); } + [[nodiscard]] int medium() const { return static_cast(12 * scale()); } + [[nodiscard]] int large() const { return static_cast(16 * scale()); } + [[nodiscard]] int largeIncreased() const { + return static_cast(20 * scale()); + } + [[nodiscard]] int extraLarge() const { + return static_cast(28 * scale()); + } + [[nodiscard]] int extraLargeIncreased() const { + return static_cast(32 * scale()); + } + [[nodiscard]] int extraExtraLarge() const { + return static_cast(48 * scale()); + } +}; + class Appearance : public ConfigObject { Q_OBJECT QML_ANONYMOUS CONFIG_SUBOBJECT(Anim, anim) CONFIG_SUBOBJECT(Deform, deform) - CONFIG_SUBOBJECT(Font, font) + CONFIG_SUBOBJECT(AppearanceFont, font) + CONFIG_SUBOBJECT(AppearanceRounding, rounding) + CONFIG_SUBOBJECT(AppearanceSpacing, spacing) + CONFIG_SUBOBJECT(AppearancePadding, padding) CONFIG_SUBOBJECT(Transparency, transparency) public: @@ -76,8 +408,11 @@ class Appearance : public ConfigObject { : ConfigObject(parent) , m_anim(new Anim(this)) , m_deform(new Deform(this)) - , m_font(new Font(this)) - , m_transparency(new Transparency(this)) {} + , m_font(new AppearanceFont(this)) + , m_rounding(new AppearanceRounding(this)) + , m_spacing(new AppearanceSpacing(this)) + , m_padding(new AppearancePadding(this)) + , m_transparency(new Transparency(this)) {}; }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configobject.cpp b/Plugins/ZShell/Config/configobject.cpp index 9ee0517..1b0d01d 100644 --- a/Plugins/ZShell/Config/configobject.cpp +++ b/Plugins/ZShell/Config/configobject.cpp @@ -65,13 +65,12 @@ void ConfigObject::loadFromJson(const QJsonValue& json) { auto prop = meta->property(i); const auto key = QString::fromUtf8(prop.name()); - known.insert(key); - if (!obj.contains(key)) continue; const auto jsonVal = obj.value(key); if (prop.metaType().flags().testFlag(QMetaType::PointerToQObject)) { + known.insert(key); if (auto* const node = prop.read(this).value()) { node->loadFromJson(jsonVal); continue; @@ -80,6 +79,8 @@ void ConfigObject::loadFromJson(const QJsonValue& json) { if (!prop.isWritable()) continue; + known.insert(key); + if (!jsonValueMatchesType(jsonVal, prop.metaType())) { qWarning() << "Config: type mismatch for" << key << "in" << meta->className() << "- expected" @@ -218,18 +219,9 @@ void ConfigObject::materializeDefaults() { for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { const auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - if (auto* const node = prop.read(this).value()) { + if (auto* const node = prop.read(this).value()) node->materializeDefaults(); - continue; - } - - if (!prop.isWritable()) continue; - - if (m_global) continue; - - m_loadedKeys.insert(key); } } diff --git a/Plugins/ZShell/Config/font.cpp b/Plugins/ZShell/Config/font.cpp new file mode 100644 index 0000000..57038f5 --- /dev/null +++ b/Plugins/ZShell/Config/font.cpp @@ -0,0 +1,290 @@ +#include "font.hpp" + +#include "appearance.hpp" +#include "config.hpp" + +namespace ZShell::config { + +FontStyleBase::FontStyleBase(QObject* parent) : QObject(parent) {} + +QFont FontStyleBase::large() const { + return m_large; +} + +QFont FontStyleBase::medium() const { + return m_medium; +} + +QFont FontStyleBase::small() const { + return m_small; +} + +QFont FontStyleBase::buildFont( + const FontSizeNode* cfg, const QString& fallbackFamily, qreal scale) { + const auto family = cfg->property("family").toString(); + const auto size = cfg->property("size").toInt(); + const auto weight = cfg->property("weight").value(); + const auto italic = cfg->property("italic").toBool(); + const auto vaxes = cfg->property("vaxes").toMap(); + + QFont font; + font.setFamily(family.isEmpty() ? fallbackFamily : family); + const int scaledSize = static_cast(size * scale); + const int cappedSize = scaledSize > 0 ? scaledSize : 1; + font.setPointSize(cappedSize); + font.setVariableAxis("opsz", static_cast(cappedSize)); + font.setWeight(weight); + font.setVariableAxis("wght", static_cast(weight)); + font.setItalic(italic); + + for (auto it = vaxes.constBegin(); it != vaxes.constEnd(); ++it) { + if (auto tag = QFont::Tag::fromString(it.key())) + font.setVariableAxis(*tag, it.value().toFloat()); + } + + return font; +} + +void FontStyleBase::bind(FontStyleNode* cfg) { + if (m_cfg == cfg) return; + + if (m_cfg) { + disconnect(m_cfg, nullptr, this, nullptr); + disconnect(m_cfg->largeNode(), nullptr, this, nullptr); + disconnect(m_cfg->mediumNode(), nullptr, this, nullptr); + disconnect(m_cfg->smallNode(), nullptr, this, nullptr); + } + + m_cfg = cfg; + + if (cfg) { + connect( + cfg, &ConfigNode::propertiesChanged, this, &FontStyleBase::rebuild); + connect( + cfg->largeNode(), + &ConfigNode::propertiesChanged, + this, + &FontStyleBase::rebuild); + connect( + cfg->mediumNode(), + &ConfigNode::propertiesChanged, + this, + &FontStyleBase::rebuild); + connect( + cfg->smallNode(), + &ConfigNode::propertiesChanged, + this, + &FontStyleBase::rebuild); + } + + rebuild(); +} + +void FontStyleBase::rebuild() { + if (m_cfg) { + const auto family = m_cfg->property("family").toString(); + m_large = buildFont(m_cfg->largeNode(), family, m_scale); + m_medium = buildFont(m_cfg->mediumNode(), family, m_scale); + m_small = buildFont(m_cfg->smallNode(), family, m_scale); + } else { + m_large = QFont(); + m_medium = QFont(); + m_small = QFont(); + } + emit fontsChanged(); +} + +void FontStyleBase::setScale(qreal scale) { + if (qFuzzyCompare(m_scale + 1.0, scale + 1.0)) return; + m_scale = scale; + rebuild(); +} + +FontStyle::FontStyle(QObject* parent) + : FontStyleBase(parent), m_builders(new FontBuilders(this, this)) {} + +FontBuilders* FontStyle::builders() const { + return m_builders; +} + +IconFontStyle::IconFontStyle(QObject* parent) + : FontStyleBase(parent), m_builders(new IconFontBuilders(this, this)) {} + +FontBuilder IconFontStyle::size(int pointSize) { + return FontBuilder(m_small).size(pointSize); +} + +FontBuilder IconFontStyle::pixelSize(int pixelSize) { + return FontBuilder(m_small).pixelSize(pixelSize); +} + +void IconFontStyle::bind(FontStyleNode* cfg) { + if (m_cfg == cfg) return; + + if (m_cfg) disconnect(m_cfg->extraLargeNode(), nullptr, this, nullptr); + + FontStyleBase::bind(cfg); + + if (cfg) + connect( + cfg->extraLargeNode(), + &ConfigNode::propertiesChanged, + this, + &IconFontStyle::rebuild); +} + +QFont IconFontStyle::extraLarge() const { + return m_extraLarge; +} + +IconFontBuilders* IconFontStyle::builders() const { + return m_builders; +} + +void IconFontStyle::rebuild() { + if (m_cfg) { + const auto family = m_cfg->property("family").toString(); + m_extraLarge = buildFont(m_cfg->extraLargeNode(), family, m_scale); + } else { + m_extraLarge = QFont(); + } + FontStyleBase::rebuild(); +} + +FontBuilders::FontBuilders(const FontStyleBase* style, QObject* parent) + : QObject(parent), m_style(style) { + connect( + style, + &FontStyleBase::fontsChanged, + this, + &FontBuilders::buildersChanged); +} + +FontBuilder FontBuilders::large() const { + return FontBuilder(m_style->large()); +} + +FontBuilder FontBuilders::medium() const { + return FontBuilder(m_style->medium()); +} + +FontBuilder FontBuilders::small() const { + return FontBuilder(m_style->small()); +} + +IconFontBuilders::IconFontBuilders(const IconFontStyle* style, QObject* parent) + : FontBuilders(style, parent) {} + +FontBuilder IconFontBuilders::extraLarge() const { + return FontBuilder( + static_cast(m_style)->extraLarge()); +} + +FontTokens::FontTokens(QObject* parent) + : ConfigObject(parent) + , m_size(new FontSizeTokens(this)) + , m_headline(new FontStyle(this)) + , m_title(new FontStyle(this)) + , m_body(new FontStyle(this)) + , m_label(new FontStyle(this)) + , m_mono(new FontStyle(this)) + , m_icon(new IconFontStyle(this)) { + if (!Config::instance()) new Config(); + bindFont(Config::instance()->appearance()->font()); +} + +FontStyle* FontTokens::headline() const { + return m_headline; +} + +FontStyle* FontTokens::title() const { + return m_title; +} + +FontStyle* FontTokens::body() const { + return m_body; +} + +FontStyle* FontTokens::label() const { + return m_label; +} + +FontStyle* FontTokens::mono() const { + return m_mono; +} + +IconFontStyle* FontTokens::icon() const { + return m_icon; +} + +FontBuilder FontTokens::clock() const { + return FontBuilder(m_clock); +} + +QString FontTokens::workspaces() const { + return m_font ? m_font->workspaces() : QString(); +} + +void FontTokens::bindFont(AppearanceFont* font) { + if (m_font == font) return; + + if (m_font) disconnect(m_font, nullptr, this, nullptr); + + m_font = font; + + if (font) { + rebuildScale(); + m_headline->bind(font->headline()); + m_title->bind(font->title()); + m_body->bind(font->body()); + m_label->bind(font->label()); + m_mono->bind(font->mono()); + m_icon->bind(font->icon()); + + connect( + font, + &AppearanceFont::clockChanged, + this, + &FontTokens::rebuildClock); + connect( + font, + &AppearanceFont::scaleChanged, + this, + &FontTokens::rebuildScale); + connect( + font, + &AppearanceFont::workspacesChanged, + this, + &FontTokens::workspacesChanged); + } else { + rebuildScale(); + m_headline->bind(nullptr); + m_title->bind(nullptr); + m_body->bind(nullptr); + m_label->bind(nullptr); + m_mono->bind(nullptr); + m_icon->bind(nullptr); + } + + rebuildClock(); + emit workspacesChanged(); +} + +void FontTokens::rebuildScale() { + const qreal s = m_font ? m_font->scale() : 1; + m_headline->setScale(s); + m_title->setScale(s); + m_body->setScale(s); + m_label->setScale(s); + m_mono->setScale(s); + m_icon->setScale(s); +} + +void FontTokens::rebuildClock() { + QFont f; + if (m_font) f.setFamily(m_font->clock()); + m_clock = f; + emit clockChanged(); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/font.hpp b/Plugins/ZShell/Config/font.hpp new file mode 100644 index 0000000..9625457 --- /dev/null +++ b/Plugins/ZShell/Config/font.hpp @@ -0,0 +1,200 @@ +#pragma once + +#include "configobject.hpp" +#include "fontbuilder.hpp" + +#include +#include +#include +#include + +namespace ZShell::config { + +class AppearanceFont; +class FontBuilders; +class FontStyleBase; +class FontSizeNode; +class FontStyleNode; +class IconFontStyle; + +class FontSizeTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(int, small, 11) + CFG_PROPERTY(int, smaller, 12) + CFG_PROPERTY(int, normal, 13) + CFG_PROPERTY(int, larger, 15) + CFG_PROPERTY(int, large, 18) + CFG_PROPERTY(int, extraLarge, 28) + + public: + explicit FontSizeTokens(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + +class FontBuilders : public QObject { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY( + ZShell::config::FontBuilder large READ large NOTIFY buildersChanged + FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder medium READ medium NOTIFY buildersChanged + FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder small READ small NOTIFY buildersChanged + FINAL) + + public: + explicit FontBuilders(const FontStyleBase* style, QObject* parent = nullptr); + + [[nodiscard]] FontBuilder large() const; + [[nodiscard]] FontBuilder medium() const; + [[nodiscard]] FontBuilder small() const; + + signals: + void buildersChanged(); + + protected: + const FontStyleBase* m_style; +}; + +class IconFontBuilders : public FontBuilders { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY( + ZShell::config::FontBuilder extraLarge READ extraLarge NOTIFY + buildersChanged FINAL) + + public: + explicit IconFontBuilders( + const IconFontStyle* style, QObject* parent = nullptr); + + [[nodiscard]] FontBuilder extraLarge() const; +}; + +class FontStyleBase : public QObject { + Q_OBJECT + + Q_PROPERTY(QFont large READ large NOTIFY fontsChanged FINAL) + Q_PROPERTY(QFont medium READ medium NOTIFY fontsChanged FINAL) + Q_PROPERTY(QFont small READ small NOTIFY fontsChanged FINAL) + + public: + explicit FontStyleBase(QObject* parent = nullptr); + + virtual void bind(FontStyleNode* cfg); + void setScale(qreal scale); + + [[nodiscard]] QFont large() const; + [[nodiscard]] QFont medium() const; + [[nodiscard]] QFont small() const; + + signals: + void fontsChanged(); + + protected: + virtual void rebuild(); + + static QFont buildFont( + const FontSizeNode* cfg, const QString& fallbackFamily, qreal scale); + + FontStyleNode* m_cfg = nullptr; + qreal m_scale = 1; + QFont m_large; + QFont m_medium; + QFont m_small; +}; + +class FontStyle : public FontStyleBase { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(FontBuilders* builders READ builders CONSTANT FINAL) + + public: + explicit FontStyle(QObject* parent = nullptr); + + [[nodiscard]] FontBuilders* builders() const; + + private: + FontBuilders* m_builders; +}; + +class IconFontStyle : public FontStyleBase { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(QFont extraLarge READ extraLarge NOTIFY fontsChanged FINAL) + Q_PROPERTY(IconFontBuilders* builders READ builders CONSTANT FINAL) + + public: + explicit IconFontStyle(QObject* parent = nullptr); + + Q_INVOKABLE FontBuilder size(int pointSize); + Q_INVOKABLE FontBuilder pixelSize(int pixelSize); + + void bind(FontStyleNode* cfg) override; + + [[nodiscard]] QFont extraLarge() const; + [[nodiscard]] IconFontBuilders* builders() const; + + protected: + void rebuild() override; + + private: + QFont m_extraLarge; + IconFontBuilders* m_builders; +}; + +class FontTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_SUBOBJECT(FontSizeTokens, size) + + Q_PROPERTY(FontStyle* headline READ headline CONSTANT FINAL) + Q_PROPERTY(FontStyle* title READ title CONSTANT FINAL) + Q_PROPERTY(FontStyle* body READ body CONSTANT FINAL) + Q_PROPERTY(FontStyle* label READ label CONSTANT FINAL) + Q_PROPERTY(FontStyle* mono READ mono CONSTANT FINAL) + Q_PROPERTY(IconFontStyle* icon READ icon CONSTANT FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder clock READ clock NOTIFY clockChanged FINAL) + Q_PROPERTY(QString workspaces READ workspaces NOTIFY workspacesChanged FINAL) + + public: + explicit FontTokens(QObject* parent = nullptr); + + void bindFont(AppearanceFont* font); + + [[nodiscard]] FontStyle* headline() const; + [[nodiscard]] FontStyle* title() const; + [[nodiscard]] FontStyle* body() const; + [[nodiscard]] FontStyle* label() const; + [[nodiscard]] FontStyle* mono() const; + [[nodiscard]] IconFontStyle* icon() const; + [[nodiscard]] FontBuilder clock() const; + [[nodiscard]] QString workspaces() const; + + signals: + void clockChanged(); + void workspacesChanged(); + + private: + void rebuildClock(); + void rebuildScale(); + + AppearanceFont* m_font = nullptr; + FontStyle* m_headline; + FontStyle* m_title; + FontStyle* m_body; + FontStyle* m_label; + FontStyle* m_mono; + IconFontStyle* m_icon; + QFont m_clock; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/fontbuilder.cpp b/Plugins/ZShell/Config/fontbuilder.cpp new file mode 100644 index 0000000..3726711 --- /dev/null +++ b/Plugins/ZShell/Config/fontbuilder.cpp @@ -0,0 +1,105 @@ +#include "fontbuilder.hpp" + +#include + +namespace { + +Q_LOGGING_CATEGORY(lcFontBuilder, "zshell.fontbuilder", QtInfoMsg) + +} // namespace + +namespace ZShell::config { + +FontBuilder::FontBuilder(const QFont& font) : m_font(font) {} + +FontBuilder FontBuilder::family(const QString& family) { + m_font.setFamily(family); + return *this; +} + +FontBuilder FontBuilder::size(int pointSize) { + if (pointSize <= 0) pointSize = 1; + m_font.setPointSize(pointSize); + m_font.setVariableAxis("opsz", static_cast(pointSize)); + return *this; +} + +FontBuilder FontBuilder::pixelSize(int pixelSize) { + if (pixelSize <= 0) pixelSize = 1; + m_font.setPixelSize(pixelSize); + return *this; +} + +FontBuilder FontBuilder::weight(QFont::Weight weight) { + m_font.setWeight(weight); + m_font.setVariableAxis("wght", static_cast(weight)); + return *this; +} + +FontBuilder FontBuilder::italic(bool on) { + m_font.setItalic(on); + return *this; +} + +FontBuilder FontBuilder::stretch(int stretch) { + m_font.setStretch(stretch); + return *this; +} + +FontBuilder FontBuilder::letterSpacing(qreal spacing, bool absolute) { + m_font.setLetterSpacing( + absolute ? QFont::AbsoluteSpacing : QFont::PercentageSpacing, spacing); + return *this; +} + +FontBuilder FontBuilder::capitalisation(QFont::Capitalization cap) { + m_font.setCapitalization(cap); + return *this; +} + +FontBuilder FontBuilder::vaxis(const QString& tag, float value) { + if (auto t = QFont::Tag::fromString(tag)) + m_font.setVariableAxis(*t, value); + else + qCWarning(lcFontBuilder) + << "Unable to convert tag" << tag << "to QFont::Tag"; + return *this; +} + +FontBuilder FontBuilder::vaxes(const QVariantMap& axes) { + for (auto it = axes.constBegin(); it != axes.constEnd(); ++it) { + if (it.value().canConvert()) { + if (auto tag = QFont::Tag::fromString(it.key())) + m_font.setVariableAxis(*tag, it.value().toFloat()); + else + qCWarning(lcFontBuilder) + << "Unable to convert tag" << it.key() << "to QFont::Tag"; + } else { + qCWarning(lcFontBuilder) + << "Unable to convert value" << it.value() << "to float"; + } + } + return *this; +} + +QFont FontBuilder::build() const { + return m_font; +} + +FontBuilder FontBuilder::fill(float value) { + return vaxis(QStringLiteral("FILL"), value); +} + +FontBuilder FontBuilder::grade(float value) { + return vaxis(QStringLiteral("GRAD"), value); +} + +FontBuilder FontBuilder::width(float value) { + return vaxis(QStringLiteral("wdth"), value); +} + +FontBuilder FontBuilder::scale(qreal factor) { + return size(static_cast(m_font.pointSize() * factor)); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/fontbuilder.hpp b/Plugins/ZShell/Config/fontbuilder.hpp new file mode 100644 index 0000000..d474f92 --- /dev/null +++ b/Plugins/ZShell/Config/fontbuilder.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::config { + +class FontBuilder { + Q_GADGET + QML_ANONYMOUS + + public: + FontBuilder() = default; + explicit FontBuilder(const QFont& font); + + [[nodiscard]] Q_INVOKABLE FontBuilder family(const QString& family); + [[nodiscard]] Q_INVOKABLE FontBuilder size(int pointSize); + [[nodiscard]] Q_INVOKABLE FontBuilder pixelSize(int pixelSize); + [[nodiscard]] Q_INVOKABLE FontBuilder weight(QFont::Weight weight); + [[nodiscard]] Q_INVOKABLE FontBuilder italic(bool on = true); + [[nodiscard]] Q_INVOKABLE FontBuilder stretch(int stretch); + [[nodiscard]] Q_INVOKABLE FontBuilder + letterSpacing(qreal spacing, bool absolute = true); + [[nodiscard]] Q_INVOKABLE FontBuilder + capitalisation(QFont::Capitalization cap); + [[nodiscard]] Q_INVOKABLE FontBuilder vaxis(const QString& tag, float value); + [[nodiscard]] Q_INVOKABLE FontBuilder vaxes(const QVariantMap& axes); + [[nodiscard]] Q_INVOKABLE QFont build() const; + + [[nodiscard]] Q_INVOKABLE FontBuilder fill(float value); + [[nodiscard]] Q_INVOKABLE FontBuilder grade(float value); + [[nodiscard]] Q_INVOKABLE FontBuilder width(float value); + + [[nodiscard]] Q_INVOKABLE FontBuilder scale(qreal factor); + + private: + QFont m_font; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index b58d8ea..07dd953 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -2,6 +2,9 @@ #include "configobject.hpp" #include "anim.hpp" +#include "appearance.hpp" +#include "config.hpp" +#include "font.hpp" #include #include @@ -13,89 +16,6 @@ namespace ZShell::config { -class AppearanceRounding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(qreal, extraSmall, 4) - CFG_PROPERTY(qreal, full, std::numeric_limits::max()) - CFG_PROPERTY(qreal, large, 16) - CFG_PROPERTY(qreal, largeIncreased, 20) - CFG_PROPERTY(qreal, extraLarge, 28) - CFG_PROPERTY(qreal, extraLargeIncreased, 32) - CFG_PROPERTY(qreal, extraExtraLarge, 48) - CFG_PROPERTY(qreal, medium, 12) - CFG_PROPERTY(qreal, scale, 1) - CFG_PROPERTY(qreal, small, 8) - - public: - explicit AppearanceRounding(QObject* parent = nullptr) - : ConfigObject(parent) {} -}; - -class AppearancePadding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, extraSmall, 4) - CFG_PROPERTY(int, smaller, 5) - CFG_PROPERTY(int, small, 8) - CFG_PROPERTY(int, medium, 12) - CFG_PROPERTY(int, large, 16) - CFG_PROPERTY(int, largeIncreased, 20) - CFG_PROPERTY(int, extraLarge, 28) - CFG_PROPERTY(int, extraLargeIncreased, 32) - CFG_PROPERTY(int, extraExtraLarge, 48) - - public: - explicit AppearancePadding(QObject* parent = nullptr) - : ConfigObject(parent) {} -}; - -class AppearanceSpacing : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, extraSmall, 4) - CFG_PROPERTY(int, small, 8) - CFG_PROPERTY(int, medium, 12) - CFG_PROPERTY(int, large, 16) - CFG_PROPERTY(int, largeIncreased, 20) - CFG_PROPERTY(int, extraLarge, 28) - CFG_PROPERTY(int, extraLargeIncreased, 32) - CFG_PROPERTY(int, extraExtraLarge, 48) - - public: - explicit AppearanceSpacing(QObject* parent = nullptr) - : ConfigObject(parent) {} -}; - -class FontSizeTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, small, 11) - CFG_PROPERTY(int, smaller, 12) - CFG_PROPERTY(int, normal, 13) - CFG_PROPERTY(int, larger, 15) - CFG_PROPERTY(int, large, 18) - CFG_PROPERTY(int, extraLarge, 28) - - public: - explicit FontSizeTokens(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - -class FontTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CONFIG_SUBOBJECT(FontSizeTokens, size) - - public: - explicit FontTokens(QObject* parent = nullptr) - : ConfigObject(parent), m_size(new FontSizeTokens(this)) {} -}; - class AnimCurves : public ConfigObject { Q_OBJECT QML_ANONYMOUS @@ -142,31 +62,11 @@ class AnimCurves : public ConfigObject { , m_expressiveSlowEffects({0.34, 0.88, 0.34, 1, 1, 1}) {} }; -class AnimDurations : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, small, 200) - CFG_PROPERTY(int, scale, 1) - CFG_PROPERTY(int, normal, 400) - CFG_PROPERTY(int, large, 600) - CFG_PROPERTY(int, extraLarge, 1000) - CFG_PROPERTY(int, expressiveFastSpatial, 350) - CFG_PROPERTY(int, expressiveDefaultSpatial, 500) - CFG_PROPERTY(int, expressiveSlowSpatial, 650) - CFG_PROPERTY(int, expressiveFastEffects, 150) - CFG_PROPERTY(int, expressiveDefaultEffects, 200) - CFG_PROPERTY(int, expressiveSlowEffects, 300) - - public: - explicit AnimDurations(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - class AnimTokens : public ConfigObject { Q_OBJECT QML_ANONYMOUS - CONFIG_SUBOBJECT(AnimDurations, durations) + Q_PROPERTY(ZShell::config::AnimDurations* durations READ durations CONSTANT) Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) Q_PROPERTY( @@ -201,7 +101,6 @@ class AnimTokens : public ConfigObject { explicit AnimTokens(QObject* parent = nullptr) : ConfigObject(parent) , m_curves(new AnimCurves(this)) - , m_durations(new AnimDurations(this)) , m_easing(new AnimEasing(this)) { connect( m_easing, @@ -211,6 +110,10 @@ class AnimTokens : public ConfigObject { m_easing->bindCurves(m_curves); } + [[nodiscard]] AnimDurations* durations() const { + return Config::instance()->appearance()->anim()->durations(); + } + [[nodiscard]] QEasingCurve emphasized() const { return m_easing->emphasized(); } @@ -269,9 +172,10 @@ class Tokens : public ConfigObject { QML_ELEMENT QML_SINGLETON - CONFIG_SUBOBJECT(AppearanceRounding, rounding) - CONFIG_SUBOBJECT(AppearancePadding, padding) - CONFIG_SUBOBJECT(AppearanceSpacing, spacing) + Q_PROPERTY( + ZShell::config::AppearanceRounding* rounding READ rounding CONSTANT) + Q_PROPERTY(ZShell::config::AppearancePadding* padding READ padding CONSTANT) + Q_PROPERTY(ZShell::config::AppearanceSpacing* spacing READ spacing CONSTANT) CONFIG_SUBOBJECT(FontTokens, font) CONFIG_SUBOBJECT(AnimTokens, anim) CONFIG_SUBOBJECT(BarTokens, bar) @@ -279,12 +183,21 @@ class Tokens : public ConfigObject { public: explicit Tokens(QObject* parent = nullptr) : ConfigObject(parent) - , m_rounding(new AppearanceRounding(this)) - , m_padding(new AppearancePadding(this)) - , m_spacing(new AppearanceSpacing(this)) , m_font(new FontTokens(this)) , m_anim(new AnimTokens(this)) - , m_bar(new BarTokens(this)) {} + , m_bar(new BarTokens(this)) { + if (!Config::instance()) new Config(); + } + + [[nodiscard]] AppearanceRounding* rounding() const { + return Config::instance()->appearance()->rounding(); + } + [[nodiscard]] AppearancePadding* padding() const { + return Config::instance()->appearance()->padding(); + } + [[nodiscard]] AppearanceSpacing* spacing() const { + return Config::instance()->appearance()->spacing(); + } static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } }; diff --git a/assets/shaders/geminiglow.frag b/assets/shaders/geminiglow.frag new file mode 100755 index 0000000..ad9de36 --- /dev/null +++ b/assets/shaders/geminiglow.frag @@ -0,0 +1,93 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +// NOTE: std140 layout. Order/types must match the ShaderEffect properties by name. +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 itemSize; // full ShaderEffect size in px (rect + glow padding) + vec2 rectSize; // size of the target rectangle in px + float radius; // corner radius of the target rectangle in px + float glowSize; // how far the glow extends outside the rectangle (px) + float innerGlow; // how far the glow bleeds inside (px), 0 = none + float lineWidth; // width of the bright edge line (px), 0 = none + float phase; // 0..1, loops seamlessly + float intensity; // overall brightness + vec4 color1; + vec4 color2; + vec4 color3; + vec4 color4; +}; + +const float TAU = 6.28318530718; + +// Signed distance to a rounded box centred at the origin. <0 inside, >0 outside. +float sdRoundBox(vec2 p, vec2 halfSize, float r) +{ + vec2 q = abs(p) - (halfSize - r); + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +// Cyclic 4-colour gradient, t is wrapped to [0,1) +vec3 palette(float t) +{ + t = fract(t) * 4.0; + float f = smoothstep(0.0, 1.0, fract(t)); + if (t < 1.0) return mix(color1.rgb, color2.rgb, f); + if (t < 2.0) return mix(color2.rgb, color3.rgb, f); + if (t < 3.0) return mix(color3.rgb, color4.rgb, f); + return mix(color4.rgb, color1.rgb, f); +} + +// Cheap hash for dithering (avoids banding in soft gradients) +float hash(vec2 p) +{ + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() +{ + // Pixel position relative to the centre of the item + vec2 p = (qt_TexCoord0 - 0.5) * itemSize; + + float r = clamp(radius, 0.0, min(rectSize.x, rectSize.y) * 0.5); + float d = sdRoundBox(p, rectSize * 0.5, r); + + // ---- colour: rotates around the rectangle, slightly wobbling ---- + float ang = atan(p.y, p.x); + float a2 = phase * TAU; + float u = ang / TAU + 0.5 + phase; + u += 0.07 * sin(ang * 2.0 + a2) + 0.04 * sin(ang * 3.0 - a2 * 2.0); + vec3 col = palette(u); + + // ---- intensity falloff ---- + float g = 0.0; + if (d > 0.0) { + float t = clamp(d / max(glowSize, 0.001), 0.0, 1.0); + g = pow(1.0 - t, 3.0); + } else if (innerGlow > 0.0) { + g = exp(d / innerGlow * 3.0); + } + + // Slow "breathing" so the glow feels alive + float pulse = 0.9 + 0.1 * sin(a2 * 2.0 + ang); + g *= pulse; + + // Crisp bright line along the edge + float line = 0.0; + if (lineWidth > 0.0) + line = 1.0 - smoothstep(0.0, lineWidth, abs(d)); + + float a = clamp(g * intensity + line * 0.9, 0.0, 1.0); + + // Brighten the colour a bit where the line is + col = mix(col, vec3(1.0), line * 0.25); + + a += (hash(gl_FragCoord.xy) - 0.5) / 255.0; + a = clamp(a, 0.0, 1.0); + + // Premultiplied alpha output + fragColor = vec4(col * a, a) * qt_Opacity; +} diff --git a/assets/shaders/geminiglow.frag.qsb b/assets/shaders/geminiglow.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..233240e6fd7552d50ef537e097b4a5c3c2609215 GIT binary patch literal 3721 zcmV;44tDVX06lSdob6e8lv`yLzi-wyEl^5XTBUp~D9JQUU$&XVA#_vf0=8IXk>c<= zNoJy%Bu(D5GiZ-RP&vn?xTB)tj^e%|F1X?bE{H4c;I6oX8|b;;``ygVW1<{AoZ}zK zIj{5X?=IiH_q*SH_q`I)QX(24B8P}hpnc@i1U;TgG)LR1PIYQhkz69`BO>}IezeGs z4XmUxHOZqXt6a6}f%*TaIz)SEmdf^Sg2wEc?@}^HTJwQ_XOTxfHK{^#M6?VNTa@$) zxmg93ozx(YCaFR`&11H)!itq>hIUhhE~XOVeC7L8Zq_C@-!g8CY zB2{RXT5#vQ?(R(4Tpm!?Sz%8%g_TsH8r7&oP1*s~K;@$DDpjjgqI0Q2Me=DHD#dOp z5o*vhdDLn7Urc1Oe17Oi z>Q{>_v4;7Y@hUaOpGQRN9imMRN7Qe5!)%B8>EZ37AF%iQ8?^Lbn;u3cG&Eq$15`HXvoIO_e*dsg`E|^d7X7MKu2S*mx4Z4>206^doOVvO9S*! z_(b3{eQJmvL4An+sO28oB6L2WH?N{TIvsv(!@67>C(r!T=Ml&q+ZhLa1~86m2jUXHcc6|V(0>$Uw<-Qvs2_>zoFENbgnuDwN%;I2B-+yE z&!Db^e+lR28#}iO{R|>H^T1Wx;xuUEy%aKxi_Ei#=(%@}>|h_C3;%}^Bh$|d?Zx51 z$Iary3sp_M2=}Zn{422s%zrufLyCVDYDwt#hid0KBD(FU&$svf6GRl-bGRV%=QzjT zJh{O3zovSR*!ux`gUJ7wi0UgZeVFinsr=!;cXOW0<;_C>g@|sx>866n4>+Cit#pFg zkJFG{_d!ljpK^(v6K!ukbUORF&Ixk54S8mo&$rHV)XG17JIg1WAV(t4G@pN+w;c3G zY0&0a9u_R~7wWS)U|r7bbJQNQ?=K6{Z%{PLUlXF=8j`;@M86|Mzb`~zuV~)0TSNS> zhv;vG=n}zRc%icO z65!8--b_ZlTPqU5dy zZ#fNH`n5RI4}p!V;V<)E2R_HXoR(PZ_3a)I__iK$9M8+Zmz=y&`5-xY6Y%FFCvOIJ zSjoHvb;&lb0nPVsg}lV_HqsM4 z*!wK{`i;uo=R<3L0a#hdd=YDk%uT?~!MSDZX6W9e{P>dc<7TD*Wjs&5q~iDr{QI(6 z_p9h(B6AC{uc-XpqU65;dFFi`be;z7^WvL!ehYdY^KS*l_us-<6Fc8VZ@!)e?cDPn zU@UVt_0xCJ^Z!NzmUkNw{Q~{}Hf87gup>3{L)6F*l+2HSORe9IoJGO=v8u&8VDom> zGk&6a#vSl6kGQ@EzO40A^mN&?pF#GgYW<(X-Wu@BDo4!wEA`pkq&sSAT@wA5`7_33yr6=${eqgTR+q z|BP70pWlXJ|E^m5uc7ntH(>hQ{vGE`YWW|K`@8bx9(7*+q3qp@`RE>{|4+vQqCyVP-s1#mN}& ztN6B5t$Q?bCcYj6Uso!bRZf6QUXOJGy;Vx)IG!Pu%<)cu-L3ZX1SilvUd6MzJ*x(M zeYSblT8%ZhzpZhC`A*h+aA?i7PJr=zybCk*N&IfBE?$>$gLM`L!ULO8Dlw-w=DEo7&6X1u{IgU92&U3oNHp1UaoQ2$N zZ~}~FB{v(Cd>rzU*NrN#@kR2wQRUSQik{Il;dPGEy^j*s7G7`ulY%8_H=W9->M zFXs2?G3vMT`!aRE9QRAnpZKgCrDlDe&Gf9l2KszvhtXf<%&t*#4~A{=|3uiAcZ9XT zPE_v*CqZwm!VK169>=*Fn5@I!fL1HJ{EcWmE`Ihgc8YrTu7iy99G>CjUGfw)zeZtK z&#y7yBC`Q9dJc{Qm*?(AwRQ}A;U(02#76imwZ92?LfLkaLpfJT$T5B|^;uf>eg)>D z;Ctr69{ z+suJ4HVd$M9iC5{f#vah+Nx%c&G>D{}bn}A=tAj|u{8}V&bcApT+)49OT zL7vW6c{&&0g3pJY^HhvaTqMQ|fJ=;ffL+ifPfvo43zdxt;9}z?vLHRD+Ef zWup#UY&3w?yX0wKC{Imba!H-zC1MhT?k~Fu5eYgDSoQu=_N`$2|RvJih&Xec`!y9CC1ide$5Q{$l7|2JBMR zYQt$Y6Xec(#XACE*@esgxxH+yHxk*7wCF=oc|GiIMZahB&dD|6!(vlbZK z+3+SS{(OM$FHNRQ#V^h5u3TJFP_s1YFF=8A(<@eHTN;_F)(?cGDz#dvxuaS?puuXT zRywy*^rto0nD$yBRlihgY2PwtvRlVKng2GU_5GaflO9*NA>D&=}}hI3;k#@z8p zWWU#(uhgc3F!%Wrk1x$_uh*MJ<^^uFijS+$){5Keb5SeQhzG*cUbW28#w8yyq&qNW z3dY;piZ)_qqlt>F*=V`ZW-JzX*ev<8&6FGVZ0`m7I^d~wM)1ygQ%lYZ2XnGuso zxH{2gQ!uTWdflIH`K1Og95+e+M^xZ!rEH?UIgNQS3k1eh95a=<=){X0qvIZq!X@liAT!Hg3jpiBvl6y2&v&m(OL=V_B}6 z5yN-6L^d;;8_Na@@2^*ihTBcFqf#5DV4}M6rAXaG)>`6dI(q^N-W1Ga)thP9_=9#} zOEYdu)`I98T)7?eJR9Z(RFi3VO>f2&Oe2g1Ok3H=W9*NAt;yI~vyHQ^NhqOTkP`YF?)~Z3Y&5HPIi<>@HYJ-oWkw3YD&ePec!${c_S1Xu-GW+>&G2KTLqBexkj2inic{9! z>?qe|kTQ$|wy5nYBi&PS->nX6klXcSy^5a9T->$|rn2IY>OdJ&FjLiuGn(F%ZIX#O zOeJ1$I&C|LD4!sElsB1FHrrl*AtdiUzQOdvJ>R>)z0efw8d z&iT=atNHPa_WYQ3T~liHM#u+jgsjvSn;xs)e0|oBR_l|V&)ZNiZp`p#W-XR&Ayw8k?S#2SO_&!fOqi+Og!zDeq+E2C?2Vnh nubaKEo4v1_y|0_SubaKEo4v1_)&JYCoBz|;+5Y_tl5~t>$k>?V literal 0 HcmV?d00001 From 0bfda04e919004fcf262f231fb8762f6d18be5a1 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 22 Sep 2026 02:35:18 +0200 Subject: [PATCH 06/21] intermediary changes, plan to revert to mousearea for gestures + config load bugfixes --- Drawers/Interactions.qml | 50 +-- Drawers/Interactions2.qml | 312 ++++++++++++++++++ Modules/Bar/Components/Clock.qml | 12 +- Modules/Dashboard/Media/LyricList.qml | 2 +- Modules/Launcher/Items/FilesItem.qml | 6 +- Modules/Lock/InputField.qml | 2 +- .../Sidebar/Utils/Cards/RecordingList.qml | 2 +- Modules/Settings/Pages/Apps/AllApps.qml | 2 +- Modules/Settings/Pages/Apps/AppInfo.qml | 2 +- Modules/Settings/Pages/AppsPage.qml | 2 +- Plugins/ZShell/Services/gpu.cpp | 2 +- Plugins/ZShell/Services/tickingservice.cpp | 2 +- 12 files changed, 351 insertions(+), 45 deletions(-) create mode 100644 Drawers/Interactions2.qml diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index a8a1bbc..327289a 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -87,15 +87,27 @@ Item { if (root.singleGestureTriggered) return; - // if (root.geometry.insetTop(root.borderThickness) > pressY) { - // if (dragY > 20) { - // root.screenState.settings = true; - // root.singleGestureTriggered = true; - // } else if (dragY < -20) { - // root.screenState.settings = false; - // root.singleGestureTriggered = true; - // } - // } + const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY); + + if (inDashboard && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { + if (dragY > Config.dashboard.dragThreshold) { + root.screenState.dashboard = true; + root.singleGestureTriggered = true; + } else if (dragY < -Config.dashboard.dragThreshold) { + root.screenState.dashboard = false; + root.singleGestureTriggered = true; + } + } + + if (root.geometry.insetTop(root.borderThickness) > pressY && (Config.dashboard.showOnHover || !inDashboard)) { + if (dragY > 20) { + root.screenState.settings = true; + root.singleGestureTriggered = true; + } else if (dragY < -20) { + root.screenState.settings = false; + root.singleGestureTriggered = true; + } + } if (pressY > root.screen.height - root.geometry.insetBottom(root.borderThickness) && pressX < root.screen.width / 5 && dragY < -50) root.screenState.clipboard = true; @@ -120,16 +132,6 @@ Item { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); root.singleGestureTriggered = true; } - - if (root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY) && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { - if (dragY > Config.dashboard.dragThreshold) { - root.screenState.dashboard = true; - root.singleGestureTriggered = true; - } else if (dragY < -Config.dashboard.dragThreshold) { - root.screenState.dashboard = false; - root.singleGestureTriggered = true; - } - } } } @@ -276,6 +278,7 @@ Item { root.screenState.settings = false; root.screenState.sidebar = false; + root.screenState.resources = false; root.popouts.hasCurrent = false; } else { root.dashboardShortcutActive = false; @@ -323,12 +326,13 @@ Item { } function onResourcesChanged() { - if (root.screenState.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { - root.popouts.hasCurrent = false; - } + if (root.screenState.resources) { + if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates")) + root.popouts.hasCurrent = false; - if (root.screenState.resources) root.screenState.settings = false; + root.screenState.dashboard = false; + } } function onSettingsChanged() { diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml new file mode 100644 index 0000000..b35cf72 --- /dev/null +++ b/Drawers/Interactions2.qml @@ -0,0 +1,312 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import ZShell.Config +import qs.Components +import qs.Modules.Bar as Bar +import qs.Modules.Bar.Popouts as BarPopouts + +CustomMouseArea { + id: root + + required property ShellScreen screen + required property BarPopouts.Wrapper popouts + required property ScreenState screenState + required property Panels panels + required property Bar.BarLoader bar + required property real borderThickness + required property bool fullscreen + + property point dragStart + property bool dashboardShortcutActive + property bool osdShortcutActive + property bool utilitiesShortcutActive + + function withinPanelHeight(panel: Item, x: real, y: real): bool { + const panelY = root.borderThickness + panel.y; + return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding; + } + + function withinPanelWidth(panel: Item, x: real, y: real): bool { + const panelX = bar.implicitWidth + panel.x; + return x >= panelX - Config.border.rounding && x <= panelX + panel.width + Config.border.rounding; + } + + function inLeftPanel(panel: Item, x: real, y: real): bool { + return x < bar.implicitWidth + panel.x + panel.width && withinPanelHeight(panel, x, y); + } + + function inRightPanel(panel: Item, x: real, y: real): bool { + return x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panel.x) && withinPanelHeight(panel, x, y); + } + + function inTopPanel(panel: Item, x: real, y: real): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property + return y < Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) && withinPanelWidth(panel, x, y); + } + + function inBottomPanel(panel: Item, x: real, y: real, isCorner = false): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property + return y > height - Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) - (isCorner ? Config.border.rounding : 0) && withinPanelWidth(panel, x, y); + } + + function onWheel(event: WheelEvent): void { + if (fullscreen) + return; + if (event.x < bar.implicitWidth) { + bar.handleWheel(event.y, event.angleDelta); + } + } + + anchors.fill: parent + acceptedButtons: fullscreen ? Qt.NoButton : Qt.AllButtons + hoverEnabled: true + + onPressed: event => dragStart = Qt.point(event.x, event.y) + onContainsMouseChanged: { + if (!containsMouse) { + // Only hide if not activated by shortcut + if (!osdShortcutActive) { + screenState.osd = false; + root.panels.osd.hovered = false; + } + + if (!dashboardShortcutActive) + screenState.dashboard = false; + + if (!utilitiesShortcutActive) + screenState.utilities = false; + + if (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) { + popouts.hasCurrent = false; + bar.closeTray(); + } + + if (Config.bar.showOnHover) + bar.isHovered = false; + + if (Config.sidebar.showOnHover) + screenState.sidebar = false; + } + } + + onPositionChanged: event => { + if (popouts.isDetached) + return; + + const x = event.x; + const y = event.y; + const dragX = x - dragStart.x; + const dragY = y - dragStart.y; + + if (fullscreen) { + root.panels.osd.hovered = inRightPanel(panels.osdWrapper, x, y); + return; + } + + // Show bar in non-exclusive mode on hover + if (!screenState.bar && Config.bar.showOnHover && x < bar.clampedWidth) + bar.isHovered = true; + + // Show/hide bar on drag + if (pressed && dragStart.x < bar.clampedWidth) { + if (dragX > Config.bar.dragThreshold) + screenState.bar = true; + else if (dragX < -Config.bar.dragThreshold) + screenState.bar = false; + } + + if (panels.sidebar.offsetScale === 1) { + // Show osd on hover + const showOsd = inRightPanel(panels.osdWrapper, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!osdShortcutActive) { + screenState.osd = showOsd; + root.panels.osd.hovered = showOsd; + } else if (showOsd) { + // If hovering over OSD area while in shortcut mode, transition to hover control + osdShortcutActive = false; + root.panels.osd.hovered = true; + } + + const showSidebar = pressed && dragStart.x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x); + + // Show sidebar on hover (top-right corner, bounded by notification panel height) + if (Config.sidebar.showOnHover) { + const sidebarTriggerY = Math.max(Config.sidebar.minHoverThreshold, panels.notifications.y + panels.notifications.height + borderThickness); + const showSidebarHover = x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x) && y <= sidebarTriggerY; + if (showSidebarHover && !screenState.sidebar) + screenState.sidebar = true; + } + + // Show/hide session on drag + if (pressed && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { + if (dragX < -Config.session.dragThreshold) + screenState.session = true; + else if (dragX > Config.session.dragThreshold) + screenState.session = false; + + // Show sidebar on drag if in session area and session is nearly fully visible + if (showSidebar && panels.session.offsetScale <= 0 && dragX < -Config.sidebar.dragThreshold) + screenState.sidebar = true; + } else if (showSidebar && dragX < -Config.sidebar.dragThreshold) { + // Show sidebar on drag if not in session area + screenState.sidebar = true; + } + } else { + const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); + // Show osd on hover + const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!osdShortcutActive) { + screenState.osd = showOsd; + root.panels.osd.hovered = showOsd; + } else if (showOsd) { + // If hovering over OSD area while in shortcut mode, transition to hover control + osdShortcutActive = false; + root.panels.osd.hovered = true; + } + + // Show/hide session on drag + if (pressed && outOfSidebar && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { + if (dragX < -Config.session.dragThreshold) + screenState.session = true; + else if (dragX > Config.session.dragThreshold) + screenState.session = false; + } + + // Show/hide sidebar on hover + if (Config.sidebar.showOnHover && !pressed) { + const sidebarTriggerY = Math.max(Config.sidebar.minHoverThreshold, panels.notifications.y + panels.notifications.height + borderThickness); + const showSidebarHover = x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x) && y <= sidebarTriggerY; + if (showSidebarHover && !screenState.sidebar) { + screenState.sidebar = true; + } else { + const inSidebarArea = inRightPanel(panels.sidebar, x, y) || inRightPanel(panels.sessionWrapper, x, y); + if (!inSidebarArea) + screenState.sidebar = false; + } + } + + // Hide sidebar on drag + if (pressed && inRightPanel(panels.sidebar, dragStart.x, 0) && dragX > Config.sidebar.dragThreshold) + screenState.sidebar = false; + } + + // Show launcher on hover, or show/hide on drag if hover is disabled + if (Config.launcher.showOnHover) { + if (!screenState.launcher && inBottomPanel(panels.launcher, x, y)) + screenState.launcher = true; + } else if (pressed && inBottomPanel(panels.launcher, dragStart.x, dragStart.y) && withinPanelWidth(panels.launcher, x, y)) { + if (dragY < -Config.launcher.dragThreshold) + screenState.launcher = true; + else if (dragY > Config.launcher.dragThreshold) + screenState.launcher = false; + } + + // Show dashboard on hover + const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboard, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!dashboardShortcutActive) { + screenState.dashboard = showDashboard; + } else if (showDashboard) { + // If hovering over dashboard area while in shortcut mode, transition to hover control + dashboardShortcutActive = false; + } + + // Show/hide dashboard on drag (for touchscreen devices) + if (pressed && inTopPanel(panels.dashboard, dragStart.x, dragStart.y) && withinPanelWidth(panels.dashboard, x, y)) { + if (dragY > Config.dashboard.dragThreshold) + screenState.dashboard = true; + else if (dragY < -Config.dashboard.dragThreshold) + screenState.dashboard = false; + } + + // Show utilities on hover + const showUtilities = inBottomPanel(panels.utilities, x, y, true); + + // Always update visibility based on hover if not in shortcut mode + if (!utilitiesShortcutActive) { + screenState.utilities = showUtilities; + } else if (showUtilities) { + // If hovering over utilities area while in shortcut mode, transition to hover control + utilitiesShortcutActive = false; + } + + // Show popouts on hover + if (x < bar.implicitWidth) { + bar.checkPopout(y); + } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) { + popouts.hasCurrent = false; + bar.closeTray(); + } + } + + // Monitor individual visibility changes + Connections { + function onLauncherChanged() { + // If launcher is hidden, clear shortcut flags for dashboard and OSD + if (!root.screenState.launcher) { + root.dashboardShortcutActive = false; + root.osdShortcutActive = false; + root.utilitiesShortcutActive = false; + + // Also hide dashboard and OSD if they're not being hovered + const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); + + if (!inDashboardArea) { + root.screenState.dashboard = false; + } + if (!inOsdArea) { + root.screenState.osd = false; + root.panels.osd.hovered = false; + } + } + } + + function onDashboardChanged() { + if (root.screenState.dashboard) { + // Dashboard became visible, immediately check if this should be shortcut mode + const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); + if (!inDashboardArea) { + root.dashboardShortcutActive = true; + } + } else { + // Dashboard hidden, clear shortcut flag + root.dashboardShortcutActive = false; + } + } + + function onOsdChanged() { + if (root.screenState.osd) { + // OSD became visible, immediately check if this should be shortcut mode + const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); + if (!inOsdArea) { + root.osdShortcutActive = true; + } + } else { + // OSD hidden, clear shortcut flag + root.osdShortcutActive = false; + } + } + + function onUtilitiesChanged() { + if (root.screenState.utilities) { + // Utilities became visible, immediately check if this should be shortcut mode + const inUtilitiesArea = root.inBottomPanel(root.panels.utilities, root.mouseX, root.mouseY); + if (!inUtilitiesArea) { + root.utilitiesShortcutActive = true; + } + } else { + // Utilities hidden, clear shortcut flag + root.utilitiesShortcutActive = false; + } + } + + target: root.screenState + } +} diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index 4791c73..3f9f3e3 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -14,7 +14,7 @@ WidgetBase { id: root readonly property font font: Tokens.font.clock.family(Config.appearance.font.family.clock).build() - readonly property color contentColor: screenState.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + readonly property color contentColor: Colors.palette.m3onSurface readonly property string effectiveFormat: { let fmt = Config.general.dateFormat; if (!root.horizontal) @@ -48,7 +48,6 @@ WidgetBase { readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 required property ScreenState screenState - color: screenState.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : size implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full @@ -146,13 +145,4 @@ WidgetBase { } } } - - StateLayer { - acceptedButtons: Qt.LeftButton - color: root.contentColor - - onClicked: { - root.screenState.dashboard = !root.screenState.dashboard; - } - } } diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml index 69ce845..cdec3dc 100644 --- a/Modules/Dashboard/Media/LyricList.qml +++ b/Modules/Dashboard/Media/LyricList.qml @@ -179,7 +179,7 @@ Item { CustomText { text: qsTr("Loading lyrics...") color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.body.normal + font: Tokens.font.body.medium } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml index b878e6e..e84ca5a 100644 --- a/Modules/Launcher/Items/FilesItem.qml +++ b/Modules/Launcher/Items/FilesItem.qml @@ -76,7 +76,7 @@ Item { id: icon anchors.verticalCenter: parent.verticalCenter - implicitSize: Tokens.font.size.extraLarge + implicitSize: Tokens.font.icon.extraLarge.pointSize source: Quickshell.iconPath(root.modelData?.icon) ?? "" visible: !root.modelData?.isImage } @@ -94,14 +94,14 @@ Item { } sourceComponent: LoadingIndicator { color: Colors.palette.m3primaryContainer - implicitSize: Tokens.font.size.extraLarge + implicitSize: Tokens.font.icon.extraLarge.pointSize } } FadeImage { id: image - property int implicitSize: Tokens.font.size.extraLarge + property int implicitSize: Tokens.font.icon.extraLarge.pointSize width: implicitSize fillMode: Image.PreserveAspectFit diff --git a/Modules/Lock/InputField.qml b/Modules/Lock/InputField.qml index b2ca56c..c7ac527 100644 --- a/Modules/Lock/InputField.qml +++ b/Modules/Lock/InputField.qml @@ -70,7 +70,7 @@ Item { anchors.centerIn: parent anchors.horizontalCenterOffset: implicitWidth > root.width ? -(implicitWidth - root.width) / 2 : 0 - implicitHeight: Tokens.font.size.normal + implicitHeight: Tokens.font.body.medium.pointSize implicitWidth: fullWidth interactive: false orientation: Qt.Horizontal diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index 5c28149..2b2d344 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -56,7 +56,7 @@ ColumnLayout { Layout.fillWidth: true Layout.rightMargin: -Tokens.spacing.small clip: true - implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.screenState.recordingListExpanded ? 10 : 3) + implicitHeight: (Tokens.font.body.large.pointSize + Tokens.padding.extraSmall) * (root.screenState.recordingListExpanded ? 10 : 3) CustomScrollBar.vertical: CustomScrollBar { flickable: list diff --git a/Modules/Settings/Pages/Apps/AllApps.qml b/Modules/Settings/Pages/Apps/AllApps.qml index 0dde289..4f9e685 100644 --- a/Modules/Settings/Pages/Apps/AllApps.qml +++ b/Modules/Settings/Pages/Apps/AllApps.qml @@ -56,7 +56,7 @@ PageBase { IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 1.8) + implicitSize: Math.round(Tokens.font.icon.large * 1.8) source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } diff --git a/Modules/Settings/Pages/Apps/AppInfo.qml b/Modules/Settings/Pages/Apps/AppInfo.qml index 333a880..4f549da 100644 --- a/Modules/Settings/Pages/Apps/AppInfo.qml +++ b/Modules/Settings/Pages/Apps/AppInfo.qml @@ -47,7 +47,7 @@ PageBase { IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 3) + implicitSize: Math.round(Tokens.font.icon.large.pointSize * 3) source: Quickshell.iconPath(root.app?.icon, "image-missing") } diff --git a/Modules/Settings/Pages/AppsPage.qml b/Modules/Settings/Pages/AppsPage.qml index 43cf99f..32206a0 100644 --- a/Modules/Settings/Pages/AppsPage.qml +++ b/Modules/Settings/Pages/AppsPage.qml @@ -143,7 +143,7 @@ PageBase { IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 1.8) + implicitSize: Math.round(Tokens.font.icon.large.pointSize * 1.8) source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } diff --git a/Plugins/ZShell/Services/gpu.cpp b/Plugins/ZShell/Services/gpu.cpp index 8d3d1da..c12d896 100644 --- a/Plugins/ZShell/Services/gpu.cpp +++ b/Plugins/ZShell/Services/gpu.cpp @@ -60,7 +60,7 @@ Gpu::Gpu(QObject* parent) : TickingService(parent) { [this, configPath]() { QTimer::singleShot(100, this, [this, configPath]() { QFile file(configPath); - if (file.exists()) { + if (file.open(QIODevice::ReadOnly)) { QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); if (!doc.isNull()) { diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 5c51cdc..96083d5 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -44,7 +44,7 @@ TickingService::TickingService(QObject* parent) [this, configPath]() { QTimer::singleShot(100, this, [this, configPath]() { QFile file(configPath); - if (!file.exists()) { + if (!file.open(QIODevice::ReadOnly)) { return; } QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); From 359f832609694346a0fb2fe68ad16554f448e80a Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 25 Sep 2026 16:51:28 +0200 Subject: [PATCH 07/21] more unfinished changes, process tab for dashboard --- Components/ButtonBase.qml | 2 +- Components/StateLayer.qml | 4 +- Drawers/Windows.qml | 17 +- Helpers/ResourceUsage.qml | 86 ++ Modules/Bar/BarLoader.qml | 2 +- Modules/Bar/Components/Common/WidgetBase.qml | 2 +- Modules/Bar/Components/Workspaces.qml | 8 +- Modules/Dashboard/Content.qml | 210 +-- .../Dashboard/Processes/ProcessesColumns.qml | 209 +++ .../Dashboard/Processes/ProcessesHeader.qml | 712 ++++++++++ Modules/Dashboard/Processes/ProcessesList.qml | 140 ++ Modules/Dashboard/Processes/SystemInfo.qml | 186 +++ Modules/Dashboard/ProcessesTab.qml | 64 + Modules/Resources/Cards/NetworkCard.qml | 5 +- Plugins/ZShell/CMakeLists.txt | 11 + Plugins/ZShell/Components/CMakeLists.txt | 10 +- .../ZShell/Components/sparklinecontent.cpp | 250 ++++ .../ZShell/Components/sparklinecontent.hpp | 69 + Plugins/ZShell/Components/sparklineitem.cpp | 179 +++ .../sparklineitem.hpp | 47 +- .../ZShell/Components/sparklinesceneitem.cpp | 447 +++++++ .../ZShell/Components/sparklinesceneitem.hpp | 167 +++ Plugins/ZShell/Config/CMakeLists.txt | 20 +- Plugins/ZShell/Config/anim.cpp | 103 +- Plugins/ZShell/Config/anim.hpp | 75 +- Plugins/ZShell/Config/appearance.cpp | 229 ++++ Plugins/ZShell/Config/appearance.hpp | 482 +++---- Plugins/ZShell/Config/background.hpp | 14 +- Plugins/ZShell/Config/bar.hpp | 72 +- Plugins/ZShell/Config/clipboard.hpp | 31 +- Plugins/ZShell/Config/colors.hpp | 34 +- Plugins/ZShell/Config/common.cpp | 22 + Plugins/ZShell/Config/common.hpp | 54 + Plugins/ZShell/Config/config.cpp | 278 ---- Plugins/ZShell/Config/config.hpp | 108 -- Plugins/ZShell/Config/configlist.cpp | 314 ----- Plugins/ZShell/Config/configlist.hpp | 139 -- Plugins/ZShell/Config/confignode.cpp | 89 -- Plugins/ZShell/Config/confignode.hpp | 65 - Plugins/ZShell/Config/configobject.cpp | 336 ----- Plugins/ZShell/Config/configobject.hpp | 101 -- Plugins/ZShell/Config/dashboard.hpp | 99 +- Plugins/ZShell/Config/display.hpp | 24 + Plugins/ZShell/Config/dock.hpp | 22 +- Plugins/ZShell/Config/font.cpp | 18 +- Plugins/ZShell/Config/font.hpp | 20 +- Plugins/ZShell/Config/general.hpp | 152 +-- Plugins/ZShell/Config/launcher.hpp | 119 +- Plugins/ZShell/Config/lock.hpp | 37 +- Plugins/ZShell/Config/notifs.hpp | 43 +- Plugins/ZShell/Config/osd.hpp | 33 +- Plugins/ZShell/Config/rootnodes.cpp | 191 +++ Plugins/ZShell/Config/rootnodes.hpp | 168 +++ Plugins/ZShell/Config/screenshot.hpp | 30 +- Plugins/ZShell/Config/services.hpp | 60 +- Plugins/ZShell/Config/sidebar.hpp | 25 +- Plugins/ZShell/Config/tokens.hpp | 320 ++--- Plugins/ZShell/Config/utilities.hpp | 91 +- Plugins/ZShell/Internal/CMakeLists.txt | 3 +- Plugins/ZShell/Internal/circularbuffer.cpp | 60 + Plugins/ZShell/Internal/circularbuffer.hpp | 17 + Plugins/ZShell/Internal/cursor.hpp | 36 + Plugins/ZShell/Internal/sparklineitem.cpp | 322 ----- Plugins/ZShell/Services/CMakeLists.txt | 2 + Plugins/ZShell/Services/lyrics.cpp | 10 +- Plugins/ZShell/Services/memory.cpp | 54 +- Plugins/ZShell/Services/memory.hpp | 12 + Plugins/ZShell/Services/processes.cpp | 1161 +++++++++++++++++ Plugins/ZShell/Services/processes.hpp | 259 ++++ Plugins/ZShell/Services/tickingservice.cpp | 5 +- Plugins/ZShell/Services/tickingservice.hpp | 1 + Plugins/ZShell/Services/usagefmt.cpp | 18 + Plugins/ZShell/Services/usagefmt.hpp | 2 + Plugins/ZShell/Settings/CMakeLists.txt | 17 + Plugins/ZShell/Settings/changebatcher.cpp | 25 + Plugins/ZShell/Settings/changebatcher.hpp | 24 + Plugins/ZShell/Settings/codecs.cpp | 371 ++++++ Plugins/ZShell/Settings/codecs.hpp | 101 ++ Plugins/ZShell/Settings/common.cpp | 131 ++ Plugins/ZShell/Settings/common.hpp | 99 ++ Plugins/ZShell/Settings/layerregistry.hpp | 75 ++ Plugins/ZShell/Settings/listnode.cpp | 445 +++++++ Plugins/ZShell/Settings/listnode.hpp | 69 + Plugins/ZShell/Settings/macros.hpp | 193 +++ Plugins/ZShell/Settings/node.cpp | 280 ++++ Plugins/ZShell/Settings/node.hpp | 100 ++ Plugins/ZShell/Settings/objectnode.cpp | 219 ++++ Plugins/ZShell/Settings/objectnode.hpp | 35 + Plugins/ZShell/Settings/quarantine.cpp | 27 + Plugins/ZShell/Settings/quarantine.hpp | 33 + Plugins/ZShell/Settings/rootnode.cpp | 46 + Plugins/ZShell/Settings/rootnode.hpp | 35 + Plugins/ZShell/Settings/schema.cpp | 133 ++ Plugins/ZShell/Settings/schema.hpp | 81 ++ Plugins/ZShell/Settings/settingsfile.cpp | 213 +++ Plugins/ZShell/Settings/settingsfile.hpp | 51 + Plugins/ZShell/toaster.cpp | 5 + Plugins/ZShell/toaster.hpp | 1 + 98 files changed, 8633 insertions(+), 2983 deletions(-) create mode 100644 Helpers/ResourceUsage.qml create mode 100644 Modules/Dashboard/Processes/ProcessesColumns.qml create mode 100644 Modules/Dashboard/Processes/ProcessesHeader.qml create mode 100644 Modules/Dashboard/Processes/ProcessesList.qml create mode 100644 Modules/Dashboard/Processes/SystemInfo.qml create mode 100644 Modules/Dashboard/ProcessesTab.qml create mode 100644 Plugins/ZShell/Components/sparklinecontent.cpp create mode 100644 Plugins/ZShell/Components/sparklinecontent.hpp create mode 100644 Plugins/ZShell/Components/sparklineitem.cpp rename Plugins/ZShell/{Internal => Components}/sparklineitem.hpp (76%) create mode 100644 Plugins/ZShell/Components/sparklinesceneitem.cpp create mode 100644 Plugins/ZShell/Components/sparklinesceneitem.hpp create mode 100644 Plugins/ZShell/Config/appearance.cpp create mode 100644 Plugins/ZShell/Config/common.cpp create mode 100644 Plugins/ZShell/Config/common.hpp delete mode 100644 Plugins/ZShell/Config/config.cpp delete mode 100644 Plugins/ZShell/Config/config.hpp delete mode 100644 Plugins/ZShell/Config/configlist.cpp delete mode 100644 Plugins/ZShell/Config/configlist.hpp delete mode 100644 Plugins/ZShell/Config/confignode.cpp delete mode 100644 Plugins/ZShell/Config/confignode.hpp delete mode 100644 Plugins/ZShell/Config/configobject.cpp delete mode 100644 Plugins/ZShell/Config/configobject.hpp create mode 100644 Plugins/ZShell/Config/display.hpp create mode 100644 Plugins/ZShell/Config/rootnodes.cpp create mode 100644 Plugins/ZShell/Config/rootnodes.hpp create mode 100644 Plugins/ZShell/Internal/cursor.hpp delete mode 100644 Plugins/ZShell/Internal/sparklineitem.cpp create mode 100644 Plugins/ZShell/Services/processes.cpp create mode 100644 Plugins/ZShell/Services/processes.hpp create mode 100644 Plugins/ZShell/Settings/CMakeLists.txt create mode 100644 Plugins/ZShell/Settings/changebatcher.cpp create mode 100644 Plugins/ZShell/Settings/changebatcher.hpp create mode 100644 Plugins/ZShell/Settings/codecs.cpp create mode 100644 Plugins/ZShell/Settings/codecs.hpp create mode 100644 Plugins/ZShell/Settings/common.cpp create mode 100644 Plugins/ZShell/Settings/common.hpp create mode 100644 Plugins/ZShell/Settings/layerregistry.hpp create mode 100644 Plugins/ZShell/Settings/listnode.cpp create mode 100644 Plugins/ZShell/Settings/listnode.hpp create mode 100644 Plugins/ZShell/Settings/macros.hpp create mode 100644 Plugins/ZShell/Settings/node.cpp create mode 100644 Plugins/ZShell/Settings/node.hpp create mode 100644 Plugins/ZShell/Settings/objectnode.cpp create mode 100644 Plugins/ZShell/Settings/objectnode.hpp create mode 100644 Plugins/ZShell/Settings/quarantine.cpp create mode 100644 Plugins/ZShell/Settings/quarantine.hpp create mode 100644 Plugins/ZShell/Settings/rootnode.cpp create mode 100644 Plugins/ZShell/Settings/rootnode.hpp create mode 100644 Plugins/ZShell/Settings/schema.cpp create mode 100644 Plugins/ZShell/Settings/schema.hpp create mode 100644 Plugins/ZShell/Settings/settingsfile.cpp create mode 100644 Plugins/ZShell/Settings/settingsfile.hpp diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml index 9b9e7bd..462b867 100644 --- a/Components/ButtonBase.qml +++ b/Components/ButtonBase.qml @@ -51,7 +51,7 @@ CustomRect { if (internalChecked) return checkedRadius; if (isRound) - return (height || implicitHeight) / 2 * Math.min(1, Tokens.rounding.scale); + return implicitHeight / 2 * Math.min(1, Tokens.rounding.scale); return defaultRadius; } diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 523f6e0..8857eb0 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -99,13 +99,13 @@ MouseArea { id: base anchors.fill: parent - bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 - bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 color: Colors.palette.m3onSurface opacity: root.stateOpacity // Pick up radius from parent if it has one (parent can be anything with radius props) // qmllint disable missing-property radius: root.parent?.radius ?? 0 + bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 + bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 topLeftRadius: root.parent?.topLeftRadius ?? radius ?? 0 topRightRadius: root.parent?.topRightRadius ?? radius ?? 0 // qmllint enable missing-property diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 161027a..b2c9887 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -145,8 +145,23 @@ CustomWindow { HyprlandFocusGrab { id: focusGrab - active: root.screenState.dock || root.screenState.resources || root.screenState.launcher || root.screenState.sidebar || (!Config.dashboard.showOnHover && root.screenState.dashboard) || root.screenState.settings || root.screenState.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) windows: [root] + active: { + const s = root.screenState; + const c = Config; + + if ((s.launcher && c.launcher.enabled) || (s.sidebar && c.sidebar.enabled) || (s.resources && c.dashboard.performance.enabled) || (s.clipboard && c.clipboard.enabled)) + return true; + if (s.dashboard && !c.dashboard.showOnHover && c.dashboard.enabled) + return true; + if (!c.dock.hoverToReveal && s.dock && c.dock.enable) + return true; + if (panels.popouts.currentName.startsWith("traymenu") && (panels.popouts.current as StackView)?.depth > 1) + return true; + if (s.settings) + return true; + return false; + } onCleared: { root.screenState.launcher = false; diff --git a/Helpers/ResourceUsage.qml b/Helpers/ResourceUsage.qml new file mode 100644 index 0000000..70de2df --- /dev/null +++ b/Helpers/ResourceUsage.qml @@ -0,0 +1,86 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Internal +import ZShell.Services + +Singleton { + id: root + + readonly property int historyLength: 30 + readonly property CircularBuffer cpuBuffer: _cpuBuffer + readonly property CircularBuffer memoryBuffer: _memoryBuffer + + readonly property list cpuInfo: [ + { + label: qsTr("Usage"), + value: `${Math.round(Cpu.percentage * 100)}%` + }, + { + label: qsTr("Temp"), + value: `${Math.ceil(Cpu.temperature)}°${"C"}` + } + ] + + readonly property list memoryInfo: [ + { + label: qsTr("Used"), + value: memoryUsageFormat(Memory.used, Memory.total) + }, + { + label: qsTr("Cached"), + value: memoryTotalFormat(Memory.cached) + }, + { + label: qsTr("Writes"), + value: memoryTotalFormat(Memory.writeTotal) + }, + { + label: qsTr("Reads"), + value: memoryTotalFormat(Memory.readTotal) + } + ] + + function memoryTotalFormat(kib: int): string { + const fmt = UsageFmt.formatKib(kib); + return `${fmt.value.toFixed(1)} ${fmt.unit}`; + } + + function memoryUsageFormat(used: int, total: int): string { + const fmt = UsageFmt.formatKib(used, total); + return `${fmt.value.toFixed(1)} / ${Math.floor(fmt.total)} ${fmt.unit}`; + } + + Connections { + function onUpdateTriggered(): void { + if (Cpu.percentage >= 0 && isFinite(Cpu.percentage)) + _cpuBuffer.push(Cpu.percentage); + } + + target: Cpu + } + + Connections { + function onUpdateTriggered(): void { + if (Memory.percentage >= 0 && isFinite(Memory.percentage)) + _memoryBuffer.push(Memory.percentage); + } + + target: Memory + } + + CircularBuffer { + id: _cpuBuffer + + capacity: root.historyLength + 1 + smoothingRadius: 2 + } + + CircularBuffer { + id: _memoryBuffer + + capacity: root.historyLength + 1 + smoothingRadius: 2 + } +} diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 41e52fb..82ff8e8 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -10,7 +10,7 @@ Item { id: root readonly property int clampedExtent: Math.max(Config.bar.border, extent) - readonly property int contentThickness: Math.floor(Math.max(Config.bar.height, Tokens.bar.innerSize * (horizontal ? 1 : 1.5))) + padding * 2 + readonly property int contentThickness: Math.floor(Math.max(Config.bar.height, Tokens.sizes.bar.innerSize * (horizontal ? 1 : 1.5))) + padding * 2 readonly property int exclusiveZone: Config.bar.autoHide ? Config.bar.border : contentThickness property real extent: fullscreen ? 0 : Config.bar.border required property bool fullscreen diff --git a/Modules/Bar/Components/Common/WidgetBase.qml b/Modules/Bar/Components/Common/WidgetBase.qml index 2a0fb95..3795a31 100644 --- a/Modules/Bar/Components/Common/WidgetBase.qml +++ b/Modules/Bar/Components/Common/WidgetBase.qml @@ -6,7 +6,7 @@ import qs.Services CustomRect { id: root - readonly property int baseSize: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : Math.max(Config.bar.height, Math.floor(Tokens.bar.innerSize * 1.5)) + readonly property int baseSize: horizontal ? Math.max(Config.bar.height, Tokens.sizes.bar.innerSize) : Math.max(Config.bar.height, Math.floor(Tokens.sizes.bar.innerSize * 1.5)) required property bool horizontal color: Colors.tPalette.m3surfaceContainer diff --git a/Modules/Bar/Components/Workspaces.qml b/Modules/Bar/Components/Workspaces.qml index cfa9a76..9c5d448 100644 --- a/Modules/Bar/Components/Workspaces.qml +++ b/Modules/Bar/Components/Workspaces.qml @@ -17,7 +17,7 @@ Item { required property bool horizontal readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) required property ShellScreen screen - readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) + readonly property real shortSize: Math.max(Config.bar.height, Tokens.sizes.bar.innerSize) readonly property real size: (workspaceButtonWidth * workspacesShown) + activeWorkspaceMargin * 2 property int workspaceButtonWidth: (horizontal ? bgRect.implicitHeight : bgRect.implicitWidth) - root.activeWorkspaceMargin * 2 property int workspaceIndexInGroup: (effectiveActiveWorkspaceId - 1) % root.workspacesShown @@ -30,14 +30,12 @@ Item { Behavior on implicitHeight { enabled: !root.horizontal - Anim { - } + Anim {} } Behavior on implicitWidth { enabled: root.horizontal - Anim { - } + Anim {} } CustomRect { diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 20c418a..d34b536 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -11,8 +11,8 @@ import qs.Components Item { id: root - readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 - readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2 + readonly property real nonAnimWidth: view.implicitWidth + clipWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: tabs.implicitHeight + clipWrapper.anchors.topMargin + view.implicitHeight + clipWrapper.anchors.margins * 2 required property ScreenState screenState @@ -36,12 +36,12 @@ Item { text: qsTr("Media"), enabled: true }, - // { - // component: testingComponent, - // iconName: "speed", - // text: qsTr("Testing"), - // enabled: true - // }, + { + component: procsComponent, + iconName: "speed", + text: qsTr("Processes"), + enabled: true + }, ]; return allTabs.filter(tab => tab.enabled); } @@ -50,130 +50,138 @@ Item { implicitHeight: nonAnimHeight Behavior on implicitHeight { - Anim {} + Anim { + type: Anim.SlowEffects + } } Behavior on implicitWidth { - Anim {} + Anim { + type: Anim.SlowEffects + } } - Tabs { - id: tabs - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) - anchors.margins: Tokens.padding.large - - nonAnimWidth: root.nonAnimWidth - anchors.margins * 2 - screenState: root.screenState - tabs: root.dashboardTabs - } - - ClippingRectangle { - id: viewWrapper - - anchors.top: tabs.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom + Item { + id: clipWrapper + anchors.fill: parent anchors.margins: Tokens.padding.small - color: "transparent" - radius: Tokens.rounding.extraLarge - anchors.margins + // radius: Tokens.rounding.extraLarge - anchors.margins + anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) - Flickable { - id: view + Tabs { + id: tabs - readonly property int currentIndex: root.screenState.dashboardTab - readonly property Item currentItem: { - repeater.count; - return repeater.itemAt(currentIndex); - } + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right - anchors.fill: parent + nonAnimWidth: view.implicitWidth + screenState: root.screenState + tabs: root.dashboardTabs + } - implicitWidth: currentItem?.implicitWidth ?? 0 - implicitHeight: currentItem?.implicitHeight ?? 0 + Item { + id: viewWrapper - contentX: currentItem?.x ?? 0 - contentWidth: row.implicitWidth - contentHeight: row.implicitHeight + anchors.top: tabs.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.spacing.small - interactive: false + Flickable { + id: view - onContentXChanged: { - if (!moving || !currentItem) - return; + readonly property int currentIndex: root.screenState.dashboardTab + readonly property Item currentItem: { + repeater.count; + return repeater.itemAt(currentIndex); + } - const x = contentX - currentItem.x; - if (x > currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); - else if (x < -currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); - } + anchors.fill: parent - RowLayout { - id: row + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 - Repeater { - id: repeater + contentX: currentItem?.x ?? 0 + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight - model: ScriptModel { - values: root.dashboardTabs - } + interactive: false - delegate: Loader { - id: paneLoader + onContentXChanged: { + if (!moving || !currentItem) + return; - required property int index - required property var modelData + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); + } - Layout.alignment: Qt.AlignTop + RowLayout { + id: row - sourceComponent: modelData.component + Repeater { + id: repeater - Component.onCompleted: active = Qt.binding(() => { - if (index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) + model: ScriptModel { + values: root.dashboardTabs + } + + delegate: Loader { + id: paneLoader + + required property int index + required property var modelData + + Layout.alignment: Qt.AlignTop + + sourceComponent: modelData.component + + Component.onCompleted: active = Qt.binding(() => { + if (index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) + } } } - } - Component { - id: dashComponent + Component { + id: dashComponent - Dash { - screenState: root.screenState + Dash { + screenState: root.screenState + } } - } - Component { - id: weatherComponent + Component { + id: weatherComponent - WeatherTab {} - } - - Component { - id: mediaComponent - - Media { - screenState: root.screenState + WeatherTab {} } - } - // Component { - // id: testingComponent - // - // WeatherTest {} - // } + Component { + id: mediaComponent - Behavior on contentX { - Anim { - type: Anim.DefaultEffects + Media { + screenState: root.screenState + } + } + + Component { + id: procsComponent + + ProcessesTab {} + } + + Behavior on contentX { + Anim { + type: Anim.DefaultEffects + } } } } diff --git a/Modules/Dashboard/Processes/ProcessesColumns.qml b/Modules/Dashboard/Processes/ProcessesColumns.qml new file mode 100644 index 0000000..40b9694 --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesColumns.qml @@ -0,0 +1,209 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Config + +Singleton { + id: root + + readonly property var columnDefs: [ + { + id: "command", + title: qsTr("Command"), + width: 200, + field: 3 + }, + { + id: "pid", + title: qsTr("PID"), + width: 60, + field: 1 + }, + { + id: "cpuUsage", + title: qsTr("CPU %"), + width: 70, + field: 7 + }, + { + id: "memoryUsage", + title: qsTr("Memory"), + width: 90, + field: 8 + }, + { + id: "elapsed", + title: qsTr("Elapsed"), + width: 80, + field: 9 + }, + { + id: "ppid", + title: qsTr("PPID"), + width: 60, + field: 2 + }, + { + id: "user", + title: qsTr("User"), + width: 100, + field: 11 + }, + { + id: "uid", + title: qsTr("UID"), + width: 60, + field: 10 + }, + { + id: "state", + title: qsTr("State"), + width: 60, + field: 5 + }, + { + id: "sleeping", + title: qsTr("Sleeping"), + width: 80, + field: 6 + }, + { + id: "commandLine", + title: qsTr("Command line"), + width: 240, + field: 4 + }, + { + id: "ioReadBytes", + title: qsTr("IO read"), + width: 90, + field: 12 + }, + { + id: "ioWriteBytes", + title: qsTr("IO write"), + width: 90, + field: 13 + } + ] + + readonly property string pinnedColumnId: "command" + + readonly property var hiddenColumns: { + const visible = Config.dashboard.processColumns.values.map(e => e.id); + return root.columnDefs.filter(d => !visible.includes(d.id)); + } + + function pinnedIndex(): int { + const values = Config.dashboard.processColumns.values; + for (let i = 0; i < values.length; i++) + if (values[i].id === root.pinnedColumnId) + return i; + return -1; + } + + function ensurePinnedFirst() { + const i = root.pinnedIndex(); + if (i > 0) + Qt.callLater(() => { + const idx = root.pinnedIndex(); + if (idx > 0) + Config.dashboard.processColumns.move(idx, 0); + }); + } + + Component.onCompleted: Qt.callLater(ensurePinnedFirst) + + function defFor(id: string): var { + for (const def of root.columnDefs) + if (def.id === id) + return def; + return null; + } + + function addColumn(id: string) { + const def = root.defFor(id); + Config.dashboard.processColumns.insert({ + id: id, + width: def ? def.width : 100 + }); + } + + function removeColumn(index: int) { + const entry = Config.dashboard.processColumns.at(index); + if (entry && entry.id === root.pinnedColumnId) + return; + Config.dashboard.processColumns.remove(index); + } + + function moveColumns(from: int, to: int) { + const list = Config.dashboard.processColumns; + const entry = list.at(from); + if (!entry) + return; + if (entry.id === root.pinnedColumnId) + return; + if (to === 0 && list.at(0)?.id === root.pinnedColumnId) + return; + + list.move(from, to); + + const i = root.pinnedIndex(); + if (i > 0) + list.move(i, 0); + } + + function resizeColumn(index: int, width: real) { + const entry = Config.dashboard.processColumns.at(index); + if (entry) + entry.width = Math.max(50, width); + } + + function dampOvershoot(overshoot: real, maxOvershoot: real): real { + return overshoot / (1 + overshoot / maxOvershoot); + } + + function formatCell(id: string, value: var): string { + switch (id) { + case "cpuUsage": + return value.toFixed(1) + "%"; + case "memoryUsage": + return root.formatBytes(value * 1024); + case "ioReadBytes": + case "ioWriteBytes": + return root.formatBytes(value); + case "elapsed": + return root.formatElapsed(value); + case "sleeping": + return value ? qsTr("Yes") : qsTr("No"); + default: + return String(value); + } + } + + function formatBytes(bytes: var): string { + if (bytes === undefined || bytes === null || bytes <= 0) + return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let v = Number(bytes); + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + ++i; + } + return (i === 0 ? v.toFixed(0) : v.toFixed(1)) + " " + units[i]; + } + + function formatElapsed(secs: var): string { + const s = Math.max(0, Math.floor(Number(secs))); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + if (h > 0) + return h + "h " + m + "m"; + if (m > 0) + return m + "m " + ss + "s"; + return ss + "s"; + } +} diff --git a/Modules/Dashboard/Processes/ProcessesHeader.qml b/Modules/Dashboard/Processes/ProcessesHeader.qml new file mode 100644 index 0000000..83225dd --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesHeader.qml @@ -0,0 +1,712 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Internal +import ZShell.Services +import qs.Components +import qs.Services + +Item { + id: root + + required property int headerHeight + required property ProcessesList pList + + readonly property alias columnList: columnList + readonly property alias columnsModel: columnsData + readonly property bool anyHeld: columnList.contentItem.children.some(c => c.held) + + z: 1 + implicitHeight: headerHeight + + function widthForColumn(id: string, configured: real): real { + for (const item of columnList.contentItem.children) { + if (item.modelData !== undefined && item.modelData.id === id) + return item.liveWidth > 0 ? item.liveWidth : (configured > 0 ? configured : (ProcessesColumns.defFor(id)?.width ?? 100)); + } + return configured > 0 ? configured : (ProcessesColumns.defFor(id)?.width ?? 100); + } + + function fillMissingWidths() { + const n = columnsData.values.length; + if (n === 0 || columnList.width <= 0) + return; + + const spacingTotal = columnList.spacing * Math.max(0, n - 1); + + let savedTotal = 0; + const unsavedIndices = []; + for (let i = 0; i < n; i++) { + const w = columnsData.values[i].width; + if (w > 0) + savedTotal += w; + else + unsavedIndices.push(i); + } + + if (unsavedIndices.length === 0) + return; + + const available = columnList.width - spacingTotal - savedTotal; + if (available <= 0) + return; + + const each = Math.max(50, available / unsavedIndices.length); + for (const idx of unsavedIndices) + ProcessesColumns.resizeColumn(idx, each); + } + + Connections { + target: columnList + function onWidthChanged() { + root.fillMissingWidths(); + } + } + + Connections { + target: columnsData + function onValuesChanged() { + root.fillMissingWidths(); + } + } + + Component.onCompleted: Qt.callLater(fillMissingWidths) + Component.onDestruction: Cursor.clearOverride() + + RowLayout { + id: rowLayout + + anchors.fill: parent + + spacing: Tokens.spacing.extraSmall / 2 + + CustomListView { + id: columnList + + Layout.fillWidth: true + orientation: ListView.Horizontal + interactive: false + spacing: parent.spacing + implicitHeight: root.headerHeight + + add: Transition { + Anim { + from: 0 + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + } + displaced: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + Anim { + properties: "x" + } + } + move: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + Anim { + properties: "x" + } + } + remove: Transition { + Anim { + to: 0 + properties: "opacity" + type: Anim.DefaultEffects + } + } + + model: DelegateModel { + id: columnsVisual + + delegate: ColumnHeaderCell { + parentModel: columnsVisual + } + + model: ScriptModel { + id: columnsData + values: Config.dashboard.processColumns.values + } + } + } + + IconButton { + id: addColumnBtn + + type: IconButton.Text + icon: "add" + font: Tokens.font.icon.medium + Layout.alignment: Qt.AlignVCenter + visible: ProcessesColumns.hiddenColumns.length > 0 + onClicked: addMenu.expanded = true + } + + Variants { + id: hiddenColumnList + + model: ProcessesColumns.hiddenColumns + + HiddenColumnItem {} + } + + Menu { + id: addMenu + + attachTo: addColumnBtn + items: hiddenColumnList.instances + onItemSelected: item => ProcessesColumns.addColumn(item.modelData.id) + } + } + + Item { + id: resizeOverlay + + property real gapBoost: pressed ? Tokens.spacing.small : Tokens.spacing.extraSmall + property bool pressed: false + property int geometryTick: 0 + property int activeGapIndex: -1 + + anchors.fill: rowLayout + z: 10 + + Connections { + target: columnsData + function onValuesChanged() { + resizeOverlay.geometryTick++; + } + } + + Connections { + target: columnList + function onCountChanged() { + resizeOverlay.geometryTick++; + } + function onContentWidthChanged() { + resizeOverlay.geometryTick++; + } + } + + Component.onCompleted: Qt.callLater(() => geometryTick++) + + Repeater { + model: Math.max(0, columnsData.values.length - 1) + + MouseArea { + id: handleRoot + + required property int index + + readonly property bool hovered: containsMouse + + property ColumnHeaderCell leftCell: { + const left = leftCell; + if (resizing && left) + return left; + const tick = resizeOverlay.geometryTick; + return resolveCell(true); + } + + property ColumnHeaderCell rightCell: { + const right = rightCell; + if (resizing && right) + return right; + const tick = resizeOverlay.geometryTick; + return resolveCell(false); + } + + property bool resizing: false + property real originX: 0 + property real originWidth: 0 + property real originRightWidth: 0 + property real originAbsX: 0 + + function resolveCell(left: bool): ColumnHeaderCell { + if (left) { // qmllint disable incompatible-type + const leftItem = columnList.itemAtIndex(index); + if (leftItem) + return leftItem; + else + return null; + } else { + const rightItem = columnList.itemAtIndex(index + 1); + if (rightItem) + return rightItem; + else + return null; + } + } + + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + preventStealing: true + hoverEnabled: true + enabled: !root.anyHeld + + x: { + if (!leftCell) + return 0; + const gapCenter = leftCell.x + leftCell.width + columnList.spacing / 2; + return gapCenter - width / 2; + } + y: 0 + implicitWidth: 16 + implicitHeight: columnList.height + + function pairAvailableWidth(rightCell) { + let othersWidth = 0; + for (const item of columnList.contentItem.children) { + if (item.modelData === undefined || item === handleRoot.leftCell || item === rightCell) + continue; + othersWidth += item.width; + } + const spacingTotal = columnList.spacing * Math.max(0, columnsData.values.length - 1); + return columnList.width - othersWidth - spacingTotal; + } + + CustomRect { + id: handle + + readonly property bool shouldBeVisible: handleRoot.hovered || handleRoot.pressed + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + + implicitWidth: shouldBeVisible ? resizeOverlay.gapBoost : 0 + color: Colors.palette.m3secondary + radius: Tokens.rounding.full + opacity: shouldBeVisible ? 1.0 : 0 + + Behavior on implicitWidth { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on opacity { + Anim {} + } + + transform: Scale { + origin { + x: handle.implicitWidth / 2 + y: handle.height / 2 + } + + yScale: handle.shouldBeVisible ? 1 : 0.5 + xScale: handle.shouldBeVisible ? 1 : 0.5 + + Behavior on xScale { + Anim {} + } + + Behavior on yScale { + Anim {} + } + } + } + + onHoveredChanged: resizeOverlay.activeGapIndex = hovered || pressed ? handleRoot.index : (resizeOverlay.activeGapIndex === handleRoot.index ? -1 : resizeOverlay.activeGapIndex) + + onPressedChanged: { + resizeOverlay.pressed = pressed; + if (!pressed) + resizeOverlay.activeGapIndex = hovered ? handleRoot.index : resizeOverlay.activeGapIndex === handleRoot.index ? -1 : resizeOverlay.activeGapIndex; + } + + onPressed: e => { + if (!handleRoot.leftCell) + return; + + Cursor.setOverride(Qt.ClosedHandCursor); + resizing = true; + originAbsX = handleRoot.mapToItem(resizeOverlay, e.x, 0).x; + originWidth = handleRoot.leftCell.implicitWidth; + originRightWidth = handleRoot.rightCell ? handleRoot.rightCell.implicitWidth : 0; + } + + onPositionChanged: e => { + if (!resizing || !handleRoot.leftCell) + return; + + const nowAbsX = handleRoot.mapToItem(resizeOverlay, e.x, 0).x; + let newLeftWidth = Math.max(50, originWidth + (nowAbsX - originAbsX)); + + const rightCell = handleRoot.rightCell; + let roundedLeft = Math.round(newLeftWidth); + + if (rightCell) { + const available = Math.round(handleRoot.pairAvailableWidth(rightCell)); + const maxLeftWidth = Math.max(50, available - 50); + roundedLeft = Math.min(roundedLeft, maxLeftWidth); + rightCell.liveWidth = Math.max(50, available - roundedLeft); + } + + handleRoot.leftCell.liveWidth = roundedLeft; + } + + onReleased: { + Cursor.clearOverride(); + resizing = false; + const cell = handleRoot.leftCell; + const rightCell = handleRoot.rightCell; + + if (cell.liveWidth !== 0) + ProcessesColumns.resizeColumn(cell.DelegateModel.itemsIndex, cell.liveWidth); + if (rightCell.liveWidth !== 0) + ProcessesColumns.resizeColumn(rightCell.DelegateModel.itemsIndex, rightCell.liveWidth); + if (cell || rightCell) { + cell.liveWidth = 0; + rightCell.liveWidth = 0; + } + resizeOverlay.geometryTick++; + } + } + } + } + + component ColumnHeaderCell: Item { + id: cell + + required property var modelData + required property int index + required property DelegateModel parentModel + + property int leftRadius: pushSide !== 1 && DelegateModel.itemsIndex === 0 ? cell.implicitHeight / 2 : Tokens.rounding.extraSmall + property int rightRadius: pushSide !== -1 && DelegateModel.itemsIndex === parentModel.count - 1 ? cell.implicitHeight / 2 : Tokens.rounding.extraSmall + property real radiusLerpProg + property bool held + property bool moved: false + property int pressIndex: -1 + property point pressPos + property real lastMoveX: 0 + property real liveWidth: 0 + + readonly property bool isActiveSort: Processes.sortField === (ProcessesColumns.defFor(cell.modelData.id)?.field ?? 0) + readonly property int pushSide: { + const gapIdx = resizeOverlay.activeGapIndex; + if (gapIdx < 0) + return 0; + const myIdx = cell.DelegateModel.itemsIndex; + if (myIdx === gapIdx) + return -1; + if (myIdx === gapIdx + 1) + return 1; + return 0; + } + property real gapBoost: resizeOverlay.gapBoost + readonly property real gapShrink: gapBoost / 2 + + property real gapProg: pushSide !== 0 ? 1 : 0 + property real gapX: pushSide === 1 ? 1 : 0 + + function lerpRadius(a: real, b: real): real { + return a + (b - a) * radiusLerpProg; + } + + Behavior on gapBoost { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on gapX { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on gapProg { + Anim { + type: Anim.FastSpatial + } + } + + implicitHeight: root.headerHeight + implicitWidth: liveWidth > 0 ? liveWidth : (modelData.width > 0 ? modelData.width : (ProcessesColumns.defFor(modelData.id)?.width ?? 100)) + z: held || returnAnim.running ? 1 : 0 + + states: State { + name: "held" + + PropertyChanges { + cellBg.color: Colors.palette.m3primaryContainer + elevation.opacity: 1 + placeholder.opacity: 0.1 + cell.radiusLerpProg: 1 + title.color: Colors.palette.m3onPrimaryContainer + removeBtn.inactiveOnColor: Colors.palette.m3onPrimaryContainer + } + } + + Behavior on leftRadius { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on rightRadius { + Anim { + type: Anim.DefaultEffects + } + } + transitions: Transition { + Anim { + properties: "opacity,radiusLerpProg" + type: Anim.SlowEffects + } + + PropertyAction { + properties: "color,inactiveOnColor" + } + } + state: held ? "held" : "" + + CustomRect { + id: placeholder + + anchors.fill: parent + color: Colors.palette.m3primary + opacity: 0 + topLeftRadius: cell.leftRadius + bottomLeftRadius: cell.leftRadius + topRightRadius: cell.rightRadius + bottomRightRadius: cell.rightRadius + } + + DragHandler { + id: dragHandler + + property real centerOffset: 0 + + enabled: cell.modelData.id !== ProcessesColumns.pinnedColumnId + target: null + xAxis.enabled: true + dragThreshold: 10 + grabPermissions: PointerHandler.CanTakeOverFromItems + + onActiveChanged: { + if (active) { + Cursor.setOverride(Qt.ClosedHandCursor); + returnAnim.stop(); + + cell.pressIndex = cell.DelegateModel.itemsIndex; + cell.lastMoveX = cell.x; + cell.moved = true; + cell.held = true; + + const p = centroid.position; + cell.pressPos = Qt.point(p.x, p.y); + dragHandler.centerOffset = p.x - cell.width / 2; + + cellContent.x = Qt.binding(() => { + const maxOvershoot = Tokens.padding.extraLarge; + const xDiff = cell.x - cell.lastMoveX; + const x = dragHandler.centroid.position.x - cell.pressPos.x - xDiff; + const absX = cell.mapToItem(columnList.contentItem, x, 0).x; + const maxX = columnList.contentWidth - cell.width; + + if (absX < 0) + return x - absX - ProcessesColumns.dampOvershoot(-absX, maxOvershoot); + + if (absX > maxX) + return x - absX + maxX + ProcessesColumns.dampOvershoot(absX - maxX, maxOvershoot); + + return x; + }); + + cellContent.y = Qt.binding(() => { + const maxOvershoot = Tokens.padding.extraLargeIncreased; + const y = dragHandler.centroid.position.y - cell.pressPos.y; + + return ProcessesColumns.dampOvershoot(Math.abs(y), maxOvershoot) * Math.sign(y); + }); + } else { + Cursor.clearOverride(); + if (cell.held) { + cellContent.x = cellContent.x; + cellContent.y = cellContent.y; + + cell.held = false; + returnAnim.start(); + + if (cell.DelegateModel.itemsIndex !== cell.pressIndex) { + ProcessesColumns.moveColumns(cell.pressIndex, cell.DelegateModel.itemsIndex); + } + } + } + } + + onCentroidChanged: { + if (!active) + return; + + const p = centroid.position; + cell.lastMoveX = cell.x; + + const cursorAbsX = cell.mapToItem(columnList.contentItem, p.x, 0).x; + + const centerAbsX = cursorAbsX - dragHandler.centerOffset; + + const movingRight = p.x > cell.pressPos.x; + const grabbedRight = dragHandler.centerOffset > 0; + + const useCursor = movingRight === grabbedRight; + const refX = useCursor ? cursorAbsX : centerAbsX; + + const myIndex = cell.DelegateModel.itemsIndex; + + const rightItem = columnList.itemAtIndex(myIndex + 1); + if (rightItem) { + const rightMid = rightItem.x + rightItem.width / 2; + if (refX > rightMid) { + columnsVisual.items.move(myIndex, myIndex + 1); + resizeOverlay.geometryTick++; + Qt.callLater(() => resizeOverlay.geometryTick++); + return; + } + } + + const leftItem = columnList.itemAtIndex(myIndex - 1); + if (leftItem) { + const leftMid = leftItem.x + leftItem.width / 2; + const pinnedAtZero = columnList.itemAtIndex(0)?.modelData?.id === ProcessesColumns.pinnedColumnId; + if (refX < leftMid && !(myIndex === 1 && pinnedAtZero)) { + columnsVisual.items.move(myIndex, myIndex - 1); + resizeOverlay.geometryTick++; + Qt.callLater(() => resizeOverlay.geometryTick++); + } + } + } + } + + Anim { + id: returnAnim + + properties: "x,y" + target: cellContent + to: 0 + } + + Item { + id: cellVisual + + x: cell.gapShrink * cell.gapX + y: 0 + width: cell.width - cell.gapShrink * cell.gapProg + height: cell.height + + Item { + id: cellContent + + width: cellVisual.width + height: cellVisual.height + + readonly property int topLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + readonly property int topRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + readonly property int bottomLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + readonly property int bottomRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + + Elevation { + id: elevation + + anchors.fill: parent + level: 3 + opacity: 0 + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + bottomLeftRadius: parent.bottomLeftRadius + bottomRightRadius: parent.bottomRightRadius + } + + CustomRect { + id: cellBg + + anchors.fill: parent + color: cell.isActiveSort ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + + topLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + topRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + bottomLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + bottomRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + + StateLayer { + id: layer + + color: cell.isActiveSort ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + manualHoverOverride: cell.held + + onClicked: { + Processes.sortBy(ProcessesColumns.defFor(cell.modelData.id)?.field ?? 0); + root.pList.positionViewAtBeginning(); + } + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + anchors.rightMargin: 12 + + CustomText { + id: title + anchors.left: parent.left + anchors.right: removeBtn.visible ? removeBtn.left : parent.right + anchors.rightMargin: Tokens.spacing.extraSmall + anchors.verticalCenter: parent.verticalCenter + color: cell.isActiveSort ? Colors.palette.m3onPrimary : Colors.palette.m3onSurfaceVariant + elide: CustomText.ElideRight + text: ProcessesColumns.defFor(cell.modelData.id)?.title ?? cell.modelData.id + } + + IconButton { + id: removeBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + type: IconButton.Text + icon: "close" + font: Tokens.font.icon.small + inactiveOnColor: Colors.palette.m3onPrimary + visible: cell.modelData.id !== ProcessesColumns.pinnedColumnId + onClicked: ProcessesColumns.removeColumn(cell.DelegateModel.itemsIndex) + } + + MaterialIcon { + anchors.right: removeBtn.visible ? removeBtn.left : parent.right + anchors.verticalCenter: parent.verticalCenter + + text: "keyboard_arrow_down" + opacity: cell.isActiveSort ? 1 : 0 + visible: opacity > 0 + rotation: !Processes.sortAscending && opacity > 0 ? 180 : 0 + color: Colors.palette.m3onPrimary + + Behavior on rotation { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + } + } + } + } + + component HiddenColumnItem: MenuItem { + required property var modelData + + text: modelData.title + } +} diff --git a/Modules/Dashboard/Processes/ProcessesList.qml b/Modules/Dashboard/Processes/ProcessesList.qml new file mode 100644 index 0000000..8f368db --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesList.qml @@ -0,0 +1,140 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +CustomListView { + id: root + + required property ProcessesHeader columnHeader + required property int rowHeight + property real rowSpacing: Tokens.spacing.small + + model: Processes.list + spacing: root.rowSpacing + clip: true + + CustomScrollBar.vertical: CustomScrollBar { + flickable: root + } + + delegate: CustomRect { + id: processRow + + required property var modelData + required property int index + + implicitWidth: ListView.view.width + implicitHeight: rowLayout.implicitHeight + radius: Tokens.rounding.full + + StateLayer { + id: rowHover + } + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + spacing: Tokens.spacing.extraSmall / 2 + + CustomClippingRect { + id: iconClip + + Layout.fillHeight: true + Layout.margins: Tokens.padding.medium + implicitWidth: height + radius: Tokens.rounding.full + + IconImage { + id: appIcon + anchors.fill: parent + implicitSize: height + source: Quickshell.iconPath(processRow.modelData.iconName, "application-x-executable") + } + } + + Repeater { + id: cellRepeater + + model: root.columnHeader.columnsModel + + Item { + id: rowCell + + required property var modelData + required property int index + + implicitWidth: root.columnHeader.widthForColumn(modelData.id, modelData.width) - (index === 0 ? iconClip.implicitWidth + iconClip.Layout.margins * 2 + rowLayout.spacing : 0) + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + Layout.preferredWidth: implicitWidth + Layout.fillHeight: true + + CustomRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Tokens.padding.small + anchors.leftMargin: -(rowLayout.spacing) + + implicitWidth: 2 + color: Colors.palette.m3outlineVariant + visible: rowCell.index !== 0 + } + + Item { + id: layout + anchors.fill: parent + anchors.margins: Tokens.padding.small + anchors.leftMargin: rowCell.index !== 0 ? Tokens.padding.medium : 0 + anchors.rightMargin: Tokens.padding.medium + implicitHeight: mainText.implicitHeight + subText.implicitHeight + mainText.anchors.margins * 2 + subText.anchors.topMargin + + CustomText { + id: mainText + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + elide: CustomText.ElideRight + text: { + const value = processRow.modelData[rowCell.modelData.id]; + return value === undefined ? "" : ProcessesColumns.formatCell(rowCell.modelData.id, value); + } + + states: State { + name: "centerAnchored" + when: !subText.visible + + AnchorChanges { + target: mainText + anchors.top: undefined + anchors.verticalCenter: layout.verticalCenter + } + } + } + + CustomText { + id: subText + anchors.top: mainText.bottom + anchors.left: parent.left + anchors.right: parent.right + visible: rowCell.modelData.id === "command" + elide: CustomText.ElideRight + text: rowCell.modelData.id === "command" ? processRow.modelData["commandLine"] : "" + font: Tokens.font.label.small + color: Colors.palette.m3outline + } + } + } + } + } + } +} diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml new file mode 100644 index 0000000..3346b56 --- /dev/null +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -0,0 +1,186 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Services +import qs.Components +import qs.Services +import qs.Helpers + +Item { + id: root + + implicitHeight: layout.implicitHeight + + ServiceRef { + service: Cpu + } + + ServiceRef { + service: Memory + } + + RowLayout { + id: layout + anchors.fill: parent + spacing: Tokens.spacing.medium + + UsageCard { + accent: Colors.palette.m3primary + buffer: ResourceUsage.cpuBuffer + icon: "memory" + label: qsTr("CPU") + values: ResourceUsage.cpuInfo + } + + UsageCard { + accent: Colors.palette.m3tertiary + buffer: ResourceUsage.memoryBuffer + icon: "memory_alt" + label: qsTr("Memory") + values: ResourceUsage.memoryInfo + } + } + + component UsageCard: CustomRect { + id: card + + property color accent + required property var buffer + property string icon + property string label + property list values + + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.largeIncreased + Layout.fillWidth: true + Layout.fillHeight: true + implicitHeight: mainLayout.implicitHeight + mainLayout.anchors.margins * 2 + + ColumnLayout { + id: mainLayout + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: 0 + + RowLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + color: card.accent + text: card.icon + } + + CustomText { + text: card.label + } + } + + Item { + id: graphWrapper + Layout.fillHeight: true + Layout.fillWidth: true + implicitHeight: infoColumn.implicitHeight + + Item { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: infoColumn.left + + SparklineItem { + id: sparkline + + anchors.fill: parent + historyLength: ResourceUsage.historyLength + line1: card.buffer + line1Color: card.accent + line1FillAlpha: 0.15 + maxValue: 1.0 + slideProgress: 1 + + Connections { + function onValuesChanged(): void { + slideAnim.restart(); + } + + target: card.buffer + } + + NumberAnimation { + id: slideAnim + + duration: Config.dashboard.resourceUpdateInterval + easing.type: Easing.Linear + from: 0 + property: "slideProgress" + target: sparkline + to: 1 + } + } + + // "Collecting data" placeholder + CustomText { + anchors.centerIn: parent + color: Colors.palette.m3outline + text: qsTr("Collecting data...") + visible: card.buffer.count < 2 + } + } + + ColumnLayout { + id: infoColumn + anchors.right: parent.right + anchors.bottom: parent.bottom + width: { + const prefWidth = graphWrapper.width / 3; + const minWidth = graphWrapper.width / 4; + if (prefWidth < implicitWidth) + return implicitWidth; + else if (prefWidth >= implicitWidth * 2) + return minWidth; + else + return prefWidth; + } + + Repeater { + model: card.values + + Item { + id: moreInfo + + required property var modelData + + Layout.leftMargin: Tokens.spacing.medium + Layout.fillWidth: true + implicitWidth: label.implicitWidth + value.implicitWidth + label.anchors.margins * 2 + implicitHeight: label.implicitHeight + + CustomText { + id: label + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + color: Colors.palette.m3onSurfaceVariant + text: moreInfo.modelData.label + } + + CustomText { + id: value + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + color: card.accent + text: moreInfo.modelData.value + } + } + } + } + } + } + } +} diff --git a/Modules/Dashboard/ProcessesTab.qml b/Modules/Dashboard/ProcessesTab.qml new file mode 100644 index 0000000..76bc140 --- /dev/null +++ b/Modules/Dashboard/ProcessesTab.qml @@ -0,0 +1,64 @@ +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Services +import qs.Modules.Dashboard.Processes +import qs.Components +import qs.Services + +Item { + id: root + + readonly property var desktopEntries: DesktopEntries.applications.values.map(e => ({ + icon: e.icon, + command: e.command + })) + + implicitWidth: Tokens.sizes.dashboard.processesWidth + implicitHeight: Tokens.sizes.dashboard.processesHeight + + ServiceRef { + service: Processes + } + + Connections { + target: DesktopEntries.applications + function onValuesChanged(): void { + Processes.setDesktopEntries(root.desktopEntries); + } + } + Component.onCompleted: Processes.setDesktopEntries(root.desktopEntries) + + ProcessesHeader { + id: header + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + headerHeight: Tokens.sizes.dashboard.processesHeaderHeight + pList: processList + } + + ProcessesList { + id: processList + + anchors.top: header.bottom + anchors.bottom: sysInfo.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Tokens.spacing.small + anchors.bottomMargin: Tokens.spacing.small + + columnHeader: header + rowHeight: Tokens.sizes.dashboard.processesRowHeight + } + + SystemInfo { + id: sysInfo + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + } +} diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index fd4b3dc..2184b53 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import ZShell.Internal +import ZShell.Components import ZShell.Config import qs.Helpers import qs.Components @@ -66,8 +66,7 @@ CustomRect { Behavior on smoothMax { enabled: sparkline.initialized - Anim { - } + Anim {} } Component.onCompleted: { diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 85f7df0..0a6d627 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -73,6 +73,17 @@ qml_module(ZShell target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}") +# Node macros expand to Q_OBJECT, automoc can't see it otherwise +list(APPEND CMAKE_AUTOMOC_MACRO_NAMES + CONFIG_NODE + CONFIG_NODE_NO_CTOR + CONFIG_LIST_TYPE + SETTINGS_NODE + SETTINGS_NODE_NO_CTOR + SETTINGS_LIST_TYPE +) + +add_subdirectory(Settings) add_subdirectory(Models) add_subdirectory(Internal) add_subdirectory(Services) diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 3d3e4a7..abc5960 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -5,6 +5,12 @@ qml_module(ZShell-components wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp - LIBRARIES - Qt::Quick + wheelinverter.hpp + sparklinecontent.hpp sparklinecontent.cpp + sparklineitem.hpp sparklineitem.cpp + sparklinesceneitem.hpp sparklinesceneitem.cpp + LIBRARIES + Qt::Quick + Qt::QuickPrivate + ZShell-internal ) diff --git a/Plugins/ZShell/Components/sparklinecontent.cpp b/Plugins/ZShell/Components/sparklinecontent.cpp new file mode 100644 index 0000000..8d51704 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinecontent.cpp @@ -0,0 +1,250 @@ +#include "sparklinecontent.hpp" + +#include +#include +#include + +namespace ZShell::components { + +SparklineContent::SparklineContent(QQuickItem* parent) + : QQuickPaintedItem(parent) { + setAntialiasing(true); +} + +qreal SparklineContent::stepX() const { + if (m_historyLength < 2 || m_viewportW <= 0.0) return 0.0; + if (m_sliding) return m_viewportW / static_cast(m_historyLength - 1); + + const int len = qMax( + 2, + (m_line1 && m_line1->count() >= 2) ? m_line1->count() + : (m_line2 ? m_line2->count() : 2)); + return m_viewportW / static_cast(len - 1); +} + +void SparklineContent::setViewportSize( + qreal viewportWidth, qreal viewportHeight) { + if (qFuzzyCompare(m_viewportW, viewportWidth) && + qFuzzyCompare(m_viewportH, viewportHeight)) + return; + m_viewportW = viewportWidth; + m_viewportH = viewportHeight; + invalidate(); +} + +void SparklineContent::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + if (m_line1) disconnect(m_line1, nullptr, this, nullptr); + m_line1 = buffer; + connectBuffer(buffer); + invalidate(); +} + +void SparklineContent::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + if (m_line2) disconnect(m_line2, nullptr, this, nullptr); + m_line2 = buffer; + connectBuffer(buffer); + invalidate(); +} + +void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) { + if (!buffer) return; + // New data, or the buffer's own smoothingRadius changed (it re-emits + // valuesChanged in that case too) -> geometry is stale. + connect( + buffer, + &internal::CircularBuffer::valuesChanged, + this, + &SparklineContent::invalidate); + connect(buffer, &QObject::destroyed, this, [this, buffer]() { + if (m_line1 == buffer) m_line1 = nullptr; + if (m_line2 == buffer) m_line2 = nullptr; + invalidate(); + }); +} + +void SparklineContent::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + update(); // visual-only, no geometry rebuild needed +} + +void SparklineContent::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + update(); +} + +void SparklineContent::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + update(); +} + +void SparklineContent::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + update(); +} + +void SparklineContent::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + invalidate(); +} + +void SparklineContent::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + invalidate(); +} + +void SparklineContent::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + invalidate(); +} + +void SparklineContent::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + invalidate(); +} + +void SparklineContent::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + invalidate(); +} + +void SparklineContent::invalidate() { + m_dirty = true; + applyGeometry(); + update(); +} + +void SparklineContent::applyGeometry() { + const qreal pad = m_sliding ? stepX() : 0.0; + setSize(QSizeF(m_viewportW + pad, m_viewportH)); +} + +SparklineContent::LineGeometry SparklineContent::buildGeometry( + internal::CircularBuffer* buffer) const { + LineGeometry geom; + if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 || + m_viewportW <= 0.0) + return geom; + if (m_historyLength < 2) return geom; + + const int len = buffer->count(); + const qreal step = stepX(); + if (step <= 0.0) return geom; + + qreal startX = 0.0; + if (m_sliding) + startX = m_viewportW - (len - 1) * step + step; + else + startX = 0.0; + + const qreal h = m_viewportH; + const qreal strokePad = qCeil(m_lineWidth / 2); + const qreal curvePad = 3.0; + const qreal plotTop = strokePad + curvePad; + const qreal plotBottom = h - strokePad; + const qreal fillBottom = h; + const qreal plotH = qMax(1.0, plotBottom - plotTop); + + QVector points; + points.reserve(len); + + for (int i = 0; i < len; ++i) { + const qreal value = + qBound(0.0, buffer->smoothedAt(i), m_maxValue); + + const qreal x = startX + i * step; + const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); + } + + QPainterPath linePath; + linePath.moveTo(points[0]); + + if (m_curveSmoothing && points.size() > 2) { + const qreal tension = 1.0; + const qreal k = tension / 6.0; + const int n = points.size(); + + for (int i = 0; i < n - 1; ++i) { + const QPointF& p0 = points[qMax(i - 1, 0)]; + const QPointF& p1 = points[i]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + linePath.cubicTo(c1, c2, p2); + } + } else { + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } + } + + geom.stroke = linePath; + + QPainterPath fillPath = linePath; + fillPath.lineTo(startX + (len - 1) * step, fillBottom); + fillPath.lineTo(startX, fillBottom); + fillPath.closeSubpath(); + geom.fill = fillPath; + geom.hasData = true; + + return geom; +} + +void SparklineContent::rebuildIfNeeded() { + if (!m_dirty) return; + m_geom1 = buildGeometry(m_line1); + m_geom2 = buildGeometry(m_line2); + m_dirty = false; +} + +void SparklineContent::paint(QPainter* painter) { + rebuildIfNeeded(); + if (!m_geom1.hasData && !m_geom2.hasData) return; + + painter->setRenderHint(QPainter::Antialiasing, true); + + auto drawOne = + [&](const LineGeometry& geom, const QColor& color, qreal fillAlpha) { + if (!geom.hasData) return; + + QColor fillColor = color; + fillColor.setAlphaF(static_cast(fillAlpha)); + painter->setPen(Qt::NoPen); + painter->setBrush(fillColor); + painter->drawPath(geom.fill); + + QPen pen(color, m_lineWidth); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(geom.stroke); + }; + + drawOne(m_geom1, m_line1Color, m_line1FillAlpha); + drawOne(m_geom2, m_line2Color, m_line2FillAlpha); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinecontent.hpp b/Plugins/ZShell/Components/sparklinecontent.hpp new file mode 100644 index 0000000..9417b94 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinecontent.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +#include "internal/circularbuffer.hpp" + +namespace ZShell::components { + +class SparklineContent : public QQuickPaintedItem { + Q_OBJECT + + public: + explicit SparklineContent(QQuickItem* parent = nullptr); + + void paint(QPainter* painter) override; + + void setLine1(internal::CircularBuffer* buffer); + void setLine2(internal::CircularBuffer* buffer); + void setLine1Color(const QColor& color); + void setLine2Color(const QColor& color); + void setLine1FillAlpha(qreal alpha); + void setLine2FillAlpha(qreal alpha); + void setMaxValue(qreal value); + void setLineWidth(qreal width); + void setCurveSmoothing(bool enabled); + void setHistoryLength(int length); + void setSliding(bool sliding); + + void setViewportSize(qreal viewportWidth, qreal viewportHeight); + + [[nodiscard]] qreal stepX() const; + + private: + struct LineGeometry { + QPainterPath stroke; + QPainterPath fill; + bool hasData = false; + }; + + void invalidate(); + void rebuildIfNeeded(); + [[nodiscard]] LineGeometry buildGeometry( + internal::CircularBuffer* buffer) const; + void connectBuffer(internal::CircularBuffer* buffer); + void applyGeometry(); + + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; + QColor m_line1Color; + QColor m_line2Color; + qreal m_line1FillAlpha = 0.15; + qreal m_line2FillAlpha = 0.2; + qreal m_maxValue = 1024.0; + qreal m_lineWidth = 2.0; + bool m_curveSmoothing = false; + int m_historyLength = 30; + bool m_sliding = true; + + qreal m_viewportW = 0.0; + qreal m_viewportH = 0.0; + + LineGeometry m_geom1; + LineGeometry m_geom2; + bool m_dirty = true; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklineitem.cpp b/Plugins/ZShell/Components/sparklineitem.cpp new file mode 100644 index 0000000..c6d1078 --- /dev/null +++ b/Plugins/ZShell/Components/sparklineitem.cpp @@ -0,0 +1,179 @@ +#include "sparklineitem.hpp" + +#include "sparklinecontent.hpp" + +namespace ZShell::components { + +SparklineItem::SparklineItem(QQuickItem* parent) : QQuickItem(parent) { + setClip(true); + m_content = new SparklineContent(this); + m_content->setHistoryLength(m_historyLength); + m_content->setSliding(m_sliding); +} + +void SparklineItem::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + m_content->setViewportSize(newGeometry.width(), newGeometry.height()); + updateContentOffset(); +} + +void SparklineItem::updateContentOffset() { + m_content->setX(-m_content->stepX() * m_slideProgress); +} + +void SparklineItem::applySmoothingRadius( + internal::CircularBuffer* buffer) const { + if (buffer) buffer->setSmoothingRadius(qRound(m_smoothing)); +} + +internal::CircularBuffer* SparklineItem::line1() const { + return m_line1; +} + +void SparklineItem::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + m_line1 = buffer; + m_content->setLine1(buffer); + applySmoothingRadius(buffer); + emit line1Changed(); +} + +internal::CircularBuffer* SparklineItem::line2() const { + return m_line2; +} + +void SparklineItem::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + m_line2 = buffer; + m_content->setLine2(buffer); + applySmoothingRadius(buffer); + emit line2Changed(); +} + +QColor SparklineItem::line1Color() const { + return m_line1Color; +} + +void SparklineItem::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + m_content->setLine1Color(color); + emit line1ColorChanged(); +} + +QColor SparklineItem::line2Color() const { + return m_line2Color; +} + +void SparklineItem::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + m_content->setLine2Color(color); + emit line2ColorChanged(); +} + +qreal SparklineItem::line1FillAlpha() const { + return m_line1FillAlpha; +} + +void SparklineItem::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + m_content->setLine1FillAlpha(alpha); + emit line1FillAlphaChanged(); +} + +qreal SparklineItem::line2FillAlpha() const { + return m_line2FillAlpha; +} + +void SparklineItem::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + m_content->setLine2FillAlpha(alpha); + emit line2FillAlphaChanged(); +} + +qreal SparklineItem::maxValue() const { + return m_maxValue; +} + +void SparklineItem::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + m_content->setMaxValue(value); + emit maxValueChanged(); +} + +qreal SparklineItem::slideProgress() const { + return m_slideProgress; +} + +void SparklineItem::setSlideProgress(qreal progress) { + if (qFuzzyCompare(m_slideProgress, progress)) return; + m_slideProgress = progress; + updateContentOffset(); + emit slideProgressChanged(); +} + +int SparklineItem::historyLength() const { + return m_historyLength; +} + +void SparklineItem::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + m_content->setHistoryLength(length); + updateContentOffset(); + emit historyLengthChanged(); +} + +qreal SparklineItem::lineWidth() const { + return m_lineWidth; +} + +void SparklineItem::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + m_content->setLineWidth(width); + emit lineWidthChanged(); +} + +void SparklineItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + m_content->setSliding(sliding); + updateContentOffset(); + emit slidingChanged(); +} + +bool SparklineItem::sliding() const { + return m_sliding; +} + +void SparklineItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + applySmoothingRadius(m_line1); + applySmoothingRadius(m_line2); + emit smoothingChanged(); +} + +qreal SparklineItem::smoothing() const { + return m_smoothing; +} + +void SparklineItem::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + m_content->setCurveSmoothing(enabled); + emit curveSmoothingChanged(); +} + +bool SparklineItem::curveSmoothing() const { + return m_curveSmoothing; +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Internal/sparklineitem.hpp b/Plugins/ZShell/Components/sparklineitem.hpp similarity index 76% rename from Plugins/ZShell/Internal/sparklineitem.hpp rename to Plugins/ZShell/Components/sparklineitem.hpp index a79be15..81d640b 100644 --- a/Plugins/ZShell/Internal/sparklineitem.hpp +++ b/Plugins/ZShell/Components/sparklineitem.hpp @@ -3,21 +3,25 @@ #include #include #include -#include +#include #include -#include "circularbuffer.hpp" +#include "internal/circularbuffer.hpp" -namespace ZShell::internal { +namespace ZShell::components { -class SparklineItem : public QQuickPaintedItem { +class SparklineContent; + +class SparklineItem : public QQuickItem { Q_OBJECT QML_ELEMENT Q_PROPERTY( - CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY line1Changed) + internal::CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY + line1Changed) Q_PROPERTY( - CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY line2Changed) + internal::CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY + line2Changed) Q_PROPERTY( QColor line1Color READ line1Color WRITE setLine1Color NOTIFY line1ColorChanged) @@ -52,13 +56,11 @@ class SparklineItem : public QQuickPaintedItem { public: explicit SparklineItem(QQuickItem* parent = nullptr); - void paint(QPainter* painter) override; + [[nodiscard]] internal::CircularBuffer* line1() const; + void setLine1(internal::CircularBuffer* buffer); - [[nodiscard]] CircularBuffer* line1() const; - void setLine1(CircularBuffer* buffer); - - [[nodiscard]] CircularBuffer* line2() const; - void setLine2(CircularBuffer* buffer); + [[nodiscard]] internal::CircularBuffer* line2() const; + void setLine2(internal::CircularBuffer* buffer); [[nodiscard]] QColor line1Color() const; void setLine1Color(const QColor& color); @@ -108,16 +110,17 @@ class SparklineItem : public QQuickPaintedItem { void smoothingChanged(); void curveSmoothingChanged(); - private: - void drawLine( - QPainter* painter, - CircularBuffer* buffer, - const QColor& color, - qreal fillAlpha); - void connectBuffer(CircularBuffer* buffer); + protected: + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; - CircularBuffer* m_line1 = nullptr; - CircularBuffer* m_line2 = nullptr; + private: + void updateContentOffset(); + void applySmoothingRadius(internal::CircularBuffer* buffer) const; + + SparklineContent* m_content = nullptr; + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; QColor m_line1Color; QColor m_line2Color; qreal m_line1FillAlpha = 0.15; @@ -131,4 +134,4 @@ class SparklineItem : public QQuickPaintedItem { bool m_curveSmoothing = false; }; -} // namespace ZShell::internal +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinesceneitem.cpp b/Plugins/ZShell/Components/sparklinesceneitem.cpp new file mode 100644 index 0000000..42170e3 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinesceneitem.cpp @@ -0,0 +1,447 @@ +#include "sparklinesceneitem.hpp" + +#include +#include +#include +#include + +namespace ZShell::components { + +SparklineSceneItem::SparklineSceneItem(QQuickItem* parent) + : QQuickItem(parent) { + setClip(true); + setFlag(QQuickItem::ItemHasContents, true); +} + +void SparklineSceneItem::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + if (newGeometry.size() != oldGeometry.size()) invalidateGeometry(); +} + +void SparklineSceneItem::invalidateGeometry() { + m_geometryDirty = true; + update(); +} + +void SparklineSceneItem::applySmoothingRadius( + internal::CircularBuffer* buffer) const { + if (buffer) buffer->setSmoothingRadius(qRound(m_smoothing)); +} + +void SparklineSceneItem::connectBuffer(internal::CircularBuffer* buffer) { + if (!buffer) return; + connect( + buffer, + &internal::CircularBuffer::valuesChanged, + this, + &SparklineSceneItem::invalidateGeometry); + connect(buffer, &QObject::destroyed, this, [this, buffer]() { + if (m_line1 == buffer) { + m_line1 = nullptr; + emit line1Changed(); + } + if (m_line2 == buffer) { + m_line2 = nullptr; + emit line2Changed(); + } + invalidateGeometry(); + }); +} + +qreal SparklineSceneItem::stepX() const { + if (m_historyLength < 2 || width() <= 0.0) return 0.0; + if (m_sliding) return width() / static_cast(m_historyLength - 1); + + const int len = qMax( + 2, + (m_line1 && m_line1->count() >= 2) ? m_line1->count() + : (m_line2 ? m_line2->count() : 2)); + return width() / static_cast(len - 1); +} + +SparklineSceneItem::LineSample SparklineSceneItem::buildSample( + internal::CircularBuffer* buffer) const { + LineSample sample; + if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 || width() <= 0.0) + return sample; + if (m_historyLength < 2) return sample; + + const int len = buffer->count(); + const qreal step = stepX(); + if (step <= 0.0) return sample; + + qreal startX = m_sliding ? (width() - (len - 1) * step + step) : 0.0; + + const qreal h = height(); + const qreal strokePad = qCeil(m_lineWidth / 2); + const qreal curvePad = 3.0; + const qreal plotTop = strokePad + curvePad; + const qreal plotBottom = h - strokePad; + const qreal plotH = qMax(1.0, plotBottom - plotTop); + + QVector points; + points.reserve(len); + + for (int i = 0; i < len; ++i) { + // Already smoothed once, incrementally, by the buffer itself. + const qreal value = + qBound(0.0, buffer->smoothedAt(i), m_maxValue); + + const qreal x = startX + i * step; + const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); + } + + QPainterPath path; + path.moveTo(points[0]); + + if (m_curveSmoothing && points.size() > 2) { + const qreal tension = 1.0; + const qreal k = tension / 6.0; + const int n = points.size(); + for (int i = 0; i < n - 1; ++i) { + const QPointF& p0 = points[qMax(i - 1, 0)]; + const QPointF& p1 = points[i]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + path.cubicTo(c1, c2, p2); + } + } else { + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + path.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } + } + + // Flatten the curve to a polyline once, here, on data change only. + // This is CPU math only -- no QPainter draw calls, no image, no + // texture upload happen as a result of this. + const auto subpaths = path.toSubpathPolygons(); + if (subpaths.isEmpty()) return sample; + + sample.polyline = subpaths.first(); + sample.fillBottom = h; + sample.hasData = sample.polyline.size() >= 2; + return sample; +} + +void SparklineSceneItem::writeFillGeometry( + QSGGeometryNode* node, const LineSample& sample) { + const int n = sample.polyline.size(); + auto* geometry = node->geometry(); + geometry->allocate(n * 2); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + + auto* vertices = geometry->vertexDataAsPoint2D(); + for (int i = 0; i < n; ++i) { + const QPointF& p = sample.polyline[i]; + vertices[i * 2].set(p.x(), p.y()); + vertices[i * 2 + 1].set(p.x(), sample.fillBottom); + } + + node->markDirty(QSGNode::DirtyGeometry); +} + +void SparklineSceneItem::writeStrokeGeometry( + QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) { + const int n = sample.polyline.size(); + auto* geometry = node->geometry(); + geometry->allocate(n * 2); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + + auto* vertices = geometry->vertexDataAsPoint2D(); + const qreal half = lineWidth * 0.5; + + for (int i = 0; i < n; ++i) { + QPointF dir; + if (i == 0) + dir = sample.polyline[1] - sample.polyline[0]; + else if (i == n - 1) + dir = sample.polyline[n - 1] - sample.polyline[n - 2]; + else + dir = sample.polyline[i + 1] - sample.polyline[i - 1]; + + const qreal len = std::hypot(dir.x(), dir.y()); + QPointF normal = len > 1e-6 ? QPointF(-dir.y() / len, dir.x() / len) + : QPointF(0, 1); + + const QPointF& p = sample.polyline[i]; + const QPointF offset = normal * half; + vertices[i * 2].set(p.x() + offset.x(), p.y() + offset.y()); + vertices[i * 2 + 1].set(p.x() - offset.x(), p.y() - offset.y()); + } + + node->markDirty(QSGNode::DirtyGeometry); +} + +void SparklineSceneItem::setMaterialColor( + QSGGeometryNode* node, const QColor& color, qreal alpha) { + auto* material = static_cast(node->material()); + QColor c = color; + c.setAlphaF(static_cast(alpha)); + material->setColor(c); + node->markDirty(QSGNode::DirtyMaterial); +} + +QSGNode* SparklineSceneItem::updatePaintNode( + QSGNode* node, UpdatePaintNodeData*) { + bool samplesRebuilt = false; + if (m_geometryDirty) { + m_sample1 = buildSample(m_line1); + m_sample2 = buildSample(m_line2); + m_geometryDirty = false; + samplesRebuilt = true; + } + + const bool hasAny = m_sample1.hasData || m_sample2.hasData; + if (!hasAny) { + delete node; + m_transformNode = nullptr; + m_line1FillNode = m_line1StrokeNode = nullptr; + m_line2FillNode = m_line2StrokeNode = nullptr; + return nullptr; + } + + auto* transformNode = static_cast(node); + bool justCreated = false; + if (!transformNode) { + transformNode = new QSGTransformNode(); + m_transformNode = transformNode; + + auto makeNode = [] { + auto* n = new QSGGeometryNode(); + n->setGeometry( + new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 0)); + n->setFlag(QSGNode::OwnsGeometry); + n->setMaterial(new QSGFlatColorMaterial()); + n->setFlag(QSGNode::OwnsMaterial); + return n; + }; + + m_line1FillNode = makeNode(); + m_line1StrokeNode = makeNode(); + m_line2FillNode = makeNode(); + m_line2StrokeNode = makeNode(); + transformNode->appendChildNode(m_line1FillNode); + transformNode->appendChildNode(m_line1StrokeNode); + transformNode->appendChildNode(m_line2FillNode); + transformNode->appendChildNode(m_line2StrokeNode); + + justCreated = true; + } + + QMatrix4x4 matrix; + matrix.translate(-stepX() * m_slideProgress, 0); + transformNode->setMatrix(matrix); + + const bool needsGeometryWrite = samplesRebuilt || justCreated; + const bool needsColorWrite = m_colorsDirty || justCreated; + + auto writeLine = [&](QSGGeometryNode* fillNode, + QSGGeometryNode* strokeNode, + const LineSample& sample, + const QColor& color, + qreal fillAlpha) { + if (needsGeometryWrite) { + if (sample.hasData) { + writeFillGeometry(fillNode, sample); + writeStrokeGeometry(strokeNode, sample, m_lineWidth); + } else { + fillNode->geometry()->allocate(0); + strokeNode->geometry()->allocate(0); + fillNode->markDirty(QSGNode::DirtyGeometry); + strokeNode->markDirty(QSGNode::DirtyGeometry); + } + } + if (needsColorWrite) { + setMaterialColor(fillNode, color, fillAlpha); + setMaterialColor(strokeNode, color, 1.0); + } + }; + + writeLine( + m_line1FillNode, + m_line1StrokeNode, + m_sample1, + m_line1Color, + m_line1FillAlpha); + writeLine( + m_line2FillNode, + m_line2StrokeNode, + m_sample2, + m_line2Color, + m_line2FillAlpha); + + m_colorsDirty = false; + + return transformNode; +} + +internal::CircularBuffer* SparklineSceneItem::line1() const { + return m_line1; +} + +void SparklineSceneItem::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + if (m_line1) disconnect(m_line1, nullptr, this, nullptr); + m_line1 = buffer; + connectBuffer(buffer); + applySmoothingRadius(buffer); + invalidateGeometry(); + emit line1Changed(); +} + +internal::CircularBuffer* SparklineSceneItem::line2() const { + return m_line2; +} + +void SparklineSceneItem::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + if (m_line2) disconnect(m_line2, nullptr, this, nullptr); + m_line2 = buffer; + connectBuffer(buffer); + applySmoothingRadius(buffer); + invalidateGeometry(); + emit line2Changed(); +} + +QColor SparklineSceneItem::line1Color() const { + return m_line1Color; +} + +void SparklineSceneItem::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + m_colorsDirty = true; + update(); + emit line1ColorChanged(); +} + +QColor SparklineSceneItem::line2Color() const { + return m_line2Color; +} + +void SparklineSceneItem::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + m_colorsDirty = true; + update(); + emit line2ColorChanged(); +} + +qreal SparklineSceneItem::line1FillAlpha() const { + return m_line1FillAlpha; +} + +void SparklineSceneItem::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + m_colorsDirty = true; + update(); + emit line1FillAlphaChanged(); +} + +qreal SparklineSceneItem::line2FillAlpha() const { + return m_line2FillAlpha; +} + +void SparklineSceneItem::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + m_colorsDirty = true; + update(); + emit line2FillAlphaChanged(); +} + +qreal SparklineSceneItem::maxValue() const { + return m_maxValue; +} + +void SparklineSceneItem::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + invalidateGeometry(); + emit maxValueChanged(); +} + +qreal SparklineSceneItem::slideProgress() const { + return m_slideProgress; +} + +void SparklineSceneItem::setSlideProgress(qreal progress) { + if (qFuzzyCompare(m_slideProgress, progress)) return; + m_slideProgress = progress; + update(); + emit slideProgressChanged(); +} + +int SparklineSceneItem::historyLength() const { + return m_historyLength; +} + +void SparklineSceneItem::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + invalidateGeometry(); + emit historyLengthChanged(); +} + +qreal SparklineSceneItem::lineWidth() const { + return m_lineWidth; +} + +void SparklineSceneItem::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + invalidateGeometry(); + emit lineWidthChanged(); +} + +bool SparklineSceneItem::sliding() const { + return m_sliding; +} + +void SparklineSceneItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + invalidateGeometry(); + emit slidingChanged(); +} + +qreal SparklineSceneItem::smoothing() const { + return m_smoothing; +} + +void SparklineSceneItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + applySmoothingRadius(m_line1); + applySmoothingRadius(m_line2); + emit smoothingChanged(); +} + +bool SparklineSceneItem::curveSmoothing() const { + return m_curveSmoothing; +} + +void SparklineSceneItem::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + invalidateGeometry(); + emit curveSmoothingChanged(); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinesceneitem.hpp b/Plugins/ZShell/Components/sparklinesceneitem.hpp new file mode 100644 index 0000000..5309793 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinesceneitem.hpp @@ -0,0 +1,167 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "internal/circularbuffer.hpp" + +class QSGGeometryNode; +class QSGTransformNode; + +namespace ZShell::components { + +class SparklineSceneItem : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + internal::CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY + line1Changed) + Q_PROPERTY( + internal::CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY + line2Changed) + Q_PROPERTY( + QColor line1Color READ line1Color WRITE setLine1Color NOTIFY + line1ColorChanged) + Q_PROPERTY( + QColor line2Color READ line2Color WRITE setLine2Color NOTIFY + line2ColorChanged) + Q_PROPERTY( + qreal line1FillAlpha READ line1FillAlpha WRITE setLine1FillAlpha NOTIFY + line1FillAlphaChanged) + Q_PROPERTY( + qreal line2FillAlpha READ line2FillAlpha WRITE setLine2FillAlpha NOTIFY + line2FillAlphaChanged) + Q_PROPERTY( + qreal maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged) + Q_PROPERTY( + qreal slideProgress READ slideProgress WRITE setSlideProgress NOTIFY + slideProgressChanged) + Q_PROPERTY( + int historyLength READ historyLength WRITE setHistoryLength NOTIFY + historyLengthChanged) + Q_PROPERTY( + qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY + lineWidthChanged) + Q_PROPERTY(bool sliding READ sliding WRITE setSliding NOTIFY slidingChanged) + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY( + bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY + curveSmoothingChanged) + + public: + explicit SparklineSceneItem(QQuickItem* parent = nullptr); + + [[nodiscard]] internal::CircularBuffer* line1() const; + void setLine1(internal::CircularBuffer* buffer); + + [[nodiscard]] internal::CircularBuffer* line2() const; + void setLine2(internal::CircularBuffer* buffer); + + [[nodiscard]] QColor line1Color() const; + void setLine1Color(const QColor& color); + + [[nodiscard]] QColor line2Color() const; + void setLine2Color(const QColor& color); + + [[nodiscard]] qreal line1FillAlpha() const; + void setLine1FillAlpha(qreal alpha); + + [[nodiscard]] qreal line2FillAlpha() const; + void setLine2FillAlpha(qreal alpha); + + [[nodiscard]] qreal maxValue() const; + void setMaxValue(qreal value); + + [[nodiscard]] qreal slideProgress() const; + void setSlideProgress(qreal progress); + + [[nodiscard]] int historyLength() const; + void setHistoryLength(int length); + + [[nodiscard]] qreal lineWidth() const; + void setLineWidth(qreal width); + + [[nodiscard]] bool sliding() const; + void setSliding(bool sliding); + + [[nodiscard]] qreal smoothing() const; + void setSmoothing(qreal smoothing); + + [[nodiscard]] bool curveSmoothing() const; + void setCurveSmoothing(bool curveSmoothing); + + signals: + void line1Changed(); + void line2Changed(); + void line1ColorChanged(); + void line2ColorChanged(); + void line1FillAlphaChanged(); + void line2FillAlphaChanged(); + void maxValueChanged(); + void slideProgressChanged(); + void historyLengthChanged(); + void lineWidthChanged(); + void slidingChanged(); + void smoothingChanged(); + void curveSmoothingChanged(); + + protected: + QSGNode* updatePaintNode(QSGNode* node, UpdatePaintNodeData* data) override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + + private: + struct LineSample { + QVector polyline; // local coords, slideProgress baked to 0 + qreal fillBottom = 0.0; + bool hasData = false; + }; + + void connectBuffer(internal::CircularBuffer* buffer); + void invalidateGeometry(); + void applySmoothingRadius(internal::CircularBuffer* buffer) const; + [[nodiscard]] qreal stepX() const; + [[nodiscard]] LineSample buildSample(internal::CircularBuffer* buffer) const; + static void writeFillGeometry( + QSGGeometryNode* node, const LineSample& sample); + static void writeStrokeGeometry( + QSGGeometryNode* node, const LineSample& sample, qreal lineWidth); + static void setMaterialColor( + QSGGeometryNode* node, const QColor& color, qreal alpha); + + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; + QColor m_line1Color; + QColor m_line2Color; + qreal m_line1FillAlpha = 0.15; + qreal m_line2FillAlpha = 0.2; + qreal m_maxValue = 1024.0; + qreal m_slideProgress = 0.0; + int m_historyLength = 30; + qreal m_lineWidth = 2.0; + bool m_sliding = true; + qreal m_smoothing = 0.0; + bool m_curveSmoothing = false; + + LineSample m_sample1; + LineSample m_sample2; + + bool m_geometryDirty = true; + bool m_colorsDirty = true; + + QSGTransformNode* m_transformNode = nullptr; + QSGGeometryNode* m_line1FillNode = nullptr; + QSGGeometryNode* m_line1StrokeNode = nullptr; + QSGGeometryNode* m_line2FillNode = nullptr; + QSGGeometryNode* m_line2StrokeNode = nullptr; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index fdb1ea2..cb55a84 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -1,16 +1,17 @@ qml_module(ZShell-config URI ZShell.Config INCLUDE_PREFIX config + DEPENDENCIES + ZShell.Settings SOURCES - confignode.hpp confignode.cpp - configobject.hpp configobject.cpp - configlist.hpp configlist.cpp - config.hpp config.cpp + common.hpp common.cpp + rootnodes.hpp rootnodes.cpp + migration.hpp migration.cpp anim.hpp anim.cpp fontbuilder.hpp fontbuilder.cpp font.hpp font.cpp tokens.hpp - appearance.hpp + appearance.hpp appearance.cpp background.hpp bar.hpp clipboard.hpp @@ -27,6 +28,11 @@ qml_module(ZShell-config services.hpp sidebar.hpp utilities.hpp - LIBRARIES - Qt::Gui +) + +target_link_libraries(ZShell-config PUBLIC + Qt::Gui + zshell-util + ZShell + ZShell-settings ) diff --git a/Plugins/ZShell/Config/anim.cpp b/Plugins/ZShell/Config/anim.cpp index 50926c2..f5b97ff 100644 --- a/Plugins/ZShell/Config/anim.cpp +++ b/Plugins/ZShell/Config/anim.cpp @@ -1,32 +1,85 @@ #include "anim.hpp" -#include "tokens.hpp" + #include +#include "appearance.hpp" +#include "tokens.hpp" + namespace ZShell::config { -QEasingCurve AnimEasing::buildCurve(const QList& points) { +AnimTokens::AnimTokens(QObject* parent) : QObject(parent) {} + +QEasingCurve AnimTokens::emphasized() const { + return m_emphasized; +} + +QEasingCurve AnimTokens::emphasizedAccel() const { + return m_emphasizedAccel; +} + +QEasingCurve AnimTokens::emphasizedDecel() const { + return m_emphasizedDecel; +} + +QEasingCurve AnimTokens::standard() const { + return m_standard; +} + +QEasingCurve AnimTokens::standardAccel() const { + return m_standardAccel; +} + +QEasingCurve AnimTokens::standardDecel() const { + return m_standardDecel; +} + +QEasingCurve AnimTokens::expressiveFastSpatial() const { + return m_expressiveFastSpatial; +} + +QEasingCurve AnimTokens::expressiveDefaultSpatial() const { + return m_expressiveDefaultSpatial; +} + +QEasingCurve AnimTokens::expressiveSlowSpatial() const { + return m_expressiveSlowSpatial; +} + +QEasingCurve AnimTokens::expressiveFastEffects() const { + return m_expressiveFastEffects; +} + +QEasingCurve AnimTokens::expressiveDefaultEffects() const { + return m_expressiveDefaultEffects; +} + +QEasingCurve AnimTokens::expressiveSlowEffects() const { + return m_expressiveSlowEffects; +} + +AnimDurations* AnimTokens::durations() const { + return m_durations; +} + +QEasingCurve AnimTokens::buildCurve(const QList& points) { QEasingCurve curve(QEasingCurve::BezierSpline); + + // Points come in pairs of (x, y) forming cubic bezier segments. + // Each segment needs 3 control points: c1, c2, endPoint. + // So 6 values per segment: c1x, c1y, c2x, c2y, endX, endY. for (int i = 0; i + 5 < points.size(); i += 6) { - QPointF c1(points[i], points[i + 1]); - QPointF c2(points[i + 2], points[i + 3]); - QPointF end(points[i + 4], points[i + 5]); + const QPointF c1(points[i], points[i + 1]); + const QPointF c2(points[i + 2], points[i + 3]); + const QPointF end(points[i + 4], points[i + 5]); curve.addCubicBezierSegment(c1, c2, end); } + return curve; } -void AnimEasing::bindCurves(AnimCurves* curves) { - m_curves = curves; - connect( - curves, - &AnimCurves::propertiesChanged, - this, - &AnimEasing::rebuildCurves); - rebuildCurves(); -} - -void AnimEasing::rebuildCurves() { +void AnimTokens::rebuildCurves() { if (!m_curves) return; + m_emphasized = buildCurve(m_curves->emphasized()); m_emphasizedAccel = buildCurve(m_curves->emphasizedAccel()); m_emphasizedDecel = buildCurve(m_curves->emphasizedDecel()); @@ -41,7 +94,25 @@ void AnimEasing::rebuildCurves() { m_expressiveDefaultEffects = buildCurve(m_curves->expressiveDefaultEffects()); m_expressiveSlowEffects = buildCurve(m_curves->expressiveSlowEffects()); + emit curvesChanged(); } +void AnimTokens::bindCurves(AnimCurves* curves) { + m_curves = curves; + + // Rebuild when any curve control points change + connect( + curves, &AnimCurves::optionChanged, this, &AnimTokens::rebuildCurves); + + rebuildCurves(); +} + +void AnimTokens::bindDurations(AnimDurations* durations) { + if (m_durations == durations) return; + + m_durations = durations; + emit durationsChanged(); +} + } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/anim.hpp b/Plugins/ZShell/Config/anim.hpp index c5f95bc..2a76c9d 100644 --- a/Plugins/ZShell/Config/anim.hpp +++ b/Plugins/ZShell/Config/anim.hpp @@ -9,8 +9,10 @@ namespace ZShell::config { class AnimCurves; class AnimDurations; -class AnimEasing : public QObject { +class AnimTokens : public QObject { Q_OBJECT + Q_MOC_INCLUDE("tokens.hpp") // AnimCurves + Q_MOC_INCLUDE("appearance.hpp") QML_ANONYMOUS Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) @@ -42,54 +44,53 @@ class AnimEasing : public QObject { QEasingCurve expressiveSlowEffects READ expressiveSlowEffects NOTIFY curvesChanged) + Q_PROPERTY( + ZShell::config::AnimDurations* durations READ durations NOTIFY + durationsChanged) + public: - explicit AnimEasing(QObject* parent = nullptr) : QObject(parent) {} + explicit AnimTokens(QObject* parent = nullptr); void bindCurves(AnimCurves* curves); + void bindDurations(AnimDurations* durations); - [[nodiscard]] QEasingCurve emphasized() const { return m_emphasized; } - [[nodiscard]] QEasingCurve emphasizedAccel() const { - return m_emphasizedAccel; - } - [[nodiscard]] QEasingCurve emphasizedDecel() const { - return m_emphasizedDecel; - } - [[nodiscard]] QEasingCurve standard() const { return m_standard; } - [[nodiscard]] QEasingCurve standardAccel() const { return m_standardAccel; } - [[nodiscard]] QEasingCurve standardDecel() const { return m_standardDecel; } - [[nodiscard]] QEasingCurve expressiveFastSpatial() const { - return m_expressiveFastSpatial; - } - [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const { - return m_expressiveDefaultSpatial; - } - [[nodiscard]] QEasingCurve expressiveSlowSpatial() const { - return m_expressiveSlowSpatial; - } - [[nodiscard]] QEasingCurve expressiveFastEffects() const { - return m_expressiveFastEffects; - } - [[nodiscard]] QEasingCurve expressiveDefaultEffects() const { - return m_expressiveDefaultEffects; - } - [[nodiscard]] QEasingCurve expressiveSlowEffects() const { - return m_expressiveSlowEffects; - } + [[nodiscard]] QEasingCurve emphasized() const; + [[nodiscard]] QEasingCurve emphasizedAccel() const; + [[nodiscard]] QEasingCurve emphasizedDecel() const; + [[nodiscard]] QEasingCurve standard() const; + [[nodiscard]] QEasingCurve standardAccel() const; + [[nodiscard]] QEasingCurve standardDecel() const; + [[nodiscard]] QEasingCurve expressiveFastSpatial() const; + [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const; + [[nodiscard]] QEasingCurve expressiveSlowSpatial() const; + [[nodiscard]] QEasingCurve expressiveFastEffects() const; + [[nodiscard]] QEasingCurve expressiveDefaultEffects() const; + [[nodiscard]] QEasingCurve expressiveSlowEffects() const; + [[nodiscard]] AnimDurations* durations() const; signals: void curvesChanged(); + void durationsChanged(); private: - static QEasingCurve buildCurve(const QList& points); void rebuildCurves(); + static QEasingCurve buildCurve(const QList& points); AnimCurves* m_curves = nullptr; - QEasingCurve m_emphasized, m_emphasizedAccel, m_emphasizedDecel; - QEasingCurve m_standard, m_standardAccel, m_standardDecel; - QEasingCurve m_expressiveFastSpatial, m_expressiveDefaultSpatial, - m_expressiveSlowSpatial; - QEasingCurve m_expressiveFastEffects, m_expressiveDefaultEffects, - m_expressiveSlowEffects; + AnimDurations* m_durations = nullptr; + + QEasingCurve m_emphasized; + QEasingCurve m_emphasizedAccel; + QEasingCurve m_emphasizedDecel; + QEasingCurve m_standard; + QEasingCurve m_standardAccel; + QEasingCurve m_standardDecel; + QEasingCurve m_expressiveFastSpatial; + QEasingCurve m_expressiveDefaultSpatial; + QEasingCurve m_expressiveSlowSpatial; + QEasingCurve m_expressiveFastEffects; + QEasingCurve m_expressiveDefaultEffects; + QEasingCurve m_expressiveSlowEffects; }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/appearance.cpp b/Plugins/ZShell/Config/appearance.cpp new file mode 100644 index 0000000..1d125ec --- /dev/null +++ b/Plugins/ZShell/Config/appearance.cpp @@ -0,0 +1,229 @@ +#include "appearance.hpp" + +#include "tokens.hpp" + +namespace ZShell::config { + +namespace { + +// Helper: connect all changed signals from a token object to a single valuesChanged signal, +// plus connect the local scaleChanged signal. +template +void connectTokenSignals(Source* source, Target* target) { + const auto* meta = source->metaObject(); + + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + + if (prop.hasNotifySignal()) + QObject::connect( + source, + prop.notifySignal(), + target, + target->metaObject()->method( + target->metaObject()->indexOfSignal("valuesChanged()"))); + } + + QObject::connect( + target, &Target::scaleChanged, target, &Target::valuesChanged); +} + +} // namespace + +// AppearanceRounding + +void AppearanceRounding::bindTokens(RoundingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearanceRounding::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearanceRounding::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearanceRounding::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearanceRounding::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearanceRounding::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearanceRounding::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearanceRounding::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearanceRounding::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +int AppearanceRounding::full() const { + return m_tokens ? m_tokens->full() : 0; +} + +// AppearanceSpacing + +void AppearanceSpacing::bindTokens(SpacingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearanceSpacing::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearanceSpacing::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearanceSpacing::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearanceSpacing::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearanceSpacing::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearanceSpacing::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearanceSpacing::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearanceSpacing::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +// AppearancePadding + +void AppearancePadding::bindTokens(PaddingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearancePadding::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearancePadding::smaller() const { + return m_tokens ? static_cast(m_tokens->smaller() * m_scale) : 0; +} + +int AppearancePadding::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearancePadding::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearancePadding::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearancePadding::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearancePadding::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearancePadding::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearancePadding::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +// AnimDurations + +void AnimDurations::bindTokens(AnimDurationTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AnimDurations::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AnimDurations::normal() const { + return m_tokens ? static_cast(m_tokens->normal() * m_scale) : 0; +} + +int AnimDurations::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AnimDurations::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AnimDurations::expressiveFastSpatial() const { + return m_tokens + ? static_cast(m_tokens->expressiveFastSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveDefaultSpatial() const { + return m_tokens ? static_cast( + m_tokens->expressiveDefaultSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveSlowSpatial() const { + return m_tokens + ? static_cast(m_tokens->expressiveSlowSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveFastEffects() const { + return m_tokens + ? static_cast(m_tokens->expressiveFastEffects() * m_scale) + : 0; +} + +int AnimDurations::expressiveDefaultEffects() const { + return m_tokens ? static_cast( + m_tokens->expressiveDefaultEffects() * m_scale) + : 0; +} + +int AnimDurations::expressiveSlowEffects() const { + return m_tokens + ? static_cast(m_tokens->expressiveSlowEffects() * m_scale) + : 0; +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/appearance.hpp b/Plugins/ZShell/Config/appearance.hpp index 5181423..4166253 100644 --- a/Plugins/ZShell/Config/appearance.hpp +++ b/Plugins/ZShell/Config/appearance.hpp @@ -1,101 +1,126 @@ #pragma once -#include "configobject.hpp" -#include -#include -#include -#include +#include +#include +#include + +#include "settings/objectnode.hpp" +#include "common.hpp" namespace ZShell::config { -class AnimDurations : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +using Qt::StringLiterals::operator""_s; - CFG_PROPERTY(qreal, scale, 1) +// Forward declare token types from tokens.hpp +class RoundingTokens; +class SpacingTokens; +class PaddingTokens; +class AnimDurationTokens; - Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) +class AppearanceRounding : public settings::ObjectNode { + CONFIG_NODE(AppearanceRounding, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) Q_PROPERTY( - int expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY - scaleChanged) - Q_PROPERTY( - int expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY - scaleChanged) - Q_PROPERTY( - int expressiveFastEffects READ expressiveFastEffects NOTIFY scaleChanged) - Q_PROPERTY( - int expressiveFastSpatial READ expressiveFastSpatial NOTIFY scaleChanged) - Q_PROPERTY( - int expressiveSlowEffects READ expressiveSlowEffects NOTIFY scaleChanged) - Q_PROPERTY( - int expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY scaleChanged) - Q_PROPERTY(int large READ large NOTIFY scaleChanged) - Q_PROPERTY(int normal READ normal NOTIFY scaleChanged) - Q_PROPERTY(int small READ small NOTIFY scaleChanged) + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) + Q_PROPERTY(int full READ full NOTIFY valuesChanged) public: - explicit AnimDurations(QObject* parent = nullptr) : ConfigObject(parent) {} + void bindTokens(RoundingTokens* tokens); - [[nodiscard]] int small() const { return static_cast(200 * scale()); } - [[nodiscard]] int normal() const { return static_cast(400 * scale()); } - [[nodiscard]] int large() const { return static_cast(600 * scale()); } - [[nodiscard]] int extraLarge() const { - return static_cast(1000 * scale()); - } - [[nodiscard]] int expressiveFastSpatial() const { - return static_cast(350 * scale()); - } - [[nodiscard]] int expressiveDefaultSpatial() const { - return static_cast(500 * scale()); - } - [[nodiscard]] int expressiveSlowSpatial() const { - return static_cast(650 * scale()); - } - [[nodiscard]] int expressiveFastEffects() const { - return static_cast(150 * scale()); - } - [[nodiscard]] int expressiveDefaultEffects() const { - return static_cast(200 * scale()); - } - [[nodiscard]] int expressiveSlowEffects() const { - return static_cast(300 * scale()); - } + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; + [[nodiscard]] int full() const; + + signals: + void valuesChanged(); + + private: + RoundingTokens* m_tokens = nullptr; }; -class Anim : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AppearanceSpacing : public settings::ObjectNode { + CONFIG_NODE(AppearanceSpacing, settings::ObjectNode) - CFG_PROPERTY(int, mediaGifSpeedAdjustment, 300) - CFG_PROPERTY(qreal, sessionGifSpeed, 0.7) - CONFIG_SUBOBJECT(AnimDurations, durations) + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) public: - explicit Anim(QObject* parent = nullptr) - : ConfigObject(parent), m_durations(new AnimDurations(this)) {} + void bindTokens(SpacingTokens* tokens); + + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; + + signals: + void valuesChanged(); + + private: + SpacingTokens* m_tokens = nullptr; }; -class Deform : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AppearancePadding : public settings::ObjectNode { + CONFIG_NODE(AppearancePadding, settings::ObjectNode) - CFG_PROPERTY(qreal, scale, 1) + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int smaller READ smaller NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) public: - explicit Deform(QObject* parent = nullptr) : ConfigObject(parent) {} -}; + void bindTokens(PaddingTokens* tokens); -class FontFamily : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int smaller() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; - CFG_PROPERTY(QString, clock, QStringLiteral("Rubik")) - CFG_PROPERTY(QString, material, QStringLiteral("Material Symbols Rounded")) - CFG_PROPERTY(QString, mono, QStringLiteral("CaskaydiaCove NF")) - CFG_PROPERTY(QString, sans, QStringLiteral("Segoe UI Variable Text")) + signals: + void valuesChanged(); - public: - explicit FontFamily(QObject* parent = nullptr) : ConfigObject(parent) {} + private: + PaddingTokens* m_tokens = nullptr; }; namespace detail { @@ -105,20 +130,19 @@ struct FontConfig { int size; int weight = QFont::Normal; bool italic = false; - QVariantMap vaxes = {{QStringLiteral("ROND"), 25}}; + QVariantMap vaxes = {{u"ROND"_s, 25}}; }; } // namespace detail -class FontSizeNode : public ConfigObject { - public: - explicit FontSizeNode(QObject* parent = nullptr) : ConfigObject(parent) {} +class FontSizeNode : public settings::ObjectNode { + CONFIG_NODE(FontSizeNode, settings::ObjectNode) }; -class FontStyleNode : public ConfigObject { - public: - explicit FontStyleNode(QObject* parent = nullptr) : ConfigObject(parent) {} +class FontStyleNode : public settings::ObjectNode { + CONFIG_NODE(FontStyleNode, settings::ObjectNode) + public: virtual FontSizeNode* largeNode() const = 0; virtual FontSizeNode* mediumNode() const = 0; virtual FontSizeNode* smallNode() const = 0; @@ -129,17 +153,16 @@ class FontStyleNode : public ConfigObject { #define FONT(...) ZShell::config::detail::FontConfig __VA_ARGS__ #define FONT_CONFIG(Style, Size, props) \ class FontConfig##Style##Size : public FontSizeNode { \ - Q_OBJECT QML_ANONYMOUS \ + CONFIG_NODE(FontConfig##Style##Size, FontSizeNode) \ \ - CFG_PROPERTY(QString, family, ARG(props).family) \ - CFG_PROPERTY(int, size, ARG(props).size) \ - CFG_PROPERTY(int, weight, ARG(props).weight) \ - CFG_PROPERTY(bool, italic, ARG(props).italic) \ - CFG_PROPERTY(QVariantMap, vaxes, ARG(props).vaxes) \ - \ - public \ - : explicit FontConfig##Style##Size(QObject* parent = nullptr) \ - : FontSizeNode(parent) {} \ + CONFIG_PROPERTY( \ + QString, \ + family, \ + ARG(props).family) /* Empty inherits the style family */ \ + CONFIG_PROPERTY(int, size, ARG(props).size) \ + CONFIG_PROPERTY(int, weight, ARG(props).weight) \ + CONFIG_PROPERTY(bool, italic, ARG(props).italic) \ + CONFIG_PROPERTY(QVariantMap, vaxes, ARG(props).vaxes) \ }; #define FONT_STYLE(Style, family_, large_, medium_, small_) \ FONT_CONFIG(Style, Large, ARG(large_)) \ @@ -147,47 +170,41 @@ class FontStyleNode : public ConfigObject { FONT_CONFIG(Style, Small, ARG(small_)) \ \ class FontStyle##Style : public FontStyleNode { \ - Q_OBJECT QML_ANONYMOUS \ + CONFIG_NODE(FontStyle##Style, FontStyleNode) \ \ - CFG_PROPERTY(QString, family, family_) \ - CONFIG_SUBOBJECT(FontConfig##Style##Large, large) \ - CONFIG_SUBOBJECT(FontConfig##Style##Medium, medium) \ - CONFIG_SUBOBJECT(FontConfig##Style##Small, small) \ - \ - public \ - : explicit FontStyle##Style(QObject* parent = nullptr) \ - : FontStyleNode(parent) \ - , m_large(new FontConfig##Style##Large(this)) \ - , m_medium(new FontConfig##Style##Medium(this)) \ - , m_small(new FontConfig##Style##Small(this)) {} \ + CONFIG_PROPERTY(QString, family, family_) \ + CONFIG_SUBOBJECT(FontConfig##Style##Large, large) \ + CONFIG_SUBOBJECT(FontConfig##Style##Medium, medium) \ + CONFIG_SUBOBJECT(FontConfig##Style##Small, small) \ \ + public: \ FontSizeNode* largeNode() const override { return m_large; } \ FontSizeNode* mediumNode() const override { return m_medium; } \ FontSizeNode* smallNode() const override { return m_small; } \ }; // clang-format off -FONT_STYLE(Headline, QStringLiteral("GoogleSansFlex"), +FONT_STYLE(Headline, u"GoogleSansFlex"_s, FONT({ .size = 32, .weight = QFont::Medium }), FONT({ .size = 28, .weight = QFont::Medium }), FONT({ .size = 24, .weight = QFont::Medium }) ) -FONT_STYLE(Title, QStringLiteral("GoogleSansFlex"), +FONT_STYLE(Title, u"GoogleSansFlex"_s, FONT({ .size = 22, .weight = QFont::Medium }), FONT({ .size = 16, .weight = QFont::Medium }), FONT({ .size = 14, .weight = QFont::Medium }) ) -FONT_STYLE(Body, QStringLiteral("GoogleSansFlex"), +FONT_STYLE(Body, u"GoogleSansFlex"_s, FONT({ .size = 16 }), FONT({ .size = 14 }), FONT({ .size = 12 }) ) -FONT_STYLE(Label, QStringLiteral("GoogleSansFlex"), +FONT_STYLE(Label, u"GoogleSansFlex"_s, FONT({ .size = 14, .weight = QFont::Medium }), FONT({ .size = 12, .weight = QFont::Medium }), FONT({ .size = 11 }) ) -FONT_STYLE(Mono, QStringLiteral("CaskaydiaCove NF"), +FONT_STYLE(Mono, u"CaskaydiaCove NF"_s, FONT({ .size = 16, .vaxes = {} }), FONT({ .size = 14, .vaxes = {} }), FONT({ .size = 12, .vaxes = {} }) @@ -200,23 +217,15 @@ FONT_CONFIG(Icon, Medium, FONT({.size = static_cast(24 / 1.33)})) FONT_CONFIG(Icon, Small, FONT({.size = static_cast(20 / 1.33)})) class FontStyleIcon : public FontStyleNode { - Q_OBJECT - QML_ANONYMOUS + CONFIG_NODE(FontStyleIcon, FontStyleNode) - CFG_PROPERTY(QString, family, QStringLiteral("Material Symbols Rounded")) + CONFIG_PROPERTY(QString, family, u"Material Symbols Rounded"_s) CONFIG_SUBOBJECT(FontConfigIconExtraLarge, extraLarge) CONFIG_SUBOBJECT(FontConfigIconLarge, large) CONFIG_SUBOBJECT(FontConfigIconMedium, medium) CONFIG_SUBOBJECT(FontConfigIconSmall, small) public: - explicit FontStyleIcon(QObject* parent = nullptr) - : FontStyleNode(parent) - , m_extraLarge(new FontConfigIconExtraLarge(this)) - , m_large(new FontConfigIconLarge(this)) - , m_medium(new FontConfigIconMedium(this)) - , m_small(new FontConfigIconSmall(this)) {} - FontSizeNode* extraLargeNode() const override { return m_extraLarge; } FontSizeNode* largeNode() const override { return m_large; } FontSizeNode* mediumNode() const override { return m_medium; } @@ -228,11 +237,20 @@ class FontStyleIcon : public FontStyleNode { #undef FONT_CONFIG #undef FONT_STYLE -class AppearanceFont : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class FontFamily : public settings::ObjectNode { + CONFIG_NODE(FontFamily, settings::ObjectNode) - CFG_PROPERTY(qreal, scale, 1) + CONFIG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + CONFIG_PROPERTY( + QString, material, QStringLiteral("Material Symbols Rounded")) + CONFIG_PROPERTY(QString, mono, QStringLiteral("CaskaydiaCove NF")) + CONFIG_PROPERTY(QString, sans, QStringLiteral("Segoe UI Variable Text")) +}; + +class AppearanceFont : public settings::ObjectNode { + CONFIG_NODE(AppearanceFont, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) CONFIG_SUBOBJECT(FontFamily, family) CONFIG_SUBOBJECT(FontStyleHeadline, headline) CONFIG_SUBOBJECT(FontStyleTitle, title) @@ -240,160 +258,83 @@ class AppearanceFont : public ConfigObject { CONFIG_SUBOBJECT(FontStyleLabel, label) CONFIG_SUBOBJECT(FontStyleMono, mono) CONFIG_SUBOBJECT(FontStyleIcon, icon) - CFG_PROPERTY(QString, clock, QStringLiteral("Rubik")) - CFG_PROPERTY(QString, workspaces, QStringLiteral("Rubik")) - - public: - explicit AppearanceFont(QObject* parent = nullptr) - : ConfigObject(parent) - , m_family(new FontFamily(this)) - , m_headline(new FontStyleHeadline(this)) - , m_title(new FontStyleTitle(this)) - , m_body(new FontStyleBody(this)) - , m_label(new FontStyleLabel(this)) - , m_mono(new FontStyleMono(this)) - , m_icon(new FontStyleIcon(this)) {} + CONFIG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + CONFIG_PROPERTY(QString, workspaces, QStringLiteral("Rubik")) }; -class Transparency : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AnimDurations : public settings::ObjectNode { + CONFIG_NODE(AnimDurations, settings::ObjectNode) - CFG_PROPERTY(qreal, base, 0.85) - CFG_PROPERTY(bool, enabled, false) - CFG_PROPERTY(qreal, layers, 0.4) + CONFIG_GLOBAL_PROPERTY(qreal, scale, 1) - public: - explicit Transparency(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - -class AppearanceRounding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(qreal, scale, 1) - - Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) - Q_PROPERTY(int small READ small NOTIFY scaleChanged) - Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) - Q_PROPERTY(int large READ large NOTIFY scaleChanged) - Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) Q_PROPERTY( - int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) - Q_PROPERTY(int full READ full CONSTANT) - - public: - explicit AppearanceRounding(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] int extraSmall() const { - return static_cast(4 * scale()); - } - [[nodiscard]] int small() const { return static_cast(8 * scale()); } - [[nodiscard]] int medium() const { return static_cast(12 * scale()); } - [[nodiscard]] int large() const { return static_cast(16 * scale()); } - [[nodiscard]] int largeIncreased() const { - return static_cast(20 * scale()); - } - [[nodiscard]] int extraLarge() const { - return static_cast(28 * scale()); - } - [[nodiscard]] int extraLargeIncreased() const { - return static_cast(32 * scale()); - } - [[nodiscard]] int extraExtraLarge() const { - return static_cast(48 * scale()); - } - [[nodiscard]] int full() const { return std::numeric_limits::max(); } -}; - -class AppearanceSpacing : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(qreal, scale, 1) - - Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) - Q_PROPERTY(int small READ small NOTIFY scaleChanged) - Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) - Q_PROPERTY(int large READ large NOTIFY scaleChanged) - Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + int expressiveFastSpatial READ expressiveFastSpatial NOTIFY + valuesChanged) Q_PROPERTY( - int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) - - public: - explicit AppearanceSpacing(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] int extraSmall() const { - return static_cast(4 * scale()); - } - [[nodiscard]] int small() const { return static_cast(8 * scale()); } - [[nodiscard]] int medium() const { return static_cast(12 * scale()); } - [[nodiscard]] int large() const { return static_cast(16 * scale()); } - [[nodiscard]] int largeIncreased() const { - return static_cast(20 * scale()); - } - [[nodiscard]] int extraLarge() const { - return static_cast(28 * scale()); - } - [[nodiscard]] int extraLargeIncreased() const { - return static_cast(32 * scale()); - } - [[nodiscard]] int extraExtraLarge() const { - return static_cast(48 * scale()); - } -}; - -class AppearancePadding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(qreal, scale, 1) - - Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) - Q_PROPERTY(int smaller READ smaller NOTIFY scaleChanged) - Q_PROPERTY(int small READ small NOTIFY scaleChanged) - Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) - Q_PROPERTY(int large READ large NOTIFY scaleChanged) - Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + int expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY + valuesChanged) Q_PROPERTY( - int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) + int expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveFastEffects READ expressiveFastEffects NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveSlowEffects READ expressiveSlowEffects NOTIFY + valuesChanged) public: - explicit AppearancePadding(QObject* parent = nullptr) - : ConfigObject(parent) {} + void bindTokens(AnimDurationTokens* tokens); - [[nodiscard]] int extraSmall() const { - return static_cast(4 * scale()); - } - [[nodiscard]] int smaller() const { return static_cast(5 * scale()); } - [[nodiscard]] int small() const { return static_cast(8 * scale()); } - [[nodiscard]] int medium() const { return static_cast(12 * scale()); } - [[nodiscard]] int large() const { return static_cast(16 * scale()); } - [[nodiscard]] int largeIncreased() const { - return static_cast(20 * scale()); - } - [[nodiscard]] int extraLarge() const { - return static_cast(28 * scale()); - } - [[nodiscard]] int extraLargeIncreased() const { - return static_cast(32 * scale()); - } - [[nodiscard]] int extraExtraLarge() const { - return static_cast(48 * scale()); - } + [[nodiscard]] int small() const; + [[nodiscard]] int normal() const; + [[nodiscard]] int large() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int expressiveFastSpatial() const; + [[nodiscard]] int expressiveDefaultSpatial() const; + [[nodiscard]] int expressiveSlowSpatial() const; + [[nodiscard]] int expressiveFastEffects() const; + [[nodiscard]] int expressiveDefaultEffects() const; + [[nodiscard]] int expressiveSlowEffects() const; + + signals: + void valuesChanged(); + + private: + AnimDurationTokens* m_tokens = nullptr; }; -class Appearance : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Anim : public settings::ObjectNode { + CONFIG_NODE(Anim, settings::ObjectNode) + + CONFIG_PROPERTY(int, mediaGifSpeedAdjustment, 300) + CONFIG_PROPERTY(qreal, sessionGifSpeed, 0.7) + CONFIG_SUBOBJECT(AnimDurations, durations) +}; + +class Deform : public settings::ObjectNode { + CONFIG_NODE(Deform, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) +}; + +class AppearanceTransparency : public settings::ObjectNode { + CONFIG_NODE(AppearanceTransparency, settings::ObjectNode) + + CONFIG_GLOBAL_PROPERTY(bool, enabled, false) + CONFIG_GLOBAL_PROPERTY(qreal, base, 0.85) + CONFIG_GLOBAL_PROPERTY(qreal, layers, 0.4) +}; + +class Appearance : public settings::ObjectNode { + CONFIG_NODE(Appearance, settings::ObjectNode) CONFIG_SUBOBJECT(Anim, anim) CONFIG_SUBOBJECT(Deform, deform) @@ -401,18 +342,7 @@ class Appearance : public ConfigObject { CONFIG_SUBOBJECT(AppearanceRounding, rounding) CONFIG_SUBOBJECT(AppearanceSpacing, spacing) CONFIG_SUBOBJECT(AppearancePadding, padding) - CONFIG_SUBOBJECT(Transparency, transparency) - - public: - explicit Appearance(QObject* parent = nullptr) - : ConfigObject(parent) - , m_anim(new Anim(this)) - , m_deform(new Deform(this)) - , m_font(new AppearanceFont(this)) - , m_rounding(new AppearanceRounding(this)) - , m_spacing(new AppearanceSpacing(this)) - , m_padding(new AppearancePadding(this)) - , m_transparency(new Transparency(this)) {}; + CONFIG_SUBOBJECT(AppearanceTransparency, transparency) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/background.hpp b/Plugins/ZShell/Config/background.hpp index 98331f8..d079a2a 100644 --- a/Plugins/ZShell/Config/background.hpp +++ b/Plugins/ZShell/Config/background.hpp @@ -1,18 +1,14 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Background : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Background : public settings::ObjectNode { + CONFIG_NODE(Background, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, wallFadeDuration, 300) - - public: - explicit Background(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, wallFadeDuration, 300) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/bar.hpp b/Plugins/ZShell/Config/bar.hpp index 4a0c8ae..25f4c66 100644 --- a/Plugins/ZShell/Config/bar.hpp +++ b/Plugins/ZShell/Config/bar.hpp @@ -1,7 +1,6 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" +#include "common.hpp" #include #include #include @@ -9,66 +8,53 @@ namespace ZShell::config { class BarEntry : public ListEntry { - Q_OBJECT - QML_ANONYMOUS - - public: - explicit BarEntry(QObject* parent = nullptr) : ListEntry(parent) {} + CONFIG_NODE(BarEntry, ListEntry) }; -class Popouts : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Popouts : public settings::ObjectNode { + CONFIG_NODE(Popouts, settings::ObjectNode) - CFG_PROPERTY(bool, statusIcons, true) - CFG_PROPERTY(bool, tray, true) - - public: - explicit Popouts(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, statusIcons, true) + CONFIG_PROPERTY(bool, tray, true) }; -class Tray : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Tray : public settings::ObjectNode { + CONFIG_NODE(Tray, settings::ObjectNode) - CFG_PROPERTY(bool, recolorIcons, false) - CFG_PROPERTY(bool, showOnHover, true) - CFG_PROPERTY(int, trayIconSize, 24) + CONFIG_PROPERTY(bool, recolorIcons, false) + CONFIG_PROPERTY(bool, showOnHover, true) + CONFIG_PROPERTY(int, trayIconSize, 24) CONFIG_LIST( EntryList, statusIcons, - { + DEFAULT_ARG({ LIST_ENTRY(audio, true), LIST_ENTRY(microphone, true), LIST_ENTRY(bluetooth, false), LIST_ENTRY(network, false), LIST_ENTRY(wifi, false), LIST_ENTRY(power, true), - }) - - public: - explicit Tray(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class Bar : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Bar : public settings::ObjectNode { + CONFIG_NODE(Bar, settings::ObjectNode) - CFG_PROPERTY(bool, autoHide, false) - CFG_PROPERTY(int, border, 4) - CFG_PROPERTY(bool, fullscreenReveal, true) - CFG_PROPERTY(int, height, 24) - CFG_PROPERTY(bool, hideWhenNotif, true) + CONFIG_PROPERTY(bool, autoHide, false) + CONFIG_PROPERTY(int, border, 4) + CONFIG_PROPERTY(bool, fullscreenReveal, true) + CONFIG_PROPERTY(int, height, 24) + CONFIG_PROPERTY(bool, hideWhenNotif, true) CONFIG_SUBOBJECT(Popouts, popouts) - CFG_PROPERTY(int, revealDelay, 100) - CFG_PROPERTY(int, rounding, 14) - CFG_PROPERTY(int, smoothing, 32) - CFG_PROPERTY(QString, position, QStringLiteral("top")) + CONFIG_PROPERTY(int, revealDelay, 100) + CONFIG_PROPERTY(int, rounding, 14) + CONFIG_PROPERTY(int, smoothing, 32) + CONFIG_PROPERTY(QString, position, QStringLiteral("top")) CONFIG_SUBOBJECT(Tray, tray) CONFIG_LIST( EntryList, entries, - { + DEFAULT_ARG({ LIST_ENTRY(workspaces, true), LIST_ENTRY(audio, true), LIST_ENTRY(media, true), @@ -85,13 +71,7 @@ class Bar : public ConfigObject { LIST_ENTRY(upower, true), LIST_ENTRY(clock, true), LIST_ENTRY(notifBell, true), - }) - - public: - explicit Bar(QObject* parent = nullptr) - : ConfigObject(parent) - , m_popouts(new Popouts(this)) - , m_tray(new Tray(this)) {} + })) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/clipboard.hpp b/Plugins/ZShell/Config/clipboard.hpp index b2c0708..321c861 100644 --- a/Plugins/ZShell/Config/clipboard.hpp +++ b/Plugins/ZShell/Config/clipboard.hpp @@ -1,33 +1,24 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class ClipboardSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class ClipboardSizes : public settings::ObjectNode { + CONFIG_NODE(ClipboardSizes, settings::ObjectNode) - CFG_PROPERTY(int, itemHeight, 60) - CFG_PROPERTY(int, minPreviewWidth, 200) - CFG_PROPERTY(int, previewWidth, 800) - CFG_PROPERTY(int, width, 500) - - public: - explicit ClipboardSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, itemHeight, 60) + CONFIG_PROPERTY(int, minPreviewWidth, 200) + CONFIG_PROPERTY(int, previewWidth, 800) + CONFIG_PROPERTY(int, width, 500) }; -class Clipboard : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Clipboard : public settings::ObjectNode { + CONFIG_NODE(Clipboard, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, maxEntriesShown, 10) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, maxEntriesShown, 10) CONFIG_SUBOBJECT(ClipboardSizes, sizes) - - public: - explicit Clipboard(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new ClipboardSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/colors.hpp b/Plugins/ZShell/Config/colors.hpp index 0554df6..fd904e6 100644 --- a/Plugins/ZShell/Config/colors.hpp +++ b/Plugins/ZShell/Config/colors.hpp @@ -1,31 +1,29 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Presets : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Presets : public settings::ObjectNode { + CONFIG_NODE(Presets, settings::ObjectNode) - CFG_PROPERTY(QString, accent, QString()) - CFG_PROPERTY(QString, name, QStringLiteral("Catppuccin")) - CFG_PROPERTY(QString, variant, QStringLiteral("latte")) - - public: - explicit Presets(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(QString, accent, QString()) + CONFIG_PROPERTY(QString, name, QStringLiteral("Catppuccin")) + CONFIG_PROPERTY(QString, variant, QStringLiteral("latte")) }; -class Colors : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Colors : public settings::ObjectNode { + CONFIG_NODE(Colors, settings::ObjectNode) CONFIG_SUBOBJECT(Presets, presets) - CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) - - public: - explicit Colors(QObject* parent = nullptr) - : ConfigObject(parent), m_presets(new Presets(this)) {} + CONFIG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) + CONFIG_PROPERTY(bool, scheduleDark, false) + CONFIG_PROPERTY(int, scheduleDarkEnd, 600) + CONFIG_PROPERTY(int, scheduleDarkStart, 1140) + CONFIG_PROPERTY(bool, schemeGen, true) + CONFIG_PROPERTY(bool, neovimColors, false) + CONFIG_PROPERTY(QString, mode, QStringLiteral("dark")) + CONFIG_PROPERTY(bool, smart, false) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/common.cpp b/Plugins/ZShell/Config/common.cpp new file mode 100644 index 0000000..b9ba202 --- /dev/null +++ b/Plugins/ZShell/Config/common.cpp @@ -0,0 +1,22 @@ +#include "common.hpp" + +#include +#include + +namespace ZShell::config { + +Q_LOGGING_CATEGORY(lcConfig, "zshell.config", QtInfoMsg) + +QString configDir() { + const QString dir = + QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + + QStringLiteral("/zshell"); + QDir().mkpath(dir); + return dir; +} + +QString monitorConfigDir() { + return configDir() + QStringLiteral("/monitors"); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/common.hpp b/Plugins/ZShell/Config/common.hpp new file mode 100644 index 0000000..c96cf57 --- /dev/null +++ b/Plugins/ZShell/Config/common.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +#include "settings/macros.hpp" +#include "settings/objectnode.hpp" + +#define CONFIG_NODE_NO_CTOR SETTINGS_NODE_NO_CTOR +#define CONFIG_NODE SETTINGS_NODE +#define CONFIG_PROPERTY SETTINGS_PROPERTY +#define CONFIG_GLOBAL_PROPERTY SETTINGS_GLOBAL_PROPERTY + +#define CONFIG_ENUM_PROPERTY(Type, name, defaultVal) \ + CONFIG_PROPERTY(ZShell::config::Type::Enum, name, defaultVal) +#define CONFIG_GLOBAL_ENUM_PROPERTY(Type, name, defaultVal) \ + CONFIG_GLOBAL_PROPERTY(ZShell::config::Type::Enum, name, defaultVal) + +#define CONFIG_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT(ZShell::config::Type, name) +#define CONFIG_GLOBAL_SUBOBJECT(Type, name) \ + SETTINGS_GLOBAL_SUBOBJECT(ZShell::config::Type, name) + +#define CONFIG_LIST_TYPE SETTINGS_LIST_TYPE +#define CONFIG_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST( \ + ZShell::config::Type, name, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define CONFIG_GLOBAL_LIST(Type, name, defaultVal, ...) \ + SETTINGS_GLOBAL_LIST( \ + ZShell::config::Type, name, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +namespace ZShell::config { + +Q_DECLARE_LOGGING_CATEGORY(lcConfig) + +QString configDir(); +QString monitorConfigDir(); + +class ListEntry : public settings::ObjectNode { + CONFIG_NODE(ListEntry, settings::ObjectNode) + + CONFIG_PROPERTY(QString, id, {}) + CONFIG_PROPERTY(bool, enabled, true) +}; +CONFIG_LIST_TYPE(ListEntry, EntryList) + +} // namespace ZShell::config + +// Shorthand for declaring an ID'd entry (bar entries/status icons, quick toggles, etc) +#define LIST_ENTRY(id, enabled) \ + settings::vmap({ \ + {QStringLiteral("id"), QStringLiteral(#id)}, \ + {QStringLiteral("enabled"), enabled}, \ + }) diff --git a/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp deleted file mode 100644 index 268b4c1..0000000 --- a/Plugins/ZShell/Config/config.cpp +++ /dev/null @@ -1,278 +0,0 @@ -#include "config.hpp" - -#include "appearance.hpp" -#include "background.hpp" -#include "bar.hpp" -#include "clipboard.hpp" -#include "colors.hpp" -#include "dashboard.hpp" -#include "dock.hpp" -#include "general.hpp" -#include "launcher.hpp" -#include "lock.hpp" -#include "notifs.hpp" -#include "osd.hpp" -#include "screenshot.hpp" -#include "services.hpp" -#include "sidebar.hpp" -#include "utilities.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -Config::Config(QObject* parent) - : ConfigObject(parent) - , m_appearance(new Appearance(this)) - , m_background(new Background(this)) - , m_bar(new Bar(this)) - , m_clipboard(new Clipboard(this)) - , m_colors(new Colors(this)) - , m_dashboard(new Dashboard(this)) - , m_dock(new Dock(this)) - , m_general(new General(this)) - , m_launcher(new Launcher(this)) - , m_lock(new Lock(this)) - , m_notifs(new Notifs(this)) - , m_osd(new Osd(this)) - , m_screenshot(new Screenshot(this)) - , m_services(new Services(this)) - , m_sidebar(new Sidebar(this)) - , m_utilities(new Utilities(this)) { - connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave); - - m_saveTimer.setSingleShot(true); - m_saveTimer.setInterval(300); - connect(&m_saveTimer, &QTimer::timeout, this, &Config::flushAsync); - - m_reloadTimer.setSingleShot(true); - m_reloadTimer.setInterval(50); - connect(&m_reloadTimer, &QTimer::timeout, this, &Config::reloadAsync); - - connectAutoSave(this); - - connect( - &m_watcher, - &QFileSystemWatcher::directoryChanged, - this, - &Config::onWatcherEvent); - - connect( - &m_watcher, - &QFileSystemWatcher::fileChanged, - this, - &Config::onWatcherEvent); - - loadSync(); - updateWatch(); - - m_firstLoadDone = true; -} - -Config* Config::create(QQmlEngine*, QJSEngine*) { - return new Config(); -} - -QString Config::filePath() const { - const QString dir = - QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + - QStringLiteral("/zshell"); - QDir().mkpath(dir); - return dir + QStringLiteral("/config.json"); -} - -void Config::loadSync() { - m_loading = true; - - QFile f(filePath()); - QJsonObject before; - - bool existed = false; - - if (f.open(QIODevice::ReadOnly)) { - const auto doc = QJsonDocument::fromJson(f.readAll()); - if (doc.isObject()) { - before = doc.object(); - existed = true; - } else { - qInfo() << "Config: existing config at" << filePath() - << "is empty or not a valid JSON object - using defaults"; - } - } else { - qInfo() << "Config: no existing config at" << filePath() - << "- using defaults"; - } - - loadFromJson(QJsonValue(before)); - m_loading = false; - - const auto after = toJson().toObject(); - if (!existed || after != before) saveNow(); -} - -void Config::updateWatch() { - const QFileInfo info(filePath()); - - const QString dir = info.absolutePath(); - const QString file = info.absoluteFilePath(); - - if (!m_watcher.directories().contains(dir)) m_watcher.addPath(dir); - if (info.exists() && !m_watcher.files().contains(file)) - m_watcher.addPath(file); -} - -void Config::onWatcherEvent(const QString&) { - updateWatch(); - - const QFileInfo info(filePath()); - if (!info.exists()) return; - - QFile file(info.absoluteFilePath()); - if (!file.open(QIODevice::ReadOnly)) return; - - const QByteArray hash = - QCryptographicHash::hash(file.readAll(), QCryptographicHash::Sha256); - - { - QMutexLocker lock(&m_writeMutex); - - if (hash == m_lastWriteHash) return; - } - - m_reloadPending = true; - - if (!m_loading) m_reloadTimer.start(); -} - -void Config::loadAsync() { - m_loading = true; - - auto future = QtConcurrent::run([this]() { - QFile f(filePath()); - - if (f.open(QIODevice::ReadOnly)) { - const auto doc = QJsonDocument::fromJson(f.readAll()); - const bool valid = doc.isObject(); - const QJsonObject before = valid ? doc.object() : QJsonObject(); - - QMetaObject::invokeMethod( - this, - [this, valid, before]() { - loadFromJson(QJsonValue(before)); - m_loading = false; - - const auto after = toJson().toObject(); - if (!valid || after != before) saveNow(); - - if (m_reloadPending) m_reloadTimer.start(); - }, - Qt::QueuedConnection); - } else { - qInfo() << "Config: failed to reload from" << filePath() - << "- using in-memory values"; - - QMetaObject::invokeMethod( - this, - [this]() { - m_loading = false; - - if (m_reloadPending) m_reloadTimer.start(); - }, - Qt::QueuedConnection); - } - }); - - m_loadFuture = future; -} - -void Config::load() { - if (!m_firstLoadDone) { - loadSync(); - } else { - loadAsync(); - } -} - -void Config::reloadAsync() { - if (m_loading) return; - - m_reloadPending = false; - loadAsync(); -} - -void Config::scheduleSave() { - if (m_loading) return; - m_saveTimer.start(); -} - -void Config::flushAsync() { - const QByteArray data = - QJsonDocument(toJson().toObject()).toJson(QJsonDocument::Indented); - - const QByteArray hash = - QCryptographicHash::hash(data, QCryptographicHash::Sha256); - - QThreadPool::globalInstance()->start([this, data, hash]() { - const bool success = writeAtomically(data); - - QMetaObject::invokeMethod( - this, - [this, success, hash]() { - if (!success) return; - - QMutexLocker lock(&m_writeMutex); - m_lastWriteHash = hash; - }, - Qt::QueuedConnection); - }); -} - -void Config::saveNow() { - m_saveTimer.stop(); - flushAsync(); -} - -void Config::connectAutoSave(ConfigNode* node) { - connect(node, &ConfigNode::propertiesChanged, this, [this] { - scheduleSave(); - }); - - for (auto* child : node->childNodes()) - connectAutoSave(child); -} - -bool Config::writeAtomically(const QByteArray& data) { - QSaveFile f(filePath()); - - if (!f.open(QIODevice::WriteOnly)) { - qWarning() << "Config: failed to open for atomic write:" - << f.errorString(); - return false; - } - - if (f.write(data) != data.size()) { - qWarning() << "Config: failed to write:" << f.errorString(); - return false; - } - - if (!f.commit()) { - qWarning() << "Config: commit failed:" << f.errorString(); - return false; - } - - return true; -} - -}; // namespace ZShell::config diff --git a/Plugins/ZShell/Config/config.hpp b/Plugins/ZShell/Config/config.hpp deleted file mode 100644 index e1881c5..0000000 --- a/Plugins/ZShell/Config/config.hpp +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once - -#include "configobject.hpp" - -#include -#include -#include -#include -#include -#include -#include - -class QQmlEngine; -class QJSEngine; - -namespace ZShell::config { - -class Appearance; -class Background; -class Bar; -class Clipboard; -class Colors; -class Dashboard; -class Dock; -class General; -class Launcher; -class Lock; -class Notifs; -class Osd; -class Screenshot; -class Services; -class Sidebar; -class Utilities; - -class Config : public ConfigObject { - Q_OBJECT - QML_ELEMENT - QML_SINGLETON - - Q_MOC_INCLUDE("appearance.hpp") - Q_MOC_INCLUDE("background.hpp") - Q_MOC_INCLUDE("bar.hpp") - Q_MOC_INCLUDE("clipboard.hpp") - Q_MOC_INCLUDE("colors.hpp") - Q_MOC_INCLUDE("dashboard.hpp") - Q_MOC_INCLUDE("dock.hpp") - Q_MOC_INCLUDE("general.hpp") - Q_MOC_INCLUDE("launcher.hpp") - Q_MOC_INCLUDE("lock.hpp") - Q_MOC_INCLUDE("notifs.hpp") - Q_MOC_INCLUDE("osd.hpp") - Q_MOC_INCLUDE("screenshot.hpp") - Q_MOC_INCLUDE("services.hpp") - Q_MOC_INCLUDE("sidebar.hpp") - Q_MOC_INCLUDE("utilities.hpp") - - CONFIG_SUBOBJECT(Appearance, appearance) - CONFIG_SUBOBJECT(Background, background) - CONFIG_SUBOBJECT(Bar, bar) - CONFIG_SUBOBJECT(Clipboard, clipboard) - CONFIG_SUBOBJECT(Colors, colors) - CONFIG_SUBOBJECT(Dashboard, dashboard) - CONFIG_SUBOBJECT(Dock, dock) - CONFIG_SUBOBJECT(General, general) - CONFIG_SUBOBJECT(Launcher, launcher) - CONFIG_SUBOBJECT(Lock, lock) - CONFIG_SUBOBJECT(Notifs, notifs) - CONFIG_SUBOBJECT(Osd, osd) - CONFIG_SUBOBJECT(Screenshot, screenshot) - CONFIG_SUBOBJECT(Services, services) - CONFIG_SUBOBJECT(Sidebar, sidebar) - CONFIG_SUBOBJECT(Utilities, utilities) - - public: - explicit Config(QObject* parent = nullptr); - static Config* create(QQmlEngine*, QJSEngine*); - - Q_INVOKABLE void load(); - Q_INVOKABLE void saveNow(); - Q_INVOKABLE void reloadAsync(); - - private Q_SLOTS: - void scheduleSave(); - void flushAsync(); - void onWatcherEvent(const QString& path); - - private: - QString filePath() const; - bool writeAtomically(const QByteArray& data); - void updateWatch(); - void loadSync(); - void loadAsync(); - void connectAutoSave(ConfigNode* node); - - QTimer m_saveTimer; - QTimer m_reloadTimer; - QFileSystemWatcher m_watcher; - - QMutex m_writeMutex; - QByteArray m_lastWriteHash; - - bool m_reloadPending = false; - bool m_loading = false; - bool m_firstLoadDone = false; - QFuture m_loadFuture; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configlist.cpp b/Plugins/ZShell/Config/configlist.cpp deleted file mode 100644 index 4f75cda..0000000 --- a/Plugins/ZShell/Config/configlist.cpp +++ /dev/null @@ -1,314 +0,0 @@ -#include "configlist.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -ConfigList::ConfigList(QObject* parent, const QVariantList& defaults) - : ConfigNode(parent), m_defaults(QJsonArray::fromVariantList(defaults)) {} - -int ConfigList::count() const { - return static_cast(m_items.size()); -} - -QVariantList ConfigList::values() const { - QVariantList vals; - vals.reserve(m_items.size()); - for (auto* const item : m_items) - vals.append(QVariant::fromValue(item)); - return vals; -} - -ConfigObject* ConfigList::itemAt(int index) const { - if (index < 0 || index >= m_items.size()) return nullptr; - - return m_items.at(index); -} - -const QList& ConfigList::items() const { - return m_items; -} - -void ConfigList::remove(int index) { - if (index < 0 || index >= m_items.size()) { - qCWarning(lcConfig) - << metaObject()->className() << "cannot remove index" << index - << "of" << count(); - return; - } - - destroyItem(m_items.takeAt(index)); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::move(int from, int to) { - if (from < 0 || from >= m_items.size() || to < 0 || to >= m_items.size()) { - qCWarning(lcConfig) << metaObject()->className() << "cannot move" - << from << "to" << to << "of" << count(); - return; - } - - if (from == to) return; - - m_items.move(from, to); - - m_loaded = true; - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::clear() { - if (m_items.isEmpty()) return; - - destroyItems(); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::loadFromJson(const QJsonValue& json) { - if (!json.isArray()) { - qCWarning( - lcConfig, - "Option '%s' must be a list, resetting to default", - qUtf8Printable(propertyPath())); - - resetToDefaults(); - materializeDefaults(); - return; - } - - populate(json.toArray()); - m_loaded = true; -} - -QJsonValue ConfigList::toJson() const { - if (m_loaded) return elementsToJson(); - - return QJsonValue::Undefined; -} - -void ConfigList::clearLoadedKeys() { - m_loaded = false; -} - -QStringList ConfigList::unknownKeys() const { - QStringList keys; - - for (int i = 0; i < m_items.size(); ++i) { - const auto childKeys = m_items.at(i)->unknownKeys(); - for (const auto& childKey : childKeys) - keys.append(joinPath(QString("[%1]").arg(i), childKey)); - } - - return keys; -} - -void ConfigList::materializeDefaults() { - for (auto* const item : m_items) - item->materializeDefaults(); - - if (m_global) return; - - m_loaded = true; -} - -void ConfigList::resyncFromGlobal() { - syncValuesFromGlobal(); -} - -ConfigObject* ConfigList::insertItem(const QVariantMap& props, int index) { - const auto pos = index < 0 || index > m_items.size() ? m_items.size() - : index; - - appendItem(QJsonObject::fromVariantMap(props)); - m_items.move(m_items.size() - 1, pos); - - auto* const item = m_items.at(pos); - const auto unknown = item->unknownKeys(); - for (const auto& key : unknown) - qCWarning(lcConfig) << "Unknown option" << key << "for" - << item->metaObject()->className(); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); - - return item; -} - -QJsonArray ConfigList::elementsToJson() const { - QJsonArray arr; - - for (auto* const item : m_items) - arr.append(item->toJson().toObject()); - - return arr; -} - -void ConfigList::resetToDefaults() { - loadFromJsonQuietly(m_defaults); - m_loaded = false; -} - -void ConfigList::syncValuesFromGlobal() { - if (m_loaded) return; - - if (auto* const global = qobject_cast(m_global)) - populate(global->elementsToJson()); -} - -void ConfigList::onGlobalPropertiesChanged(const QMap&) { - syncValuesFromGlobal(); -} - -QString ConfigList::childPath(const ConfigNode* child) const { - for (int i = 0; i < m_items.size(); ++i) - if (m_items.at(i) == child) return QString("[%1]").arg(i); - - return {}; -} - -void ConfigList::populate(const QJsonArray& arr) { - const auto current = elementsToJson(); - if (current == arr) return; - - const auto old = m_items; - QList reused(old.size(), false); - - m_items.clear(); - m_items.reserve(arr.size()); - - for (int i = 0; i < arr.size(); ++i) { - auto match = - i < old.size() && !reused.at(i) && current.at(i) == arr.at(i) ? i - : -1; - - for (int j = 0; match < 0 && j < old.size(); ++j) - if (!reused.at(j) && current.at(j) == arr.at(i)) match = j; - - auto update = false; - for (int j = 0; match < 0 && j < old.size(); ++j) { - if (!reused.at(j) && - sameElement(old.at(j), current.at(j), arr.at(i))) { - match = j; - update = true; - } - } - - if (match < 0) { - appendItem(arr.at(i)); - continue; - } - - reused[match] = true; - - if (update) old.at(match)->loadFromJsonQuietly(arr.at(i)); - - m_items.append(old.at(match)); - } - - for (int i = 0; i < old.size(); ++i) - if (!reused.at(i)) destroyItem(old.at(i)); - - if (m_items.size() != old.size()) emit countChanged(); - - emit valuesChanged(); - - notifyChanged(); -} - -bool ConfigList::sameElement( - const ConfigObject* item, - const QJsonValue& current, - const QJsonValue& json) { - const auto keys = item->identityKeys(); - if (keys.isEmpty()) return false; - - const auto currentObj = current.toObject(); - const auto newObj = json.toObject(); - - if (currentObj.keys() != newObj.keys()) return false; - - for (const auto& key : keys) - if (!newObj.contains(key) || currentObj.value(key) != newObj.value(key)) - return false; - - return true; -} - -void ConfigList::appendItem(const QJsonValue& json) { - auto* const item = createItem(); - QQmlEngine::setObjectOwnership(item, QQmlEngine::CppOwnership); - - if (m_items.isEmpty()) rejectGlobalOnlyElement(item); - - item->loadFromJsonQuietly(json); - - connectItem(item); - - m_items.append(item); -} - -void ConfigList::rejectGlobalOnlyElement(const ConfigObject* item) const { - const auto keys = item->globalOnlyKeys(); - if (keys.isEmpty()) return; - - qCCritical( - lcConfig, - "%s declares global-only options (%s) which do not work inside a list, " - "use CONFIG_GLOBAL_LIST on %s instead", - item->metaObject()->className(), - qUtf8Printable(keys.join(", ")), - metaObject()->className()); -} - -void ConfigList::connectItem(ConfigNode* node) { - connect( - node, &ConfigNode::propertiesChanged, this, &ConfigList::onItemChanged); - - const auto children = node->childNodes(); - for (auto* const child : children) - connectItem(child); -} - -void ConfigList::disconnectItem(ConfigNode* node) { - node->disconnect(this); - - const auto children = node->childNodes(); - for (auto* const child : children) - disconnectItem(child); -} - -void ConfigList::destroyItems() { - for (auto* const item : std::as_const(m_items)) - destroyItem(item); - - m_items.clear(); -} - -void ConfigList::destroyItem(ConfigObject* item) { - disconnectItem(item); - item->deleteLater(); -} - -void ConfigList::onItemChanged() { - m_loaded = true; - notifyChanged(); -} - -void ConfigList::notifyChanged() { - notifyPropertyChanged("values", count()); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configlist.hpp b/Plugins/ZShell/Config/configlist.hpp deleted file mode 100644 index 5787526..0000000 --- a/Plugins/ZShell/Config/configlist.hpp +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include "configobject.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -class ConfigList : public ConfigNode { - Q_OBJECT - QML_ANONYMOUS - - Q_PROPERTY(int count READ count NOTIFY countChanged) - Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) - - public: - explicit ConfigList( - QObject* parent = nullptr, const QVariantList& defaults = {}); - - [[nodiscard]] int count() const; - [[nodiscard]] QVariantList values() const; - [[nodiscard]] ConfigObject* itemAt(int index) const; - [[nodiscard]] const QList& items() const; - - Q_INVOKABLE void remove(int index); - Q_INVOKABLE void move(int from, int to); - Q_INVOKABLE void clear(); - - void loadFromJson(const QJsonValue& json) override; - [[nodiscard]] QJsonValue toJson() const override; - void clearLoadedKeys() override; - [[nodiscard]] QStringList unknownKeys() const override; - void materializeDefaults() override; - void resyncFromGlobal() override; - - signals: - void countChanged(); - void valuesChanged(); - - protected: - virtual ConfigObject* createItem() = 0; - - void resetToDefaults(); - - ConfigObject* insertItem(const QVariantMap& props, int index = -1); - - [[nodiscard]] QJsonArray elementsToJson() const; - - void syncValuesFromGlobal() override; - void onGlobalPropertiesChanged( - const QMap& changed) override; - [[nodiscard]] QString childPath(const ConfigNode* child) const override; - - private: - void populate(const QJsonArray& arr); - static bool sameElement( - const ConfigObject* item, - const QJsonValue& current, - const QJsonValue& json); - void appendItem(const QJsonValue& json); - void destroyItems(); - void destroyItem(ConfigObject* item); - void rejectGlobalOnlyElement(const ConfigObject* item) const; - void connectItem(ConfigNode* node); - void disconnectItem(ConfigNode* node); - void onItemChanged(); - void notifyChanged(); - - QJsonArray m_defaults; - QList m_items; - bool m_loaded = false; -}; - -} // namespace ZShell::config - -#define CONFIG_LIST_TYPE(Element, Name) \ - class Name : public ZShell::config::ConfigList { \ - Q_OBJECT \ - QML_ANONYMOUS \ - \ - public: \ - explicit Name( \ - QObject* parent = nullptr, const QVariantList& defaults = {}) \ - : ZShell::config::ConfigList(parent, defaults) { \ - resetToDefaults(); \ - } \ - \ - Q_INVOKABLE ZShell::config::Element* at(int index) const { \ - return static_cast(itemAt(index)); \ - } \ - Q_INVOKABLE ZShell::config::Element* insert( \ - const QVariantMap& props, int index = -1) { \ - return static_cast( \ - insertItem(props, index)); \ - } \ - \ - protected: \ - [[nodiscard]] ZShell::config::ConfigObject* createItem() override { \ - return new ZShell::config::Element(this); \ - } \ - }; - -#define CONFIG_LIST(Type, name, ...) \ - Q_PROPERTY(ZShell::config::Type* name READ name CONSTANT) \ - \ - public: \ - [[nodiscard]] Type* name() const { \ - return m_##name; \ - } \ - \ - private: \ - Type* m_##name = new Type(this __VA_OPT__(, __VA_ARGS__)); - -#define LIST_ENTRY(id, enabled) \ - vmap({{"id", QString::fromUtf8(#id)}, {"enabled", enabled}}) - -namespace ZShell::config { - -class ListEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(QString, id) - CFG_PROPERTY(bool, enabled, true) - - public: - explicit ListEntry(QObject* parent = nullptr) : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("id")}; - } -}; - -CONFIG_LIST_TYPE(ListEntry, EntryList) - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/confignode.cpp b/Plugins/ZShell/Config/confignode.cpp deleted file mode 100644 index b120c6e..0000000 --- a/Plugins/ZShell/Config/confignode.cpp +++ /dev/null @@ -1,89 +0,0 @@ -#include "confignode.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -Q_LOGGING_CATEGORY(lcConfig, "zshell.config") - -ConfigNode::ConfigNode(QObject* parent) : QObject(parent) { - m_batchTimer = new QTimer(this); - m_batchTimer->setSingleShot(true); - m_batchTimer->setInterval(0); - connect( - m_batchTimer, &QTimer::timeout, this, &ConfigNode::emitBatchedChanges); -} - -void ConfigNode::loadFromJsonQuietly(const QJsonValue& json) { - setNotifySuppressed(true); - loadFromJson(json); - setNotifySuppressed(false); -} - -void ConfigNode::setNotifySuppressed(bool suppressed) { - m_suppressNotify = suppressed; - - const auto children = childNodes(); - for (auto* const child : children) - child->setNotifySuppressed(suppressed); -} - -QList ConfigNode::childNodes() const { - return {}; -} - -void ConfigNode::syncFromGlobal(ConfigNode* global) { - m_global = global; - resyncFromGlobal(); -} - -bool ConfigNode::isOverlay() const { - return m_global != nullptr && this != m_global; -} - -QString ConfigNode::propertyPath(const QString& name) const { - if (name.isEmpty()) return objectName(); - - QObject* const parentObj = parent(); - if (parentObj) { - if (auto* parentNode = qobject_cast(parentObj)) { - const auto childName = parentNode->childPath(this); - if (!childName.isEmpty()) { - return joinPath(parentNode->propertyPath(childName), name); - } - } - } - return name; -} - -int ConfigNode::basePropertyOffset() { - return QObject::staticMetaObject.propertyCount(); -} - -void ConfigNode::notifyPropertyChanged( - const QString& name, const QVariant& value) { - if (m_suppressNotify) return; - - m_pendingChanges[name] = value; - if (m_batchTimer && !m_batchTimer->isActive()) m_batchTimer->start(); -} - -QString ConfigNode::joinPath(const QString& parent, const QString& child) { - // List elements are addressed as parent[i], everything else as parent.child - if (child.startsWith(QLatin1Char('['))) return parent + child; - - return parent + QLatin1Char('.') + child; -} - -void ConfigNode::emitBatchedChanges() { - if (m_pendingChanges.isEmpty()) return; - - QMap changes = m_pendingChanges; - m_pendingChanges.clear(); - emit propertiesChanged(changes); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/confignode.hpp b/Plugins/ZShell/Config/confignode.hpp deleted file mode 100644 index d4e4389..0000000 --- a/Plugins/ZShell/Config/confignode.hpp +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -Q_DECLARE_LOGGING_CATEGORY(lcConfig) - -class ConfigNode : public QObject { - Q_OBJECT - - public: - explicit ConfigNode(QObject* parent = nullptr); - - virtual void loadFromJson(const QJsonValue& json) = 0; - void loadFromJsonQuietly(const QJsonValue& json); - [[nodiscard]] virtual QJsonValue toJson() const = 0; - - virtual void clearLoadedKeys() = 0; - [[nodiscard]] virtual QStringList unknownKeys() const = 0; - [[nodiscard]] virtual QList childNodes() const; - - virtual void materializeDefaults() = 0; - - void syncFromGlobal(ConfigNode* global); - virtual void resyncFromGlobal() = 0; - - [[nodiscard]] bool isOverlay() const; - [[nodiscard]] QString propertyPath(const QString& name = {}) const; - - [[nodiscard]] static int basePropertyOffset(); - - signals: - void propertiesChanged(const QMap& changed); - - protected: - virtual void syncValuesFromGlobal() = 0; - virtual void onGlobalPropertiesChanged( - const QMap& changed) = 0; - - [[nodiscard]] virtual QString childPath(const ConfigNode* child) const = 0; - - void notifyPropertyChanged(const QString& name, const QVariant& value); - - [[nodiscard]] static QString joinPath( - const QString& parent, const QString& child); - - ConfigNode* m_global = nullptr; - - private: - void setNotifySuppressed(bool suppressed); - void emitBatchedChanges(); - - QMap m_pendingChanges; - QTimer* m_batchTimer = nullptr; - bool m_suppressNotify = false; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configobject.cpp b/Plugins/ZShell/Config/configobject.cpp deleted file mode 100644 index 1b0d01d..0000000 --- a/Plugins/ZShell/Config/configobject.cpp +++ /dev/null @@ -1,336 +0,0 @@ -#include "configobject.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -namespace { - -bool isStringArray(const QJsonArray& arr) { - for (const auto& v : arr) { - if (!v.isString()) return false; - } - return true; -} - -bool jsonValueMatchesType(const QJsonValue& val, QMetaType type) { - switch (val.type()) { - case QJsonValue::Bool: - return type.id() == QMetaType::Bool; - - case QJsonValue::Double: - switch (type.id()) { - case QMetaType::Int: - case QMetaType::UInt: - case QMetaType::LongLong: - case QMetaType::ULongLong: - case QMetaType::Double: - case QMetaType::Float: - return true; - default: - return false; - } - - case QJsonValue::String: - return type.id() == QMetaType::QString; - - case QJsonValue::Array: - if (type.id() == QMetaType::QStringList) - return isStringArray(val.toArray()); - return type.id() == QMetaType::QVariantList; - - case QJsonValue::Object: - case QJsonValue::Null: - case QJsonValue::Undefined: - default: - return false; - } -} - -} // namespace - -ConfigObject::ConfigObject(QObject* parent) : ConfigNode(parent) {} - -void ConfigObject::loadFromJson(const QJsonValue& json) { - const auto obj = json.toObject(); - const auto* meta = metaObject(); - - QSet known; - QSet invalid; - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (!obj.contains(key)) continue; - - const auto jsonVal = obj.value(key); - - if (prop.metaType().flags().testFlag(QMetaType::PointerToQObject)) { - known.insert(key); - if (auto* const node = prop.read(this).value()) { - node->loadFromJson(jsonVal); - continue; - } - } - - if (!prop.isWritable()) continue; - - known.insert(key); - - if (!jsonValueMatchesType(jsonVal, prop.metaType())) { - qWarning() << "Config: type mismatch for" << key << "in" - << meta->className() << "- expected" - << prop.metaType().name() << "got value" << jsonVal - << "- resetting to default"; - invalid.insert(key); - continue; - } - - if (prop.metaType().id() == QMetaType::QStringList) { - QStringList list; - const auto jsonArr = jsonVal.toArray(); - for (const auto& v : jsonArr) - list.append(v.toString()); - prop.write(this, QVariant::fromValue(list)); - m_loadedKeys.insert(key); - continue; - } - - if (prop.metaType().id() == QMetaType::QVariantList) { - prop.write(this, jsonVal.toVariant()); - m_loadedKeys.insert(key); - continue; - } - - prop.write(this, jsonVal.toVariant()); - m_loadedKeys.insert(key); - } - - m_extras = {}; - for (auto it = obj.begin(); it != obj.end(); ++it) { - if (!known.contains(it.key()) || invalid.contains(it.key())) { - m_extras.insert(it.key(), it.value()); - } - } - - materializeDefaults(); -} - -QJsonValue ConfigObject::toJson() const { - QJsonObject obj; - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (!prop.isReadable()) continue; - - const auto key = QString::fromUtf8(prop.name()); - - const auto value = prop.read(this); - - if (prop.metaType().flags().testFlag(QMetaType::PointerToQObject)) { - if (auto* const node = value.value()) { - const auto childJson = node->toJson(); - if (!childJson.isUndefined()) obj.insert(key, childJson); - continue; - } - } - - if (!m_loadedKeys.contains(key)) continue; - - if (!prop.isWritable()) continue; - - if (prop.metaType().id() == QMetaType::QStringList) { - QJsonArray arr; - const auto strList = value.toStringList(); - for (const auto& s : strList) - arr.append(s); - obj.insert(key, arr); - continue; - } - - if (prop.metaType().id() == QMetaType::QVariantList) { - obj.insert(key, QJsonArray::fromVariantList(value.toList())); - continue; - } - - obj.insert(key, QJsonValue::fromVariant(value)); - } - - if (obj.isEmpty()) return QJsonValue::Undefined; - - return obj; -} - -void ConfigObject::clearLoadedKeys() { - m_loadedKeys.clear(); - m_extras = {}; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - if (auto* const node = prop.read(this).value()) - node->clearLoadedKeys(); - } -} - -QStringList ConfigObject::identityKeys() const { - return {}; -} - -QStringList ConfigObject::unknownKeys() const { - auto keys = m_extras.keys(); - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - auto* const node = prop.read(this).value(); - if (!node) continue; - - const auto childKeys = node->unknownKeys(); - for (const auto& childKey : childKeys) - keys.append(joinPath(QString::fromUtf8(prop.name()), childKey)); - } - - return keys; -} - -QList ConfigObject::childNodes() const { - QList nodes; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (auto* const node = prop.read(this).value()) - nodes.append(node); - } - - return nodes; -} - -void ConfigObject::materializeDefaults() { - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (auto* const node = prop.read(this).value()) - node->materializeDefaults(); - } -} - -void ConfigObject::syncValuesFromGlobal() { - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (auto* const node = prop.read(this).value()) { - if (auto* const globalNode = - prop.read(m_global).value()) - node->syncFromGlobal(globalNode); - continue; - } - - if (!prop.isWritable()) continue; - - if (!m_loadedKeys.contains(key)) { - auto val = prop.read(m_global); - prop.write(this, val); - m_loadedKeys.remove(key); - } - } -} - -void ConfigObject::resyncFromGlobal() { - if (!m_global) return; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (auto* const node = prop.read(this).value()) { - node->resyncFromGlobal(); - continue; - } - - if (!prop.isWritable()) continue; - - if (!m_loadedKeys.contains(key)) { - prop.write(this, prop.read(m_global)); - m_loadedKeys.remove(key); - } - } -} - -bool ConfigObject::isPropertyLoaded(const QString& name) const { - return m_loadedKeys.contains(name); -} - -bool ConfigObject::isGlobalOnly(const QString& name) const { - return m_globalOnlyKeys.contains(name); -} - -QStringList ConfigObject::globalOnlyKeys() const { - return {m_globalOnlyKeys.begin(), m_globalOnlyKeys.end()}; -} - -void ConfigObject::resetOption(const QString& name) { - m_loadedKeys.remove(name); - - const int idx = metaObject()->indexOfProperty(name.toUtf8().constData()); - if (idx < 0) return; - - const auto prop = metaObject()->property(idx); - - if (auto* const node = prop.read(this).value()) { - node->clearLoadedKeys(); - node->resyncFromGlobal(); - return; - } - - if (m_global && prop.isWritable()) prop.write(this, prop.read(m_global)); -} - -void ConfigObject::onGlobalPropertiesChanged( - const QMap& changed) { - for (auto it = changed.begin(); it != changed.end(); ++it) { - if (m_loadedKeys.contains(it.key()) || isGlobalOnly(it.key())) continue; - - int idx = metaObject()->indexOfProperty(it.key().toUtf8().constData()); - if (idx >= 0) { - metaObject()->property(idx).write(this, it.value()); - m_loadedKeys.remove(it.key()); - } - } -} - -QString ConfigObject::childPath(const ConfigNode* child) const { - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (prop.read(this).value() == child) - return QString::fromUtf8(prop.name()); - } - - return {}; -} - -void ConfigObject::markPropertyLoaded(const QString& name) { - m_loadedKeys.insert(name); -} - -void ConfigObject::markGlobalOnly(const QString& name) { - m_globalOnlyKeys.insert(name); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configobject.hpp b/Plugins/ZShell/Config/configobject.hpp deleted file mode 100644 index 7de39ed..0000000 --- a/Plugins/ZShell/Config/configobject.hpp +++ /dev/null @@ -1,101 +0,0 @@ -#pragma once - -#include "confignode.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -inline QVariantMap vmap( - std::initializer_list> entries) { - QVariantMap map; - for (const auto& [key, value] : entries) - map.insert(key, value); - return map; -} - -#define CFG_PROPERTY(Type, name, ...) \ - Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ - public: \ - [[nodiscard]] Type name() const { \ - return m_##name; \ - } \ - void set_##name(const Type& value) { \ - if (ConfigObject::updateMember(m_##name, value)) { \ - markPropertyLoaded(QStringLiteral(#name)); \ - Q_EMIT name##Changed(); \ - notifyPropertyChanged( \ - QStringLiteral(#name), QVariant::fromValue(m_##name)); \ - } \ - } \ - Q_SIGNAL void name##Changed(); \ - \ - private: \ - Type m_##name __VA_OPT__(= __VA_ARGS__); - -#define CONFIG_SUBOBJECT(Type, name) \ - Q_PROPERTY(ZShell::config::Type* name READ name CONSTANT) \ - \ - public: \ - [[nodiscard]] Type* name() const { \ - return m_##name; \ - } \ - \ - private: \ - Type* m_##name = nullptr; - -class ConfigObject : public ConfigNode { - Q_OBJECT - - public: - explicit ConfigObject(QObject* parent = nullptr); - - void loadFromJson(const QJsonValue& json) override; - [[nodiscard]] QJsonValue toJson() const override; - void clearLoadedKeys() override; - [[nodiscard]] QStringList unknownKeys() const override; - [[nodiscard]] QList childNodes() const override; - void materializeDefaults() override; - void resyncFromGlobal() override; - - [[nodiscard]] virtual QStringList identityKeys() const; - - [[nodiscard]] bool isPropertyLoaded(const QString& name) const; - [[nodiscard]] bool isGlobalOnly(const QString& name) const; - [[nodiscard]] QStringList globalOnlyKeys() const; - - Q_INVOKABLE void resetOption(const QString& name); - - template static bool updateMember(T& member, const T& value) { - if constexpr (std::is_floating_point_v) { - if (qFuzzyCompare(member + 1.0, value + 1.0)) return false; - } else { - if (member == value) return false; - } - member = value; - return true; - } - - protected: - void syncValuesFromGlobal() override; - void onGlobalPropertiesChanged( - const QMap& changed) override; - [[nodiscard]] QString childPath(const ConfigNode* child) const override; - - void markPropertyLoaded(const QString& name); - void markGlobalOnly(const QString& name); - - private: - QSet m_loadedKeys; - QSet m_globalOnlyKeys; - QJsonObject m_extras; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/dashboard.hpp b/Plugins/ZShell/Config/dashboard.hpp index 271c6f5..3160a48 100644 --- a/Plugins/ZShell/Config/dashboard.hpp +++ b/Plugins/ZShell/Config/dashboard.hpp @@ -1,65 +1,68 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Performance : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Performance : public settings::ObjectNode { + CONFIG_NODE(Performance, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(bool, showBattery, false) - CFG_PROPERTY(bool, showCpu, true) - CFG_PROPERTY(bool, showGpu, true) - CFG_PROPERTY(bool, showMemory, true) - CFG_PROPERTY(bool, showNetwork, true) - CFG_PROPERTY(bool, showStorage, true) - CFG_PROPERTY(bool, showVram, true) - - public: - explicit Performance(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, showBattery, false) + CONFIG_PROPERTY(bool, showCpu, true) + CONFIG_PROPERTY(bool, showGpu, true) + CONFIG_PROPERTY(bool, showMemory, true) + CONFIG_PROPERTY(bool, showNetwork, true) + CONFIG_PROPERTY(bool, showStorage, true) + CONFIG_PROPERTY(bool, showVram, true) }; -class DashboardSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class DashboardSizes : public settings::ObjectNode { + CONFIG_NODE(DashboardSizes, settings::ObjectNode) - CFG_PROPERTY(int, dateTimeWidth, 110) - CFG_PROPERTY(int, infoIconSize, 25) - CFG_PROPERTY(int, infoWidth, 200) - CFG_PROPERTY(int, mediaCoverArtSize, 150) - CFG_PROPERTY(int, mediaProgressSweep, 180) - CFG_PROPERTY(int, mediaProgressThickness, 8) - CFG_PROPERTY(int, mediaVisualizerSize, 200) - CFG_PROPERTY(int, mediaWidth, 200) - CFG_PROPERTY(int, resourceProgessThickness, 10) - CFG_PROPERTY(int, resourceSize, 200) - CFG_PROPERTY(int, tabIndicatorHeight, 3) - CFG_PROPERTY(int, tabIndicatorSpacing, 5) - CFG_PROPERTY(int, weatherWidth, 250) - - public: - explicit DashboardSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, dateTimeWidth, 110) + CONFIG_PROPERTY(int, infoIconSize, 25) + CONFIG_PROPERTY(int, infoWidth, 200) + CONFIG_PROPERTY(int, mediaCoverArtSize, 150) + CONFIG_PROPERTY(int, mediaProgressSweep, 180) + CONFIG_PROPERTY(int, mediaProgressThickness, 8) + CONFIG_PROPERTY(int, mediaVisualizerSize, 200) + CONFIG_PROPERTY(int, mediaWidth, 200) + CONFIG_PROPERTY(int, resourceProgessThickness, 10) + CONFIG_PROPERTY(int, resourceSize, 200) + CONFIG_PROPERTY(int, tabIndicatorHeight, 3) + CONFIG_PROPERTY(int, tabIndicatorSpacing, 5) + CONFIG_PROPERTY(int, weatherWidth, 250) }; -class Dashboard : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class ProcessColumnEntry : public settings::ObjectNode { + CONFIG_NODE(ProcessColumnEntry, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(bool, showOnHover, true) - CFG_PROPERTY(int, dragThreshold, 50) - CFG_PROPERTY(int, mediaUpdateInterval, 2000) + CONFIG_PROPERTY(QString, id, {}) + CONFIG_PROPERTY(double, width, 0) +}; + +CONFIG_LIST_TYPE(ProcessColumnEntry, ProcessColumnList) + +class Dashboard : public settings::ObjectNode { + CONFIG_NODE(Dashboard, settings::ObjectNode) + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, showOnHover, true) + CONFIG_PROPERTY(int, dragThreshold, 50) + CONFIG_PROPERTY(int, mediaUpdateInterval, 2000) CONFIG_SUBOBJECT(Performance, performance) - CFG_PROPERTY(int, resourceUpdateInterval, 1000) + CONFIG_PROPERTY(int, resourceUpdateInterval, 1000) CONFIG_SUBOBJECT(DashboardSizes, sizes) - - public: - explicit Dashboard(QObject* parent = nullptr) - : ConfigObject(parent) - , m_performance(new Performance(this)) - , m_sizes(new DashboardSizes(this)) {} + CONFIG_LIST( + ProcessColumnList, + processColumns, + DEFAULT_ARG({ + settings::vmap({{"id", "command"}}), + settings::vmap({{"id", "pid"}}), + settings::vmap({{"id", "cpuUsage"}}), + settings::vmap({{"id", "memoryUsage"}}), + })) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/display.hpp b/Plugins/ZShell/Config/display.hpp new file mode 100644 index 0000000..c36da44 --- /dev/null +++ b/Plugins/ZShell/Config/display.hpp @@ -0,0 +1,24 @@ +#pragma once +#include "common.hpp" +#include + +namespace ZShell::config { + +class Nightlight : public settings::ObjectNode { + CONFIG_NODE(Nightlight, settings::ObjectNode) + + CONFIG_PROPERTY(bool, schedule, true) + CONFIG_PROPERTY(int, scheduleStart, 1200) + CONFIG_PROPERTY(int, scheduleEnd, 570) + CONFIG_PROPERTY(bool, useNative, true) + CONFIG_PROPERTY(int, fadeDuration, 2000) + CONFIG_PROPERTY(int, temp, 2600) +}; + +class Display : public settings::ObjectNode { + CONFIG_NODE(Display, settings::ObjectNode) + + CONFIG_SUBOBJECT(Nightlight, nightlight) +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/dock.hpp b/Plugins/ZShell/Config/dock.hpp index 817ce9a..1ea4e10 100644 --- a/Plugins/ZShell/Config/dock.hpp +++ b/Plugins/ZShell/Config/dock.hpp @@ -1,29 +1,25 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include #include namespace ZShell::config { -class Dock : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Dock : public settings::ObjectNode { + CONFIG_NODE(Dock, settings::ObjectNode) - CFG_PROPERTY(bool, enable, false) - CFG_PROPERTY(int, height, 80) - CFG_PROPERTY(bool, hoverToReveal, false) - CFG_PROPERTY(QStringList, ignoredAppRegexes, {}) - CFG_PROPERTY( + CONFIG_PROPERTY(bool, enable, false) + CONFIG_PROPERTY(int, height, 80) + CONFIG_PROPERTY(bool, hoverToReveal, false) + CONFIG_PROPERTY(QStringList, ignoredAppRegexes, {}) + CONFIG_PROPERTY( QStringList, pinnedApps, (QStringList{ QStringLiteral("com.ayugram.desktop"), QStringLiteral("com.obsproject.studio"), QStringLiteral("librewolf")})) - CFG_PROPERTY(bool, pinnedOnStartup, false) - - public: - explicit Dock(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, pinnedOnStartup, false) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/font.cpp b/Plugins/ZShell/Config/font.cpp index 57038f5..8e89cab 100644 --- a/Plugins/ZShell/Config/font.cpp +++ b/Plugins/ZShell/Config/font.cpp @@ -1,7 +1,7 @@ #include "font.hpp" #include "appearance.hpp" -#include "config.hpp" +#include "rootnodes.hpp" namespace ZShell::config { @@ -59,20 +59,20 @@ void FontStyleBase::bind(FontStyleNode* cfg) { if (cfg) { connect( - cfg, &ConfigNode::propertiesChanged, this, &FontStyleBase::rebuild); + cfg, &settings::Node::optionChanged, this, &FontStyleBase::rebuild); connect( cfg->largeNode(), - &ConfigNode::propertiesChanged, + &settings::Node::optionChanged, this, &FontStyleBase::rebuild); connect( cfg->mediumNode(), - &ConfigNode::propertiesChanged, + &settings::Node::optionChanged, this, &FontStyleBase::rebuild); connect( cfg->smallNode(), - &ConfigNode::propertiesChanged, + &settings::Node::optionChanged, this, &FontStyleBase::rebuild); } @@ -128,7 +128,7 @@ void IconFontStyle::bind(FontStyleNode* cfg) { if (cfg) connect( cfg->extraLargeNode(), - &ConfigNode::propertiesChanged, + &settings::Node::optionChanged, this, &IconFontStyle::rebuild); } @@ -181,16 +181,14 @@ FontBuilder IconFontBuilders::extraLarge() const { } FontTokens::FontTokens(QObject* parent) - : ConfigObject(parent) - , m_size(new FontSizeTokens(this)) + : QObject(parent) , m_headline(new FontStyle(this)) , m_title(new FontStyle(this)) , m_body(new FontStyle(this)) , m_label(new FontStyle(this)) , m_mono(new FontStyle(this)) , m_icon(new IconFontStyle(this)) { - if (!Config::instance()) new Config(); - bindFont(Config::instance()->appearance()->font()); + bindFont(ConfigSingleton::instance()->appearance()->font()); } FontStyle* FontTokens::headline() const { diff --git a/Plugins/ZShell/Config/font.hpp b/Plugins/ZShell/Config/font.hpp index 9625457..ac5c4a7 100644 --- a/Plugins/ZShell/Config/font.hpp +++ b/Plugins/ZShell/Config/font.hpp @@ -1,6 +1,5 @@ #pragma once -#include "configobject.hpp" #include "fontbuilder.hpp" #include @@ -17,21 +16,6 @@ class FontSizeNode; class FontStyleNode; class IconFontStyle; -class FontSizeTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, small, 11) - CFG_PROPERTY(int, smaller, 12) - CFG_PROPERTY(int, normal, 13) - CFG_PROPERTY(int, larger, 15) - CFG_PROPERTY(int, large, 18) - CFG_PROPERTY(int, extraLarge, 28) - - public: - explicit FontSizeTokens(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - class FontBuilders : public QObject { Q_OBJECT QML_ANONYMOUS @@ -149,12 +133,10 @@ class IconFontStyle : public FontStyleBase { IconFontBuilders* m_builders; }; -class FontTokens : public ConfigObject { +class FontTokens : public QObject { Q_OBJECT QML_ANONYMOUS - CONFIG_SUBOBJECT(FontSizeTokens, size) - Q_PROPERTY(FontStyle* headline READ headline CONSTANT FINAL) Q_PROPERTY(FontStyle* title READ title CONSTANT FINAL) Q_PROPERTY(FontStyle* body READ body CONSTANT FINAL) diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 1195e6f..c90b4cf 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -1,165 +1,103 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" +#include "common.hpp" #include #include namespace ZShell::config { -class BatteryThresholdEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class BatteryThresholdEntry : public settings::ObjectNode { + CONFIG_NODE(BatteryThresholdEntry, settings::ObjectNode) - CFG_PROPERTY(QString, icon) - CFG_PROPERTY(QString, message) - CFG_PROPERTY(QString, name) - CFG_PROPERTY(int, perc) - - public: - explicit BatteryThresholdEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("perc")}; - } + CONFIG_PROPERTY(QString, icon, {}) + CONFIG_PROPERTY(QString, message, {}) + CONFIG_PROPERTY(QString, name, {}) + CONFIG_PROPERTY(int, perc, {}) }; CONFIG_LIST_TYPE(BatteryThresholdEntry, BatteryThresholdList) -class IdleTimeoutEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class IdleTimeoutEntry : public settings::ObjectNode { + CONFIG_NODE(IdleTimeoutEntry, settings::ObjectNode) - CFG_PROPERTY(QString, idleAction) - CFG_PROPERTY(QString, name) - CFG_PROPERTY(int, timeout) - - public: - explicit IdleTimeoutEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("name")}; - } + CONFIG_PROPERTY(QString, idleAction, {}) + CONFIG_PROPERTY(QString, name, {}) + CONFIG_PROPERTY(int, timeout, {}) }; CONFIG_LIST_TYPE(IdleTimeoutEntry, IdleTimeoutList) -class Apps : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Apps : public settings::ObjectNode { + CONFIG_NODE(Apps, settings::ObjectNode) - CFG_PROPERTY(QStringList, archiver, {QStringLiteral("ark")}) - CFG_PROPERTY(QStringList, audio, {QStringLiteral("pavucontrol")}) - CFG_PROPERTY(QStringList, document, {QStringLiteral("libreoffice")}) - CFG_PROPERTY(QStringList, editor, {QStringLiteral("kate")}) - CFG_PROPERTY(QStringList, explorer, {QStringLiteral("dolphin")}) - CFG_PROPERTY(QStringList, image, {QStringLiteral("imv")}) - CFG_PROPERTY(QStringList, playback, {QStringLiteral("vlc")}) - CFG_PROPERTY(QStringList, terminal, {QStringLiteral("wezterm")}) - - public: - explicit Apps(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(QStringList, archiver, {QStringLiteral("ark")}) + CONFIG_PROPERTY(QStringList, audio, {QStringLiteral("pavucontrol")}) + CONFIG_PROPERTY(QStringList, document, {QStringLiteral("libreoffice")}) + CONFIG_PROPERTY(QStringList, editor, {QStringLiteral("kate")}) + CONFIG_PROPERTY(QStringList, explorer, {QStringLiteral("dolphin")}) + CONFIG_PROPERTY(QStringList, image, {QStringLiteral("imv")}) + CONFIG_PROPERTY(QStringList, playback, {QStringLiteral("vlc")}) + CONFIG_PROPERTY(QStringList, terminal, {QStringLiteral("wezterm")}) }; -class Battery : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Battery : public settings::ObjectNode { + CONFIG_NODE(Battery, settings::ObjectNode) - CFG_PROPERTY(int, critPerc, 5) + CONFIG_PROPERTY(int, critPerc, 5) CONFIG_LIST( BatteryThresholdList, popupThresholds, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery low"}, {"name", "Low battery"}, {"perc", 20}}), - vmap( + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery lower"}, {"name", "Low battery"}, {"perc", 15}}), - vmap( + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery lowest"}, {"name", "Low battery"}, {"perc", 10}}), - }) - - public: - explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class ColorSettings : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, hyprsunsetTemp, 2600) - CFG_PROPERTY(QString, mode, QStringLiteral("dark")) - CFG_PROPERTY(bool, neovimColors, false) - CFG_PROPERTY(bool, useNativeNightlight, true) - CFG_PROPERTY(int, nativeFadeDuration, 2000) - CFG_PROPERTY(bool, scheduleDark, false) - CFG_PROPERTY(int, scheduleDarkEnd, 600) - CFG_PROPERTY(int, scheduleDarkStart, 1140) - CFG_PROPERTY(bool, scheduleHyprsunset, true) - CFG_PROPERTY(int, scheduleHyprsunsetEnd, 570) - CFG_PROPERTY(int, scheduleHyprsunsetStart, 1200) - CFG_PROPERTY(bool, schemeGeneration, true) - CFG_PROPERTY(bool, smart, false) - - public: - explicit ColorSettings(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - -class Idle : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Idle : public settings::ObjectNode { + CONFIG_NODE(Idle, settings::ObjectNode) CONFIG_LIST( IdleTimeoutList, timeouts, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"idleAction", QStringLiteral("lock")}, {"name", QStringLiteral("Lock")}, {"timeout", 180}}), - }) - - public: - explicit Idle(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class General : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class General : public settings::ObjectNode { + CONFIG_NODE(General, settings::ObjectNode) CONFIG_SUBOBJECT(Apps, apps) CONFIG_SUBOBJECT(Battery, battery) - CONFIG_SUBOBJECT(ColorSettings, color) - CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) - CFG_PROPERTY(bool, desktopIcons, true) + CONFIG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) + CONFIG_PROPERTY(bool, desktopIcons, true) CONFIG_SUBOBJECT(Idle, idle) - CFG_PROPERTY( + CONFIG_PROPERTY( QString, lyricsDir, - QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + QStringLiteral("/Lyrics/")) - CFG_PROPERTY(QString, logo, QString()) - CFG_PROPERTY(bool, showOverFullscreen, true) - CFG_PROPERTY( + QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + + QStringLiteral("/Lyrics/")) + CONFIG_PROPERTY(QString, logo, QString()) + CONFIG_PROPERTY(bool, showOverFullscreen, true) + CONFIG_PROPERTY( QString, wallpaperPath, QStringLiteral("/mnt/IronWolf/SDImages/SWWW_Wals/")) - - public: - explicit General(QObject* parent = nullptr) - : ConfigObject(parent) - , m_apps(new Apps(this)) - , m_battery(new Battery(this)) - , m_color(new ColorSettings(this)) - , m_idle(new Idle(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/launcher.hpp b/Plugins/ZShell/Config/launcher.hpp index 24976ca..27dafca 100644 --- a/Plugins/ZShell/Config/launcher.hpp +++ b/Plugins/ZShell/Config/launcher.hpp @@ -1,68 +1,53 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" + +#include "common.hpp" #include namespace ZShell::config { -class LauncherActionEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +using Qt::StringLiterals::operator""_s; +using settings::vmap; - CFG_PROPERTY(QVariantList, command) - CFG_PROPERTY(bool, dangerous, false) - CFG_PROPERTY(QString, description) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(QString, icon) - CFG_PROPERTY(QString, name) +class LauncherActionEntry : public settings::ObjectNode { + CONFIG_NODE(LauncherActionEntry, settings::ObjectNode) - public: - explicit LauncherActionEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("name")}; - } + CONFIG_PROPERTY(QVariantList, command, {}) + CONFIG_PROPERTY(bool, dangerous, false) + CONFIG_PROPERTY(QString, description, {}) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(QString, icon, {}) + CONFIG_PROPERTY(QString, name, {}) }; CONFIG_LIST_TYPE(LauncherActionEntry, LauncherActionList) -class LauncherSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class LauncherSizes : public settings::ObjectNode { + CONFIG_NODE(LauncherSizes, settings::ObjectNode) - CFG_PROPERTY(int, itemHeight, 50) - CFG_PROPERTY(int, itemWidth, 600) - CFG_PROPERTY(int, wallpaperHeight, 200) - CFG_PROPERTY(int, wallpaperWidth, 280) - - public: - explicit LauncherSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, itemHeight, 50) + CONFIG_PROPERTY(int, itemWidth, 600) + CONFIG_PROPERTY(int, wallpaperHeight, 200) + CONFIG_PROPERTY(int, wallpaperWidth, 280) }; -class UseFuzzy : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class UseFuzzy : public settings::ObjectNode { + CONFIG_NODE(UseFuzzy, settings::ObjectNode) - CFG_PROPERTY(bool, actions, true) - CFG_PROPERTY(bool, apps, true) - CFG_PROPERTY(bool, schemes, true) - CFG_PROPERTY(bool, variants, true) - CFG_PROPERTY(bool, wallpapers, true) - - public: - explicit UseFuzzy(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, actions, true) + CONFIG_PROPERTY(bool, apps, true) + CONFIG_PROPERTY(bool, schemes, true) + CONFIG_PROPERTY(bool, variants, true) + CONFIG_PROPERTY(bool, wallpapers, true) }; -class Launcher : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Launcher : public settings::ObjectNode { + CONFIG_NODE(Launcher, settings::ObjectNode) - CFG_PROPERTY(QString, actionPrefix, QStringLiteral("<")) + CONFIG_PROPERTY(QString, actionPrefix, QStringLiteral("<")) CONFIG_LIST( LauncherActionList, actions, - { + DEFAULT_ARG({ vmap( {{"command", QVariantList{"autocomplete", "calc"}}, {"dangerous", false}, @@ -134,44 +119,18 @@ class Launcher : public ConfigObject { {"enabled", true}, {"icon", "bedtime"}, {"name", "Sleep"}}), - }) - CFG_PROPERTY(int, dragThreshold, 150) - CFG_PROPERTY(bool, enableDangerousActions, true) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY( - QStringList, - favoriteApps, - {QStringLiteral("Balatro"), - QStringLiteral("Slay the Spire 2"), - QStringLiteral("com.obsproject.Studio")}) - CFG_PROPERTY( - QStringList, - hiddenApps, - {QStringLiteral("zellij"), - QStringLiteral("vim"), - QStringLiteral("jshell-java-openjdk"), - QStringLiteral("jconsole-java-openjdk"), - QStringLiteral("jshell-java21-openjdk"), - QStringLiteral("jconsole-java21-openjdk"), - QStringLiteral("jshell-java17-openjdk"), - QStringLiteral("jconsole-java17-openjdk"), - QStringLiteral("org.neovim.nvim"), - QStringLiteral("avahi-discover"), - QStringLiteral("bvnc"), - QStringLiteral("bssh"), - QStringLiteral("nm-connection-editor")}) - CFG_PROPERTY(int, maxAppsShown, 18) - CFG_PROPERTY(int, maxWallpapers, 7) + })) + CONFIG_PROPERTY(int, dragThreshold, 150) + CONFIG_PROPERTY(bool, enableDangerousActions, true) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(QStringList, favoriteApps, {}) + CONFIG_PROPERTY(QStringList, hiddenApps, {}) + CONFIG_PROPERTY(int, maxAppsShown, 18) + CONFIG_PROPERTY(int, maxWallpapers, 7) CONFIG_SUBOBJECT(LauncherSizes, sizes) - CFG_PROPERTY(QString, specialPrefix, QStringLiteral("@")) + CONFIG_PROPERTY(QString, specialPrefix, QStringLiteral("@")) CONFIG_SUBOBJECT(UseFuzzy, useFuzzy) - CFG_PROPERTY(bool, uwsm, true) - - public: - explicit Launcher(QObject* parent = nullptr) - : ConfigObject(parent) - , m_sizes(new LauncherSizes(this)) - , m_useFuzzy(new UseFuzzy(this)) {} + CONFIG_PROPERTY(bool, uwsm, true) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/lock.hpp b/Plugins/ZShell/Config/lock.hpp index 479f3ef..c911a18 100644 --- a/Plugins/ZShell/Config/lock.hpp +++ b/Plugins/ZShell/Config/lock.hpp @@ -1,36 +1,27 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class LockSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class LockSizes : public settings::ObjectNode { + CONFIG_NODE(LockSizes, settings::ObjectNode) - CFG_PROPERTY(int, centerWidth, 600) - CFG_PROPERTY(qreal, heightMult, 0.7) - CFG_PROPERTY(qreal, ratio, 1.78) - - public: - explicit LockSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, centerWidth, 600) + CONFIG_PROPERTY(qreal, heightMult, 0.7) + CONFIG_PROPERTY(qreal, ratio, 1.78) }; -class Lock : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Lock : public settings::ObjectNode { + CONFIG_NODE(Lock, settings::ObjectNode) - CFG_PROPERTY(int, blurAmount, 10) - CFG_PROPERTY(bool, enableFprint, true) - CFG_PROPERTY(int, maxFprintTries, 3) - CFG_PROPERTY(bool, recolorLogo, false) - CFG_PROPERTY(bool, showNotifContent, true) - CFG_PROPERTY(bool, showNotifIcon, true) + CONFIG_PROPERTY(int, blurAmount, 10) + CONFIG_PROPERTY(bool, enableFprint, true) + CONFIG_PROPERTY(int, maxFprintTries, 3) + CONFIG_PROPERTY(bool, recolorLogo, false) + CONFIG_PROPERTY(bool, showNotifContent, true) + CONFIG_PROPERTY(bool, showNotifIcon, true) CONFIG_SUBOBJECT(LockSizes, sizes) - - public: - explicit Lock(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new LockSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/notifs.hpp b/Plugins/ZShell/Config/notifs.hpp index b229f11..9283020 100644 --- a/Plugins/ZShell/Config/notifs.hpp +++ b/Plugins/ZShell/Config/notifs.hpp @@ -1,39 +1,30 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class NotifsSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class NotifsSizes : public settings::ObjectNode { + CONFIG_NODE(NotifsSizes, settings::ObjectNode) - CFG_PROPERTY(int, badge, 20) - CFG_PROPERTY(int, image, 41) - CFG_PROPERTY(int, width, 400) - - public: - explicit NotifsSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, badge, 20) + CONFIG_PROPERTY(int, image, 41) + CONFIG_PROPERTY(int, width, 400) }; -class Notifs : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Notifs : public settings::ObjectNode { + CONFIG_NODE(Notifs, settings::ObjectNode) - CFG_PROPERTY(bool, actionOnClick, false) - CFG_PROPERTY(int, appNotifCooldown, 0) - CFG_PROPERTY(qreal, clearThreshold, 0.3) - CFG_PROPERTY(int, defaultExpireTimeout, 5000) - CFG_PROPERTY(int, expandThreshold, 20) - CFG_PROPERTY(bool, expire, true) - CFG_PROPERTY(int, groupPreviewNum, 5) - CFG_PROPERTY(bool, openExpanded, false) - CFG_PROPERTY(bool, showInFullscreen, false) + CONFIG_PROPERTY(bool, actionOnClick, false) + CONFIG_PROPERTY(int, appNotifCooldown, 0) + CONFIG_PROPERTY(qreal, clearThreshold, 0.3) + CONFIG_PROPERTY(int, defaultExpireTimeout, 5000) + CONFIG_PROPERTY(int, expandThreshold, 20) + CONFIG_PROPERTY(bool, expire, true) + CONFIG_PROPERTY(int, groupPreviewNum, 5) + CONFIG_PROPERTY(bool, openExpanded, false) + CONFIG_PROPERTY(bool, showInFullscreen, false) CONFIG_SUBOBJECT(NotifsSizes, sizes) - - public: - explicit Notifs(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new NotifsSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/osd.hpp b/Plugins/ZShell/Config/osd.hpp index bb4bae3..96de78e 100644 --- a/Plugins/ZShell/Config/osd.hpp +++ b/Plugins/ZShell/Config/osd.hpp @@ -1,34 +1,25 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class OsdSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class OsdSizes : public settings::ObjectNode { + CONFIG_NODE(OsdSizes, settings::ObjectNode) - CFG_PROPERTY(int, sliderHeight, 150) - CFG_PROPERTY(int, sliderWidth, 30) - - public: - explicit OsdSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, sliderHeight, 150) + CONFIG_PROPERTY(int, sliderWidth, 30) }; -class Osd : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Osd : public settings::ObjectNode { + CONFIG_NODE(Osd, settings::ObjectNode) - CFG_PROPERTY(bool, allMonBrightness, true) - CFG_PROPERTY(bool, enableBrightness, true) - CFG_PROPERTY(bool, enableMicrophone, true) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, hideDelay, 3000) + CONFIG_PROPERTY(bool, allMonBrightness, true) + CONFIG_PROPERTY(bool, enableBrightness, true) + CONFIG_PROPERTY(bool, enableMicrophone, true) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, hideDelay, 3000) CONFIG_SUBOBJECT(OsdSizes, sizes) - - public: - explicit Osd(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new OsdSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/rootnodes.cpp b/Plugins/ZShell/Config/rootnodes.cpp new file mode 100644 index 0000000..82218b7 --- /dev/null +++ b/Plugins/ZShell/Config/rootnodes.cpp @@ -0,0 +1,191 @@ +#include "rootnodes.hpp" + +#include +#include + +#include "../toaster.hpp" +#include "common.hpp" +#include "migration.hpp" + +namespace ZShell::config { + +using Qt::StringLiterals::operator""_s; + +namespace { + +QString nameFor(const QString& key) { + return key.isEmpty() ? u"global"_s : key; +} + +} // namespace + +namespace detail { + +void loaded(ConfigKind kind, settings::RootNode* layer, const QString& screen) { + if (kind != ConfigKind::Shell || !screen.isEmpty()) return; + + auto* const config = static_cast(layer); + if (!config->utilities()->toasts()->configLoaded()) return; + + const auto issues = static_cast(config->diagnostics().count()); + const auto message = issues > 0 + ? QStringLiteral("Config loaded with %1 issue%2") + .arg(issues) + .arg(issues == 1 ? "" : "s") + : QStringLiteral("Config loaded successfully!"); + Toaster::instance()->toast( + QStringLiteral("Config loaded"), + message, + issues > 0 ? QStringLiteral("settings_alert") + : QStringLiteral("rule_settings"), + issues > 0 ? Toast::Type::Warning : Toast::Type::Info); +} + +void loadFailed(ConfigKind kind, const QString& error, const QString& screen) { + QString title; + if (kind == ConfigKind::Tokens) + title = screen.isEmpty() + ? QStringLiteral("Failed to parse token config") + : QStringLiteral("Failed to parse token config for %1") + .arg(screen); + else + title = + screen.isEmpty() + ? QStringLiteral("Failed to parse config") + : QStringLiteral("Failed to parse config for %1").arg(screen); + Toaster::instance()->toast( + title, error, QStringLiteral("settings_alert"), Toast::Type::Warning); +} + +void saveFailed(ConfigKind kind, const QString& error, const QString& screen) { + if (kind != ConfigKind::Shell) return; + + const auto title = + screen.isEmpty() + ? QStringLiteral("Failed to save config") + : QStringLiteral("Failed to save config for %1").arg(screen); + Toaster::instance()->toast( + title, error, QStringLiteral("settings_alert"), Toast::Type::Error); +} + +} // namespace detail + +ConfigRoot::ConfigRoot( + const QString& path, ConfigRoot* fallback, QObject* parent) + : RootNode(path, fallback, parent) { + migrateFile(path); + bindTokens(); + qCDebug(lcConfig) << "Created config root for" << nameFor(key()); +} + +void ConfigRoot::migrateFile(const QString& path) { + QFile file(path); + if (!file.exists() || !file.open(QIODevice::ReadOnly)) return; + + const auto doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) return; + + const QJsonObject raw = doc.object(); + const QJsonObject migrated = ConfigMigrations::apply(raw); + if (migrated == raw) return; + + file.close(); + + QSaveFile save(path); + if (!save.open(QIODevice::WriteOnly)) { + qCWarning(lcConfig) << "Failed to open" << path + << "for migration write:" << save.errorString(); + return; + } + + if (save.write(QJsonDocument(migrated).toJson(QJsonDocument::Indented)) != + save.size()) { + qCWarning(lcConfig) + << "Failed to write migrated config:" << save.errorString(); + return; + } + + if (!save.commit()) { + qCWarning(lcConfig) + << "Failed to commit migrated config:" << save.errorString(); + return; + } + + qCDebug(lcConfig) << "Migrated config file" << path; +} + +void ConfigRoot::bindTokens() { + qCDebug(lcConfig) << "Binding appearance to token values for" + << nameFor(key()); + + auto* const tokens = TokensSingleton::instance()->appearance(); + m_appearance->rounding()->bindTokens(tokens->rounding()); + m_appearance->spacing()->bindTokens(tokens->spacing()); + m_appearance->padding()->bindTokens(tokens->padding()); + m_appearance->anim()->durations()->bindTokens(tokens->animDurations()); +} + +TokensRoot::TokensRoot( + const QString& path, TokensRoot* fallback, QObject* parent) + : RootNode(path, fallback, parent) { + qCDebug(lcConfig) << "Created tokens root for" << nameFor(key()); +} + +#define SINGLETON_IMPL(Type, Root, file, kind) \ + Type* Type::instance() { \ + static Type instance; \ + return &instance; \ + } \ + \ + Type* Type::create(QQmlEngine*, QJSEngine*) { \ + QQmlEngine::setObjectOwnership(instance(), QQmlEngine::CppOwnership); \ + return instance(); \ + } \ + \ + Root* Type::forScreen(const QString& screen) { \ + bool created; \ + auto* const layer = m_layers.get(screen, this, &created); \ + if (created) initLayer(layer); \ + return layer; \ + } \ + \ + Type::Type(QObject* parent) \ + : Root(configDir() + u'/' + file, nullptr, parent) \ + , m_layers(monitorConfigDir(), file, this) { \ + initLayer(this); \ + } \ + \ + void Type::onTreeLoaded(settings::RootNode* layer) { \ + detail::loaded( \ + kind, layer, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::onTreeLoadFailed( \ + settings::RootNode* layer, const QString& error) { \ + detail::loadFailed( \ + kind, error, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::onTreeSaveFailed( \ + settings::RootNode* layer, const QString& error) { \ + detail::saveFailed( \ + kind, error, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::initLayer(Root* layer) { \ + QObject::connect(layer, &Root::treeLoaded, this, &Type::onTreeLoaded); \ + QObject::connect( \ + layer, &Root::treeLoadFailed, this, &Type::onTreeLoadFailed); \ + QObject::connect( \ + layer, &Root::treeSaveFailed, this, &Type::onTreeSaveFailed); \ + layer->load(); \ + } + +SINGLETON_IMPL( + ConfigSingleton, ConfigRoot, u"config.json"_s, detail::ConfigKind::Shell) +SINGLETON_IMPL( + TokensSingleton, TokensRoot, u"tokens.json"_s, detail::ConfigKind::Tokens) + +#undef SINGLETON_IMPL + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/rootnodes.hpp b/Plugins/ZShell/Config/rootnodes.hpp new file mode 100644 index 0000000..308e90a --- /dev/null +++ b/Plugins/ZShell/Config/rootnodes.hpp @@ -0,0 +1,168 @@ +#pragma once + +#include +#include + +#include "settings/layerregistry.hpp" +#include "settings/rootnode.hpp" +#include "appearance.hpp" +#include "background.hpp" +#include "bar.hpp" +#include "clipboard.hpp" +#include "colors.hpp" +#include "common.hpp" +#include "dashboard.hpp" +#include "display.hpp" +#include "dock.hpp" +#include "general.hpp" +#include "launcher.hpp" +#include "llm.hpp" +#include "lock.hpp" +#include "notifs.hpp" +#include "osd.hpp" +#include "screenshot.hpp" +#include "services.hpp" +#include "sidebar.hpp" +#include "tokens.hpp" +#include "utilities.hpp" + +namespace ZShell::config { + +class ConfigRoot : public settings::RootNode { + CONFIG_NODE_NO_CTOR(ConfigRoot, settings::RootNode) + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_SUBOBJECT(Appearance, appearance) + CONFIG_SUBOBJECT(Background, background) + CONFIG_SUBOBJECT(Bar, bar) + CONFIG_SUBOBJECT(Clipboard, clipboard) + CONFIG_SUBOBJECT(Colors, colors) + CONFIG_SUBOBJECT(Dashboard, dashboard) + CONFIG_SUBOBJECT(Display, display) + CONFIG_SUBOBJECT(Dock, dock) + CONFIG_SUBOBJECT(General, general) + CONFIG_SUBOBJECT(Launcher, launcher) + CONFIG_SUBOBJECT(Llm, llm) + CONFIG_SUBOBJECT(Lock, lock) + CONFIG_SUBOBJECT(Notifs, notifs) + CONFIG_SUBOBJECT(Osd, osd) + CONFIG_SUBOBJECT(Screenshot, screenshot) + CONFIG_SUBOBJECT(Services, services) + CONFIG_SUBOBJECT(Sidebar, sidebar) + CONFIG_SUBOBJECT(Utilities, utilities) + + public: + explicit ConfigRoot( + const QString& path, + ConfigRoot* fallback = nullptr, + QObject* parent = nullptr); + + private: + // Binds the computed appearance values to the global token base values + void bindTokens(); + + // Applies ConfigMigrations to the file before the first load + void migrateFile(const QString& path); +}; + +class TokensRoot : public settings::RootNode { + CONFIG_NODE_NO_CTOR(TokensRoot, settings::RootNode) + QML_ANONYMOUS + + CONFIG_SUBOBJECT(AppearanceTokens, appearance) + CONFIG_SUBOBJECT(SizeTokens, sizes) + + public: + explicit TokensRoot( + const QString& path, + TokensRoot* fallback = nullptr, + QObject* parent = nullptr); +}; + +namespace detail { + +enum class ConfigKind : quint8 { Shell, Tokens }; + +void loaded(ConfigKind kind, settings::RootNode* layer, const QString& screen); +void loadFailed(ConfigKind kind, const QString& error, const QString& screen); +void saveFailed(ConfigKind kind, const QString& error, const QString& screen); + +} // namespace detail + +#define SINGLETON_DECL(Type, Root, QmlName) \ + class Type : public Root { \ + Q_OBJECT \ + QML_NAMED_ELEMENT(QmlName) \ + QML_SINGLETON \ + \ + public: \ + static Type* instance(); \ + static Type* create(QQmlEngine*, QJSEngine*); \ + \ + [[nodiscard]] Q_INVOKABLE Root* forScreen(const QString& screen); \ + \ + private: \ + explicit Type(QObject* parent = nullptr); \ + \ + void onTreeLoaded(settings::RootNode* layer); \ + void onTreeLoadFailed(settings::RootNode* layer, const QString& error); \ + void onTreeSaveFailed(settings::RootNode* layer, const QString& error); \ + \ + void initLayer(Root* layer); \ + \ + settings::LayerRegistry m_layers; \ + }; + +SINGLETON_DECL(ConfigSingleton, ConfigRoot, Config) +SINGLETON_DECL(TokensSingleton, TokensRoot, TokenConfig) + +#undef SINGLETON_DECL + +class Tokens : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY( + ZShell::config::AppearanceRounding* rounding READ rounding CONSTANT) + Q_PROPERTY(ZShell::config::AppearanceSpacing* spacing READ spacing CONSTANT) + Q_PROPERTY(ZShell::config::AppearancePadding* padding READ padding CONSTANT) + Q_PROPERTY(ZShell::config::SizeTokens* sizes READ sizes CONSTANT) + Q_PROPERTY(ZShell::config::FontTokens* font READ font CONSTANT) + Q_PROPERTY(ZShell::config::AnimTokens* anim READ anim CONSTANT) + + public: + explicit Tokens(QObject* parent = nullptr) + : QObject(parent) + , m_font(new FontTokens(this)) + , m_anim(new AnimTokens(this)) { + m_anim->bindCurves(TokensSingleton::instance()->appearance()->curves()); + m_anim->bindDurations( + ConfigSingleton::instance()->appearance()->anim()->durations()); + m_font->bindFont(ConfigSingleton::instance()->appearance()->font()); + } + + [[nodiscard]] AppearanceRounding* rounding() const { + return ConfigSingleton::instance()->appearance()->rounding(); + } + [[nodiscard]] AppearancePadding* padding() const { + return ConfigSingleton::instance()->appearance()->padding(); + } + [[nodiscard]] AppearanceSpacing* spacing() const { + return ConfigSingleton::instance()->appearance()->spacing(); + } + [[nodiscard]] SizeTokens* sizes() const { + return TokensSingleton::instance()->sizes(); + } + [[nodiscard]] FontTokens* font() const { return m_font; } + [[nodiscard]] AnimTokens* anim() const { return m_anim; } + + static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } + + private: + FontTokens* m_font; + AnimTokens* m_anim; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/screenshot.hpp b/Plugins/ZShell/Config/screenshot.hpp index 101ea41..a38828b 100644 --- a/Plugins/ZShell/Config/screenshot.hpp +++ b/Plugins/ZShell/Config/screenshot.hpp @@ -1,27 +1,23 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include #include namespace ZShell::config { -class Screenshot : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Screenshot : public settings::ObjectNode { + CONFIG_NODE(Screenshot, settings::ObjectNode) - CFG_PROPERTY(bool, enable_pp, false) - CFG_PROPERTY(QString, mode, QStringLiteral("manual")) - CFG_PROPERTY(int, radius, 49) - CFG_PROPERTY(bool, rounding, false) - CFG_PROPERTY(bool, saveOnCopy, false) - CFG_PROPERTY(bool, shadow, true) - CFG_PROPERTY(int, shadow_blur, 0) - CFG_PROPERTY(QVariantList, shadow_color, (QVariantList{0, 0, 0, 160})) - CFG_PROPERTY(int, shadow_offset_x, 30) - CFG_PROPERTY(int, shadow_offset_y, 50) - - public: - explicit Screenshot(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enable_pp, false) + CONFIG_PROPERTY(QString, mode, QStringLiteral("manual")) + CONFIG_PROPERTY(int, radius, 49) + CONFIG_PROPERTY(bool, rounding, false) + CONFIG_PROPERTY(bool, saveOnCopy, false) + CONFIG_PROPERTY(bool, shadow, true) + CONFIG_PROPERTY(int, shadow_blur, 0) + CONFIG_PROPERTY(QVariantList, shadow_color, (QVariantList{0, 0, 0, 160})) + CONFIG_PROPERTY(int, shadow_offset_x, 30) + CONFIG_PROPERTY(int, shadow_offset_y, 50) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/services.hpp b/Plugins/ZShell/Config/services.hpp index 00e0ea5..1f341f8 100644 --- a/Plugins/ZShell/Config/services.hpp +++ b/Plugins/ZShell/Config/services.hpp @@ -1,56 +1,42 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" +#include "common.hpp" #include "enums.hpp" namespace ZShell::config { -class PlayerAliasEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class PlayerAliasEntry : public settings::ObjectNode { + CONFIG_NODE(PlayerAliasEntry, settings::ObjectNode) - CFG_PROPERTY(QString, from) - CFG_PROPERTY(QString, to) - - public: - explicit PlayerAliasEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("from")}; - } + CONFIG_PROPERTY(QString, from, {}) + CONFIG_PROPERTY(QString, to, {}) }; CONFIG_LIST_TYPE(PlayerAliasEntry, PlayerAliasList) -class Services : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Services : public settings::ObjectNode { + CONFIG_NODE(Services, settings::ObjectNode) - CFG_PROPERTY(qreal, audioIncrement, 0.1) - CFG_PROPERTY(qreal, brightnessIncrement, 0.1) - CFG_PROPERTY(bool, ddcutilService, true) - CFG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) - CFG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) - CFG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) - CFG_PROPERTY(qreal, maxVolume, 1) - CFG_PROPERTY(qreal, minBrightness, 0.01) + CONFIG_PROPERTY(qreal, audioIncrement, 0.1) + CONFIG_PROPERTY(qreal, brightnessIncrement, 0.1) + CONFIG_PROPERTY(bool, ddcutilService, true) + CONFIG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) + CONFIG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) + CONFIG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) + CONFIG_PROPERTY(qreal, maxVolume, 1) + CONFIG_PROPERTY(qreal, minBrightness, 0.01) CONFIG_LIST( PlayerAliasList, playerAliases, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"from", QStringLiteral("com.github.th_ch.youtube_music")}, {"to", QStringLiteral("YT Music")}}), - }) - CFG_PROPERTY(bool, updates, true) - CFG_PROPERTY(bool, useFahrenheit, false) - CFG_PROPERTY(bool, useTwelveHourClock, false) - CFG_PROPERTY(int, visualizerBars, 50) - CFG_PROPERTY(QString, weatherLocation, QString()) - - public: - explicit Services(QObject* parent = nullptr) : ConfigObject(parent) {} + })) + CONFIG_PROPERTY(bool, updates, true) + CONFIG_PROPERTY(bool, useFahrenheit, false) + CONFIG_PROPERTY(bool, useTwelveHourClock, false) + CONFIG_PROPERTY(int, visualizerBars, 50) + CONFIG_PROPERTY(QString, weatherLocation, QString()) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/sidebar.hpp b/Plugins/ZShell/Config/sidebar.hpp index bd9840a..f9db473 100644 --- a/Plugins/ZShell/Config/sidebar.hpp +++ b/Plugins/ZShell/Config/sidebar.hpp @@ -1,30 +1,21 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class SidebarSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class SidebarSizes : public settings::ObjectNode { + CONFIG_NODE(SidebarSizes, settings::ObjectNode) - CFG_PROPERTY(int, width, 520) - - public: - explicit SidebarSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, width, 520) }; -class Sidebar : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Sidebar : public settings::ObjectNode { + CONFIG_NODE(Sidebar, settings::ObjectNode) - CFG_PROPERTY(int, dragThreshold, 30) - CFG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, dragThreshold, 30) + CONFIG_PROPERTY(bool, enabled, true) CONFIG_SUBOBJECT(SidebarSizes, sizes) - - public: - explicit Sidebar(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new SidebarSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index 07dd953..25c8388 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -1,205 +1,161 @@ #pragma once -#include "configobject.hpp" -#include "anim.hpp" -#include "appearance.hpp" -#include "config.hpp" -#include "font.hpp" - -#include -#include +#include #include #include #include -#include #include +#include + +#include "settings/objectnode.hpp" +#include "anim.hpp" +#include "common.hpp" +#include "font.hpp" + namespace ZShell::config { -class AnimCurves : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AnimCurves : public settings::ObjectNode { + CONFIG_NODE(AnimCurves, settings::ObjectNode) - CFG_PROPERTY(QList, emphasized) - CFG_PROPERTY(QList, emphasizedAccel) - CFG_PROPERTY(QList, emphasizedDecel) - CFG_PROPERTY(QList, standard) - CFG_PROPERTY(QList, standardAccel) - CFG_PROPERTY(QList, standardDecel) - CFG_PROPERTY(QList, expressiveFastSpatial) - CFG_PROPERTY(QList, expressiveDefaultSpatial) - CFG_PROPERTY(QList, expressiveSlowSpatial) - CFG_PROPERTY(QList, expressiveFastEffects) - CFG_PROPERTY(QList, expressiveDefaultEffects) - CFG_PROPERTY(QList, expressiveSlowEffects) - - public: - explicit AnimCurves(QObject* parent = nullptr) - : ConfigObject(parent) - , m_emphasized( - {0.05, - 0, - 2.0 / 15.0, - 0.06, - 1.0 / 6.0, - 0.4, - 5.0 / 24.0, - 0.82, - 0.25, - 1, - 1, - 1}) - , m_emphasizedAccel({0.3, 0, 0.8, 0.15, 1, 1}) - , m_emphasizedDecel({0.05, 0.7, 0.1, 1, 1, 1}) - , m_standard({0.2, 0, 0, 1, 1, 1}) - , m_standardAccel({0.3, 0, 1, 1, 1, 1}) - , m_standardDecel({0, 0, 0, 1, 1, 1}) - , m_expressiveFastSpatial({0.42, 1.67, 0.21, 0.9, 1, 1}) - , m_expressiveDefaultSpatial({0.38, 1.21, 0.22, 1, 1, 1}) - , m_expressiveSlowSpatial({0.39, 1.29, 0.35, 0.98, 1, 1}) - , m_expressiveFastEffects({0.31, 0.94, 0.34, 1, 1, 1}) - , m_expressiveDefaultEffects({0.34, 0.8, 0.34, 1, 1, 1}) - , m_expressiveSlowEffects({0.34, 0.88, 0.34, 1, 1, 1}) {} + CONFIG_GLOBAL_PROPERTY( + QList, + emphasized, + DEFAULT_ARG( + {0.05, + 0, + 2.0 / 15.0, + 0.06, + 1.0 / 6.0, + 0.4, + 5.0 / 24.0, + 0.82, + 0.25, + 1, + 1, + 1})) + CONFIG_GLOBAL_PROPERTY( + QList, emphasizedAccel, DEFAULT_ARG({0.3, 0, 0.8, 0.15, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, emphasizedDecel, DEFAULT_ARG({0.05, 0.7, 0.1, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standard, DEFAULT_ARG({0.2, 0, 0, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standardAccel, DEFAULT_ARG({0.3, 0, 1, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standardDecel, DEFAULT_ARG({0, 0, 0, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveFastSpatial, + DEFAULT_ARG({0.42, 1.67, 0.21, 0.9, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveDefaultSpatial, + DEFAULT_ARG({0.38, 1.21, 0.22, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveSlowSpatial, + DEFAULT_ARG({0.39, 1.29, 0.35, 0.98, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveFastEffects, + DEFAULT_ARG({0.31, 0.94, 0.34, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveDefaultEffects, + DEFAULT_ARG({0.34, 0.8, 0.34, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveSlowEffects, + DEFAULT_ARG({0.34, 0.88, 0.34, 1, 1, 1})) }; -class AnimTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class RoundingTokens : public settings::ObjectNode { + CONFIG_NODE(RoundingTokens, settings::ObjectNode) - Q_PROPERTY(ZShell::config::AnimDurations* durations READ durations CONSTANT) - - Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve emphasizedAccel READ emphasizedAccel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve emphasizedDecel READ emphasizedDecel NOTIFY curvesChanged) - Q_PROPERTY(QEasingCurve standard READ standard NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve standardAccel READ standardAccel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve standardDecel READ standardDecel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveFastSpatial READ expressiveFastSpatial NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveDefaultSpatial READ expressiveDefaultSpatial - NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveFastEffects READ expressiveFastEffects NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveDefaultEffects READ expressiveDefaultEffects - NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveSlowEffects READ expressiveSlowEffects NOTIFY - curvesChanged) - - public: - explicit AnimTokens(QObject* parent = nullptr) - : ConfigObject(parent) - , m_curves(new AnimCurves(this)) - , m_easing(new AnimEasing(this)) { - connect( - m_easing, - &AnimEasing::curvesChanged, - this, - &AnimTokens::curvesChanged); - m_easing->bindCurves(m_curves); - } - - [[nodiscard]] AnimDurations* durations() const { - return Config::instance()->appearance()->anim()->durations(); - } - - [[nodiscard]] QEasingCurve emphasized() const { - return m_easing->emphasized(); - } - [[nodiscard]] QEasingCurve emphasizedAccel() const { - return m_easing->emphasizedAccel(); - } - [[nodiscard]] QEasingCurve emphasizedDecel() const { - return m_easing->emphasizedDecel(); - } - [[nodiscard]] QEasingCurve standard() const { return m_easing->standard(); } - [[nodiscard]] QEasingCurve standardAccel() const { - return m_easing->standardAccel(); - } - [[nodiscard]] QEasingCurve standardDecel() const { - return m_easing->standardDecel(); - } - [[nodiscard]] QEasingCurve expressiveFastSpatial() const { - return m_easing->expressiveFastSpatial(); - } - [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const { - return m_easing->expressiveDefaultSpatial(); - } - [[nodiscard]] QEasingCurve expressiveSlowSpatial() const { - return m_easing->expressiveSlowSpatial(); - } - [[nodiscard]] QEasingCurve expressiveFastEffects() const { - return m_easing->expressiveFastEffects(); - } - [[nodiscard]] QEasingCurve expressiveDefaultEffects() const { - return m_easing->expressiveDefaultEffects(); - } - [[nodiscard]] QEasingCurve expressiveSlowEffects() const { - return m_easing->expressiveSlowEffects(); - } - - signals: - void curvesChanged(); - - private: - AnimCurves* m_curves; - AnimEasing* m_easing; + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) + CONFIG_PROPERTY(int, full, std::numeric_limits::max()) }; -class BarTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class SpacingTokens : public settings::ObjectNode { + CONFIG_NODE(SpacingTokens, settings::ObjectNode) - CFG_PROPERTY(int, innerSize, 28) - - public: - explicit BarTokens(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) }; -class Tokens : public ConfigObject { - Q_OBJECT - QML_ELEMENT - QML_SINGLETON +class PaddingTokens : public settings::ObjectNode { + CONFIG_NODE(PaddingTokens, settings::ObjectNode) - Q_PROPERTY( - ZShell::config::AppearanceRounding* rounding READ rounding CONSTANT) - Q_PROPERTY(ZShell::config::AppearancePadding* padding READ padding CONSTANT) - Q_PROPERTY(ZShell::config::AppearanceSpacing* spacing READ spacing CONSTANT) - CONFIG_SUBOBJECT(FontTokens, font) - CONFIG_SUBOBJECT(AnimTokens, anim) + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, smaller, 5) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) +}; + +class AnimDurationTokens : public settings::ObjectNode { + CONFIG_NODE(AnimDurationTokens, settings::ObjectNode) + + CONFIG_GLOBAL_PROPERTY(int, small, 200) + CONFIG_GLOBAL_PROPERTY(int, normal, 400) + CONFIG_GLOBAL_PROPERTY(int, large, 600) + CONFIG_GLOBAL_PROPERTY(int, extraLarge, 1000) + CONFIG_GLOBAL_PROPERTY(int, expressiveFastSpatial, 350) + CONFIG_GLOBAL_PROPERTY(int, expressiveDefaultSpatial, 500) + CONFIG_GLOBAL_PROPERTY(int, expressiveSlowSpatial, 650) + CONFIG_GLOBAL_PROPERTY(int, expressiveFastEffects, 150) + CONFIG_GLOBAL_PROPERTY(int, expressiveDefaultEffects, 200) + CONFIG_GLOBAL_PROPERTY(int, expressiveSlowEffects, 300) +}; + +class AppearanceTokens : public settings::ObjectNode { + CONFIG_NODE(AppearanceTokens, settings::ObjectNode) + + CONFIG_SUBOBJECT(AnimCurves, curves) + CONFIG_SUBOBJECT(RoundingTokens, rounding) + CONFIG_SUBOBJECT(SpacingTokens, spacing) + CONFIG_SUBOBJECT(PaddingTokens, padding) + CONFIG_SUBOBJECT(AnimDurationTokens, animDurations) +}; + +class BarTokens : public settings::ObjectNode { + CONFIG_NODE(BarTokens, settings::ObjectNode) + + CONFIG_PROPERTY(int, innerSize, 28) +}; + +class DashboardTokens : public settings::ObjectNode { + CONFIG_NODE(DashboardTokens, settings::ObjectNode) + + CONFIG_PROPERTY(int, processesWidth, 1200) + CONFIG_PROPERTY(int, processesHeight, 800) + CONFIG_PROPERTY(int, processesHeaderHeight, 40) + CONFIG_PROPERTY(int, processesRowHeight, 40) +}; + +class SizeTokens : public settings::ObjectNode { + CONFIG_NODE(SizeTokens, settings::ObjectNode) + + CONFIG_SUBOBJECT(DashboardTokens, dashboard) CONFIG_SUBOBJECT(BarTokens, bar) - - public: - explicit Tokens(QObject* parent = nullptr) - : ConfigObject(parent) - , m_font(new FontTokens(this)) - , m_anim(new AnimTokens(this)) - , m_bar(new BarTokens(this)) { - if (!Config::instance()) new Config(); - } - - [[nodiscard]] AppearanceRounding* rounding() const { - return Config::instance()->appearance()->rounding(); - } - [[nodiscard]] AppearancePadding* padding() const { - return Config::instance()->appearance()->padding(); - } - [[nodiscard]] AppearanceSpacing* spacing() const { - return Config::instance()->appearance()->spacing(); - } - - static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/utilities.hpp b/Plugins/ZShell/Config/utilities.hpp index 4d3fc21..553754f 100644 --- a/Plugins/ZShell/Config/utilities.hpp +++ b/Plugins/ZShell/Config/utilities.hpp @@ -1,7 +1,6 @@ #pragma once -#include "configobject.hpp" -#include "configlist.hpp" +#include "common.hpp" #include #include @@ -9,75 +8,57 @@ namespace ZShell::config { -class UtilitiesSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class UtilitiesSizes : public settings::ObjectNode { + CONFIG_NODE(UtilitiesSizes, settings::ObjectNode) - CFG_PROPERTY(int, toastWidth, 430) - CFG_PROPERTY(int, width, 430) - - public: - explicit UtilitiesSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, toastWidth, 430) + CONFIG_PROPERTY(int, width, 430) }; -class Toasts : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Toasts : public settings::ObjectNode { + CONFIG_NODE(Toasts, settings::ObjectNode) - CFG_PROPERTY(bool, audioInputChanged, true) - CFG_PROPERTY(bool, audioOutputChanged, true) - CFG_PROPERTY(bool, capsLockChanged, true) - CFG_PROPERTY(bool, chargingChanged, true) - CFG_PROPERTY(bool, configLoaded, true) - CFG_PROPERTY(bool, dndChanged, true) - CFG_PROPERTY(bool, gameModeChanged, true) - CFG_PROPERTY(bool, kbLayoutChanged, true) - CFG_PROPERTY(bool, kbLimit, true) - CFG_PROPERTY(bool, nowPlaying, false) - CFG_PROPERTY(bool, numLockChanged, true) - CFG_PROPERTY(bool, vpnChanged, true) - - public: - explicit Toasts(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, audioInputChanged, true) + CONFIG_PROPERTY(bool, audioOutputChanged, true) + CONFIG_PROPERTY(bool, capsLockChanged, true) + CONFIG_PROPERTY(bool, chargingChanged, true) + CONFIG_PROPERTY(bool, configLoaded, true) + CONFIG_PROPERTY(bool, dndChanged, true) + CONFIG_PROPERTY(bool, gameModeChanged, true) + CONFIG_PROPERTY(bool, kbLayoutChanged, true) + CONFIG_PROPERTY(bool, kbLimit, true) + CONFIG_PROPERTY(bool, nowPlaying, false) + CONFIG_PROPERTY(bool, numLockChanged, true) + CONFIG_PROPERTY(bool, vpnChanged, true) }; -class Vpn : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Vpn : public settings::ObjectNode { + CONFIG_NODE(Vpn, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, false) - CFG_PROPERTY(QStringList, provider, (QStringList{QStringLiteral("netbird")})) - - public: - explicit Vpn(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, false) + CONFIG_PROPERTY( + QStringList, provider, (QStringList{QStringLiteral("netbird")})) }; -class Utilities : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Utilities : public settings::ObjectNode { + CONFIG_NODE(Utilities, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, maxToasts, 4) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, maxToasts, 4) CONFIG_SUBOBJECT(UtilitiesSizes, sizes) CONFIG_SUBOBJECT(Toasts, toasts) CONFIG_SUBOBJECT(Vpn, vpn) CONFIG_LIST( EntryList, quickToggles, - {LIST_ENTRY(dnd, true), - LIST_ENTRY(wifi, true), - LIST_ENTRY(settings, true), - LIST_ENTRY(bluetooth, true), - LIST_ENTRY(mic, true), - LIST_ENTRY(audio, true), - LIST_ENTRY(gameMode, true)}) - - public: - explicit Utilities(QObject* parent = nullptr) - : ConfigObject(parent) - , m_sizes(new UtilitiesSizes(this)) - , m_toasts(new Toasts(this)) - , m_vpn(new Vpn(this)) {} + DEFAULT_ARG( + {LIST_ENTRY(dnd, true), + LIST_ENTRY(wifi, true), + LIST_ENTRY(settings, true), + LIST_ENTRY(bluetooth, true), + LIST_ENTRY(mic, true), + LIST_ENTRY(audio, true), + LIST_ENTRY(gameMode, true)})) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 0726068..f789bba 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,12 +1,12 @@ qml_module(ZShell-internal URI ZShell.Internal + INCLUDE_PREFIX internal SOURCES hyprextras.hpp hyprextras.cpp hyprdevices.hpp hyprdevices.cpp cachingimagemanager.hpp cachingimagemanager.cpp circularindicatormanager.hpp circularindicatormanager.cpp circularbuffer.hpp circularbuffer.cpp - sparklineitem.hpp sparklineitem.cpp arcgauge.hpp arcgauge.cpp wallpaperimage.hpp wallpaperimage.cpp lidwatcher.hpp lidwatcher.cpp @@ -14,6 +14,7 @@ qml_module(ZShell-internal linearindicatormanager.hpp linearindicatormanager.cpp strokecanvasitem.hpp strokecanvasitem.cpp strokecanvasrenderer.hpp strokecanvasrenderer.cpp + cursor.hpp LIBRARIES Qt::Gui Qt::Quick diff --git a/Plugins/ZShell/Internal/circularbuffer.cpp b/Plugins/ZShell/Internal/circularbuffer.cpp index b1c12bc..dcf7b8c 100644 --- a/Plugins/ZShell/Internal/circularbuffer.cpp +++ b/Plugins/ZShell/Internal/circularbuffer.cpp @@ -19,6 +19,8 @@ void CircularBuffer::setCapacity(int capacity) { m_capacity = capacity; m_data.resize(capacity); m_data.fill(0.0); + m_smoothed.resize(capacity); + m_smoothed.fill(0.0); m_head = 0; m_count = 0; @@ -30,6 +32,8 @@ void CircularBuffer::setCapacity(int capacity) { m_count++; } + recomputeAllSmoothed(); + emit capacityChanged(); emit countChanged(); emit valuesChanged(); @@ -56,6 +60,19 @@ qreal CircularBuffer::maximum() const { return maxVal; } +int CircularBuffer::smoothingRadius() const { + return m_smoothingRadius; +} + +void CircularBuffer::setSmoothingRadius(int radius) { + radius = std::max(0, radius); + if (m_smoothingRadius == radius) return; + m_smoothingRadius = radius; + recomputeAllSmoothed(); + emit smoothingRadiusChanged(); + emit valuesChanged(); +} + void CircularBuffer::push(qreal value) { if (m_capacity <= 0) return; @@ -65,6 +82,9 @@ void CircularBuffer::push(qreal value) { m_count++; emit countChanged(); } + + updateSmoothedTail(); + emit valuesChanged(); } @@ -85,4 +105,44 @@ qreal CircularBuffer::at(int index) const { return m_data[actualIndex]; } +qreal CircularBuffer::smoothedAt(int index) const { + if (index < 0 || index >= m_count) return 0.0; + + const int actualIndex = + (m_head - m_count + index + m_capacity) % m_capacity; + return m_smoothed[actualIndex]; +} + +void CircularBuffer::writeSmoothedAt(int logicalIndex) { + const int actualIndex = + (m_head - m_count + logicalIndex + m_capacity) % m_capacity; + + if (m_smoothingRadius <= 0) { + m_smoothed[actualIndex] = m_data[actualIndex]; + return; + } + + qreal sum = 0.0; + int sampleCount = 0; + for (int j = -m_smoothingRadius; j <= m_smoothingRadius; ++j) { + const int idx = logicalIndex + j; + if (idx < 0 || idx >= m_count) continue; + const int a = (m_head - m_count + idx + m_capacity) % m_capacity; + sum += m_data[a]; + sampleCount++; + } + m_smoothed[actualIndex] = sampleCount > 0 ? sum / sampleCount : 0.0; +} + +void CircularBuffer::updateSmoothedTail() { + const int start = std::max(0, m_count - 1 - m_smoothingRadius); + for (int i = start; i < m_count; ++i) + writeSmoothedAt(i); +} + +void CircularBuffer::recomputeAllSmoothed() { + for (int i = 0; i < m_count; ++i) + writeSmoothedAt(i); +} + } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/circularbuffer.hpp b/Plugins/ZShell/Internal/circularbuffer.hpp index a2496f5..26475c1 100644 --- a/Plugins/ZShell/Internal/circularbuffer.hpp +++ b/Plugins/ZShell/Internal/circularbuffer.hpp @@ -15,6 +15,9 @@ class CircularBuffer : public QObject { Q_PROPERTY(int count READ count NOTIFY countChanged) Q_PROPERTY(QList values READ values NOTIFY valuesChanged) Q_PROPERTY(qreal maximum READ maximum NOTIFY valuesChanged) + Q_PROPERTY( + int smoothingRadius READ smoothingRadius WRITE setSmoothingRadius NOTIFY + smoothingRadiusChanged) public: explicit CircularBuffer(QObject* parent = nullptr); @@ -26,20 +29,34 @@ class CircularBuffer : public QObject { [[nodiscard]] QList values() const; [[nodiscard]] qreal maximum() const; + [[nodiscard]] int smoothingRadius() const; + void setSmoothingRadius(int radius); + Q_INVOKABLE void push(qreal value); Q_INVOKABLE void clear(); Q_INVOKABLE [[nodiscard]] qreal at(int index) const; + Q_INVOKABLE [[nodiscard]] qreal smoothedAt(int index) const; + signals: void capacityChanged(); void countChanged(); void valuesChanged(); + void smoothingRadiusChanged(); private: + void writeSmoothedAt(int logicalIndex); + + void updateSmoothedTail(); + + void recomputeAllSmoothed(); + QVector m_data; + QVector m_smoothed; int m_head = 0; int m_count = 0; int m_capacity = 0; + int m_smoothingRadius = 0; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/cursor.hpp b/Plugins/ZShell/Internal/cursor.hpp new file mode 100644 index 0000000..7b1356e --- /dev/null +++ b/Plugins/ZShell/Internal/cursor.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::internal { + +class Cursor : public QObject { + Q_OBJECT + QML_SINGLETON + QML_ELEMENT + + public: + Q_INVOKABLE void setOverride(Qt::CursorShape shape) { + if (m_override) + QGuiApplication::changeOverrideCursor(QCursor(shape)); + else { + QGuiApplication::setOverrideCursor(QCursor(shape)); + m_override = true; + } + } + + Q_INVOKABLE void clearOverride() { + if (!m_override) return; + + QGuiApplication::restoreOverrideCursor(); + m_override = false; + } + + private: + bool m_override = false; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/sparklineitem.cpp b/Plugins/ZShell/Internal/sparklineitem.cpp deleted file mode 100644 index 7c0b2f2..0000000 --- a/Plugins/ZShell/Internal/sparklineitem.cpp +++ /dev/null @@ -1,322 +0,0 @@ -#include "sparklineitem.hpp" - -#include -#include -#include -#include -#include -#include - -namespace ZShell::internal { - -SparklineItem::SparklineItem(QQuickItem* parent) : QQuickPaintedItem(parent) { - setAntialiasing(true); -} - -void SparklineItem::paint(QPainter* painter) { - const bool has1 = m_line1 && m_line1->count() >= 2; - const bool has2 = m_line2 && m_line2->count() >= 2; - if (!has1 && !has2) return; - - painter->setRenderHint(QPainter::Antialiasing, true); - - // Draw line1 first (behind), then line2 (in front) - if (has1) drawLine(painter, m_line1, m_line1Color, m_line1FillAlpha); - if (has2) drawLine(painter, m_line2, m_line2Color, m_line2FillAlpha); -} - -void SparklineItem::drawLine( - QPainter* painter, - CircularBuffer* buffer, - const QColor& color, - qreal fillAlpha) { - if (m_historyLength < 2) return; - - const qreal w = width(); - const qreal h = height(); - const int len = buffer->count(); - if (len < 2 || m_maxValue <= 0.0) return; - - qreal stepX = 0.0; - - if (m_sliding) - stepX = w / static_cast(m_historyLength - 1); - else - stepX = w / static_cast(len - 1); - - qreal startX = 0.0; - - if (m_sliding) - startX = w - (len - 1) * stepX - stepX * m_slideProgress + stepX; - else - startX = 0; - - const qreal strokePad = qCeil(m_lineWidth / 2); - const qreal curvePad = 3.0; - const qreal topPad = strokePad + curvePad; - const qreal bottomPad = strokePad; - const qreal plotTop = topPad; - const qreal plotBottom = h - bottomPad; - const qreal fillBottom = h; - const qreal plotH = qMax(1.0, plotBottom - plotTop); - - QVector points; - points.reserve(len); - - for (int i = 0; i < len; ++i) { - qreal value = 0.0; - - if (m_smoothing > 0.0) { - const int radius = qRound(m_smoothing); - qreal sum = 0.0; - int count = 0; - - for (int j = -radius; j <= radius; ++j) { - const int index = i + j; - - if (index < 0 || index >= len) continue; - - sum += buffer->at(index); - count++; - } - - value = sum / static_cast(count); - } else { - value = buffer->at(i); - } - - value = qBound(0.0, value, m_maxValue); - - const qreal x = startX + i * stepX; - const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; - - points.append(QPointF(x, y)); - } - - QPainterPath linePath; - linePath.moveTo(points[0]); - - if (m_curveSmoothing && points.size() > 2) { - // 1.0 = standard uniform Catmull-Rom. Lower (e.g. 0.5) = tighter, less overshoot. - const qreal tension = 1.0; - const qreal k = - tension / 6.0; // Catmull-Rom -> Bezier factor (1/6 for uniform) - - const int n = points.size(); - - for (int i = 0; i < n - 1; ++i) { - // Duplicate endpoints so the first/last segments are well-defined - const QPointF& p0 = points[qMax(i - 1, 0)]; - const QPointF& p1 = points[i]; - const QPointF& p2 = points[i + 1]; - const QPointF& p3 = points[qMin(i + 2, n - 1)]; - - QPointF c1 = p1 + (p2 - p0) * k; - QPointF c2 = p2 - (p3 - p1) * k; - - // Keep control points inside the vertical range of the segment's - // endpoints so we never overshoot (no dips below zero, no - // bulges above a peak). Remember: smaller y = higher value. - const qreal segMinY = qMin(p1.y(), p2.y()); - const qreal segMaxY = qMax(p1.y(), p2.y()); - c1.setY(qBound(segMinY, c1.y(), segMaxY)); - c2.setY(qBound(segMinY, c2.y(), segMaxY)); - - linePath.cubicTo(c1, c2, p2); - } - } else { - // Existing horizontal-tangent (smoothstep-style) curve - for (int i = 0; i < points.size() - 1; ++i) { - const QPointF& p0 = points[i]; - const QPointF& p1 = points[i + 1]; - - const qreal ctrlX = (p0.x() + p1.x()) * 0.5; - linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); - } - } - - QPen pen(color, m_lineWidth); - pen.setCapStyle(Qt::RoundCap); - pen.setJoinStyle(Qt::RoundJoin); - painter->setPen(pen); - painter->setBrush(Qt::NoBrush); - painter->drawPath(linePath); - - QPainterPath fillPath = linePath; - fillPath.lineTo(startX + (len - 1) * stepX, fillBottom); - fillPath.lineTo(startX, fillBottom); - fillPath.closeSubpath(); - - QColor fillColor = color; - fillColor.setAlphaF(static_cast(fillAlpha)); - painter->setPen(Qt::NoPen); - painter->setBrush(fillColor); - painter->drawPath(fillPath); -} - -void SparklineItem::connectBuffer(CircularBuffer* buffer) { - if (!buffer) return; - - connect(buffer, &CircularBuffer::valuesChanged, this, [this]() { - update(); - }); - connect(buffer, &QObject::destroyed, this, [this, buffer]() { - if (m_line1 == buffer) { - m_line1 = nullptr; - emit line1Changed(); - } - if (m_line2 == buffer) { - m_line2 = nullptr; - emit line2Changed(); - } - update(); - }); -} - -CircularBuffer* SparklineItem::line1() const { - return m_line1; -} - -void SparklineItem::setLine1(CircularBuffer* buffer) { - if (m_line1 == buffer) return; - if (m_line1) disconnect(m_line1, nullptr, this, nullptr); - m_line1 = buffer; - connectBuffer(buffer); - emit line1Changed(); - update(); -} - -CircularBuffer* SparklineItem::line2() const { - return m_line2; -} - -void SparklineItem::setLine2(CircularBuffer* buffer) { - if (m_line2 == buffer) return; - if (m_line2) disconnect(m_line2, nullptr, this, nullptr); - m_line2 = buffer; - connectBuffer(buffer); - emit line2Changed(); - update(); -} - -QColor SparklineItem::line1Color() const { - return m_line1Color; -} - -void SparklineItem::setLine1Color(const QColor& color) { - if (m_line1Color == color) return; - m_line1Color = color; - emit line1ColorChanged(); - update(); -} - -QColor SparklineItem::line2Color() const { - return m_line2Color; -} - -void SparklineItem::setLine2Color(const QColor& color) { - if (m_line2Color == color) return; - m_line2Color = color; - emit line2ColorChanged(); - update(); -} - -qreal SparklineItem::line1FillAlpha() const { - return m_line1FillAlpha; -} - -void SparklineItem::setLine1FillAlpha(qreal alpha) { - if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; - m_line1FillAlpha = alpha; - emit line1FillAlphaChanged(); - update(); -} - -qreal SparklineItem::line2FillAlpha() const { - return m_line2FillAlpha; -} - -void SparklineItem::setLine2FillAlpha(qreal alpha) { - if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; - m_line2FillAlpha = alpha; - emit line2FillAlphaChanged(); - update(); -} - -qreal SparklineItem::maxValue() const { - return m_maxValue; -} - -void SparklineItem::setMaxValue(qreal value) { - if (qFuzzyCompare(m_maxValue, value)) return; - m_maxValue = value; - emit maxValueChanged(); - update(); -} - -qreal SparklineItem::slideProgress() const { - return m_slideProgress; -} - -void SparklineItem::setSlideProgress(qreal progress) { - if (qFuzzyCompare(m_slideProgress, progress)) return; - m_slideProgress = progress; - emit slideProgressChanged(); - update(); -} - -int SparklineItem::historyLength() const { - return m_historyLength; -} - -void SparklineItem::setHistoryLength(int length) { - if (m_historyLength == length) return; - m_historyLength = length; - emit historyLengthChanged(); - update(); -} - -qreal SparklineItem::lineWidth() const { - return m_lineWidth; -} - -void SparklineItem::setLineWidth(qreal width) { - if (qFuzzyCompare(m_lineWidth, width)) return; - m_lineWidth = width; - emit lineWidthChanged(); - update(); -} - -void SparklineItem::setSliding(bool sliding) { - if (m_sliding == sliding) return; - m_sliding = sliding; - emit slidingChanged(); -} - -bool SparklineItem::sliding() const { - return m_sliding; -}; - -void SparklineItem::setSmoothing(qreal smoothing) { - smoothing = qMax(0.0, smoothing); - - if (qFuzzyCompare(m_smoothing, smoothing)) return; - m_smoothing = smoothing; - emit smoothingChanged(); -} - -qreal SparklineItem::smoothing() const { - return m_smoothing; -}; - -void SparklineItem::setCurveSmoothing(bool sliding) { - if (m_curveSmoothing == sliding) return; - m_curveSmoothing = sliding; - emit curveSmoothingChanged(); -} - -bool SparklineItem::curveSmoothing() const { - return m_curveSmoothing; -}; - -} // namespace ZShell::internal diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 4dcae3c..e6c413c 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -18,6 +18,7 @@ qml_module(ZShell-services memory.hpp memory.cpp diskinfo.hpp diskinfo.cpp storage.hpp storage.cpp + processes.hpp processes.cpp lyriccandidate.hpp lyriccandidate.cpp lyrics.hpp lyrics.cpp LIBRARIES @@ -28,6 +29,7 @@ qml_module(ZShell-services PkgConfig::Cava Sensors::Sensors ZShell-config + ZShell-settings DEPENDENCIES ZShell.Config ) diff --git a/Plugins/ZShell/Services/lyrics.cpp b/Plugins/ZShell/Services/lyrics.cpp index 6a85e75..29fe616 100644 --- a/Plugins/ZShell/Services/lyrics.cpp +++ b/Plugins/ZShell/Services/lyrics.cpp @@ -7,7 +7,7 @@ #include #include -#include "config/config.hpp" +#include "config/rootnodes.hpp" #include "config/general.hpp" #include "config/services.hpp" @@ -75,7 +75,7 @@ Lyrics::Lyrics(QObject* parent) m_loadDebounce->setInterval(k_loadDebounceMs); QObject::connect(m_loadDebounce, &QTimer::timeout, this, &Lyrics::doLoad); - const auto* cfg = config::Config::instance(); + const auto* cfg = config::ConfigSingleton::instance(); const auto* svcCfg = cfg->services(); const auto* genCfg = cfg->general(); @@ -114,7 +114,7 @@ void Lyrics::setPreferredBackend(LyricsBackend value) { m_preferredBackend = value; emit preferredBackendChanged(); - config::Config::instance()->services()->set_lyricsBackend(value); + config::ConfigSingleton::instance()->services()->set_lyricsBackend(value); scheduleLoad(); } @@ -870,7 +870,7 @@ QNetworkReply* Lyrics::getJson( void Lyrics::onPreferredBackendConfigChanged() { const LyricsBackend desired = - config::Config::instance()->services()->lyricsBackend(); + config::ConfigSingleton::instance()->services()->lyricsBackend(); if (desired == m_preferredBackend) { return; } @@ -942,7 +942,7 @@ void Lyrics::persistTrackPrefs() { } QString Lyrics::lyricsDir() { - QString dir = config::Config::instance()->general()->lyricsDir(); + QString dir = config::ConfigSingleton::instance()->general()->lyricsDir(); if (dir.isEmpty()) { return {}; } diff --git a/Plugins/ZShell/Services/memory.cpp b/Plugins/ZShell/Services/memory.cpp index eb8020c..1b1c2b9 100644 --- a/Plugins/ZShell/Services/memory.cpp +++ b/Plugins/ZShell/Services/memory.cpp @@ -3,6 +3,8 @@ #include #include +#include + namespace ZShell::services { Memory::Memory(QObject* parent) : TickingService(parent) {} @@ -15,6 +17,18 @@ qreal Memory::total() const { return m_total; } +qreal Memory::cached() const { + return m_cached; +} + +qreal Memory::readTotal() const { + return m_readTotal; +} + +qreal Memory::writeTotal() const { + return m_writeTotal; +} + qreal Memory::percentage() const { return m_total > 0.0 ? m_used / m_total : 0.0; } @@ -31,6 +45,8 @@ void Memory::tick() { QStringLiteral("MemTotal: *(\\d+)")); static const QRegularExpression reAvail( QStringLiteral("MemAvailable: *(\\d+)")); + static const QRegularExpression reCached(QStringLiteral( + "Cached: *(\\d+)")); const QString text = QString::fromLatin1(data); const auto totalMatch = reTotal.match(text); @@ -45,14 +61,50 @@ void Memory::tick() { return; } const quint64 usedKib = totalKib > availKib ? totalKib - availKib : 0; + const auto cachedMatch = reCached.match(text); + const quint64 cachedKib = + cachedMatch.hasMatch() ? cachedMatch.captured(1).toULongLong() : 0; - if (totalKib == m_lastTotal && usedKib == m_lastUsed) { + static const QRegularExpression rePgin(QStringLiteral("pgpgin (\\d+)")); + static const QRegularExpression rePgout(QStringLiteral("pgpgout (\\d+)")); + quint64 readKib = 0; + quint64 writeKib = 0; + { + QFile vf(QStringLiteral("/proc/vmstat")); + if (vf.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QString vtext = QString::fromLatin1(vf.readAll()); + vf.close(); + const double pageKib = getpagesize() / 1024.0; + const auto pgin = rePgin.match(vtext); + const auto pgout = rePgout.match(vtext); + if (pgin.hasMatch()) { + readKib = static_cast(static_cast( + pgin.captured(1).toULongLong()) * + pageKib); + } + if (pgout.hasMatch()) { + writeKib = static_cast(static_cast( + pgout.captured(1).toULongLong()) * + pageKib); + } + } + } + + if (totalKib == m_lastTotal && usedKib == m_lastUsed && + cachedKib == m_lastCached && readKib == m_lastReadKib && + writeKib == m_lastWriteKib) { return; } m_lastTotal = totalKib; m_lastUsed = usedKib; + m_lastCached = cachedKib; + m_lastReadKib = readKib; + m_lastWriteKib = writeKib; m_total = static_cast(totalKib); m_used = static_cast(usedKib); + m_cached = static_cast(cachedKib); + m_readTotal = static_cast(readKib); + m_writeTotal = static_cast(writeKib); Q_EMIT changed(); } diff --git a/Plugins/ZShell/Services/memory.hpp b/Plugins/ZShell/Services/memory.hpp index e21bbc5..cb33ac7 100644 --- a/Plugins/ZShell/Services/memory.hpp +++ b/Plugins/ZShell/Services/memory.hpp @@ -14,14 +14,20 @@ class Memory : public TickingService { Q_PROPERTY(qreal used READ used NOTIFY changed) Q_PROPERTY(qreal total READ total NOTIFY changed) + Q_PROPERTY(qreal cached READ cached NOTIFY changed) Q_PROPERTY(qreal percentage READ percentage NOTIFY changed) + Q_PROPERTY(qreal readTotal READ readTotal NOTIFY changed) + Q_PROPERTY(qreal writeTotal READ writeTotal NOTIFY changed) public: explicit Memory(QObject* parent = nullptr); [[nodiscard]] qreal used() const; [[nodiscard]] qreal total() const; + [[nodiscard]] qreal cached() const; [[nodiscard]] qreal percentage() const; + [[nodiscard]] qreal readTotal() const; + [[nodiscard]] qreal writeTotal() const; signals: void changed(); @@ -32,8 +38,14 @@ class Memory : public TickingService { private: qreal m_used = 0.0; qreal m_total = 1.0; + qreal m_cached = 0.0; + qreal m_readTotal = 0.0; + qreal m_writeTotal = 0.0; quint64 m_lastUsed = 0; quint64 m_lastTotal = 0; + quint64 m_lastCached = 0; + quint64 m_lastReadKib = 0; + quint64 m_lastWriteKib = 0; }; } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/processes.cpp b/Plugins/ZShell/Services/processes.cpp new file mode 100644 index 0000000..6175736 --- /dev/null +++ b/Plugins/ZShell/Services/processes.cpp @@ -0,0 +1,1161 @@ +#include "processes.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcProcessModel, "ZShell.services.processes", QtInfoMsg) + +namespace ZShell::services { + +namespace { + +QVector splitWhitespace(QByteArrayView s) { + QVector out; + qsizetype i = 0; + const qsizetype n = s.size(); + while (i < n) { + while (i < n && std::isspace(static_cast(s[i]))) { + ++i; + } + const qsizetype start = i; + while (i < n && !std::isspace(static_cast(s[i]))) { + ++i; + } + if (i > start) { + out.push_back(s.mid(start, i - start)); + } + } + return out; +} + +qint64 toI64(QByteArrayView v) { + return QByteArray(v.data(), v.size()).toLongLong(); +} + +quint64 toU64(QByteArrayView v) { + return QByteArray(v.data(), v.size()).toULongLong(); +} + +template int cmpVals(const T& a, const T& b) { + return (a < b) - (a > b); +} + + +struct FuzzyText { + QString chars; + QVector classes; +}; + +constexpr int kFuzzyScoreMatch = 16; +constexpr int kFuzzyScoreGapStart = -3; +constexpr int kFuzzyScoreGapExtension = -1; +constexpr int kFuzzyBonusBoundary = kFuzzyScoreMatch / 2; +constexpr int kFuzzyBonusNonWord = kFuzzyScoreMatch / 2; +constexpr int kFuzzyBonusCamel123 = + kFuzzyBonusBoundary + kFuzzyScoreGapExtension; +constexpr int kFuzzyBonusConsecutive = + -(kFuzzyScoreGapStart + kFuzzyScoreGapExtension); +constexpr int kFuzzyFirstCharMultiplier = 2; + +int fuzzyCharClass(QChar c) { + if (c.isLower()) { + return 1; + } + if (c.isUpper()) { + return 2; + } + if (c.isDigit() || c.category() == QChar::Number_Other) { + return 4; + } + if (c.isLetter()) { + return 3; + } + return 0; +} + +int fuzzyBonusFor(int prevClass, int currClass) { + if (prevClass == 0 && currClass != 0) { + return kFuzzyBonusBoundary; + } + if ((prevClass == 1 && currClass == 2) || + (prevClass != 4 && currClass == 4)) { + return kFuzzyBonusCamel123; + } + if (currClass == 0) { + return kFuzzyBonusNonWord; + } + return 0; +} + +FuzzyText fuzzyPrepare(const QString& src) { + FuzzyText out; + const QString decomposed = src.normalized(QString::NormalizationForm_D); + out.chars.reserve(decomposed.size()); + out.classes.reserve(decomposed.size()); + for (int i = 0; i < decomposed.size(); ++i) { + const QChar c = decomposed.at(i); + const uint cp = c.unicode(); + if (cp >= 0x0300 && cp <= 0x036F) { + continue; + } + out.classes.append(static_cast(fuzzyCharClass(c))); + out.chars.append(c.toLower()); + } + return out; +} + +int fuzzyTermScore( + const FuzzyText& text, const QString& term, int* matchEnd = nullptr) { + const int n = static_cast(text.chars.size()); + const int m = static_cast(term.size()); + if (m == 0) { + if (matchEnd) { + *matchEnd = 0; + } + return 0; + } + if (m > n) { + return -1; + } + + int pidx = 0; + int sidx = -1; + int eidx = -1; + for (int i = 0; i < n; ++i) { + if (text.chars.at(i) == term.at(pidx)) { + if (sidx < 0) { + sidx = i; + } + ++pidx; + if (pidx == m) { + eidx = i + 1; + break; + } + } + } + if (eidx < 0) { + return -1; + } + + pidx = m - 1; + for (int i = eidx - 1; i >= sidx; --i) { + if (text.chars.at(i) == term.at(pidx)) { + --pidx; + if (pidx < 0) { + sidx = i; + break; + } + } + } + if (matchEnd) { + *matchEnd = eidx; + } + + int score = 0; + pidx = 0; + bool inGap = false; + int consecutive = 0; + int firstBonus = 0; + int prevClass = sidx > 0 ? text.classes.at(sidx - 1) : 0; + for (int i = sidx; i < eidx; ++i) { + const int cc = text.classes.at(i); + if (text.chars.at(i) == term.at(pidx)) { + score += kFuzzyScoreMatch; + int bonus = fuzzyBonusFor(prevClass, cc); + if (consecutive == 0) { + firstBonus = bonus; + } else { + if (bonus == kFuzzyBonusBoundary) { + firstBonus = bonus; + } + bonus = qMax(bonus, qMax(firstBonus, kFuzzyBonusConsecutive)); + } + score += (pidx == 0) ? bonus * kFuzzyFirstCharMultiplier : bonus; + inGap = false; + ++consecutive; + ++pidx; + } else { + score += inGap ? kFuzzyScoreGapExtension : kFuzzyScoreGapStart; + inGap = true; + consecutive = 0; + firstBonus = 0; + } + prevClass = cc; + } + return score; +} + +int fuzzyQueryScore(const FuzzyText& text, const QStringList& terms) { + int score = 0; + int from = 0; + for (const QString& term : terms) { + const FuzzyText rest{text.chars.mid(from), text.classes.mid(from)}; + int end = 0; + const int s = fuzzyTermScore(rest, term, &end); + if (s < 0) { + return -1; + } + score += s; + from += end; + } + return score; +} + +const QSet& launcherWrappers() { + static const QSet s = { + "flatpak", + "env", + "distrobox", + "distrobox-enter", + "toolbox", + "toolbox-enter", + "toolbx", + "toolbx-enter", + }; + return s; +} + +const QSet& cmdlineInterpreters() { + static const QSet s = { + "sh", + "bash", + "dash", + "zsh", + "fish", + "ash", + "python", + "python2", + "python3", + "node", + "nodejs", + "perl", + "ruby", + "lua", + "php", + "java", + "dotnet", + }; + return s; +} + +const QHash& execNameExceptions() { + static const QHash s = { + {"firefox-bin", "firefox"}, + {"soffice.bin", "libreoffice"}, + {"oosplash", "libreoffice"}, + {"gnome-terminal-server", "gnome-terminal"}, + {"chrome", "google-chrome-stable"}, + }; + return s; +} + +QString sanitizeExec(const QString& execLine, const QStringList& pathDirs) { + const QStringList tokens = + execLine.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + + bool skippingLaunchers = true; + for (const QString& rawTok : tokens) { + if (rawTok.startsWith('-') || rawTok.startsWith('%')) { + continue; + } + + QString tok = rawTok; + tok.remove('"'); + + QString resolved; + const QFileInfo fi(tok); + + if (fi.isAbsolute()) { + resolved = fi.canonicalFilePath(); + } else { + for (const QString& dir : pathDirs) { + QFileInfo cand(QDir(dir).filePath(tok)); + if (cand.exists()) { + resolved = cand.canonicalFilePath(); + break; + } + } + } + + if (resolved.isEmpty()) { + continue; + } + + const QString base = QFileInfo(resolved).fileName(); + if (skippingLaunchers && launcherWrappers().contains(base)) { + continue; + } + + skippingLaunchers = false; + + return cmdlineInterpreters().contains(base) ? QString() : resolved; + } + + return {}; +} + +} // namespace + +ProcessModel::ProcessModel(QObject* parent) : QAbstractListModel(parent) { + const long ticks = ::sysconf(_SC_CLK_TCK); + if (ticks > 0) { + mClockTicksPerSec = ticks; + } + m_currentUid = ::getuid(); +} + +int ProcessModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) { + return 0; + } + return static_cast(mProcesses.size()); +} + +QVariant ProcessModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || + index.row() >= mProcesses.size()) { + return {}; + } + + const ProcessEntry& p = mProcesses.at(index.row()); + switch (role) { + case PidRole: + return static_cast(p.pid); + case PpidRole: + return static_cast(p.ppid); + case CommandRole: + return p.command; + case CommandLineRole: + return p.commandLine; + case StateRole: + return QString(QChar::fromLatin1(p.state)); + case SleepingRole: + return p.state == 'S' || p.state == 'D'; + case CpuUsageRole: + return p.cpuPercent; + case MemoryUsageRole: + return static_cast(p.rssKb); + case ElapsedRole: + return p.elapsedSecs; + case UidRole: + return static_cast(p.uid); + case UserRole: + return p.username; + case IoReadBytesRole: + return static_cast(p.ioReadBytes); + case IoWriteBytesRole: + return static_cast(p.ioWriteBytes); + case IconNameRole: + return p.iconName; + default: + return {}; + } +} + +QHash ProcessModel::roleNames() const { + return { + {PidRole, "pid"}, + {PpidRole, "ppid"}, + {CommandRole, "command"}, + {CommandLineRole, "commandLine"}, + {StateRole, "state"}, + {SleepingRole, "sleeping"}, + {CpuUsageRole, "cpuUsage"}, + {MemoryUsageRole, "memoryUsage"}, + {ElapsedRole, "elapsed"}, + {UidRole, "uid"}, + {UserRole, "user"}, + {IoReadBytesRole, "ioReadBytes"}, + {IoWriteBytesRole, "ioWriteBytes"}, + {IconNameRole, "iconName"}, + }; +} + +int ProcessModel::count() const { + return static_cast(mProcesses.size()); +} + +void ProcessModel::refresh() { + doRefresh(); +} + +bool ProcessModel::killProcess(qint64 pid, int signal) const { + if (pid <= 0) { + return false; + } + return ::kill(static_cast(pid), signal) == 0; +} + +QString ProcessModel::searchText() const { + return m_searchText; +} + +void ProcessModel::setSearchText(const QString& text) { + if (text == m_searchText) { + return; + } + m_searchText = text; + Q_EMIT searchTextChanged(); + applyView(); +} + +bool ProcessModel::currentUserOnly() const { + return m_currentUserOnly; +} + +void ProcessModel::setCurrentUserOnly(bool enabled) { + if (enabled == m_currentUserOnly) { + return; + } + m_currentUserOnly = enabled; + Q_EMIT currentUserOnlyChanged(); + applyView(); +} + +int ProcessModel::sortField() const { + return m_sortField; +} + +void ProcessModel::setSortField(int field) { + if (field == m_sortField) { + return; + } + m_sortField = field; + Q_EMIT sortFieldChanged(); + applyView(); +} + +bool ProcessModel::sortAscending() const { + return m_sortAscending; +} + +void ProcessModel::setSortAscending(bool ascending) { + if (ascending == m_sortAscending) { + return; + } + m_sortAscending = ascending; + Q_EMIT sortAscendingChanged(); + applyView(); +} + +void ProcessModel::setDesktopEntryIcons( + QHash execPathToIcon, + QHash execNameToIcon) { + mExecPathToIcon = std::move(execPathToIcon); + mExecNameToIcon = std::move(execNameToIcon); + ++m_iconVersion; +} + +QString ProcessModel::resolveProcessExe(pid_t pid) { + const QString path = QStringLiteral("/proc/%1/exe").arg(pid); + const QString target = QFile::symLinkTarget(path); + + return target; +} + +std::optional ProcessModel::readStat(pid_t pid) { + QFile f(QStringLiteral("/proc/%1/stat").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + const QByteArray data = f.readAll(); + f.close(); + + const qsizetype openParen = data.indexOf('('); + const qsizetype closeParen = data.lastIndexOf(')'); + if (openParen < 0 || closeParen < 0 || closeParen <= openParen) { + return std::nullopt; + } + + RawStat out; + out.pid = pid; + out.comm = QString::fromLatin1( + data.mid(openParen + 1, closeParen - openParen - 1)); + + const QByteArrayView rest = + QByteArrayView(data).mid(closeParen + 1).trimmed(); + const auto fields = splitWhitespace(rest); + if (fields.size() < 20) { + return std::nullopt; + } + out.state = fields[0].isEmpty() ? '?' : fields[0][0]; + out.ppid = static_cast(toI64(fields[1])); + out.utime = toU64(fields[11]); + out.stime = toU64(fields[12]); + out.starttime = toU64(fields[19]); + return out; +} + +QString ProcessModel::readCommandLine(pid_t pid, const QString& fallbackComm) { + QFile f(QStringLiteral("/proc/%1/cmdline").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return QStringLiteral("[%1]").arg(fallbackComm); + } + QByteArray data = f.readAll(); + f.close(); + + if (data.isEmpty()) { + return QStringLiteral("[%1]").arg(fallbackComm); + } + + while (!data.isEmpty() && data.endsWith('\0')) { + data.chop(1); + } + for (qsizetype i = 0; i < data.size(); ++i) { + if (data[i] == '\0') { + data[i] = ' '; + } + } + return QString::fromUtf8(data); +} + +ProcessModel::StatusInfo ProcessModel::readStatus(pid_t pid) { + QFile f(QStringLiteral("/proc/%1/status").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + const QByteArray data = f.readAll(); + f.close(); + + StatusInfo out; + qsizetype pos = 0; + while (pos < data.size()) { + const qsizetype nl = data.indexOf('\n', pos); + const qsizetype lineLen = nl < 0 ? data.size() - pos : nl - pos; + const QByteArrayView line = QByteArrayView(data).mid(pos, lineLen); + pos = nl < 0 ? data.size() : nl + 1; + + if (line.startsWith("VmRSS:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.rssKb = toU64(fields[1]); + } + } else if (line.startsWith("Uid:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.uid = static_cast(toU64(fields[1])); + } + } + } + return out; +} + +ProcessModel::IoInfo ProcessModel::readIo(pid_t pid) { + QFile f(QStringLiteral("/proc/%1/io").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + const QByteArray data = f.readAll(); + f.close(); + + IoInfo out; + qsizetype pos = 0; + while (pos < data.size()) { + const qsizetype nl = data.indexOf('\n', pos); + const qsizetype lineLen = nl < 0 ? data.size() - pos : nl - pos; + const QByteArrayView line = QByteArrayView(data).mid(pos, lineLen); + pos = nl < 0 ? data.size() : nl + 1; + + if (line.startsWith("read_bytes:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.readBytes = toU64(fields[1]); + } + } else if (line.startsWith("write_bytes:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.writeBytes = toU64(fields[1]); + } + } + } + return out; +} + +QString ProcessModel::resolveUsername(uid_t uid, QHash& cache) { + if (const auto it = cache.constFind(uid); it != cache.constEnd()) { + return it.value(); + } + + std::array buf{}; + struct passwd pwd{}; + struct passwd* result = nullptr; + + QString name; + if (::getpwuid_r(uid, &pwd, buf.data(), buf.size(), &result) == 0 && + result != nullptr) { + name = QString::fromLocal8Bit(pwd.pw_name); + } else { + name = QString::number(uid); + } + + cache.insert(uid, name); + return name; +} + +std::optional ProcessModel::readUptimeSeconds() { + QFile f(QStringLiteral("/proc/uptime")); + if (!f.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + const QByteArray data = f.readAll(); + f.close(); + + const auto fields = splitWhitespace(QByteArrayView(data)); + if (fields.isEmpty()) { + return std::nullopt; + } + bool ok = false; + const double uptime = + QByteArray(fields[0].data(), fields[0].size()).toDouble(&ok); + return ok ? std::make_optional(uptime) : std::nullopt; +} + +ProcessModel::ScanResult ProcessModel::scanProcesses( + double uptime, + double deltaSecs, + QHash prevPidToAll, + QVector prevAll, + QHash uidCache, + long clockTicksPerSec, + QHash execPathToIcon, + QHash execNameToIcon, + QStringList pathDirs, + bool desktopEntryIconsChanged, + quint64 iconVersion) { + Q_UNUSED( + pathDirs); // kept for future cgroup fallback; sanitizeExec already ran on GUI thread + + QDir procDir(QStringLiteral("/proc")); + const auto entries = + procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::NoSort); + + QVector fresh; + fresh.reserve(entries.size()); + + for (const QString& name : entries) { + bool isPid = false; + const pid_t pid = static_cast(name.toLong(&isPid)); + if (!isPid || pid <= 0) { + continue; + } + + const auto statOpt = readStat(pid); + if (!statOpt) { + continue; + } + const RawStat& stat = *statOpt; + + ProcessEntry entry; + entry.pid = stat.pid; + entry.ppid = stat.ppid; + entry.command = stat.comm; + entry.state = stat.state; + entry.starttime = stat.starttime; + + const auto prevIt = prevPidToAll.constFind(pid); + const bool samePidInstance = prevIt != prevPidToAll.constEnd() && + prevAll.at(prevIt.value()).starttime == + stat.starttime; + if (samePidInstance) { + entry.commandLine = prevAll.at(prevIt.value()).commandLine; + } else { + entry.commandLine = readCommandLine(pid, stat.comm); + } + + if (!samePidInstance || desktopEntryIconsChanged) { + const QString exePath = resolveProcessExe(pid); + + QString iconName; + + if (!exePath.isEmpty()) { + iconName = execPathToIcon.value(exePath); + } + + if (iconName.isEmpty()) { + QString execName = execNameExceptions().value( + QFileInfo(exePath).fileName(), + QFileInfo(exePath).fileName()); + + iconName = execNameToIcon.value(execName); + } + + if (iconName.isEmpty()) { + QString baseName = stat.comm; + const auto exIt = execNameExceptions().constFind(baseName); + if (exIt != execNameExceptions().constEnd()) { + baseName = exIt.value(); + } + iconName = execNameToIcon.value(baseName); + } + + entry.iconName = iconName; + } else { + entry.iconName = prevAll.at(prevIt.value()).iconName; + } + + const StatusInfo status = readStatus(pid); + entry.rssKb = status.rssKb; + entry.uid = status.uid; + entry.username = resolveUsername(status.uid, uidCache); + + const IoInfo io = readIo(pid); + entry.ioReadBytes = io.readBytes; + entry.ioWriteBytes = io.writeBytes; + + entry.elapsedSecs = std::max( + 0.0, + uptime - (static_cast(stat.starttime) / + static_cast(clockTicksPerSec))); + entry.lastUtime = stat.utime; + entry.lastStime = stat.stime; + + if (samePidInstance && deltaSecs > 0.0) { + const ProcessEntry& prev = prevAll.at(prevIt.value()); + const quint64 prevTicks = prev.lastUtime + prev.lastStime; + const quint64 nowTicks = stat.utime + stat.stime; + const quint64 deltaTicks = + nowTicks > prevTicks ? nowTicks - prevTicks : 0; + entry.cpuPercent = + (static_cast(deltaTicks) / + (deltaSecs * static_cast(clockTicksPerSec))) * + 100.0; + } else { + entry.cpuPercent = 0.0; + } + + fresh.push_back(std::move(entry)); + } + + ScanResult result; + result.entries = std::move(fresh); + result.newUidNames = std::move(uidCache); + result.iconVersion = iconVersion; + return result; +} + +void ProcessModel::doRefresh() { + if (mWatcher) { + mRefreshPending = true; + return; + } + + const auto uptimeOpt = readUptimeSeconds(); + if (!uptimeOpt) { + qCWarning(lcProcessModel, "failed to read /proc/uptime"); + return; + } + const double uptime = *uptimeOpt; + const double deltaSecs = mLastUptimeSecs >= 0.0 ? (uptime - mLastUptimeSecs) + : -1.0; + mPendingUptime = uptime; + + QHash prevPidToAll = mPidToAll; + QVector prevAll = mAll; + QHash uidCache = mUidToName; + QHash execPathToIcon = mExecPathToIcon; + QHash execNameToIcon = mExecNameToIcon; + const long ticks = mClockTicksPerSec; + const bool desktopEntryIconsChanged = + (m_iconVersion != m_scannedIconVersion); + + mWatcher = new QFutureWatcher(this); + QObject::connect( + mWatcher, + &QFutureWatcher::finished, + this, + &ProcessModel::onScanFinished); + + mWatcher->setFuture( + QtConcurrent::run( + &ProcessModel::scanProcesses, + uptime, + deltaSecs, + std::move(prevPidToAll), + std::move(prevAll), + std::move(uidCache), + ticks, + std::move(execPathToIcon), + std::move(execNameToIcon), + QStringList{}, + desktopEntryIconsChanged, + m_iconVersion)); +} + +void ProcessModel::onScanFinished() { + QFutureWatcher* watcher = mWatcher; + mWatcher = nullptr; + if (!watcher) { + return; + } + ScanResult result = watcher->result(); + watcher->deleteLater(); + + mAll = std::move(result.entries); + m_scannedIconVersion = result.iconVersion; + mPidToAll.clear(); + mPidToAll.reserve(mAll.size()); + for (int i = 0; i < mAll.size(); ++i) { + mPidToAll.insert(mAll.at(i).pid, i); + } + for (auto it = result.newUidNames.constBegin(); + it != result.newUidNames.constEnd(); + ++it) { + mUidToName.insert(it.key(), it.value()); + } + + mLastUptimeSecs = mPendingUptime; + applyView(); + + if (mRefreshPending) { + mRefreshPending = false; + doRefresh(); + } +} + +void ProcessModel::applyView() { + const bool searching = !m_searchText.isEmpty(); + QStringList terms; + if (searching) { + terms = m_searchText.split( + QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + for (int i = 0; i < terms.size(); ++i) { + terms[i] = fuzzyPrepare(terms.at(i)).chars; + } + } + + QVector filtered; + filtered.reserve(mAll.size()); + + for (const ProcessEntry& src : mAll) { + if (m_currentUserOnly && src.uid != m_currentUid) { + continue; + } + + ProcessEntry entry = src; + if (searching) { + int score = fuzzyQueryScore(fuzzyPrepare(src.command), terms); + if (!src.commandLine.isEmpty() && src.commandLine != src.command) { + score = qMax( + score, + fuzzyQueryScore(fuzzyPrepare(src.commandLine), terms)); + } + if (score < 0) { + continue; + } + entry.searchScore = score; + } + filtered.push_back(std::move(entry)); + } + + QVector desired; + desired.reserve(filtered.size()); + if (searching || m_sortField != 0) { + desired = filtered; + std::stable_sort( + desired.begin(), + desired.end(), + [this, searching](const ProcessEntry& a, const ProcessEntry& b) { + if (searching && a.searchScore != b.searchScore) { + return a.searchScore > b.searchScore; + } + const int c = compareEntries(a, b); + if (c != 0) { + return m_sortAscending ? c < 0 : c > 0; + } + return a.pid < b.pid; + }); + } else { + QSet shown; + shown.reserve(mProcesses.size()); + for (const ProcessEntry& e : mProcesses) { + shown.insert(e.pid); + } + QSet placed; + placed.reserve(filtered.size()); + for (const ProcessEntry& e : filtered) { + if (shown.contains(e.pid)) { + desired.push_back(e); + placed.insert(e.pid); + } + } + for (const ProcessEntry& e : filtered) { + if (!placed.contains(e.pid)) { + desired.push_back(e); + } + } + } + + converge(desired); +} + +void ProcessModel::converge(const QVector& desired) { + const qsizetype oldCount = mProcesses.size(); + + if (desired.size() == oldCount) { + bool sameOrder = true; + for (qsizetype i = 0; i < desired.size(); ++i) { + if (desired.at(i).pid != mProcesses.at(i).pid) { + sameOrder = false; + break; + } + } + if (sameOrder) { + for (qsizetype i = 0; i < desired.size(); ++i) { + mProcesses[i] = desired.at(i); + } + static const QList kVolatileRoles = { + CpuUsageRole, + MemoryUsageRole, + ElapsedRole, + StateRole, + SleepingRole, + IoReadBytesRole, + IoWriteBytesRole, + IconNameRole}; + if (!mProcesses.isEmpty()) { + const int last = static_cast(desired.size()) - 1; + Q_EMIT dataChanged(index(0), index(last), kVolatileRoles); + } + return; + } + } + + + QSet oldPids; + oldPids.reserve(oldCount); + for (const auto& e : mProcesses) { + oldPids.insert(e.pid); + } + QSet keepPids; + keepPids.reserve(desired.size()); + QVector added; + for (const auto& e : desired) { + keepPids.insert(e.pid); + if (!oldPids.contains(e.pid)) { + added.append(&e); + } + } + + QVector removed; + for (qsizetype i = 0; i < oldCount; ++i) { + if (!keepPids.contains(mProcesses.at(i).pid)) { + removed.append(static_cast(i)); + } + } + std::sort(removed.begin(), removed.end(), [](int a, int b) { + return a > b; + }); + for (int idx : removed) { + beginRemoveRows(QModelIndex(), idx, idx); + mProcesses.removeAt(idx); + endRemoveRows(); + } + + if (!added.isEmpty()) { + const int start = static_cast(mProcesses.size()); + beginInsertRows( + QModelIndex(), start, start + static_cast(added.size()) - 1); + for (const auto* e : added) { + mProcesses.append(*e); + } + endInsertRows(); + } + + bool ordered = true; + for (qsizetype i = 0; i < desired.size(); ++i) { + if (mProcesses.at(i).pid != desired.at(i).pid) { + ordered = false; + break; + } + } + if (!ordered) { + mProcesses = desired; + layoutChanged( + QList(), + QAbstractItemModel::VerticalSortHint); + } + + if (mProcesses.size() != oldCount) { + Q_EMIT countChanged(); + } +} + +int ProcessModel::compareEntries( + const ProcessEntry& a, const ProcessEntry& b) const { + switch (m_sortField) { + case 1: + return cmpVals(a.pid, b.pid); + case 2: + return cmpVals(a.ppid, b.ppid); + case 3: + return QString::compare(a.command, b.command, Qt::CaseInsensitive); + case 4: + return QString::compare( + a.commandLine, b.commandLine, Qt::CaseInsensitive); + case 5: + return cmpVals(a.state, b.state); + case 6: + return cmpVals(isSleeping(a), isSleeping(b)); + case 7: + return cmpVals(a.cpuPercent, b.cpuPercent); + case 8: + return cmpVals(a.rssKb, b.rssKb); + case 9: + return cmpVals(a.elapsedSecs, b.elapsedSecs); + case 10: + return cmpVals(a.uid, b.uid); + case 11: + return QString::compare(a.username, b.username, Qt::CaseInsensitive); + case 12: + return cmpVals(a.ioReadBytes, b.ioReadBytes); + case 13: + return cmpVals(a.ioWriteBytes, b.ioWriteBytes); + default: + return 0; + } +} + +bool ProcessModel::isSleeping(const ProcessEntry& e) { + return e.state == 'S' || e.state == 'D'; +} + +Processes::Processes(QObject* parent) + : TickingService(parent), m_model(new ProcessModel(this)) { + QObject::connect( + m_model, &ProcessModel::countChanged, this, &Processes::countChanged); + QObject::connect( + m_model, + &ProcessModel::sortFieldChanged, + this, + &Processes::sortFieldChanged); + QObject::connect( + m_model, + &ProcessModel::sortAscendingChanged, + this, + &Processes::sortAscendingChanged); + QObject::connect( + m_model, + &ProcessModel::searchTextChanged, + this, + &Processes::searchTextChanged); + QObject::connect( + m_model, + &ProcessModel::currentUserOnlyChanged, + this, + &Processes::currentUserOnlyChanged); +} + +QAbstractItemModel* Processes::list() const { + return m_model; +} + +int Processes::count() const { + return m_model->count(); +} + +Processes::SortField Processes::sortField() const { + return static_cast(m_model->sortField()); +} + +void Processes::setSortField(SortField field) { + m_model->setSortField(static_cast(field)); +} + +bool Processes::sortAscending() const { + return m_model->sortAscending(); +} + +void Processes::setSortAscending(bool ascending) { + m_model->setSortAscending(ascending); +} + +QString Processes::searchText() const { + return m_model->searchText(); +} + +void Processes::setSearchText(const QString& text) { + m_model->setSearchText(text); +} + +bool Processes::currentUserOnly() const { + return m_model->currentUserOnly(); +} + +void Processes::setCurrentUserOnly(bool enabled) { + m_model->setCurrentUserOnly(enabled); +} + +void Processes::setDesktopEntries(const QVariantList& entries) { + QHash execPathToIcon; + QHash execNameToIcon; + execPathToIcon.reserve(entries.size()); + execNameToIcon.reserve(entries.size()); + + const QStringList pathDirs = + qEnvironmentVariable("PATH").split(':', Qt::SkipEmptyParts); + + for (const QVariant& v : entries) { + const QVariantMap m = v.toMap(); + + const QString icon = m.value(QStringLiteral("icon")).toString(); + + const QStringList command = + m.value(QStringLiteral("command")).toStringList(); + + if (icon.isEmpty()) { + continue; + } + + if (command.isEmpty()) { + continue; + } + + const QString execPath = sanitizeExec(command.join(' '), pathDirs); + + if (execPath.isEmpty()) { + continue; + } + + execPathToIcon.insert(execPath, icon); + execNameToIcon.insert(QFileInfo(execPath).fileName(), icon); + } + + m_model->setDesktopEntryIcons( + std::move(execPathToIcon), std::move(execNameToIcon)); +} + +void Processes::sortBy(SortField field) { + if (m_model->sortField() == static_cast(field)) { + m_model->setSortAscending(!m_model->sortAscending()); + } else { + m_model->setSortField(static_cast(field)); + m_model->setSortAscending(true); + } +} + +void Processes::refresh() { + m_model->refresh(); +} + +void Processes::tick() { + m_model->refresh(); +} + +bool Processes::killProcess(qint64 pid, int signal) const { + return m_model->killProcess(pid, signal); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/processes.hpp b/Plugins/ZShell/Services/processes.hpp new file mode 100644 index 0000000..b82cac6 --- /dev/null +++ b/Plugins/ZShell/Services/processes.hpp @@ -0,0 +1,259 @@ +#pragma once + +#include "tickingservice.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +template class QFutureWatcher; + +namespace ZShell::services { + +class ProcessModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) + + public: + enum Roles : quint16 { + PidRole = Qt::UserRole + 1, + PpidRole, + CommandRole, + CommandLineRole, + StateRole, + SleepingRole, + CpuUsageRole, + MemoryUsageRole, + ElapsedRole, + UidRole, + UserRole, + IoReadBytesRole, + IoWriteBytesRole, + IconNameRole, + }; + Q_ENUM(Roles) + + explicit ProcessModel(QObject* parent = nullptr); + + [[nodiscard]] int rowCount( + const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data( + const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] int count() const; + + void refresh(); + bool killProcess(qint64 pid, int signal = 15) const; + void setDesktopEntryIcons( + QHash execPathToIcon, + QHash execNameToIcon); + + [[nodiscard]] QString searchText() const; + void setSearchText(const QString& text); + + [[nodiscard]] bool currentUserOnly() const; + void setCurrentUserOnly(bool enabled); + + [[nodiscard]] int sortField() const; + void setSortField(int field); + + [[nodiscard]] bool sortAscending() const; + void setSortAscending(bool ascending); + + signals: + void countChanged(); + void searchTextChanged(); + void currentUserOnlyChanged(); + void sortFieldChanged(); + void sortAscendingChanged(); + + private: + struct RawStat { + pid_t pid = 0; + pid_t ppid = 0; + QString comm; + char state = '?'; + quint64 utime = 0; + quint64 stime = 0; + quint64 starttime = 0; + }; + + struct ProcessEntry { + pid_t pid = 0; + pid_t ppid = 0; + QString command; + QString commandLine; + char state = '?'; + double cpuPercent = 0.0; + quint64 rssKb = 0; + double elapsedSecs = 0.0; + uid_t uid = 0; + QString username; + QString iconName; + quint64 ioReadBytes = 0; + quint64 ioWriteBytes = 0; + int searchScore = -1; + + quint64 lastUtime = 0; + quint64 lastStime = 0; + quint64 starttime = 0; + }; + + struct StatusInfo { + quint64 rssKb = 0; + uid_t uid = 0; + }; + + struct IoInfo { + quint64 readBytes = 0; + quint64 writeBytes = 0; + }; + + struct ScanResult { + QVector entries; + QHash newUidNames; + quint64 iconVersion = 0; + }; + + static std::optional readStat(pid_t pid); + static QString readCommandLine(pid_t pid, const QString& fallbackComm); + static StatusInfo readStatus(pid_t pid); + static IoInfo readIo(pid_t pid); + static std::optional readUptimeSeconds(); + static QString resolveUsername(uid_t uid, QHash& cache); + static QString resolveProcessExe(pid_t pid); + + static ScanResult scanProcesses( + double uptime, + double deltaSecs, + QHash prevPidToAll, + QVector prevAll, + QHash uidCache, + long clockTicksPerSec, + QHash execPathToIcon, + QHash execNameToIcon, + QStringList pathDirs, + bool desktopEntryIconsChanged, + quint64 iconVersion); + + void doRefresh(); + void onScanFinished(); + void applyView(); + void converge(const QVector& desired); + [[nodiscard]] int compareEntries( + const ProcessEntry& a, const ProcessEntry& b) const; + [[nodiscard]] static bool isSleeping(const ProcessEntry& e); + + QVector mAll; + QHash mPidToAll; + + QVector mProcesses; + QHash mUidToName; + + double mLastUptimeSecs = -1.0; + long mClockTicksPerSec = 100; + + QFutureWatcher* mWatcher = nullptr; + bool mRefreshPending = false; + double mPendingUptime = -1.0; + + QString m_searchText; + bool m_currentUserOnly = true; + uid_t m_currentUid = 0; + int m_sortField = 0; + bool m_sortAscending = true; + + QHash mExecPathToIcon; + QHash mExecNameToIcon; + + quint64 m_iconVersion = 0; + quint64 m_scannedIconVersion = 0; +}; + +class Processes : public TickingService { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + enum SortField : int { + Unsorted = 0, + Pid, + Ppid, + Command, + CommandLine, + State, + Sleeping, + CpuUsage, + MemoryUsage, + Elapsed, + Uid, + User, + IoReadBytes, + IoWriteBytes + }; + Q_ENUM(SortField) + + private: + Q_PROPERTY(QAbstractItemModel* list READ list CONSTANT) + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY( + SortField sortField READ sortField WRITE setSortField NOTIFY + sortFieldChanged) + Q_PROPERTY( + bool sortAscending READ sortAscending WRITE setSortAscending NOTIFY + sortAscendingChanged) + Q_PROPERTY( + QString searchText READ searchText WRITE setSearchText NOTIFY + searchTextChanged) + Q_PROPERTY( + bool currentUserOnly READ currentUserOnly WRITE setCurrentUserOnly + NOTIFY currentUserOnlyChanged) + + public: + explicit Processes(QObject* parent = nullptr); + + [[nodiscard]] QAbstractItemModel* list() const; + + [[nodiscard]] int count() const; + + [[nodiscard]] SortField sortField() const; + void setSortField(SortField field); + + [[nodiscard]] bool sortAscending() const; + void setSortAscending(bool ascending); + + [[nodiscard]] QString searchText() const; + void setSearchText(const QString& text); + + [[nodiscard]] bool currentUserOnly() const; + void setCurrentUserOnly(bool enabled); + + Q_INVOKABLE void sortBy(SortField field); + Q_INVOKABLE void refresh(); + Q_INVOKABLE bool killProcess(qint64 pid, int signal = 15) const; + Q_INVOKABLE void setDesktopEntries(const QVariantList& entries); + + + signals: + void countChanged(); + void sortFieldChanged(); + void sortAscendingChanged(); + void searchTextChanged(); + void currentUserOnlyChanged(); + + protected: + void tick() override; + + private: + ProcessModel* m_model; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 96083d5..05e6c83 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -12,7 +12,10 @@ namespace ZShell::services { TickingService::TickingService(QObject* parent) : Service(parent), m_timer(new QTimer(this)) { m_timer->setSingleShot(false); - QObject::connect(m_timer, &QTimer::timeout, this, [this] { tick(); }); + QObject::connect(m_timer, &QTimer::timeout, this, [this] { + tick(); + updateTriggered(); + }); QString configPath = QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp index 324707f..e73a911 100644 --- a/Plugins/ZShell/Services/tickingservice.hpp +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -18,6 +18,7 @@ class TickingService : public Service { signals: void updateIntervalChanged(); + void updateTriggered(); protected: void start() final; diff --git a/Plugins/ZShell/Services/usagefmt.cpp b/Plugins/ZShell/Services/usagefmt.cpp index 1012da1..1d4cf32 100644 --- a/Plugins/ZShell/Services/usagefmt.cpp +++ b/Plugins/ZShell/Services/usagefmt.cpp @@ -14,6 +14,24 @@ bool finitePositive(qreal v) { namespace ZShell::services::usagefmt { +FormatResult UsageFmt::formatKib(qreal kib) const { + if (!finitePositive(kib)) { + return {0.0, 0.0, "KiB"}; + } + + if (kib >= kGib) { + return {kib / kGib, 0.0, "GiB"}; + } + if (kib >= kMib) { + return {kib / kMib, 0.0, "MiB"}; + } + if (kib >= kKib) { + return {kib / kKib, 0.0, "KiB"}; + } + + return {kib, 0.0, "KiB"}; +} + FormatResult UsageFmt::formatKib(qreal kib, qreal total) const { if (!finitePositive(kib) || !finitePositive(total)) { return {0.0, 0.0, "KiB"}; diff --git a/Plugins/ZShell/Services/usagefmt.hpp b/Plugins/ZShell/Services/usagefmt.hpp index ca910d4..38a06fb 100644 --- a/Plugins/ZShell/Services/usagefmt.hpp +++ b/Plugins/ZShell/Services/usagefmt.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -27,6 +28,7 @@ class UsageFmt : public QObject { QML_SINGLETON public: + Q_INVOKABLE [[nodiscard]] FormatResult formatKib(qreal kib) const; Q_INVOKABLE [[nodiscard]] FormatResult formatKib( qreal kib, qreal total) const; }; diff --git a/Plugins/ZShell/Settings/CMakeLists.txt b/Plugins/ZShell/Settings/CMakeLists.txt new file mode 100644 index 0000000..eb0c6f2 --- /dev/null +++ b/Plugins/ZShell/Settings/CMakeLists.txt @@ -0,0 +1,17 @@ +qml_module(ZShell-settings + URI ZShell.Settings + INCLUDE_PREFIX settings + SOURCES + common.cpp + schema.cpp + codecs.cpp + quarantine.cpp + node.cpp + objectnode.cpp + listnode.cpp + rootnode.cpp + settingsfile.cpp + changebatcher.cpp + LIBRARIES + zshell-util +) diff --git a/Plugins/ZShell/Settings/changebatcher.cpp b/Plugins/ZShell/Settings/changebatcher.cpp new file mode 100644 index 0000000..ac4b215 --- /dev/null +++ b/Plugins/ZShell/Settings/changebatcher.cpp @@ -0,0 +1,25 @@ +#include "changebatcher.hpp" + +namespace ZShell::settings { + +ChangeBatcher::ChangeBatcher(QObject* parent) + : QObject(parent) + , m_dirty(false) {} + +void ChangeBatcher::dirty() { + if (m_dirty) + return; + + m_dirty = true; + QMetaObject::invokeMethod(this, &ChangeBatcher::flush, Qt::QueuedConnection); +} + +void ChangeBatcher::flush() { + if (!m_dirty) + return; + + m_dirty = false; + emit dirtied(); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/changebatcher.hpp b/Plugins/ZShell/Settings/changebatcher.hpp new file mode 100644 index 0000000..17ee2b3 --- /dev/null +++ b/Plugins/ZShell/Settings/changebatcher.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace ZShell::settings { + +class ChangeBatcher : public QObject { + Q_OBJECT + +public: + explicit ChangeBatcher(QObject* parent = nullptr); + + void dirty(); + +signals: + void dirtied(); + +private: + bool m_dirty; + + void flush(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/codecs.cpp b/Plugins/ZShell/Settings/codecs.cpp new file mode 100644 index 0000000..478d34c --- /dev/null +++ b/Plugins/ZShell/Settings/codecs.cpp @@ -0,0 +1,371 @@ +#include "codecs.hpp" + +#include +#include +#include + +#include + +#include "util/metaenum.hpp" + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +// We don't know the option here so it isn't set, it should be set by the caller +DecodeResult error(DiagnosticType::Type type, const QString& message) { + Diagnostic error; + error.type = type; + error.message = message; + return { .value = QVariant(), .error = error, .indexPath = {} }; +} + +DecodeResult mismatch(ExpectedType expected, const QJsonValue& value) { + return { .value = QVariant(), .error = Diagnostic::mismatch(expected, value), .indexPath = {} }; +} + +DecodeResult mismatch(const QList& expected, const QJsonValue& value) { + return { .value = QVariant(), .error = Diagnostic::mismatch(expected, value), .indexPath = {} }; +} + +template ValueCodec* makeListCodec(const QMetaType& type) { + const auto* elementCodec = ValueCodec::codecFor(QMetaType::fromType()); + return elementCodec ? new ListCodec(type, elementCodec) : nullptr; +} + +// Unions are keyed by their alternatives in order, since order decides which one wins +QString unionKey(const QList& types) { + QStringList ids; + ids.reserve(types.size()); + for (const auto& type : types) + ids << QString::number(type.id()); + return ids.join(u","_s); +} + +QList expectedTypesOf(const QList& alternatives) { + QList types; + types.reserve(alternatives.size()); + for (const auto* codec : alternatives) + types << codec->expected(); + return types; +} + +using ListFactory = ValueCodec* (*)(const QMetaType&); + +const QHash& listFactories() { + static const QHash k_factories{ + { QMetaType::fromType().id(), &makeListCodec }, + { QMetaType::fromType>().id(), &makeListCodec> }, + }; + return k_factories; +} + +} // namespace + +ValueCodec::ValueCodec(const QMetaType& type, ExpectedType expected) + : m_type(type) + , m_expected(expected) {} + +QMetaType ValueCodec::type() const { + return m_type; +} + +ExpectedType ValueCodec::expected() const { + return m_expected; +} + +ValueCodec* ValueCodec::codecFor(const QMetaType& type) { + // Cache for codecs, keyed by type id + static QHash s_registry; + + // Cached lookup + if (const auto it = s_registry.constFind(type.id()); it != s_registry.constEnd()) + return *it; + + ValueCodec* codec = nullptr; + switch (type.id()) { + case QMetaType::Bool: + codec = new BoolCodec(type); + break; + case QMetaType::Int: + codec = new IntCodec(type); + break; + case QMetaType::Double: + codec = new RealCodec(type); + break; + case QMetaType::QString: + codec = new StringCodec(type); + break; + case QMetaType::QVariantList: + codec = new VariantListCodec(type); + break; + case QMetaType::QVariantMap: + codec = new VariantMapCodec(type); + break; + default: + if (const auto factory = listFactories().constFind(type.id()); factory != listFactories().constEnd()) + codec = (*factory)(type); + else if (util::isSupportedEnum(type)) + codec = new EnumCodec(type, util::metaEnumFor(type)); + break; + } + + // Cache codec + if (codec) + s_registry.insert(type.id(), codec); + + return codec; +} + +ValueCodec* ValueCodec::unionFor(const QList& types) { + // Cache for union codecs, keyed by their alternatives + static QHash s_registry; + + if (types.size() < 2) { + qCCritical(lcSettings, "A union needs at least two types, got %lld", types.size()); + return nullptr; + } + + const auto key = unionKey(types); + + // Cached lookup + if (const auto it = s_registry.constFind(key); it != s_registry.constEnd()) + return *it; + + QList alternatives; + alternatives.reserve(types.size()); + + for (const auto& type : types) { + const auto* codec = codecFor(type); + if (!codec) { + qCCritical(lcSettings, "No codec found for type %s, cannot build union", type.name()); + return nullptr; + } + alternatives << codec; + } + + auto* const codec = new UnionCodec(alternatives); + s_registry.insert(key, codec); + + return codec; +} + +QJsonValue BoolCodec::encode(const QVariant& value) const { + return value.toBool(); +} + +DecodeResult BoolCodec::decode(const QJsonValue& value) const { + // 1 and "true" are not booleans + if (!value.isBool()) + return mismatch(ExpectedType::Bool, value); + + return { .value = value.toBool(), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue IntCodec::encode(const QVariant& value) const { + return value.toInt(); +} + +DecodeResult IntCodec::decode(const QJsonValue& value) const { + if (!value.isDouble()) + return mismatch(ExpectedType::Int, value); + + const auto num = value.toDouble(); + + double integral; + if (std::fpclassify(std::modf(num, &integral)) != FP_ZERO) + return error( + DiagnosticType::InvalidValue, QStringLiteral("Expected an integer, got the real %1").arg(QString::number(num))); + + constexpr auto k_min = std::numeric_limits::min(); + constexpr auto k_max = std::numeric_limits::max(); + if (num < static_cast(k_min) || num > static_cast(k_max)) { + const auto message = QStringLiteral("Integer %1 is out of range, expected between %2 and %3").arg(QString::number(num, 'f', 0), QString::number(k_min), QString::number(k_max)); + return error(DiagnosticType::InvalidValue, message); + } + + return { .value = static_cast(num), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue RealCodec::encode(const QVariant& value) const { + return value.toDouble(); +} + +DecodeResult RealCodec::decode(const QJsonValue& value) const { + if (!value.isDouble()) + return mismatch(ExpectedType::Real, value); + + return { .value = QVariant::fromValue(value.toDouble()), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue StringCodec::encode(const QVariant& value) const { + return value.toString(); +} + +DecodeResult StringCodec::decode(const QJsonValue& value) const { + if (!value.isString()) + return mismatch(ExpectedType::String, value); + + return { .value = value.toString(), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue VariantListCodec::encode(const QVariant& value) const { + return QJsonArray::fromVariantList(value.toList()); +} + +DecodeResult VariantListCodec::decode(const QJsonValue& value) const { + if (!value.isArray()) + return mismatch(ExpectedType::Array, value); + + return { .value = value.toArray().toVariantList(), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue VariantMapCodec::encode(const QVariant& value) const { + return QJsonObject::fromVariantMap(value.toMap()); +} + +DecodeResult VariantMapCodec::decode(const QJsonValue& value) const { + if (!value.isObject()) + return mismatch(ExpectedType::Object, value); + + return { .value = value.toObject().toVariantMap(), .error = std::nullopt, .indexPath = {} }; +} + +EnumCodec::EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum) + : ValueCodec(type, ExpectedType::String) + , m_metaEnum(metaEnum) {} + +QJsonValue EnumCodec::encode(const QVariant& value) const { + const auto* key = util::enumKeyFor(m_metaEnum, value); + if (!key) { + qCWarning( + lcSettings, "Cannot encode value %lld of enum %s, no such enumerator", value.toLongLong(), m_type.name()); + return {}; + } + + return QString::fromUtf8(key); +} + +DecodeResult EnumCodec::decode(const QJsonValue& value) const { + if (!value.isString()) + return mismatch(ExpectedType::String, value); + + const auto key = value.toString(); + + for (int i = 0; i < m_metaEnum.keyCount(); ++i) { + if (QString::compare(QString::fromUtf8(m_metaEnum.key(i)), key, Qt::CaseInsensitive) != 0) + continue; + + const auto raw = m_metaEnum.value(i); + QVariant decoded(m_type); + if (!QMetaType::convert(QMetaType::fromType(), &raw, m_type, decoded.data())) { + qCCritical(lcSettings, "Failed to convert value %d to enum %s", raw, m_type.name()); + return error(DiagnosticType::InvalidValue, + QStringLiteral("Could not convert %1 to %2").arg(key, QString::fromUtf8(m_type.name()))); + } + + return { .value = decoded, .error = std::nullopt, .indexPath = {} }; + } + + QStringList options; + options.reserve(m_metaEnum.keyCount()); + for (int i = 0; i < m_metaEnum.keyCount(); ++i) + options << QString::fromUtf8(m_metaEnum.key(i)); + + return error(DiagnosticType::InvalidValue, + QStringLiteral("Invalid enum value %1. Expected one of %2").arg(key, options.join(u", "_s))); +} + +template +ListCodec::ListCodec(const QMetaType& type, const ValueCodec* elementCodec) + : ValueCodec(type, ExpectedType::Array) + , m_elementCodec(elementCodec) {} + +template QJsonValue ListCodec::encode(const QVariant& value) const { + QJsonArray array; + const auto list = value.value(); + for (const auto& element : list) + array.append(m_elementCodec->encode(QVariant::fromValue(element))); + return array; +} + +template DecodeResult ListCodec::decode(const QJsonValue& value) const { + if (!value.isArray()) + return mismatch(ExpectedType::Array, value); + + const auto array = value.toArray(); + Container list; + list.reserve(array.size()); + + for (qsizetype i = 0; i < array.size(); ++i) { + auto result = m_elementCodec->decode(array.at(i)); + + // Reject the entire list if any element is invalid + if (result.error) { + result.indexPath.prepend(i); + return result; + } + + list.append(result.value.value()); + } + + return { .value = QVariant::fromValue(list), .error = std::nullopt, .indexPath = {} }; +} + +UnionCodec::UnionCodec(const QList& alternatives) + : ValueCodec(QMetaType::fromType(), alternatives.first()->expected()) + , m_alternatives(alternatives) + , m_expectedTypes(expectedTypesOf(alternatives)) { + m_byType.reserve(alternatives.size()); + + for (const auto* codec : alternatives) + m_byType.insert(codec->type().id(), codec); +} + +QJsonValue UnionCodec::encode(const QVariant& value) const { + // An unset union is simply absent from the file + if (!value.isValid()) + return QJsonValue::Undefined; + + const auto* codec = m_byType.value(value.metaType().id()); + if (!codec) { + qCWarning(lcSettings, "Cannot encode value of type %s, it is not one of the allowed types", value.typeName()); + return QJsonValue::Undefined; + } + + return codec->encode(value); +} + +DecodeResult UnionCodec::decode(const QJsonValue& value) const { + std::optional best; + + for (const auto* codec : m_alternatives) { + auto result = codec->decode(value); + + // The first alternative to accept the value wins + if (!result.error) + return result; + + // Non-nested type mismatch just means try another alternative + if (result.indexPath.isEmpty() && result.error->type == DiagnosticType::TypeMismatch) + continue; + + // Report the deepest error that isn't a type mismatch + if (!best || result.indexPath.size() > best->indexPath.size()) + best = std::move(result); + } + + if (best) + return *best; + + // Nothing matched the shape, so report every alternative + return mismatch(m_expectedTypes, value); +} + +// Instantiated for types as needed +template class ListCodec; +template class ListCodec>; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/codecs.hpp b/Plugins/ZShell/Settings/codecs.hpp new file mode 100644 index 0000000..2bbf2b2 --- /dev/null +++ b/Plugins/ZShell/Settings/codecs.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "common.hpp" + +namespace ZShell::settings { + +struct DecodeResult { + QVariant value; + std::optional error; + QList indexPath; // Index path of the failing element, outermost list first +}; + +class ValueCodec { +public: + explicit ValueCodec(const QMetaType& type, ExpectedType expected); + virtual ~ValueCodec() = default; + + // Returns the shared codec for a type, or nullptr if the type is unsupported + static ValueCodec* codecFor(const QMetaType& type); + + // Returns the shared codec for a union of types, or nullptr if any of them is unsupported + static ValueCodec* unionFor(const QList& types); + + [[nodiscard]] QMetaType type() const; + [[nodiscard]] ExpectedType expected() const; // The JSON type this decodes from + [[nodiscard]] virtual QJsonValue encode(const QVariant& value) const = 0; + [[nodiscard]] virtual DecodeResult decode(const QJsonValue& value) const = 0; + +protected: + const QMetaType m_type; + const ExpectedType m_expected; + + Q_DISABLE_COPY_MOVE(ValueCodec) +}; + +#define CODEC(Type, Expected) \ + class Type##Codec : public ValueCodec { \ + public: \ + explicit Type##Codec(const QMetaType& type) \ + : ValueCodec(type, ExpectedType::Expected) {} \ + \ + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; \ + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; \ + }; + +CODEC(Bool, Bool) +CODEC(Int, Int) +CODEC(Real, Real) +CODEC(String, String) +CODEC(VariantList, Array) +CODEC(VariantMap, Object) + +#undef CODEC + +class EnumCodec : public ValueCodec { +public: + explicit EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + +private: + const QMetaEnum m_metaEnum; +}; + +template class ListCodec : public ValueCodec { + using Value = Container::value_type; + +public: + explicit ListCodec(const QMetaType& type, const ValueCodec* elementCodec); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + +private: + const ValueCodec* m_elementCodec; +}; + +// Decodes any one of several types, for options that accept more than one shape +class UnionCodec : public ValueCodec { +public: + explicit UnionCodec(const QList& alternatives); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + +private: + const QList m_alternatives; // Tried in order, so the first to accept a value wins + const QList m_expectedTypes; // Types of the alternatives, for diagnostics + QHash m_byType; // Type id to alternative, for encoding +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/common.cpp b/Plugins/ZShell/Settings/common.cpp new file mode 100644 index 0000000..b7113fc --- /dev/null +++ b/Plugins/ZShell/Settings/common.cpp @@ -0,0 +1,131 @@ +#include "common.hpp" + +#include "node.hpp" + +using Qt::StringLiterals::operator""_s; + +namespace ZShell::settings { + +Q_LOGGING_CATEGORY(lcSettings, "zshell.settings", QtInfoMsg) + +WriteScope::WriteScope(Node* node, WriteOrigin origin) + : m_root(node->rootNode()), m_previous(m_root->m_writeOrigin) { + m_root->m_writeOrigin = origin; +} + +WriteScope::~WriteScope() { + m_root->m_writeOrigin = m_previous; +} + +InternalRead::InternalRead(Node* node) + : m_root(node->rootNode()), m_previous(m_root->m_internalRead) { + m_root->m_internalRead = true; +} + +InternalRead::~InternalRead() { + m_root->m_internalRead = m_previous; +} + +QString DiagnosticType::toString(Type t) { + switch (t) { + case UnknownOption: + return u"UnknownOption"_s; + case GlobalOption: + return u"GlobalOption"_s; + case TypeMismatch: + return u"TypeMismatch"_s; + case InvalidValue: + return u"InvalidValue"_s; + } + + Q_UNREACHABLE_RETURN(QString()); +} + +namespace { + +QString expectedStr(ExpectedType expected) { + switch (expected) { + case ExpectedType::Bool: + return QStringLiteral("a boolean"); + case ExpectedType::Int: + return QStringLiteral("an integer"); + case ExpectedType::Real: + return QStringLiteral("a number"); + case ExpectedType::String: + return QStringLiteral("a string"); + case ExpectedType::Array: + return QStringLiteral("an array"); + case ExpectedType::Object: + return QStringLiteral("an object"); + } + + Q_UNREACHABLE_RETURN(QString()); +} + +QString receivedStr(const QJsonValue& value) { + switch (value.type()) { + case QJsonValue::Null: + return QStringLiteral("null"); + case QJsonValue::Bool: + return QStringLiteral("a boolean"); + case QJsonValue::Double: + return QStringLiteral("a number"); + case QJsonValue::String: + return QStringLiteral("a string"); + case QJsonValue::Array: + return QStringLiteral("an array"); + case QJsonValue::Object: + return QStringLiteral("an object"); + default: + return QStringLiteral("nothing"); + } +} + +QString mismatchStr( + const QList& expected, const QJsonValue& value) { + QStringList types; + types.reserve(expected.size()); + for (const auto type : expected) + types << expectedStr(type); + const auto received = receivedStr(value); + + switch (expected.size()) { + case 2: + return QStringLiteral("Expected %1 or %2, got %3") + .arg(types[0], types[1], received); + case 3: + return QStringLiteral("Expected %1, %2 or %3, got %4") + .arg(types[0], types[1], types[2], received); + case 4: + return QStringLiteral("Expected one of: %1, %2, %3, %4; got %5") + .arg(types[0], types[1], types[2], types[3], received); + default: + // The bounds are checked in macros.hpp `unionType<...T>` + Q_UNREACHABLE_RETURN(QString()); + } +} + +} // namespace + +Diagnostic Diagnostic::mismatch( + ExpectedType expected, const QJsonValue& value, const QString& option) { + return { + .type = DiagnosticType::TypeMismatch, + .option = option, + .message = QStringLiteral("Expected %1, got %2") + .arg(expectedStr(expected), receivedStr(value)), + }; +} + +Diagnostic Diagnostic::mismatch( + const QList& expected, + const QJsonValue& value, + const QString& option) { + return { + .type = DiagnosticType::TypeMismatch, + .option = option, + .message = mismatchStr(expected, value), + }; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/common.hpp b/Plugins/ZShell/Settings/common.hpp new file mode 100644 index 0000000..829de23 --- /dev/null +++ b/Plugins/ZShell/Settings/common.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::settings { + +Q_DECLARE_LOGGING_CATEGORY(lcSettings) + +class Node; + +enum class WriteOrigin : quint8 { + Init, // On init + File, // From the JSON file + FileReset, // When option not present in file + Layer, // From the fallback layer + Qml, // From QML + QmlReset, // On option reset +}; + +class WriteScope { + public: + explicit WriteScope(Node* node, WriteOrigin origin); + ~WriteScope(); + + private: + Node* const m_root; + const WriteOrigin m_previous; + + Q_DISABLE_COPY_MOVE(WriteScope) +}; + +class InternalRead { + public: + explicit InternalRead(Node* node); + ~InternalRead(); + + private: + Node* const m_root; + const bool m_previous; + + Q_DISABLE_COPY_MOVE(InternalRead) +}; + +class DiagnosticType : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + enum Type : quint8 { + UnknownOption = 0, + GlobalOption, + TypeMismatch, + InvalidValue, + }; + Q_ENUM(Type) + + Q_INVOKABLE static QString toString(Type t); +}; + +enum class ExpectedType : quint8 { + Bool, + Int, + Real, + String, + Array, + Object, +}; + +struct Diagnostic { + Q_GADGET + QML_VALUE_TYPE(diagnostic) + + Q_PROPERTY(DiagnosticType::Type type MEMBER type) + Q_PROPERTY(QString option MEMBER option) + Q_PROPERTY(QString message MEMBER message) + + public: + DiagnosticType::Type type = DiagnosticType::UnknownOption; + QString option; + QString message; + + static Diagnostic mismatch( + ExpectedType expected, + const QJsonValue& value, + const QString& option = {}); + static Diagnostic mismatch( + const QList& expected, + const QJsonValue& value, + const QString& option = {}); + + bool operator==(const Diagnostic& other) const = default; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/layerregistry.hpp b/Plugins/ZShell/Settings/layerregistry.hpp new file mode 100644 index 0000000..a41d6b1 --- /dev/null +++ b/Plugins/ZShell/Settings/layerregistry.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include + +#include "node.hpp" + +namespace ZShell::settings { + +// Derived from Node and has ctor(path, fallback, parent) +template +concept LayerType = std::derived_from && std::constructible_from; + +template class LayerRegistry { +public: + explicit LayerRegistry(const QString& prefix, const QString& suffix, QObject* parent); + + [[nodiscard]] QString pathFor(const QString& name) const; + [[nodiscard]] QString nameFor(T* layer) const; + [[nodiscard]] T* get(const QString& name, T* fallback, bool* created = nullptr); // Created on demand + +private: + const QString m_prefix; + const QString m_suffix; + QObject* const m_parent; + QHash m_layers; +}; + +namespace detail { + +inline QString stripTrailingSlashes(QStringView str) { + while (str.endsWith(u'/')) + str = str.left(str.length() - 1); + return str.toString(); +} + +inline QString stripLeadingSlashes(QStringView str) { + while (str.startsWith(u'/')) + str = str.mid(1); + return str.toString(); +} + +} // namespace detail + +template +LayerRegistry::LayerRegistry(const QString& prefix, const QString& suffix, QObject* parent) + : m_prefix(detail::stripTrailingSlashes(prefix)) + , m_suffix(detail::stripLeadingSlashes(suffix)) + , m_parent(parent) {} + +template QString LayerRegistry::pathFor(const QString& name) const { + return m_prefix + u'/' + name + u'/' + m_suffix; +} + +template QString LayerRegistry::nameFor(T* layer) const { + return m_layers.key(layer); +} + +template T* LayerRegistry::get(const QString& name, T* fallback, bool* created) { + if (auto* const layer = m_layers.value(name)) { + if (created) + *created = false; + return layer; + } + + if (created) + *created = true; + + auto* const layer = new T(pathFor(name), fallback, m_parent); + m_layers.insert(name, layer); + return layer; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.cpp b/Plugins/ZShell/Settings/listnode.cpp new file mode 100644 index 0000000..f1cd4ea --- /dev/null +++ b/Plugins/ZShell/Settings/listnode.cpp @@ -0,0 +1,445 @@ +#include "listnode.hpp" + +#include +#include + + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +QString valuesKey() { + return u"values"_s; +} + +void deleteNode(Node* node) { + node->detachFallback(); + node->setParent(nullptr); + node->deleteLater(); +} + +bool isVariantListType(const QVariant& value) { + return value.typeId() == QMetaType::QVariantList || value.typeId() == qMetaTypeId>(); +} + +QList toMaps(const QVariant& value) { + if (value.typeId() == qMetaTypeId>()) + return value.value>(); + + const auto list = value.toList(); + QList maps; + maps.reserve(list.count()); + + for (const auto& el : list) { + if (el.typeId() != QMetaType::QVariantMap) { + qCCritical(lcSettings, "Expected a map in list properties, got %s.", el.typeName()); + continue; + } + + maps << el.toMap(); + } + + return maps; +} + +void syncNodeRecursive(Node* node, const QVariantMap& props) { + const auto& schema = node->schema(); + + for (const auto& [key, val] : props.asKeyValueRange()) { + const auto* desc = schema.get(key); + + if (!desc) { + qCCritical(lcSettings, "Unknown key %s in props for %s, something is wrong.", qUtf8Printable(key), + qUtf8Printable(node->path())); + continue; + } + + // `setValue` warns on type mismatches itself + if (!desc->isNode) { + node->setValue(key, val); + continue; + } + + auto* const child = node->value(key).value(); + + // Nested lists take their elements straight from props + if (auto* const list = qobject_cast(child)) { + if (!isVariantListType(val)) { + qCCritical(lcSettings, "Expected a list for node key %s on %s, got %s.", qUtf8Printable(key), + qUtf8Printable(node->path()), val.typeName()); + continue; + } + + list->setValue(valuesKey(), val); + continue; + } + + if (val.typeId() != QMetaType::QVariantMap) { + qCCritical(lcSettings, "Expected a map for node key %s on %s, got %s.", qUtf8Printable(key), + qUtf8Printable(node->path()), val.typeName()); + continue; + } + + syncNodeRecursive(child, val.toMap()); + } +} + +} // namespace + +ListNode::ListNode(ListNode* fallback, QObject* parent, bool globalOnly) + : Node(fallback, parent, globalOnly) { + if (fallback) { + // Disconnect generic fallback notify, lists use a custom one + QObject::disconnect(fallback, &ListNode::optionChanged, this, nullptr); + + if (!m_globalOnly) + QObject::connect(fallback, &ListNode::elementsChanged, this, &ListNode::onFallbackListNotify); + } +} + +qsizetype ListNode::count() const { + return m_elements.count(); +} + +QVariantList ListNode::values() const { + QVariantList list; + list.reserve(m_elements.count()); + for (auto* const element : m_elements) + list << QVariant::fromValue(element); + return list; +} + +void ListNode::remove(qsizetype index) { + if (rejectGlobalMutation()) + return; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (!validIndex(index)) { + qCWarning(lcSettings, "Attempted to remove invalid index %lld from list node %s, ignoring.", index, + qUtf8Printable(path())); + + recordWrite(valuesKey(), false); + return; + } + + deleteNode(m_elements.takeAt(index)); + + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({}, { index }, {}); +} + +void ListNode::move(qsizetype from, qsizetype to) { + if (rejectGlobalMutation()) + return; + + const WriteScope scope(this, WriteOrigin::Qml); + + const auto fromInvalid = !validIndex(from); + if (fromInvalid || !validIndex(to)) { + if (fromInvalid) + qCWarning(lcSettings, "Attempted to move invalid index %lld to index %lld on list node %s, ignoring.", from, + to, qUtf8Printable(path())); + else + qCWarning(lcSettings, "Attempted to move index %lld to invalid index %lld on list node %s, ignoring.", from, + to, qUtf8Printable(path())); + + recordWrite(valuesKey(), false); + return; + } + + m_elements.move(from, to); + + recordWrite(valuesKey(), true); + emit elementsChanged({}, {}, { { from, to } }); +} + +void ListNode::clear() { + if (rejectGlobalMutation()) + return; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (m_elements.isEmpty()) { + recordWrite(valuesKey(), false); + return; + } + + const auto len = m_elements.count(); + + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); + + recordWrite(valuesKey(), true); + emit countChanged(); + + NodeChanges removed(len); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged({}, removed, {}); +} + +QString ListNode::pathFor(const QString& key) const { + return elementPath(path(), key); +} + +const Schema& ListNode::schema() const { + // Offset +1 so count is not registered (allow read only cause values is read only) + static const auto k_schema = Schema::build(&staticMetaObject, Node::staticMetaObject.propertyCount() + 1, true); + return k_schema; +} + +QVariant ListNode::value(const QString& key) const { + if (key != valuesKey()) { + qCCritical(lcSettings, + "Attempted to read %s on list node %s. List nodes only have a 'values' key, something is wrong.", + qUtf8Printable(key), qUtf8Printable(path())); + return {}; + } + + return QVariant::fromValue(m_elements); +} + +bool ListNode::setValue(const QString& key, const QVariant& value) { + return setValue(key, value, nullptr); +} + +bool ListNode::setValue(const QString& key, const QVariant& value, QList* diagnostics) { + if (key != valuesKey()) { + qCCritical(lcSettings, + "Attempted to write %s on list node %s. List nodes only have a 'values' key, something is wrong.", + qUtf8Printable(key), qUtf8Printable(path())); + return false; + } + + const auto len = m_elements.count(); + + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); + + if (value.typeId() == qMetaTypeId>()) { + // On reset to fallback + const auto nodes = value.value>(); + for (auto* const node : nodes) + insertNode(m_elements.count(), node); + } else if (isVariantListType(value)) { + // From defaults or QML + const auto list = toMaps(value); + for (qsizetype i = 0; i < list.count(); ++i) + insertNode(i, list.at(i), fallbackFor(i)); + } else if (value.typeId() == QMetaType::QJsonArray) { + // From syncJson + const auto array = value.toJsonArray(); + for (const auto& json : array) + insertNode(m_elements.count(), json.toObject(), *diagnostics); + } else { + qCCritical(lcSettings, "Unexpected type %s for list node %s, something is wrong.", value.typeName(), + qUtf8Printable(path())); + return false; + } + + if (!recordWrite(valuesKey(), true)) + return false; + + if (len != m_elements.count()) + emit countChanged(); + + NodeChanges added(m_elements.count()); + NodeChanges removed(len); + std::iota(added.begin(), added.end(), 0); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged(added, removed, {}); + + return true; +} + +void ListNode::resetToDefaults() { + if (isNested()) { + qCCritical(lcSettings, "List node %s has a parent list node, resetToDefaults should never be called on it.", + qUtf8Printable(path())); + return; + } + + // Don't reset global only list nodes on overlays + if (fallbackNode() && m_globalOnly) + return; + + const WriteScope scope(this, WriteOrigin::FileReset); + setValue(valuesKey(), fallbackNode() ? fallbackNode()->value(valuesKey()) : QVariant::fromValue(defaultValue())); +} + +QJsonValue ListNode::toJson(bool sparse) const { + Q_UNUSED(sparse) // Lists can't be sparse + + QJsonArray array; + for (auto* const element : m_elements) + array << element->toJson(false); + return array; +} + +bool ListNode::syncJson(const QJsonValue& json, QList& diagnostics) { + if (!json.isArray()) { + const auto d = Diagnostic::mismatch(ExpectedType::Array, json, path()); + qCWarning(lcSettings, "Error decoding option %s: %s", qUtf8Printable(d.option), + qUtf8Printable(d.message)); + diagnostics << d; + return false; + } + + // Refuse syncs to global only list nodes on overlays + if (rejectGlobalSync(diagnostics)) + return false; + + const WriteScope scope(this, WriteOrigin::File); + setValue(valuesKey(), json.toArray(), &diagnostics); + + return true; +} + +bool ListNode::recordWrite(const QString& key, bool changed) { + const auto wasOverride = isOverride(key); + const auto notify = Node::recordWrite(key, changed); + + // Elements inside overridden lists have no fallbacks, so detach here + if (!wasOverride && isOverride(key)) + for (auto* const element : std::as_const(m_elements)) + element->detachFallback(); + + return notify; +} + +QString ListNode::keyOf(const Node* child) const { + return QString::number(m_elements.indexOf(const_cast(child))); +} + +Node* ListNode::elementAt(qsizetype index) const { + if (!validIndex(index)) + return nullptr; + return m_elements.at(index); +} + +Node* ListNode::insertElement(const QVariantMap& props, qsizetype index) { + if (rejectGlobalMutation()) + return nullptr; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (!validIndex(index)) // Invalid index means append + index = m_elements.count(); + + auto* const element = insertNode(index, props, nullptr); + + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({ index }, {}, {}); + + return element; +} + +QList ListNode::defaultValue() const { + const auto* desc = getDescriptor(); + if (!desc) + return {}; + return desc->defaultValue().value>(); +} + +bool ListNode::isNested() const { + return qobject_cast(parentNode()); +} + +bool ListNode::rejectGlobalMutation() const { + if (!m_globalOnly || !fallbackNode()) + return false; + + qCWarning(lcSettings, + "Attempted to mutate global list %s from an overlay layer, ignoring. " + "This should not be used, mutate global lists from the global layer instead.", + qUtf8Printable(path())); + + return true; +} + +bool ListNode::validIndex(qsizetype index) const { + return index >= 0 && index < m_elements.count(); +} + +const Descriptor* ListNode::getDescriptor() const { + if (!parentNode()) { // List nodes should not be root nodes + qCCritical(lcSettings, "List node %s has no parent, something is wrong.", qUtf8Printable(path())); + return nullptr; + } + + const auto* desc = parentNode()->schema().get(key()); + + if (!desc) { + qCCritical(lcSettings, "List node %s is not in parent schema, something is wrong.", qUtf8Printable(path())); + return nullptr; + } + + return desc; +} + +Node* ListNode::fallbackFor(qsizetype index) const { + if (isOverride(valuesKey())) + return nullptr; + + const auto* fallback = static_cast(fallbackNode()); + if (!fallback || !fallback->validIndex(index)) + return nullptr; + + return fallback->m_elements.at(index); +} + +Node* ListNode::insertNode(qsizetype index, const QVariantMap& props, Node* fallback) { + auto* const node = createElement(fallback); + m_elements.insert(index, node); + + const WriteScope scope(node, WriteOrigin::Init); + syncNodeRecursive(node, props); + + return node; +} + +Node* ListNode::insertNode(qsizetype index, Node* node) { + auto* const copy = createElement(node); + m_elements.insert(index, copy); + return copy; +} + +Node* ListNode::insertNode(qsizetype index, const QJsonObject& json, QList& diagnostics) { + // Write origin will be file/file reset, which is correct. + // This function should only be called from the JSON path of setValue. + auto* const node = createElement(nullptr); // Nodes synced from JSON get no fallback as they are overrides + m_elements.insert(index, node); + + node->syncJson(json, diagnostics); + + return node; +} + +void ListNode::onFallbackListNotify(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved) { + if (isOverride(valuesKey())) + return; + + const WriteScope scope(this, WriteOrigin::Layer); + + for (const auto index : removed | std::views::reverse) + deleteNode(m_elements.takeAt(index)); + for (const auto index : added) + insertNode(index, fallbackFor(index)); + for (const auto& move : moved) + m_elements.move(move.first, move.second); + + if (!recordWrite(valuesKey(), true)) + return; + + if (!added.isEmpty() || !removed.isEmpty()) + emit countChanged(); + emit elementsChanged(added, removed, moved); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.hpp b/Plugins/ZShell/Settings/listnode.hpp new file mode 100644 index 0000000..e6b51dc --- /dev/null +++ b/Plugins/ZShell/Settings/listnode.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include "node.hpp" + +namespace ZShell::settings { + +class ListNode : public Node { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(qsizetype count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY elementsChanged) + + using NodeChanges = QList; + using MoveChanges = QList>; + +public: + explicit ListNode(ListNode* fallback, QObject* parent = nullptr, bool globalOnly = false); + + [[nodiscard]] qsizetype count() const; + [[nodiscard]] QVariantList values() const; + + Q_INVOKABLE void remove(qsizetype index); + Q_INVOKABLE void move(qsizetype from, qsizetype to); + Q_INVOKABLE void clear(); + + [[nodiscard]] QString pathFor(const QString& key) const override; + [[nodiscard]] const Schema& schema() const override; + [[nodiscard]] QVariant value(const QString& key) const override; + bool setValue(const QString& key, const QVariant& value) override; + bool setValue(const QString& key, const QVariant& value, QList* diagnostics); + void resetToDefaults() override; + + [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; + bool syncJson(const QJsonValue& json, QList& diagnostics) override; + +signals: + void countChanged(); + void elementsChanged(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved); + +protected: + bool recordWrite(const QString& key, bool changed) override; + [[nodiscard]] QString keyOf(const Node* child) const override; + + [[nodiscard]] Node* elementAt(qsizetype index) const; + [[nodiscard]] Node* insertElement(const QVariantMap& props, qsizetype index = -1); + [[nodiscard]] virtual Node* createElement(Node* fallback) = 0; + +private: + QList m_elements; + + [[nodiscard]] QList defaultValue() const; + [[nodiscard]] bool isNested() const; + + // Returns true if the mutation should be skipped, overlays cannot mutate global lists + [[nodiscard]] bool rejectGlobalMutation() const; + + [[nodiscard]] bool validIndex(qsizetype index) const; + [[nodiscard]] const Descriptor* getDescriptor() const; + [[nodiscard]] Node* fallbackFor(qsizetype index) const; + + Node* insertNode(qsizetype index, const QVariantMap& props, Node* fallback); + Node* insertNode(qsizetype index, Node* node); + Node* insertNode(qsizetype index, const QJsonObject& json, QList& diagnostics); + + void onFallbackListNotify(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/macros.hpp b/Plugins/ZShell/Settings/macros.hpp new file mode 100644 index 0000000..6d065fe --- /dev/null +++ b/Plugins/ZShell/Settings/macros.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include +#include +#include + +#include "listnode.hpp" // IWYU pragma: keep +#include "schema.hpp" // IWYU pragma: keep + +namespace ZShell::settings { + +inline QVariantMap vmap(std::initializer_list> entries) { + QVariantMap map; + for (const auto& [key, value] : entries) + map.insert(key, value); + return map; +} + +template inline QList unionTypes() { + static_assert(sizeof...(Ts) >= 2, "A union needs at least two types"); + // If the max size is changed, common.cpp `mismatchStr` must be updated + static_assert(sizeof...(Ts) <= 4, "A union cannot have more than 4 types"); + static_assert((!std::is_same_v && ...), "A union cannot contain QVariant"); + + return { QMetaType::fromType()... }; +} + +namespace detail { + +template inline bool compare(const T& a, const T& b) { + return a == b; +} + +template bool compare(const T& a, const T& b) { + return qFuzzyCompare(a + T(1), b + T(1)); +} + +template bool compare(const QList& a, const QList& b) { + if (a.size() != b.size()) + return false; + + for (qsizetype i = 0; i < a.size(); ++i) + if (!compare(a.at(i), b.at(i))) + return false; + return true; +} + +} // namespace detail + +} // namespace ZShell::settings + +// Helper macro to prevent splitting initialiser lists +#define DEFAULT_ARG(...) __VA_ARGS__ + +// Declares a class to be a node class. This replaces the Q_OBJECT call at the top of the class. +#define SETTINGS_NODE_NO_CTOR(Class, Base) \ + Q_OBJECT \ + \ +public: \ + [[nodiscard]] const ZShell::settings::Schema& schema() const override { \ + static const auto schema = \ + ZShell::settings::Schema::build(&staticMetaObject, Base::staticMetaObject.propertyCount()); \ + return schema; \ + } \ + \ +private: \ + using Self = Class; // For use in the below macros + +#define SETTINGS_NODE(Class, Base) \ + SETTINGS_NODE_NO_CTOR(Class, Base) \ + QML_ANONYMOUS \ + \ +public: \ + explicit Class(Class* fallback = nullptr, QObject* parent = nullptr, bool globalOnly = false) \ + : Base(fallback, parent, globalOnly) {} \ + \ +private: + +// Defines a property on a node. +#define SETTINGS_PROPERTY_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ + \ +public: \ + [[nodiscard]] Type name() const { \ + if (global || m_globalOnly) \ + warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + void set_##name(const Type& value) { \ + if (!true /* TODO: validation */) \ + return; \ + \ + if (rejectInvalidWrite(QStringLiteral(#name), value)) \ + return; /* Skip writes of the wrong type */ \ + \ + if (rejectGlobalWrite(QStringLiteral(#name))) \ + return; /* Skip writes to global only keys, they should be sent to the global layer */ \ + \ + const auto needsNotify = !ZShell::settings::detail::compare(value, m_##name); \ + m_##name = value; \ + if (recordWrite(QStringLiteral(#name), needsNotify)) \ + Q_EMIT name##Changed(); \ + } \ + \ + Q_SIGNAL void name##Changed(); \ + \ +private: \ + Type m_##name = fallbackValue(&Self::m_##name, defaultVal); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate(&staticMetaObject, QStringLiteral(#name), \ + { .defaultValue = QVariant::fromValue(defaultVal), .globalOnly = global, __VA_ARGS__ }), \ + true); + +#define SETTINGS_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +// Defines a global property on a node. Shorthand for .globalOnly = true. +#define SETTINGS_GLOBAL_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +// Defines a subobject property on a node. Subobject properties are CONSTANT. +#define SETTINGS_SUBOBJECT_IMPL(Type, name, global) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ +public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) \ + warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ +private: \ + Type* m_##name = new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, QStringLiteral(#name), { .defaultValue = {}, .globalOnly = global }), \ + true); + +#define SETTINGS_SUBOBJECT(Type, name) SETTINGS_SUBOBJECT_IMPL(Type, name, false) + +// Defines a global subobject on a node. Everything inside it is global only. +#define SETTINGS_GLOBAL_SUBOBJECT(Type, name) SETTINGS_SUBOBJECT_IMPL(Type, name, true) + +// Defines a list type for use with SETTINGS_LIST. +#define SETTINGS_LIST_TYPE(Element, Name) \ + class Name : public ZShell::settings::ListNode { \ + Q_OBJECT \ + QML_ANONYMOUS \ + \ + public: \ + explicit Name(Name* fallback = nullptr, QObject* parent = nullptr, bool globalOnly = false) \ + : ZShell::settings::ListNode(fallback, parent, globalOnly) {} \ + \ + [[nodiscard]] Q_INVOKABLE Element* at(qsizetype index) const { /* Format ugh */ \ + return static_cast(elementAt(index)); \ + } \ + [[nodiscard]] Q_INVOKABLE Element* insert(const QVariantMap& props, qsizetype index = -1) { \ + return static_cast(insertElement(props, index)); \ + } \ + \ + protected: \ + [[nodiscard]] ZShell::settings::Node* createElement(ZShell::settings::Node* fallback) override { \ + return new Element(static_cast(fallback), this); \ + } \ + }; + +// Defines a list property on a node. List properties are CONSTANT. +#define SETTINGS_LIST_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ +public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) \ + warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ +private: \ + Type* m_##name = new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate(&staticMetaObject, QStringLiteral(#name), \ + { .defaultValue = QVariant::fromValue>(defaultVal), \ + .globalOnly = global, \ + __VA_ARGS__ }), \ + true); + +#define SETTINGS_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +// Defines a global list on a node. Everything inside it is global only. +#define SETTINGS_GLOBAL_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) diff --git a/Plugins/ZShell/Settings/node.cpp b/Plugins/ZShell/Settings/node.cpp new file mode 100644 index 0000000..9bacd19 --- /dev/null +++ b/Plugins/ZShell/Settings/node.cpp @@ -0,0 +1,280 @@ +#include "node.hpp" + + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +Node::Node(Node* fallback, QObject* parent, bool globalOnly) + : QObject(parent) + , m_globalOnly(globalOnly || (parentNode() && parentNode()->m_globalOnly)) + , m_rootNode(parentNode() ? parentNode()->rootNode() : this) + , m_fallbackNode(fallback) + , m_writeOrigin(WriteOrigin::Qml) + , m_internalRead(false) + , m_batcher(m_rootNode == this ? new ChangeBatcher(this) : nullptr) { + if (fallback && !m_globalOnly) + QObject::connect(fallback, &Node::optionChanged, this, &Node::onFallbackNotify); +} + +QString Node::key() const { + return parentNode() ? parentNode()->keyOf(this) : QString(); +} + +QString Node::path() const { + return parentNode() ? parentNode()->pathFor(key()) : key(); +} + +QString Node::pathFor(const QString& key) const { + const auto p = path(); + return p.isEmpty() ? key : p + u'.' + key; +} + +QString Node::elementPath(const QString& path, const QString& index) { + return path + u'[' + index + u']'; +} + +Node* Node::parentNode() const { + return qobject_cast(parent()); +} + +Node* Node::rootNode() const { + return m_rootNode; +} + +Node* Node::fallbackNode() const { + return m_fallbackNode; +} + +void Node::detachFallback() { + if (m_fallbackNode) { + QObject::disconnect(m_fallbackNode, nullptr, this, nullptr); + m_fallbackNode = nullptr; + } + + const auto childNodes = findChildren(Qt::FindDirectChildrenOnly); + for (auto* const child : childNodes) + child->detachFallback(); +} + +bool Node::isGlobalOnly() const { + return m_globalOnly; +} + +bool Node::isOverride(const QString& key) const { + return m_overrides.contains(key); +} + +const QSet& Node::overrides() const { + return m_overrides; +} + +bool Node::hasContent() const { + return !m_overrides.isEmpty() || m_quarantine || + std::ranges::any_of(findChildren(Qt::FindDirectChildrenOnly), &Node::hasContent); +} + +QVariant Node::value(const QString& key) const { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, "Attempted to read an unknown key %s, something is wrong.", qUtf8Printable(pathFor(key))); + return {}; + } + + // Generated getters warn on global reads, this silences them as the warning is only for QML reads + const InternalRead guard(m_rootNode); + return metaObject()->property(desc->metaIndex).read(this); +} + +bool Node::setValue(const QString& key, const QVariant& value) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical(lcSettings, "Attempted to set an unknown key %s, something is wrong.", qUtf8Printable(pathFor(key))); + return false; + } + + if (desc->isNode) { + qCCritical(lcSettings, "Attempted to set node %s directly, something is wrong.", qUtf8Printable(pathFor(key))); + return false; + } + + // Type mismatch, conversion should happen before this function is called + if (rejectInvalidWrite(key, value)) + return false; + + return metaObject()->property(desc->metaIndex).write(this, value); +} + +void Node::resetToDefaults() { + // Don't reset global only nodes on overlays, the whole subtree belongs to the global layer + if (m_globalOnly && m_fallbackNode) + return; + + m_quarantine.reset(); // Reset quarantine as well + + // No write scope, callers should create the scope + for (const auto& desc : schema().descriptors()) { + if (desc.isNode) + value(desc.key).value()->resetToDefaults(); + else if (!desc.globalOnly() || !m_fallbackNode) // Skip resetting global options on overlays + setValue(desc.key, m_fallbackNode ? m_fallbackNode->value(desc.key) : desc.defaultValue()); + } +} + +const Quarantine* Node::quarantine() const { + return m_quarantine.get(); +} + +bool Node::rejectInvalidWrite(const QString& key, const QVariant& value) const { + const auto* desc = schema().get(key); + if (!desc || desc->accepts(value.metaType())) + return false; + + qCWarning(lcSettings, "Type mismatch for %s, expected %s got %s", qUtf8Printable(pathFor(key)), + qUtf8Printable(desc->typeString()), value.metaType().name()); + return true; +} + +void Node::warnGlobalRead(const QString& key) const { + if (!m_fallbackNode || m_rootNode->m_internalRead) + return; + + qCWarning(lcSettings, + "Global option %s was read from an overlay layer. " + "This should not be used, read global options from the global layer instead.", + qUtf8Printable(pathFor(key))); +} + +bool Node::rejectGlobalWrite(const QString& key) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical(lcSettings, "Attempted to check a write for an unknown key %s, something is seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } + + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || origin == WriteOrigin::QmlReset; + + if ((!m_globalOnly && !desc->globalOnly()) || !fromUser || !m_fallbackNode) + return false; + + if (origin == WriteOrigin::QmlReset) + qCWarning(lcSettings, + "Attempted to reset global option %s from an overlay layer, ignoring. " + "This should not be used, reset global options from the global layer instead.", + qUtf8Printable(pathFor(key))); + else + qCWarning(lcSettings, + "Attempted to write global option %s from an overlay layer, ignoring. " + "This should not be used, write global options from the global layer instead.", + qUtf8Printable(pathFor(key))); + + return true; +} + +void Node::warnGlobalSync(QList& diagnostics, const QString& path) { + qCWarning(lcSettings, "Global option definition %s found in overlay file, ignoring.", qUtf8Printable(path)); + diagnostics << Diagnostic{ + .type = DiagnosticType::GlobalOption, + .option = path, + .message = QStringLiteral("Global options should not be defined in overlay files"), + }; +} + +bool Node::rejectGlobalSync(QList& diagnostics) const { + if (!m_globalOnly || !m_fallbackNode) + return false; + + warnGlobalSync(diagnostics, path()); + return true; +} + +bool Node::recordWrite(const QString& key, bool changed) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical(lcSettings, "Attempted to record a write for an unknown key %s, something is seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } + + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || origin == WriteOrigin::QmlReset; + + bool dirty = changed; + switch (origin) { + // Init does not notify or write to file + case WriteOrigin::Init: + return false; + + // File and qml both count as overrides + case WriteOrigin::File: + case WriteOrigin::Qml: + dirty |= !m_overrides.contains(key); + m_overrides << key; + break; + + // Layer is not an override, it is a sync with the fallback value + case WriteOrigin::Layer: + break; + + // Both resets clear the override + case WriteOrigin::FileReset: + case WriteOrigin::QmlReset: + dirty |= m_overrides.remove(key); + break; + } + + // User writes/reset override quarantine + if (fromUser) + dirty |= removeQuarantined(key); + + // Both qml and reset write to the file (only write if dirty) + if (fromUser && dirty) + m_rootNode->m_batcher->dirty(); + + if (changed) + emit optionChanged(key); + + return changed; +} + +bool Node::removeQuarantined(const QString& key) { + if (!m_quarantine) + return false; + + const auto removed = m_quarantine->remove(key); + if (m_quarantine->isEmpty()) + m_quarantine.reset(); + + return removed; +} + +ChangeBatcher* Node::batcher() const { + return m_rootNode->m_batcher; +} + +QString Node::keyOf(const Node* child) const { + for (const auto& desc : schema().descriptors()) { + if (desc.isNode && child == value(desc.key).value()) + return desc.key; + } + + return {}; +} + +void Node::onFallbackNotify(const QString& key) { + if (m_overrides.contains(key)) + return; + + // Don't mirror global options onto overlays + const auto* desc = schema().get(key); + if (desc && desc->globalOnly()) + return; + + const WriteScope scope(this, WriteOrigin::Layer); + setValue(key, m_fallbackNode->value(key)); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/node.hpp b/Plugins/ZShell/Settings/node.hpp new file mode 100644 index 0000000..23237a8 --- /dev/null +++ b/Plugins/ZShell/Settings/node.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include + +#include "changebatcher.hpp" +#include "common.hpp" +#include "quarantine.hpp" +#include "schema.hpp" + +namespace ZShell::settings { + +class Node : public QObject { + Q_OBJECT + +public: + // Global only nodes are inherited, anything inside one is also global only + explicit Node(Node* fallback, QObject* parent = nullptr, bool globalOnly = false); + + [[nodiscard]] QString key() const; // The key of this in the parent node + [[nodiscard]] QString path() const; + [[nodiscard]] virtual QString pathFor(const QString& key) const; + [[nodiscard]] static QString elementPath(const QString& path, const QString& index); + [[nodiscard]] Node* parentNode() const; + [[nodiscard]] Node* rootNode() const; + [[nodiscard]] Node* fallbackNode() const; + void detachFallback(); // Recursive, unlinks this and its children from the fallback layer + + [[nodiscard]] Q_INVOKABLE bool isGlobalOnly() const; + [[nodiscard]] bool isOverride(const QString& key) const; + [[nodiscard]] const QSet& overrides() const; + [[nodiscard]] bool hasContent() const; // Recursive + + [[nodiscard]] virtual const Schema& schema() const = 0; + + [[nodiscard]] virtual QVariant value(const QString& key) const; + virtual bool setValue(const QString& key, const QVariant& value); // Returns whether the write was successful or not + virtual void resetToDefaults(); // Recursive, resets to fallbacks then defaults if not overridden + + [[nodiscard]] virtual QJsonValue toJson(bool sparse = true) const = 0; + // Returns false if the entire node was rejected + virtual bool syncJson(const QJsonValue& json, QList& diagnostics) = 0; + [[nodiscard]] const Quarantine* quarantine() const; + +signals: + void optionChanged(const QString& key); + +protected: + // Null means empty, otherwise it has content + std::unique_ptr m_quarantine; + const bool m_globalOnly; // Own flag or inherited from the parent node + + void warnGlobalRead(const QString& key) const; + // Returns true if the write should be skipped afterwards, the value is not one of the allowed types + [[nodiscard]] bool rejectInvalidWrite(const QString& key, const QVariant& value) const; + template [[nodiscard]] bool rejectInvalidWrite(const QString& key, const T& value) const; + // Returns true if the write should be skipped afterwards, overlays cannot write global options + bool rejectGlobalWrite(const QString& key); + static void warnGlobalSync(QList& diagnostics, const QString& path); + bool rejectGlobalSync(QList& diagnostics) const; // Returns true if the sync should be rejected + // Returns true if the notify signal should be emitted + virtual bool recordWrite(const QString& key, bool changed); + + [[nodiscard]] bool removeQuarantined(const QString& key); + [[nodiscard]] ChangeBatcher* batcher() const; + + [[nodiscard]] virtual QString keyOf(const Node* child) const; + + template + [[nodiscard]] T fallbackValue(T C::* member, std::type_identity_t defaultValue) const; + +private: + QSet m_overrides; // Overridden keys from file/qml writes + Node* const m_rootNode; + Node* m_fallbackNode; // No fallback node either means global tree or inside overridden list + + // For root node use only + WriteOrigin m_writeOrigin; + bool m_internalRead; + ChangeBatcher* const m_batcher; + + void onFallbackNotify(const QString& key); + + friend class WriteScope; + friend class InternalRead; +}; + +template bool Node::rejectInvalidWrite(const QString& key, const T& value) const { + Q_UNUSED(key) + Q_UNUSED(value) + return false; // Only QVariant unions can be given the wrong type +} + +template T Node::fallbackValue(T C::* member, std::type_identity_t defaultValue) const { + const auto* fallback = static_cast(m_fallbackNode); + return fallback ? fallback->*member : defaultValue; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/objectnode.cpp b/Plugins/ZShell/Settings/objectnode.cpp new file mode 100644 index 0000000..5453989 --- /dev/null +++ b/Plugins/ZShell/Settings/objectnode.cpp @@ -0,0 +1,219 @@ +#include "objectnode.hpp" + +#include + +#include "codecs.hpp" + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +ObjectNode::ObjectNode(ObjectNode* fallback, QObject* parent, bool globalOnly) + : Node(fallback, parent, globalOnly) {} + +void ObjectNode::resetOption(const QString& key) { + const auto* desc = schema().get(key); + + if (!desc) { + qCWarning(lcSettings) + << "Attempted to reset unknown option" << pathFor(key); + return; + } + + // Warn and ignore if this is a global node property and we are an overlay + if (desc->isNode && (m_globalOnly || desc->globalOnly()) && + fallbackNode()) { + qCWarning( + lcSettings, + "Attempted to reset global node %s, ignoring. " + "This should not be used, reset global nodes from the global layer " + "instead.", + qUtf8Printable(pathFor(key))); + return; + } + + const WriteScope scope(this, WriteOrigin::QmlReset); + if (desc->isNode) + value(key).value()->resetToDefaults(); + else + setValue( + key, + fallbackNode() ? fallbackNode()->value(key) : desc->defaultValue()); +} + +Descriptor ObjectNode::descriptorFor(const QString& key) const { + const auto* desc = schema().get(key); + if (!desc) { + qCWarning(lcSettings) + << "Attempted to get descriptor for unknown option" << pathFor(key); + return {}; + } + + return *desc; +} + +QJsonValue ObjectNode::toJson(bool sparse) const { + QJsonObject json; + + for (const auto& desc : schema().descriptors()) { + const auto val = value(desc.key); + + if (const auto* node = val.value()) { + if (!sparse || node->hasContent()) + json.insert(desc.key, node->toJson(sparse)); + continue; + } + + if (sparse && !isOverride(desc.key)) continue; + + if (!desc.codec) { // This should not happen + qCCritical( + lcSettings, + "No codec found for type %s, not serialising %s", + desc.type.name(), + qUtf8Printable(pathFor(desc.key))); + continue; + } + + json.insert(desc.key, desc.codec->encode(val)); + } + + if (m_quarantine) return m_quarantine->apply(json); + + return json; +} + +bool ObjectNode::syncJson( + const QJsonValue& json, QList& diagnostics) { + m_quarantine.reset(); // Clear out old quarantine + + if (!json.isObject()) { + const auto d = Diagnostic::mismatch(ExpectedType::Object, json, path()); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(d.option), + qUtf8Printable(d.message)); + diagnostics << d; + return false; + } + + // Refuse syncs to global only nodes on overlays + if (rejectGlobalSync(diagnostics)) return false; + + const auto obj = json.toObject(); + + qCDebug(lcSettings) << "Loading JSON into" << metaObject()->className() + << "with" << obj.size() << "keys:" << obj.keys(); + + const auto visited = loadFromJson(obj, diagnostics); + resetUnvisited(visited); + + return true; +} + +void ObjectNode::quarantineKey(const QString& key, const QJsonValue& value) { + if (!m_quarantine) m_quarantine = std::make_unique(); + m_quarantine->insert(key, value); +} + +QSet ObjectNode::loadFromJson( + const QJsonObject& json, QList& diagnostics) { + const WriteScope scope(this, WriteOrigin::File); + + QSet visited; + visited.reserve(json.size()); + + // Convenience macro for quarantine then skip +#define SKIP \ + quarantineKey(key, v); \ + continue + + for (const auto [k, v] : json.asKeyValueRange()) { + const auto key = k.toString(); + const auto* desc = schema().get(key); + + if (!desc) { + const auto path = pathFor(key); + qCWarning(lcSettings) << "Unknown option" << path; + diagnostics << Diagnostic{ + .type = DiagnosticType::UnknownOption, + .option = path, + .message = QStringLiteral("Unknown option %1").arg(key), + }; + SKIP; + } + + // Recurse into child nodes + if (desc->isNode) { + qCDebug(lcSettings) << " Recursing into" << key; + auto* const node = value(key).value(); + if (node->syncJson(v, diagnostics)) + visited << key; + else + quarantineKey( + key, v); // Quarantine entire node cause sync failed + continue; + } + + if ((m_globalOnly || desc->globalOnly()) && fallbackNode()) { + warnGlobalSync(diagnostics, pathFor(key)); + SKIP; + } + + if (!desc->codec) { // This should not happen + qCCritical( + lcSettings, + "No codec found for type %s, not loading %s", + desc->type.name(), + qUtf8Printable(pathFor(key))); + SKIP; + } + + auto val = desc->codec->decode(v); + if (val.error) { + auto path = pathFor(key); + for (const auto index : std::as_const(val.indexPath)) + path = elementPath(path, QString::number(index)); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(path), + qUtf8Printable(val.error->message)); + val.error->option = path; + diagnostics << *val.error; + SKIP; + } + + visited << key; + setValue(key, val.value); + } + +#undef SKIP + + return visited; +} + +void ObjectNode::resetUnvisited(const QSet& visited) { + const WriteScope scope(this, WriteOrigin::FileReset); + + for (const auto& desc : schema().descriptors()) { + if (visited.contains(desc.key)) continue; + + // Reset nodes recursively + if (desc.isNode) { + value(desc.key).value()->resetToDefaults(); + continue; + } + + // Skip global options on overlays + if ((m_globalOnly || desc.globalOnly()) && fallbackNode()) continue; + + setValue( + desc.key, + fallbackNode() ? fallbackNode()->value(desc.key) + : desc.defaultValue()); + } +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/objectnode.hpp b/Plugins/ZShell/Settings/objectnode.hpp new file mode 100644 index 0000000..2aa24fd --- /dev/null +++ b/Plugins/ZShell/Settings/objectnode.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include "node.hpp" + +namespace ZShell::settings { + +class ObjectNode : public Node { + Q_OBJECT + + public: + explicit ObjectNode( + ObjectNode* fallback, + QObject* parent = nullptr, + bool globalOnly = false); + + Q_INVOKABLE void resetOption(const QString& key); + [[nodiscard]] Q_INVOKABLE Descriptor descriptorFor(const QString& key) const; + + [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; + bool syncJson( + const QJsonValue& json, QList& diagnostics) override; + + private: + void quarantineKey(const QString& key, const QJsonValue& value); + + QSet loadFromJson( + const QJsonObject& json, QList& diagnostics); + void resetUnvisited(const QSet& visited); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.cpp b/Plugins/ZShell/Settings/quarantine.cpp new file mode 100644 index 0000000..d573c6d --- /dev/null +++ b/Plugins/ZShell/Settings/quarantine.cpp @@ -0,0 +1,27 @@ +#include "quarantine.hpp" + +#include + +namespace ZShell::settings { + +void ObjectQuarantine::insert(const QString& key, const QJsonValue& value) { + m_quarantine.insert(key, value); +} + +bool ObjectQuarantine::remove(const QString& key) { + return m_quarantine.remove(key); +} + +bool ObjectQuarantine::isEmpty() const { + return m_quarantine.isEmpty(); +} + +QJsonValue ObjectQuarantine::apply(const QJsonValue& json) const { + auto result = json.toObject(); + for (const auto& [key, value] : m_quarantine.asKeyValueRange()) + if (!result.contains(key)) // Don't clobber existing values + result.insert(key, value); + return result; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.hpp b/Plugins/ZShell/Settings/quarantine.hpp new file mode 100644 index 0000000..979995d --- /dev/null +++ b/Plugins/ZShell/Settings/quarantine.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::settings { + +class Quarantine { +public: + explicit Quarantine() = default; + virtual ~Quarantine() = default; + + virtual void insert(const QString& key, const QJsonValue& value) = 0; + [[nodiscard]] virtual bool remove(const QString& key) = 0; + [[nodiscard]] virtual bool isEmpty() const = 0; + [[nodiscard]] virtual QJsonValue apply(const QJsonValue& json) const = 0; + + Q_DISABLE_COPY_MOVE(Quarantine) +}; + +class ObjectQuarantine : public Quarantine { +public: + void insert(const QString& key, const QJsonValue& value) override; + [[nodiscard]] bool remove(const QString& key) override; + [[nodiscard]] bool isEmpty() const override; + [[nodiscard]] QJsonValue apply(const QJsonValue& json) const override; + +private: + QHash m_quarantine; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.cpp b/Plugins/ZShell/Settings/rootnode.cpp new file mode 100644 index 0000000..b9d8868 --- /dev/null +++ b/Plugins/ZShell/Settings/rootnode.cpp @@ -0,0 +1,46 @@ +#include "rootnode.hpp" + +#include + +namespace ZShell::settings { + +RootNode::RootNode(const QString& path, RootNode* fallback, QObject* parent) + : ObjectNode(fallback, parent) + , m_file(new SettingsFile(path, this)) { + QObject::connect(batcher(), &ChangeBatcher::dirtied, this, &RootNode::saveToFile); + QObject::connect(m_file, &SettingsFile::changed, this, &RootNode::reloadFromFile); + QObject::connect(m_file, &SettingsFile::readFailed, this, [this](const QString& error) { + emit treeLoadFailed(this, error); + }); + QObject::connect(m_file, &SettingsFile::writeFailed, this, [this](const QString& error) { + emit treeSaveFailed(this, error); + }); +} + +void RootNode::load() { + if (m_file->load() != SettingsFile::LoadResult::Changed) + reloadFromFile(); +} + +QList RootNode::diagnostics() const { + return m_diagnostics; +} + +void RootNode::reloadFromFile() { + const auto oldDiagnostics = m_diagnostics; + m_diagnostics.clear(); + + const auto data = m_file->read(); + syncJson(data ? data.value() : QJsonObject(), m_diagnostics); + + if (m_diagnostics != oldDiagnostics) + emit diagnosticsChanged(); + + emit treeLoaded(this); +} + +void RootNode::saveToFile() { + m_file->write(toJson()); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.hpp b/Plugins/ZShell/Settings/rootnode.hpp new file mode 100644 index 0000000..71a5ead --- /dev/null +++ b/Plugins/ZShell/Settings/rootnode.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "common.hpp" +#include "objectnode.hpp" +#include "settingsfile.hpp" + +namespace ZShell::settings { + +class RootNode : public ObjectNode { + Q_OBJECT + + Q_PROPERTY(QList diagnostics READ diagnostics NOTIFY diagnosticsChanged) + +public: + explicit RootNode(const QString& path, RootNode* fallback, QObject* parent = nullptr); + + [[nodiscard]] QList diagnostics() const; + + void load(); + +signals: + void diagnosticsChanged(); + void treeLoaded(RootNode* self); + void treeLoadFailed(RootNode* self, const QString& error); + void treeSaveFailed(RootNode* self, const QString& error); + +private: + SettingsFile* const m_file; + QList m_diagnostics; + + void reloadFromFile(); + void saveToFile(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.cpp b/Plugins/ZShell/Settings/schema.cpp new file mode 100644 index 0000000..370a695 --- /dev/null +++ b/Plugins/ZShell/Settings/schema.cpp @@ -0,0 +1,133 @@ +#include "schema.hpp" + +#include "codecs.hpp" +#include "node.hpp" + +namespace { + +Q_LOGGING_CATEGORY(lcSchema, "zshell.settings.schema", QtInfoMsg) + +} // namespace + +namespace ZShell::settings { + +namespace { + +// Descriptor annotations are stored here by Schema::annotate, which is called in the CONFIG_XXX macros. +// Schema::build then takes them from here. +QHash>& annotationCache() { + static QHash> s_cache; + return s_cache; +} + +// Union options carry their alternatives in the annotation, everything else goes by property type +const ValueCodec* resolveCodec(Descriptor& desc) { + auto& allowed = desc.annotation.allowedTypes; + + if (allowed.isEmpty()) + return ValueCodec::codecFor(desc.type); + + if (desc.type.id() != QMetaType::QVariant) { + qCCritical(lcSchema, "Allowed types are only valid for QVariant properties, ignoring them for %s", + qUtf8Printable(desc.key)); + allowed.clear(); + return ValueCodec::codecFor(desc.type); + } + + if (const auto* codec = ValueCodec::unionFor(allowed)) + return codec; + + allowed.clear(); + return nullptr; +} + +bool isNodeType(const QMetaType& type) { + if (!type.flags().testFlag(QMetaType::PointerToQObject)) + return false; + const auto* meta = type.metaObject(); + return meta && meta->inherits(&Node::staticMetaObject); +} + +} // namespace + +QString Descriptor::typeString() const { + if (annotation.allowedTypes.isEmpty()) + return QString::fromUtf8(type.name()); + + QStringList names; + names.reserve(annotation.allowedTypes.size()); + for (const auto& allowed : annotation.allowedTypes) + names << QString::fromUtf8(allowed.name()); + + return names.join(QStringLiteral(" | ")); +} + +bool Descriptor::accepts(const QMetaType& valueType) const { + // Unset is a valid state for QVariant options, they are simply absent from the file + if (type.id() == QMetaType::QVariant && !valueType.isValid()) + return true; + + if (annotation.allowedTypes.isEmpty()) + return type == valueType; + + return annotation.allowedTypes.contains(valueType); +} + +Schema Schema::build(const QMetaObject* meta, int baseOffset, bool includeReadOnly) { + Schema schema; + schema.m_descriptors.reserve(meta->propertyCount() - baseOffset); + + // Descriptors are taken from cache since this should be called exactly once per class + const auto annotations = annotationCache().take(meta); + + qCDebug(lcSchema) << "Building schema for" << meta->className(); + + for (int i = baseOffset; i < meta->propertyCount(); ++i) { + const auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + const auto isNode = isNodeType(prop.metaType()); + + // Skip read only properties (unless includeReadOnly) + if (!isNode && !includeReadOnly && !prop.isWritable()) { + qCDebug(lcSchema) << " Skipping computed property" << key; + continue; + } + + qCDebug(lcSchema) << " Adding property" << key; + + Descriptor desc{ + .key = key, + .type = prop.metaType(), + .metaIndex = i, + .isNode = isNode, + .annotation = annotations.value(key), + }; + + if (!isNode) { + desc.codec = resolveCodec(desc); + if (!desc.codec) + qCCritical(lcSchema, "No codec for %s of type %s, it will not be loaded or saved", qUtf8Printable(key), + desc.type.name()); + } + + schema.m_descriptors.append(std::move(desc)); + schema.m_keyToIndex.insert(key, schema.m_descriptors.size() - 1); + } + + return schema; +} + +void Schema::annotate(const QMetaObject* meta, const QString& key, Annotation annotation) { + annotationCache()[meta].insert(key, std::move(annotation)); +} + +const QList& Schema::descriptors() const { + return m_descriptors; +} + +const Descriptor* Schema::get(const QString& key) const { + const auto it = m_keyToIndex.find(key); + return it != m_keyToIndex.end() ? &m_descriptors[it.value()] : nullptr; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.hpp b/Plugins/ZShell/Settings/schema.hpp new file mode 100644 index 0000000..2857530 --- /dev/null +++ b/Plugins/ZShell/Settings/schema.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::settings { + +class ValueCodec; + +struct Annotation { + QVariant defaultValue; + bool globalOnly = false; + // NOLINTNEXTLINE(readability-redundant-member-init) callers will get missing field init warnings without + QList allowedTypes = {}; // For QVariant properties +}; + +namespace detail { + +// Return trivially copyable types (e.g. bool) by value and others (e.g. QVariant) by const ref +template +using AnnotationReturn = std::conditional_t && sizeof(T) <= sizeof(void*), T, const T&>; + +} // namespace detail + +#define ANNOTATION(Type, name) \ + Q_PROPERTY(Type name READ name) \ + \ +public: \ + [[nodiscard]] detail::AnnotationReturn name() const { \ + return annotation.name; \ + } \ + \ +private: + +struct Descriptor { + Q_GADGET + QML_VALUE_TYPE(descriptor) + + Q_PROPERTY(QString key MEMBER key) + Q_PROPERTY(QString type READ typeString) + Q_PROPERTY(int metaIndex MEMBER metaIndex) + Q_PROPERTY(bool isNode MEMBER isNode) + + ANNOTATION(QVariant, defaultValue) + ANNOTATION(bool, globalOnly) + +public: + QString key; + QMetaType type; + int metaIndex; + bool isNode; + Annotation annotation; + const ValueCodec* codec = nullptr; // Null for nodes and unsupported types + + [[nodiscard]] QString typeString() const; + [[nodiscard]] bool accepts(const QMetaType& valueType) const; +}; + +#undef ANNOTATION + +class Schema { +public: + static Schema build(const QMetaObject* meta, int baseOffset, bool includeReadOnly = false); + static void annotate(const QMetaObject* meta, const QString& key, Annotation annotation); + + [[nodiscard]] const QList& descriptors() const; + [[nodiscard]] const Descriptor* get(const QString& key) const; + +private: + explicit Schema() = default; + + QList m_descriptors; + QHash m_keyToIndex; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.cpp b/Plugins/ZShell/Settings/settingsfile.cpp new file mode 100644 index 0000000..abd4883 --- /dev/null +++ b/Plugins/ZShell/Settings/settingsfile.cpp @@ -0,0 +1,213 @@ +#include "settingsfile.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + + +namespace { + +Q_LOGGING_CATEGORY(lcSettingsFile, "zshell.settings.file", QtInfoMsg) + +} // namespace + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +// Max retries for loads which fail due to malformed JSON, e.g. partial writes +constexpr int k_maxLoadRetries = 3; + +} // namespace + +SettingsFile::SettingsFile(QString path, QObject* parent) + : QObject(parent) + , m_path(std::move(path)) + , m_watcher(new QFileSystemWatcher(this)) + , m_saveDebounce(new QTimer(this)) + , m_loadDebounce(new QTimer(this)) + , m_loadRetries(0) { + m_saveDebounce->setSingleShot(true); + m_saveDebounce->setInterval(500); // Save at most once every 500ms + + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(50); // Coalesce watcher events within 50ms + QObject::connect(m_loadDebounce, &QTimer::timeout, this, &SettingsFile::onLoadDebounced); + + QObject::connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &SettingsFile::onFileChanged); + QObject::connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &SettingsFile::onDirChanged); + + initWatcher(); +} + +std::optional SettingsFile::read() const { + return m_lastData; +} + +void SettingsFile::write(const QJsonValue& json) { + m_pendingWrite = json; + save(); +} + +void SettingsFile::onFileChanged() { + initWatcher(); // Re-add path in case the file was replaced + scheduleLoad(); +} + +void SettingsFile::onDirChanged() { + // Ignore if the file is already watched + if (m_watcher->files().contains(m_path)) + return; + + initWatcher(); + + if (QFile::exists(m_path)) + scheduleLoad(); +} + +void SettingsFile::initWatcher() { + const QFileInfo info(m_path); + const auto dir = info.absolutePath(); + + if (QDir(dir).exists() && !m_watcher->directories().contains(dir)) + m_watcher->addPath(dir); + + if (info.exists() && !m_watcher->files().contains(m_path)) + m_watcher->addPath(m_path); +} + +void SettingsFile::scheduleLoad() { + // Ignore if a load is already pending + if (m_loadDebounce->isActive()) + return; + + m_loadRetries = 0; + m_loadDebounce->start(); +} + +void SettingsFile::onLoadDebounced() { + const auto isFinalTry = m_loadRetries >= k_maxLoadRetries; + + if (load(isFinalTry) == LoadResult::ParseError && !isFinalTry) { + // Likely a partial write, retry after another debounce + ++m_loadRetries; + qCDebug(lcSettingsFile, "Retrying load of %s (%d/%d)", qUtf8Printable(m_path), m_loadRetries, k_maxLoadRetries); + m_loadDebounce->start(); + } +} + +SettingsFile::LoadResult SettingsFile::load(bool reportErrors) { + QFile file(m_path); + + if (!file.exists()) { + if (m_lastData) { + m_lastData = std::nullopt; + emit changed(); + return LoadResult::Changed; + } + return LoadResult::Unchanged; + } + + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcSettingsFile, "Failed to open %s for reading: %s", qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit readFailed(QStringLiteral("Failed to open: %1").arg(file.errorString())); + return LoadResult::Error; + } + + const auto data = file.readAll(); + file.close(); + + QJsonParseError error; + const auto doc = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + if (reportErrors) { + qCWarning(lcSettingsFile, "Failed to parse %s as JSON: %s", qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + emit readFailed(QStringLiteral("Failed to parse: %1").arg(error.errorString())); + } else { + qCDebug(lcSettingsFile, "Failed to parse %s as JSON: %s", qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + } + return LoadResult::ParseError; + } + + const auto json = doc.isObject() ? QJsonValue(doc.object()) : QJsonValue(doc.array()); + + if (json == m_lastData) + return LoadResult::Unchanged; + + // Clear pending write, stuff loaded from file should take precedence + m_pendingWrite = std::nullopt; + + m_lastData = json; + emit changed(); + + qCDebug(lcSettingsFile) << "Read JSON from" << m_path; + qCDebug(lcSettingsFile) << " " << json; + + return LoadResult::Changed; +} + +void SettingsFile::save() { + if (!m_pendingWrite) + return; + + if (m_saveDebounce->isActive()) { + // Queue save for debounce end + QObject::connect(m_saveDebounce, &QTimer::timeout, this, &SettingsFile::save, + // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) ConnectionType is OR-able by design + static_cast(Qt::UniqueConnection | Qt::SingleShotConnection)); + return; + } + + // Debounce regardless of whether the save actually succeeded + m_saveDebounce->start(); + + const auto json = m_pendingWrite.value(); + const auto data = (json.isObject() ? QJsonDocument(json.toObject()) : QJsonDocument(json.toArray())).toJson(); + m_pendingWrite = std::nullopt; + + const auto dir = QFileInfo(m_path).absolutePath(); + + if (!QDir().mkpath(dir)) { + qCWarning(lcSettingsFile) << "Failed to create dir" << dir; + emit writeFailed(QStringLiteral("Failed to create parent directory")); + return; + } + + // Write atomically + QSaveFile file(m_path); + + if (!file.open(QIODevice::WriteOnly)) { + qCWarning(lcSettingsFile, "Failed to open %s for writing: %s", qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit writeFailed(QStringLiteral("Failed to open: %1").arg(file.errorString())); + return; + } + + file.write(data); + + if (!file.commit()) { + qCWarning(lcSettingsFile, "Failed to write %s: %s", qUtf8Printable(m_path), qUtf8Printable(file.errorString())); + emit writeFailed(QStringLiteral("Failed to write: %1").arg(file.errorString())); + return; + } + + m_lastData = json; + qCDebug(lcSettingsFile) << "Saved" << m_path; + + initWatcher(); // The file may have just been created, or replaced by the rename +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.hpp b/Plugins/ZShell/Settings/settingsfile.hpp new file mode 100644 index 0000000..d517d33 --- /dev/null +++ b/Plugins/ZShell/Settings/settingsfile.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::settings { + +class SettingsFile : public QObject { + Q_OBJECT + +public: + enum class LoadResult : quint8 { + Unchanged, + Changed, + ParseError, + Error + }; + + explicit SettingsFile(QString path, QObject* parent = nullptr); + + [[nodiscard]] std::optional read() const; + void write(const QJsonValue& json); + + LoadResult load(bool reportErrors = true); + +signals: + void changed(); // Data changed, not file watcher event + void readFailed(const QString& error); + void writeFailed(const QString& error); + +private: + QString m_path; + QFileSystemWatcher* m_watcher; + QTimer* m_saveDebounce; + QTimer* m_loadDebounce; + int m_loadRetries; + std::optional m_lastData; + std::optional m_pendingWrite; + + void onFileChanged(); + void onDirChanged(); + + void initWatcher(); + void scheduleLoad(); + void onLoadDebounced(); + void save(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/toaster.cpp b/Plugins/ZShell/toaster.cpp index a8aa59f..9e5e7a8 100644 --- a/Plugins/ZShell/toaster.cpp +++ b/Plugins/ZShell/toaster.cpp @@ -102,6 +102,11 @@ void Toast::unlock(QObject* sender) { Toaster::Toaster(QObject* parent) : QObject(parent) {} +Toaster* Toaster::instance() { + static Toaster toaster; + return &toaster; +} + QQmlListProperty Toaster::toasts() { return QQmlListProperty(this, &m_toasts); } diff --git a/Plugins/ZShell/toaster.hpp b/Plugins/ZShell/toaster.hpp index c2e546a..667ffab 100644 --- a/Plugins/ZShell/toaster.hpp +++ b/Plugins/ZShell/toaster.hpp @@ -67,6 +67,7 @@ class Toaster : public QObject { public: explicit Toaster(QObject* parent = nullptr); + static Toaster* instance(); [[nodiscard]] QQmlListProperty toasts(); From 3373f11193198c07ee733f332f2c6abf95ad62b4 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 27 Sep 2026 22:47:31 +0200 Subject: [PATCH 08/21] fix: column index desync after reorder --- Components/StateLayer.qml | 1 - Modules/Dashboard/Content.qml | 53 +-- Modules/Dashboard/Dash.qml | 2 +- Modules/Dashboard/Dash/User.qml | 8 +- .../Dashboard/Processes/ProcessesColumns.qml | 15 +- .../Dashboard/Processes/ProcessesHeader.qml | 153 +++------ .../Dashboard/Processes}/ResourceUsage.qml | 0 Modules/Dashboard/Processes/SystemInfo.qml | 1 - Modules/Dashboard/ProcessesTab.qml | 13 - Modules/Resources/Cards/NetworkCard.qml | 16 +- .../Settings/Pages/Panels/DashboardPanel.qml | 40 +++ Plugins/ZShell/CMakeLists.txt | 1 + Plugins/ZShell/Config/anim.cpp | 4 - Plugins/ZShell/Config/anim.hpp | 2 +- Plugins/ZShell/Config/appearance.cpp | 10 - Plugins/ZShell/Config/appearance.hpp | 1 - Plugins/ZShell/Config/dashboard.hpp | 23 +- Plugins/ZShell/Config/tokens.hpp | 13 + Plugins/ZShell/Services/CMakeLists.txt | 6 + Plugins/ZShell/Services/networkusage.cpp | 153 +++++++++ Plugins/ZShell/Services/networkusage.hpp | 66 ++++ Plugins/ZShell/Services/processes.cpp | 318 ++++++++++++++---- Plugins/ZShell/Services/processes.hpp | 29 ++ Plugins/ZShell/Services/tickingservice.cpp | 64 +--- Plugins/ZShell/Services/tickingservice.hpp | 3 +- Plugins/ZShell/Services/usagefmt.cpp | 16 + Plugins/ZShell/Services/usagefmt.hpp | 1 + Plugins/ZShell/Settings/macros.hpp | 311 +++++++++-------- 28 files changed, 859 insertions(+), 464 deletions(-) rename {Helpers => Modules/Dashboard/Processes}/ResourceUsage.qml (100%) create mode 100644 Plugins/ZShell/Services/networkusage.cpp create mode 100644 Plugins/ZShell/Services/networkusage.hpp diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 8857eb0..aeda8ed 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -3,7 +3,6 @@ import QtQuick.Shapes import ZShell import ZShell.Config import qs.Services -import qs.Services MouseArea { id: root diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index d34b536..b4173e7 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -12,7 +12,7 @@ Item { id: root readonly property real nonAnimWidth: view.implicitWidth + clipWrapper.anchors.margins * 2 - readonly property real nonAnimHeight: tabs.implicitHeight + clipWrapper.anchors.topMargin + view.implicitHeight + clipWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + clipWrapper.anchors.margins + clipWrapper.anchors.topMargin required property ScreenState screenState @@ -22,25 +22,25 @@ Item { component: dashComponent, iconName: "dashboard", text: qsTr("Dashboard"), - enabled: true + enabled: Config.dashboard.showDashboard }, { component: weatherComponent, iconName: "cloud", text: qsTr("Weather"), - enabled: true + enabled: Config.dashboard.showWeather }, { component: mediaComponent, iconName: "queue_music", text: qsTr("Media"), - enabled: true + enabled: Config.dashboard.showMedia }, { component: procsComponent, iconName: "speed", text: qsTr("Processes"), - enabled: true + enabled: Config.dashboard.showProcesses }, ]; return allTabs.filter(tab => tab.enabled); @@ -60,33 +60,34 @@ Item { } } - Item { - id: clipWrapper - anchors.fill: parent - anchors.margins: Tokens.padding.small - // radius: Tokens.rounding.extraLarge - anchors.margins + Tabs { + id: tabs + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + nonAnimWidth: view.implicitWidth + screenState: root.screenState + tabs: root.dashboardTabs anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) + anchors.margins: Tokens.padding.small + } - Tabs { - id: tabs - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - - nonAnimWidth: view.implicitWidth - screenState: root.screenState - tabs: root.dashboardTabs - } + CustomClippingRect { + id: clipWrapper + anchors.top: tabs.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.spacing.small + anchors.margins: Tokens.padding.small + radius: Tokens.rounding.extraLarge - anchors.margins Item { id: viewWrapper - anchors.top: tabs.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.topMargin: Tokens.spacing.small + anchors.fill: parent Flickable { id: view diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index 54d6e51..31a78b6 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -67,7 +67,7 @@ GridLayout { Rect { Layout.columnSpan: 2 Layout.fillHeight: true - Layout.preferredWidth: Config.dashboard.sizes.weatherWidth + Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth Layout.row: 0 radius: root.radius diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 264ae5f..34da84c 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -51,7 +51,7 @@ Row { id: icon anchors.left: parent.left - anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + anchors.leftMargin: (Tokens.sizes.dashboard.infoIconSize - implicitWidth) / 2 color: Colors.palette.m3primary implicitSize: Math.floor(13 * 1.34) source: SystemInfo.osLogo @@ -65,7 +65,7 @@ Row { anchors.verticalCenter: icon.verticalCenter elide: Text.ElideRight text: `: ${SystemInfo.osPrettyName || SystemInfo.osName}` - width: Config.dashboard.sizes.infoWidth + width: Tokens.sizes.dashboard.infoWidth } } @@ -98,7 +98,7 @@ Row { id: icon anchors.left: parent.left - anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + anchors.leftMargin: (Tokens.sizes.dashboard.infoIconSize - implicitWidth) / 2 color: line.colour fill: 1 fontStyle: Tokens.font.icon.small @@ -113,7 +113,7 @@ Row { anchors.verticalCenter: icon.verticalCenter elide: Text.ElideNone text: `: ${line.text}` - width: Config.dashboard.sizes.infoWidth + width: Tokens.sizes.dashboard.infoWidth } } } diff --git a/Modules/Dashboard/Processes/ProcessesColumns.qml b/Modules/Dashboard/Processes/ProcessesColumns.qml index 40b9694..0dec3d4 100644 --- a/Modules/Dashboard/Processes/ProcessesColumns.qml +++ b/Modules/Dashboard/Processes/ProcessesColumns.qml @@ -3,6 +3,7 @@ pragma Singleton import QtQuick import Quickshell import ZShell.Config +import ZShell.Services Singleton { id: root @@ -88,6 +89,18 @@ Singleton { } ] + readonly property var desktopEntries: DesktopEntries.applications.values.map(e => ({ + icon: e.icon, + command: e.command + })) + + Component.onCompleted: { + Processes.setDesktopEntries(root.desktopEntries); + Qt.callLater(ensurePinnedFirst); + } + + onDesktopEntriesChanged: Processes.setDesktopEntries(root.desktopEntries) + readonly property string pinnedColumnId: "command" readonly property var hiddenColumns: { @@ -113,8 +126,6 @@ Singleton { }); } - Component.onCompleted: Qt.callLater(ensurePinnedFirst) - function defFor(id: string): var { for (const def of root.columnDefs) if (def.id === id) diff --git a/Modules/Dashboard/Processes/ProcessesHeader.qml b/Modules/Dashboard/Processes/ProcessesHeader.qml index 83225dd..307d4da 100644 --- a/Modules/Dashboard/Processes/ProcessesHeader.qml +++ b/Modules/Dashboard/Processes/ProcessesHeader.qml @@ -64,109 +64,83 @@ Item { function onWidthChanged() { root.fillMissingWidths(); } + + function onCountChanged() { + resizeOverlay.geometryTick++; + root.fillMissingWidths(); + } + + function onContentWidthChanged() { + resizeOverlay.geometryTick++; + } } Connections { target: columnsData function onValuesChanged() { root.fillMissingWidths(); + resizeOverlay.geometryTick++; } } Component.onCompleted: Qt.callLater(fillMissingWidths) Component.onDestruction: Cursor.clearOverride() - RowLayout { - id: rowLayout + CustomListView { + id: columnList anchors.fill: parent - + orientation: ListView.Horizontal + interactive: false spacing: Tokens.spacing.extraSmall / 2 - CustomListView { - id: columnList - - Layout.fillWidth: true - orientation: ListView.Horizontal - interactive: false - spacing: parent.spacing - implicitHeight: root.headerHeight - - add: Transition { - Anim { - from: 0 - properties: "opacity" - to: 1 - type: Anim.DefaultEffects - } + add: Transition { + Anim { + from: 0 + properties: "opacity" + to: 1 + type: Anim.DefaultEffects } - displaced: Transition { - Anim { - properties: "opacity" - to: 1 - type: Anim.DefaultEffects - } - Anim { - properties: "x" - } + } + displaced: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects } - move: Transition { - Anim { - properties: "opacity" - to: 1 - type: Anim.DefaultEffects - } - Anim { - properties: "x" - } + Anim { + properties: "x" } - remove: Transition { - Anim { - to: 0 - properties: "opacity" - type: Anim.DefaultEffects - } + } + move: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects } - - model: DelegateModel { - id: columnsVisual - - delegate: ColumnHeaderCell { - parentModel: columnsVisual - } - - model: ScriptModel { - id: columnsData - values: Config.dashboard.processColumns.values - } + Anim { + properties: "x" + } + } + remove: Transition { + Anim { + to: 0 + properties: "opacity" + type: Anim.DefaultEffects } } - IconButton { - id: addColumnBtn + model: DelegateModel { + id: columnsVisual - type: IconButton.Text - icon: "add" - font: Tokens.font.icon.medium - Layout.alignment: Qt.AlignVCenter - visible: ProcessesColumns.hiddenColumns.length > 0 - onClicked: addMenu.expanded = true - } + delegate: ColumnHeaderCell { + parentModel: columnsVisual + } - Variants { - id: hiddenColumnList - - model: ProcessesColumns.hiddenColumns - - HiddenColumnItem {} - } - - Menu { - id: addMenu - - attachTo: addColumnBtn - items: hiddenColumnList.instances - onItemSelected: item => ProcessesColumns.addColumn(item.modelData.id) + model: ScriptModel { + id: columnsData + values: Config.dashboard.processColumns.values + } } } @@ -178,26 +152,9 @@ Item { property int geometryTick: 0 property int activeGapIndex: -1 - anchors.fill: rowLayout + anchors.fill: columnList z: 10 - Connections { - target: columnsData - function onValuesChanged() { - resizeOverlay.geometryTick++; - } - } - - Connections { - target: columnList - function onCountChanged() { - resizeOverlay.geometryTick++; - } - function onContentWidthChanged() { - resizeOverlay.geometryTick++; - } - } - Component.onCompleted: Qt.callLater(() => geometryTick++) Repeater { @@ -571,8 +528,6 @@ Item { const rightMid = rightItem.x + rightItem.width / 2; if (refX > rightMid) { columnsVisual.items.move(myIndex, myIndex + 1); - resizeOverlay.geometryTick++; - Qt.callLater(() => resizeOverlay.geometryTick++); return; } } @@ -583,8 +538,6 @@ Item { const pinnedAtZero = columnList.itemAtIndex(0)?.modelData?.id === ProcessesColumns.pinnedColumnId; if (refX < leftMid && !(myIndex === 1 && pinnedAtZero)) { columnsVisual.items.move(myIndex, myIndex - 1); - resizeOverlay.geometryTick++; - Qt.callLater(() => resizeOverlay.geometryTick++); } } } diff --git a/Helpers/ResourceUsage.qml b/Modules/Dashboard/Processes/ResourceUsage.qml similarity index 100% rename from Helpers/ResourceUsage.qml rename to Modules/Dashboard/Processes/ResourceUsage.qml diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml index 3346b56..086e90b 100644 --- a/Modules/Dashboard/Processes/SystemInfo.qml +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -7,7 +7,6 @@ import ZShell.Components import ZShell.Services import qs.Components import qs.Services -import qs.Helpers Item { id: root diff --git a/Modules/Dashboard/ProcessesTab.qml b/Modules/Dashboard/ProcessesTab.qml index 76bc140..35687fc 100644 --- a/Modules/Dashboard/ProcessesTab.qml +++ b/Modules/Dashboard/ProcessesTab.qml @@ -9,11 +9,6 @@ import qs.Services Item { id: root - readonly property var desktopEntries: DesktopEntries.applications.values.map(e => ({ - icon: e.icon, - command: e.command - })) - implicitWidth: Tokens.sizes.dashboard.processesWidth implicitHeight: Tokens.sizes.dashboard.processesHeight @@ -21,14 +16,6 @@ Item { service: Processes } - Connections { - target: DesktopEntries.applications - function onValuesChanged(): void { - Processes.setDesktopEntries(root.desktopEntries); - } - } - Component.onCompleted: Processes.setDesktopEntries(root.desktopEntries) - ProcessesHeader { id: header diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index 2184b53..445ccf6 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -1,8 +1,8 @@ import QtQuick import QtQuick.Layouts import ZShell.Components +import ZShell.Services import ZShell.Config -import qs.Helpers import qs.Components import qs.Services @@ -14,7 +14,7 @@ CustomRect { implicitWidth: 300 radius: Tokens.rounding.largeIncreased - Ref { + ServiceRef { service: NetworkUsage } @@ -129,8 +129,8 @@ CustomRect { CustomText { color: Colors.palette.m3tertiary text: { - const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0); - return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + const fmt = UsageFmt.formatBytes(NetworkUsage.downloadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}/s` : "0.0 B/s"; } } } @@ -157,8 +157,8 @@ CustomRect { CustomText { color: Colors.palette.m3secondary text: { - const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0); - return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + const fmt = UsageFmt.formatBytes(NetworkUsage.uploadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}/s` : "0.0 B/s"; } } } @@ -185,8 +185,8 @@ CustomRect { CustomText { color: Colors.palette.m3onSurfaceVariant text: { - const down = NetworkUsage.formatBytesTotal(NetworkUsage.downloadTotal ?? 0); - const up = NetworkUsage.formatBytesTotal(NetworkUsage.uploadTotal ?? 0); + const down = UsageFmt.formatBytes(NetworkUsage.downloadTotal ?? 0); + const up = UsageFmt.formatBytes(NetworkUsage.uploadTotal ?? 0); return (down && up) ? `↓${down.value.toFixed(1)}${down.unit} ↑${up.value.toFixed(1)}${up.unit}` : "↓0.0B ↑0.0B"; } } diff --git a/Modules/Settings/Pages/Panels/DashboardPanel.qml b/Modules/Settings/Pages/Panels/DashboardPanel.qml index af8beb8..12d7d3e 100644 --- a/Modules/Settings/Pages/Panels/DashboardPanel.qml +++ b/Modules/Settings/Pages/Panels/DashboardPanel.qml @@ -34,6 +34,45 @@ PageBase { onToggled: Config.dashboard.enabled = checked } + // Tabs + SectionHeader { + text: qsTr("Tabs") + } + + ToggleRow { + first: true + checked: Config.dashboard.showDashboard + settingAnchor: "dashboard-show-dashboard" + text: qsTr("Dashboard") + + onToggled: Config.dashboard.showDashboard = checked + } + + ToggleRow { + checked: Config.dashboard.showWeather + settingAnchor: "dashboard-show-weather" + text: qsTr("Weather") + + onToggled: Config.dashboard.showWeather = checked + } + + ToggleRow { + checked: Config.dashboard.showMedia + settingAnchor: "dashboard-show-media" + text: qsTr("Media") + + onToggled: Config.dashboard.showMedia = checked + } + + ToggleRow { + checked: Config.dashboard.showProcesses + settingAnchor: "dashboard-show-processes" + text: qsTr("Processes") + last: true + + onToggled: Config.dashboard.showProcesses = checked + } + // Behavior SectionHeader { text: qsTr("Behavior") @@ -58,6 +97,7 @@ PageBase { text: qsTr("Drag threshold") to: 100 value: Config.dashboard.dragThreshold + last: true onMoved: v => Config.dashboard.dragThreshold = v } diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 0a6d627..9822b65 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -52,6 +52,7 @@ set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIA qml_module(ZShell URI ZShell + INCLUDE_PREFIX core SOURCES writefile.hpp writefile.cpp appdb.hpp appdb.cpp diff --git a/Plugins/ZShell/Config/anim.cpp b/Plugins/ZShell/Config/anim.cpp index f5b97ff..0f89423 100644 --- a/Plugins/ZShell/Config/anim.cpp +++ b/Plugins/ZShell/Config/anim.cpp @@ -64,9 +64,6 @@ AnimDurations* AnimTokens::durations() const { QEasingCurve AnimTokens::buildCurve(const QList& points) { QEasingCurve curve(QEasingCurve::BezierSpline); - // Points come in pairs of (x, y) forming cubic bezier segments. - // Each segment needs 3 control points: c1, c2, endPoint. - // So 6 values per segment: c1x, c1y, c2x, c2y, endX, endY. for (int i = 0; i + 5 < points.size(); i += 6) { const QPointF c1(points[i], points[i + 1]); const QPointF c2(points[i + 2], points[i + 3]); @@ -101,7 +98,6 @@ void AnimTokens::rebuildCurves() { void AnimTokens::bindCurves(AnimCurves* curves) { m_curves = curves; - // Rebuild when any curve control points change connect( curves, &AnimCurves::optionChanged, this, &AnimTokens::rebuildCurves); diff --git a/Plugins/ZShell/Config/anim.hpp b/Plugins/ZShell/Config/anim.hpp index 2a76c9d..8ff9694 100644 --- a/Plugins/ZShell/Config/anim.hpp +++ b/Plugins/ZShell/Config/anim.hpp @@ -11,7 +11,7 @@ class AnimDurations; class AnimTokens : public QObject { Q_OBJECT - Q_MOC_INCLUDE("tokens.hpp") // AnimCurves + Q_MOC_INCLUDE("tokens.hpp") Q_MOC_INCLUDE("appearance.hpp") QML_ANONYMOUS diff --git a/Plugins/ZShell/Config/appearance.cpp b/Plugins/ZShell/Config/appearance.cpp index 1d125ec..3ba878d 100644 --- a/Plugins/ZShell/Config/appearance.cpp +++ b/Plugins/ZShell/Config/appearance.cpp @@ -6,8 +6,6 @@ namespace ZShell::config { namespace { -// Helper: connect all changed signals from a token object to a single valuesChanged signal, -// plus connect the local scaleChanged signal. template void connectTokenSignals(Source* source, Target* target) { const auto* meta = source->metaObject(); @@ -30,8 +28,6 @@ void connectTokenSignals(Source* source, Target* target) { } // namespace -// AppearanceRounding - void AppearanceRounding::bindTokens(RoundingTokens* tokens) { m_tokens = tokens; connectTokenSignals(tokens, this); @@ -77,8 +73,6 @@ int AppearanceRounding::full() const { return m_tokens ? m_tokens->full() : 0; } -// AppearanceSpacing - void AppearanceSpacing::bindTokens(SpacingTokens* tokens) { m_tokens = tokens; connectTokenSignals(tokens, this); @@ -120,8 +114,6 @@ int AppearanceSpacing::extraExtraLarge() const { : 0; } -// AppearancePadding - void AppearancePadding::bindTokens(PaddingTokens* tokens) { m_tokens = tokens; connectTokenSignals(tokens, this); @@ -167,8 +159,6 @@ int AppearancePadding::extraExtraLarge() const { : 0; } -// AnimDurations - void AnimDurations::bindTokens(AnimDurationTokens* tokens) { m_tokens = tokens; connectTokenSignals(tokens, this); diff --git a/Plugins/ZShell/Config/appearance.hpp b/Plugins/ZShell/Config/appearance.hpp index 4166253..b774969 100644 --- a/Plugins/ZShell/Config/appearance.hpp +++ b/Plugins/ZShell/Config/appearance.hpp @@ -11,7 +11,6 @@ namespace ZShell::config { using Qt::StringLiterals::operator""_s; -// Forward declare token types from tokens.hpp class RoundingTokens; class SpacingTokens; class PaddingTokens; diff --git a/Plugins/ZShell/Config/dashboard.hpp b/Plugins/ZShell/Config/dashboard.hpp index 3160a48..e9fd3d0 100644 --- a/Plugins/ZShell/Config/dashboard.hpp +++ b/Plugins/ZShell/Config/dashboard.hpp @@ -17,24 +17,6 @@ class Performance : public settings::ObjectNode { CONFIG_PROPERTY(bool, showVram, true) }; -class DashboardSizes : public settings::ObjectNode { - CONFIG_NODE(DashboardSizes, settings::ObjectNode) - - CONFIG_PROPERTY(int, dateTimeWidth, 110) - CONFIG_PROPERTY(int, infoIconSize, 25) - CONFIG_PROPERTY(int, infoWidth, 200) - CONFIG_PROPERTY(int, mediaCoverArtSize, 150) - CONFIG_PROPERTY(int, mediaProgressSweep, 180) - CONFIG_PROPERTY(int, mediaProgressThickness, 8) - CONFIG_PROPERTY(int, mediaVisualizerSize, 200) - CONFIG_PROPERTY(int, mediaWidth, 200) - CONFIG_PROPERTY(int, resourceProgessThickness, 10) - CONFIG_PROPERTY(int, resourceSize, 200) - CONFIG_PROPERTY(int, tabIndicatorHeight, 3) - CONFIG_PROPERTY(int, tabIndicatorSpacing, 5) - CONFIG_PROPERTY(int, weatherWidth, 250) -}; - class ProcessColumnEntry : public settings::ObjectNode { CONFIG_NODE(ProcessColumnEntry, settings::ObjectNode) @@ -51,9 +33,12 @@ class Dashboard : public settings::ObjectNode { CONFIG_PROPERTY(bool, showOnHover, true) CONFIG_PROPERTY(int, dragThreshold, 50) CONFIG_PROPERTY(int, mediaUpdateInterval, 2000) + CONFIG_PROPERTY(bool, showWeather, true) + CONFIG_PROPERTY(bool, showDashboard, true) + CONFIG_PROPERTY(bool, showMedia, true) + CONFIG_PROPERTY(bool, showProcesses, false) CONFIG_SUBOBJECT(Performance, performance) CONFIG_PROPERTY(int, resourceUpdateInterval, 1000) - CONFIG_SUBOBJECT(DashboardSizes, sizes) CONFIG_LIST( ProcessColumnList, processColumns, diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index 25c8388..c19848f 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -149,6 +149,19 @@ class DashboardTokens : public settings::ObjectNode { CONFIG_PROPERTY(int, processesHeight, 800) CONFIG_PROPERTY(int, processesHeaderHeight, 40) CONFIG_PROPERTY(int, processesRowHeight, 40) + CONFIG_PROPERTY(int, dateTimeWidth, 110) + CONFIG_PROPERTY(int, infoIconSize, 25) + CONFIG_PROPERTY(int, infoWidth, 200) + CONFIG_PROPERTY(int, mediaCoverArtSize, 150) + CONFIG_PROPERTY(int, mediaProgressSweep, 180) + CONFIG_PROPERTY(int, mediaProgressThickness, 8) + CONFIG_PROPERTY(int, mediaVisualizerSize, 200) + CONFIG_PROPERTY(int, mediaWidth, 200) + CONFIG_PROPERTY(int, resourceProgessThickness, 10) + CONFIG_PROPERTY(int, resourceSize, 200) + CONFIG_PROPERTY(int, tabIndicatorHeight, 3) + CONFIG_PROPERTY(int, tabIndicatorSpacing, 5) + CONFIG_PROPERTY(int, weatherWidth, 250) }; class SizeTokens : public settings::ObjectNode { diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index e6c413c..2e99e62 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -19,6 +19,7 @@ qml_module(ZShell-services diskinfo.hpp diskinfo.cpp storage.hpp storage.cpp processes.hpp processes.cpp + networkusage.hpp networkusage.cpp lyriccandidate.hpp lyriccandidate.cpp lyrics.hpp lyrics.cpp LIBRARIES @@ -30,6 +31,11 @@ qml_module(ZShell-services Sensors::Sensors ZShell-config ZShell-settings + ZShell-internal + ZShell + zshell-util DEPENDENCIES + ZShell ZShell.Config + ZShell.Internal ) diff --git a/Plugins/ZShell/Services/networkusage.cpp b/Plugins/ZShell/Services/networkusage.cpp new file mode 100644 index 0000000..493b03f --- /dev/null +++ b/Plugins/ZShell/Services/networkusage.cpp @@ -0,0 +1,153 @@ +#include "networkusage.hpp" + +#include +#include +#include + +#include +#include +#include + +#include "config/rootnodes.hpp" +#include "config/dashboard.hpp" + +namespace ZShell::services { + +using Qt::StringLiterals::operator""_s; + +NetworkUsage::NetworkUsage(QObject* parent) + : TickingService(parent) + , m_downloadBuffer(new internal::CircularBuffer(this)) + , m_uploadBuffer(new internal::CircularBuffer(this)) { + m_downloadBuffer->setSmoothingRadius(2); + m_uploadBuffer->setSmoothingRadius(2); + + auto* resources = + config::ConfigSingleton::instance()->dashboard()->performance(); + setHistoryLength(resources->showVram() ? 70 : 30); + QObject::connect( + resources, + &ZShell::config::Performance::showVramChanged, + this, + [this, resources] { + setHistoryLength(resources->showVram() ? 70 : 30); + }); +} + +qreal NetworkUsage::downloadSpeed() const { + return m_downloadSpeed; +} + +qreal NetworkUsage::uploadSpeed() const { + return m_uploadSpeed; +} + +qreal NetworkUsage::downloadTotal() const { + return m_downloadTotal; +} + +qreal NetworkUsage::uploadTotal() const { + return m_uploadTotal; +} + +int NetworkUsage::historyLength() const { + return m_historyLength; +} + +void NetworkUsage::setHistoryLength(int length) { + if (length <= 0 || length == m_historyLength) { + return; + } + m_historyLength = length; + m_downloadBuffer->setCapacity(m_historyLength + 1); + m_uploadBuffer->setCapacity(m_historyLength + 1); + Q_EMIT historyLengthChanged(); +} + +internal::CircularBuffer* NetworkUsage::downloadBuffer() const { + return m_downloadBuffer; +} + +internal::CircularBuffer* NetworkUsage::uploadBuffer() const { + return m_uploadBuffer; +} + +void NetworkUsage::tick() { + QFile f(u"/proc/net/dev"_s); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + // Skip headers + f.readLine(); + f.readLine(); + + quint64 totalRx = 0; + quint64 totalTx = 0; + + while (!f.atEnd()) { + const QByteArray line = f.readLine(); + const qsizetype splitIdx = line.indexOf(':'); + if (splitIdx == -1) { + continue; + } + const QByteArray iface = line.left(splitIdx).trimmed(); + if (iface == QByteArrayView("lo")) { + continue; // Skip loopback interface + } + + // Parse every counter through tx bytes to validate the row + const char* pos = line.constData() + splitIdx + 1; + const char* const end = line.constData() + line.size(); + + std::array fields{}; + bool valid = true; + for (unsigned long long& field : fields) { + while (pos < end && (*pos == ' ' || *pos == '\t')) + ++pos; + + const auto [next, ec] = std::from_chars(pos, end, field); + if (ec != std::errc{}) { + valid = false; + break; + } + pos = next; + } + + if (!valid) continue; + + totalRx += static_cast(fields[0]); + totalTx += static_cast(fields[8]); + } + f.close(); + + if (!m_initialized) { + m_prevRx = totalRx; + m_prevTx = totalTx; + m_timer.start(); + m_initialized = true; + return; + } + + const qreal elapsed = static_cast(m_timer.restart()) / 1000.0; + const quint64 rxDelta = totalRx >= m_prevRx ? totalRx - m_prevRx : 0; + const quint64 txDelta = totalTx >= m_prevTx ? totalTx - m_prevTx : 0; + + m_downloadTotal += static_cast(rxDelta); + m_uploadTotal += static_cast(txDelta); + + if (elapsed > 0.0) { + // Calculate speeds + m_downloadSpeed = static_cast(rxDelta) / elapsed; + m_uploadSpeed = static_cast(txDelta) / elapsed; + + m_downloadBuffer->push(m_downloadSpeed); + m_uploadBuffer->push(m_uploadSpeed); + } + + m_prevRx = totalRx; + m_prevTx = totalTx; + + emit changed(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/networkusage.hpp b/Plugins/ZShell/Services/networkusage.hpp new file mode 100644 index 0000000..7a6555b --- /dev/null +++ b/Plugins/ZShell/Services/networkusage.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include + +#include "internal/circularbuffer.hpp" +#include "tickingservice.hpp" + +namespace ZShell::services { + +class NetworkUsage : public TickingService { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(qreal downloadSpeed READ downloadSpeed NOTIFY changed) + Q_PROPERTY(qreal uploadSpeed READ uploadSpeed NOTIFY changed) + Q_PROPERTY(qreal downloadTotal READ downloadTotal NOTIFY changed) + Q_PROPERTY(qreal uploadTotal READ uploadTotal NOTIFY changed) + Q_PROPERTY(int historyLength READ historyLength NOTIFY historyLengthChanged) + + Q_PROPERTY( + ZShell::internal::CircularBuffer* downloadBuffer READ downloadBuffer + CONSTANT) + Q_PROPERTY( + ZShell::internal::CircularBuffer* uploadBuffer READ uploadBuffer + CONSTANT) + + public: + explicit NetworkUsage(QObject* parent = nullptr); + + [[nodiscard]] qreal downloadSpeed() const; + [[nodiscard]] qreal uploadSpeed() const; + [[nodiscard]] qreal downloadTotal() const; + [[nodiscard]] qreal uploadTotal() const; + [[nodiscard]] int historyLength() const; + + [[nodiscard]] internal::CircularBuffer* downloadBuffer() const; + [[nodiscard]] internal::CircularBuffer* uploadBuffer() const; + + signals: + void changed(); + void historyLengthChanged(); + + protected: + void tick() override; + + private: + void setHistoryLength(int length); + + qreal m_downloadSpeed = 0.0; + qreal m_uploadSpeed = 0.0; + qreal m_downloadTotal = 0.0; + qreal m_uploadTotal = 0.0; + int m_historyLength = 30; + + internal::CircularBuffer* m_downloadBuffer = nullptr; + internal::CircularBuffer* m_uploadBuffer = nullptr; + + quint64 m_prevRx = 0; + quint64 m_prevTx = 0; + bool m_initialized = false; + QElapsedTimer m_timer; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/processes.cpp b/Plugins/ZShell/Services/processes.cpp index 6175736..96cef22 100644 --- a/Plugins/ZShell/Services/processes.cpp +++ b/Plugins/ZShell/Services/processes.cpp @@ -2,8 +2,13 @@ #include #include +#include +#include +#include +#include #include #include +#include #include #include @@ -12,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +59,82 @@ template int cmpVals(const T& a, const T& b) { return (a < b) - (a > b); } +// glibc hides getdents64 behind __USE_MISC; declare directly (no _GNU_SOURCE +// in this project). +extern "C" { +struct ProcDirEnt64 { + unsigned long d_ino; + off_t d_off; + unsigned short d_reclen; + unsigned char d_type; + char d_name[1]; +}; + +ssize_t getdents64(int fd, void* dirp, size_t count); +} + +// Raw open/read/close instead of QFile: QFSFileEngine issues an extra +// statx per open, and the per-file object churn adds up across ~500 +// processes per refresh. +QByteArray readProcFile(const char* path) { + const int fd = ::open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + return {}; + } + QByteArray data; + char buf[4096]; + for (;;) { + const ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (n == 0) { + break; + } + data.append(buf, static_cast(n)); + } + ::close(fd); + return data; +} + +void procPath(char* out, size_t size, pid_t pid, const char* what) { + ::snprintf(out, size, "/proc/%d/%s", static_cast(pid), what); +} + +QVector listProcPids() { + QVector pids; + const int fd = ::openat(AT_FDCWD, "/proc", O_RDONLY | O_DIRECTORY); + if (fd < 0) { + return pids; + } + + std::array buf; + for (;;) { + const ssize_t n = ::getdents64( + fd, reinterpret_cast(buf.data()), buf.size()); + if (n <= 0) { + break; + } + for (ssize_t off = 0; off < n;) { + const auto* d = + reinterpret_cast(buf.data() + off); + if (d->d_type == DT_DIR) { + bool ok = false; + const long v = QByteArray(d->d_name).toLong(&ok); + if (ok && v > 0) { + pids.push_back(static_cast(v)); + } + } + off += static_cast(d->d_reclen); + } + } + ::close(fd); + return pids; +} + struct FuzzyText { QString chars; @@ -460,19 +542,24 @@ void ProcessModel::setDesktopEntryIcons( } QString ProcessModel::resolveProcessExe(pid_t pid) { - const QString path = QStringLiteral("/proc/%1/exe").arg(pid); - const QString target = QFile::symLinkTarget(path); - - return target; + char path[40]; + procPath(path, sizeof(path), pid, "exe"); + char buf[4096]; + const ssize_t n = ::readlink(path, buf, sizeof(buf) - 1); + if (n < 0) { + return {}; + } + buf[n] = '\0'; + return QString::fromLocal8Bit(buf, static_cast(n)); } std::optional ProcessModel::readStat(pid_t pid) { - QFile f(QStringLiteral("/proc/%1/stat").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { + char path[40]; + procPath(path, sizeof(path), pid, "stat"); + const QByteArray data = readProcFile(path); + if (data.isEmpty()) { return std::nullopt; } - const QByteArray data = f.readAll(); - f.close(); const qsizetype openParen = data.indexOf('('); const qsizetype closeParen = data.lastIndexOf(')'); @@ -500,12 +587,9 @@ std::optional ProcessModel::readStat(pid_t pid) { } QString ProcessModel::readCommandLine(pid_t pid, const QString& fallbackComm) { - QFile f(QStringLiteral("/proc/%1/cmdline").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { - return QStringLiteral("[%1]").arg(fallbackComm); - } - QByteArray data = f.readAll(); - f.close(); + char path[40]; + procPath(path, sizeof(path), pid, "cmdline"); + QByteArray data = readProcFile(path); if (data.isEmpty()) { return QStringLiteral("[%1]").arg(fallbackComm); @@ -523,12 +607,9 @@ QString ProcessModel::readCommandLine(pid_t pid, const QString& fallbackComm) { } ProcessModel::StatusInfo ProcessModel::readStatus(pid_t pid) { - QFile f(QStringLiteral("/proc/%1/status").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { - return {}; - } - const QByteArray data = f.readAll(); - f.close(); + char path[40]; + procPath(path, sizeof(path), pid, "status"); + const QByteArray data = readProcFile(path); StatusInfo out; qsizetype pos = 0; @@ -554,12 +635,9 @@ ProcessModel::StatusInfo ProcessModel::readStatus(pid_t pid) { } ProcessModel::IoInfo ProcessModel::readIo(pid_t pid) { - QFile f(QStringLiteral("/proc/%1/io").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { - return {}; - } - const QByteArray data = f.readAll(); - f.close(); + char path[40]; + procPath(path, sizeof(path), pid, "io"); + const QByteArray data = readProcFile(path); IoInfo out; qsizetype pos = 0; @@ -606,12 +684,10 @@ QString ProcessModel::resolveUsername(uid_t uid, QHash& cache) { } std::optional ProcessModel::readUptimeSeconds() { - QFile f(QStringLiteral("/proc/uptime")); - if (!f.open(QIODevice::ReadOnly)) { + const QByteArray data = readProcFile("/proc/uptime"); + if (data.isEmpty()) { return std::nullopt; } - const QByteArray data = f.readAll(); - f.close(); const auto fields = splitWhitespace(QByteArrayView(data)); if (fields.isEmpty()) { @@ -623,35 +699,17 @@ std::optional ProcessModel::readUptimeSeconds() { return ok ? std::make_optional(uptime) : std::nullopt; } -ProcessModel::ScanResult ProcessModel::scanProcesses( - double uptime, - double deltaSecs, - QHash prevPidToAll, - QVector prevAll, - QHash uidCache, - long clockTicksPerSec, - QHash execPathToIcon, - QHash execNameToIcon, - QStringList pathDirs, - bool desktopEntryIconsChanged, - quint64 iconVersion) { - Q_UNUSED( - pathDirs); // kept for future cgroup fallback; sanitizeExec already ran on GUI thread - - QDir procDir(QStringLiteral("/proc")); - const auto entries = - procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::NoSort); - - QVector fresh; - fresh.reserve(entries.size()); - - for (const QString& name : entries) { - bool isPid = false; - const pid_t pid = static_cast(name.toLong(&isPid)); - if (!isPid || pid <= 0) { - continue; - } +void ProcessModel::scanChunk( + const ScanContext& ctx, + const QVector& pids, + QVector& out, + QHash& uidCache) { + const QHash& prevPidToAll = *ctx.prevPidToAll; + const QVector& prevAll = *ctx.prevAll; + const QHash& execPathToIcon = *ctx.execPathToIcon; + const QHash& execNameToIcon = *ctx.execNameToIcon; + for (pid_t pid : pids) { const auto statOpt = readStat(pid); if (!statOpt) { continue; @@ -675,7 +733,7 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( entry.commandLine = readCommandLine(pid, stat.comm); } - if (!samePidInstance || desktopEntryIconsChanged) { + if (!samePidInstance || ctx.iconsChanged) { const QString exePath = resolveProcessExe(pid); QString iconName; @@ -711,18 +769,23 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( entry.uid = status.uid; entry.username = resolveUsername(status.uid, uidCache); - const IoInfo io = readIo(pid); + // /proc/PID/io is only readable by root or the process owner; skip + // the open when it is guaranteed to fail. + IoInfo io; + if (ctx.selfEuid == 0 || status.uid == ctx.selfEuid) { + io = readIo(pid); + } entry.ioReadBytes = io.readBytes; entry.ioWriteBytes = io.writeBytes; entry.elapsedSecs = std::max( 0.0, - uptime - (static_cast(stat.starttime) / - static_cast(clockTicksPerSec))); + ctx.uptime - (static_cast(stat.starttime) / + static_cast(ctx.clockTicksPerSec))); entry.lastUtime = stat.utime; entry.lastStime = stat.stime; - if (samePidInstance && deltaSecs > 0.0) { + if (samePidInstance && ctx.deltaSecs > 0.0) { const ProcessEntry& prev = prevAll.at(prevIt.value()); const quint64 prevTicks = prev.lastUtime + prev.lastStime; const quint64 nowTicks = stat.utime + stat.stime; @@ -730,13 +793,97 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( nowTicks > prevTicks ? nowTicks - prevTicks : 0; entry.cpuPercent = (static_cast(deltaTicks) / - (deltaSecs * static_cast(clockTicksPerSec))) * + (ctx.deltaSecs * + static_cast(ctx.clockTicksPerSec))) * 100.0; } else { entry.cpuPercent = 0.0; } - fresh.push_back(std::move(entry)); + out.push_back(std::move(entry)); + } +} + +ProcessModel::ScanResult ProcessModel::scanProcesses( + double uptime, + double deltaSecs, + QHash prevPidToAll, + QVector prevAll, + QHash uidCache, + long clockTicksPerSec, + QHash execPathToIcon, + QHash execNameToIcon, + QStringList pathDirs, + bool desktopEntryIconsChanged, + quint64 iconVersion) { + Q_UNUSED( + pathDirs); // kept for future cgroup fallback; sanitizeExec already ran on GUI thread + + const QVector pids = listProcPids(); + + ScanContext ctx; + ctx.uptime = uptime; + ctx.deltaSecs = deltaSecs; + ctx.prevPidToAll = &prevPidToAll; + ctx.prevAll = &prevAll; + ctx.clockTicksPerSec = clockTicksPerSec; + ctx.execPathToIcon = &execPathToIcon; + ctx.execNameToIcon = &execNameToIcon; + ctx.iconsChanged = desktopEntryIconsChanged; + ctx.selfEuid = ::geteuid(); + + QVector fresh; + fresh.reserve(pids.size()); + + // Split the pid list across worker threads; a single pass over /proc is + // syscall-bound (open/read/close per file per process) and serializes + // badly on large systems. Keep one pool thread free for this function, + // which itself runs inside the global pool. + int nThreads = qMin(16, QThreadPool::globalInstance()->maxThreadCount() - 1); + if (pids.size() < 128 || nThreads < 2) { + nThreads = 1; + } + + if (nThreads <= 1) { + scanChunk(ctx, pids, fresh, uidCache); + } else { + const qsizetype per = (pids.size() + nThreads - 1) / nThreads; + QVector> chunks(nThreads); + QVector> outs(nThreads); + QVector> uidParts(nThreads); + QVector> futures; + + for (int t = 0; t < nThreads; ++t) { + const qsizetype a = static_cast(t) * per; + const qsizetype b = qMin(a + per, pids.size()); + if (a >= b) { + continue; + } + chunks[t] = pids.mid(a, b - a); + futures.append(QtConcurrent::run( + [&ctx, &chunks, &outs, &uidParts, t]() { + scanChunk(ctx, chunks[t], outs[t], uidParts[t]); + })); + } + for (QFuture& f : futures) { + f.waitForFinished(); + } + + for (qsizetype t = 0; t < nThreads; ++t) { + for (ProcessEntry& e : outs[t]) { + fresh.push_back(std::move(e)); + } + for (auto it = uidParts[t].constBegin(); + it != uidParts[t].constEnd(); + ++it) { + uidCache.insert(it.key(), it.value()); + } + } + std::sort( + fresh.begin(), fresh.end(), [](const ProcessEntry& a, + const ProcessEntry& b) { + return a.pid < b.pid; + }); } ScanResult result; @@ -810,6 +957,13 @@ void ProcessModel::onScanFinished() { for (int i = 0; i < mAll.size(); ++i) { mPidToAll.insert(mAll.at(i).pid, i); } + for (auto it = mFuzzyCache.begin(); it != mFuzzyCache.end();) { + if (mPidToAll.contains(it.key())) { + ++it; + } else { + it = mFuzzyCache.erase(it); + } + } for (auto it = result.newUidNames.constBegin(); it != result.newUidNames.constEnd(); ++it) { @@ -846,11 +1000,35 @@ void ProcessModel::applyView() { ProcessEntry entry = src; if (searching) { - int score = fuzzyQueryScore(fuzzyPrepare(src.command), terms); - if (!src.commandLine.isEmpty() && src.commandLine != src.command) { - score = qMax( - score, - fuzzyQueryScore(fuzzyPrepare(src.commandLine), terms)); + // Normalizing (NFD) + lowering per process per tick is the + // expensive part of search; commands are stable for the life of + // a process instance (pid + starttime), so cache the prepared + // text. comm can still change without an exec (prctl), so + // re-prepare when the raw command differs. + FuzzyCacheEntry& fe = mFuzzyCache[src.pid]; + if (fe.starttime != src.starttime || fe.command != src.command) { + fe.starttime = src.starttime; + fe.command = src.command; + const FuzzyText prep = fuzzyPrepare(src.command); + fe.commandChars = std::move(prep.chars); + fe.commandClasses = std::move(prep.classes); + fe.hasCommandLine = + !src.commandLine.isEmpty() && src.commandLine != src.command; + if (fe.hasCommandLine) { + const FuzzyText prepCl = fuzzyPrepare(src.commandLine); + fe.commandLineChars = std::move(prepCl.chars); + fe.commandLineClasses = std::move(prepCl.classes); + } else { + fe.commandLineChars = QString(); + fe.commandLineClasses = {}; + } + } + const FuzzyText cmdFuzzy{fe.commandChars, fe.commandClasses}; + int score = fuzzyQueryScore(cmdFuzzy, terms); + if (fe.hasCommandLine) { + const FuzzyText clFuzzy{ + fe.commandLineChars, fe.commandLineClasses}; + score = qMax(score, fuzzyQueryScore(clFuzzy, terms)); } if (score < 0) { continue; diff --git a/Plugins/ZShell/Services/processes.hpp b/Plugins/ZShell/Services/processes.hpp index b82cac6..ea48f4c 100644 --- a/Plugins/ZShell/Services/processes.hpp +++ b/Plugins/ZShell/Services/processes.hpp @@ -75,6 +75,16 @@ class ProcessModel : public QAbstractListModel { void sortAscendingChanged(); private: + struct FuzzyCacheEntry { + quint64 starttime = 0; + QString command; + QString commandChars; + QVector commandClasses; + QString commandLineChars; + QVector commandLineClasses; + bool hasCommandLine = false; + }; + struct RawStat { pid_t pid = 0; pid_t ppid = 0; @@ -116,6 +126,18 @@ class ProcessModel : public QAbstractListModel { quint64 writeBytes = 0; }; + struct ScanContext { + double uptime = 0.0; + double deltaSecs = -1.0; + const QHash* prevPidToAll = nullptr; + const QVector* prevAll = nullptr; + long clockTicksPerSec = 100; + const QHash* execPathToIcon = nullptr; + const QHash* execNameToIcon = nullptr; + bool iconsChanged = false; + uid_t selfEuid = 0; + }; + struct ScanResult { QVector entries; QHash newUidNames; @@ -130,6 +152,12 @@ class ProcessModel : public QAbstractListModel { static QString resolveUsername(uid_t uid, QHash& cache); static QString resolveProcessExe(pid_t pid); + static void scanChunk( + const ScanContext& ctx, + const QVector& pids, + QVector& out, + QHash& uidCache); + static ScanResult scanProcesses( double uptime, double deltaSecs, @@ -153,6 +181,7 @@ class ProcessModel : public QAbstractListModel { QVector mAll; QHash mPidToAll; + QHash mFuzzyCache; QVector mProcesses; QHash mUidToName; diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 05e6c83..0dc67fa 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -1,11 +1,7 @@ #include "tickingservice.hpp" -#include -#include -#include -#include -#include -#include -#include + +#include "config/dashboard.hpp" +#include "config/rootnodes.hpp" namespace ZShell::services { @@ -17,53 +13,13 @@ TickingService::TickingService(QObject* parent) updateTriggered(); }); - QString configPath = - QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); - - auto reloadConfig = [this, configPath]() { - QFile file(configPath); - if (!file.open(QIODevice::ReadOnly)) { - return; - } - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (doc.isNull()) { - return; - } - QJsonObject dashboard = doc.object().value("dashboard").toObject(); - if (dashboard.contains("resourceUpdateInterval")) { - applyInterval( - dashboard.value("resourceUpdateInterval").toInt(1000)); - } - }; - - reloadConfig(); - - static auto* watcher = new QFileSystemWatcher(); - if (!watcher->files().contains(configPath)) { - QObject::connect( - watcher, - &QFileSystemWatcher::fileChanged, - this, - [this, configPath]() { - QTimer::singleShot(100, this, [this, configPath]() { - QFile file(configPath); - if (!file.open(QIODevice::ReadOnly)) { - return; - } - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (doc.isNull()) { - return; - } - QJsonObject dashboard = - doc.object().value("dashboard").toObject(); - if (dashboard.contains("resourceUpdateInterval")) { - applyInterval(dashboard.value("resourceUpdateInterval") - .toInt(1000)); - } - }); - }); - watcher->addPath(configPath); - } + auto* dash = ZShell::config::ConfigSingleton::instance()->dashboard(); + applyInterval(dash->resourceUpdateInterval()); + QObject::connect( + dash, + &ZShell::config::Dashboard::resourceUpdateIntervalChanged, + this, + [this, dash] { applyInterval(dash->resourceUpdateInterval()); }); } int TickingService::updateInterval() const { diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp index e73a911..5cd7fe1 100644 --- a/Plugins/ZShell/Services/tickingservice.hpp +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -1,8 +1,9 @@ #pragma once -#include "service.hpp" #include +#include "service.hpp" + namespace ZShell::services { class TickingService : public Service { diff --git a/Plugins/ZShell/Services/usagefmt.cpp b/Plugins/ZShell/Services/usagefmt.cpp index 1d4cf32..28d1f5b 100644 --- a/Plugins/ZShell/Services/usagefmt.cpp +++ b/Plugins/ZShell/Services/usagefmt.cpp @@ -48,4 +48,20 @@ FormatResult UsageFmt::formatKib(qreal kib, qreal total) const { return {kib, total, "KiB"}; } +FormatResult UsageFmt::formatBytes(qreal bytes) const { + static constexpr std::array units = { + "B", "KB", "MB", "GB", "TB"}; + + qreal value = finitePositive(bytes) ? bytes : 0.0; + const qreal k = 1024.0; + std::size_t i = 0; + + while (value >= k && i < units.size() - 1) { + value /= k; + i++; + } + + return {value, 0.0, units[i]}; +} + } // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/Services/usagefmt.hpp b/Plugins/ZShell/Services/usagefmt.hpp index 38a06fb..288ce2e 100644 --- a/Plugins/ZShell/Services/usagefmt.hpp +++ b/Plugins/ZShell/Services/usagefmt.hpp @@ -31,6 +31,7 @@ class UsageFmt : public QObject { Q_INVOKABLE [[nodiscard]] FormatResult formatKib(qreal kib) const; Q_INVOKABLE [[nodiscard]] FormatResult formatKib( qreal kib, qreal total) const; + Q_INVOKABLE [[nodiscard]] FormatResult formatBytes(qreal bytes) const; }; } // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/Settings/macros.hpp b/Plugins/ZShell/Settings/macros.hpp index 6d065fe..b3b47fb 100644 --- a/Plugins/ZShell/Settings/macros.hpp +++ b/Plugins/ZShell/Settings/macros.hpp @@ -4,190 +4,205 @@ #include #include -#include "listnode.hpp" // IWYU pragma: keep -#include "schema.hpp" // IWYU pragma: keep +#include "listnode.hpp" +#include "schema.hpp" namespace ZShell::settings { -inline QVariantMap vmap(std::initializer_list> entries) { - QVariantMap map; - for (const auto& [key, value] : entries) - map.insert(key, value); - return map; +inline QVariantMap vmap( + std::initializer_list> entries) { + QVariantMap map; + for (const auto& [key, value] : entries) + map.insert(key, value); + return map; } template inline QList unionTypes() { - static_assert(sizeof...(Ts) >= 2, "A union needs at least two types"); - // If the max size is changed, common.cpp `mismatchStr` must be updated - static_assert(sizeof...(Ts) <= 4, "A union cannot have more than 4 types"); - static_assert((!std::is_same_v && ...), "A union cannot contain QVariant"); + static_assert(sizeof...(Ts) >= 2, "A union needs at least two types"); + static_assert(sizeof...(Ts) <= 4, "A union cannot have more than 4 types"); + static_assert( + (!std::is_same_v && ...), + "A union cannot contain QVariant"); - return { QMetaType::fromType()... }; + return {QMetaType::fromType()...}; } namespace detail { template inline bool compare(const T& a, const T& b) { - return a == b; + return a == b; } template bool compare(const T& a, const T& b) { - return qFuzzyCompare(a + T(1), b + T(1)); + return qFuzzyCompare(a + T(1), b + T(1)); } -template bool compare(const QList& a, const QList& b) { - if (a.size() != b.size()) - return false; +template +bool compare(const QList& a, const QList& b) { + if (a.size() != b.size()) return false; - for (qsizetype i = 0; i < a.size(); ++i) - if (!compare(a.at(i), b.at(i))) - return false; - return true; + for (qsizetype i = 0; i < a.size(); ++i) + if (!compare(a.at(i), b.at(i))) return false; + return true; } } // namespace detail } // namespace ZShell::settings -// Helper macro to prevent splitting initialiser lists #define DEFAULT_ARG(...) __VA_ARGS__ -// Declares a class to be a node class. This replaces the Q_OBJECT call at the top of the class. -#define SETTINGS_NODE_NO_CTOR(Class, Base) \ - Q_OBJECT \ - \ -public: \ - [[nodiscard]] const ZShell::settings::Schema& schema() const override { \ - static const auto schema = \ - ZShell::settings::Schema::build(&staticMetaObject, Base::staticMetaObject.propertyCount()); \ - return schema; \ - } \ - \ -private: \ - using Self = Class; // For use in the below macros +#define SETTINGS_NODE_NO_CTOR(Class, Base) \ + Q_OBJECT \ + \ + public: \ + [[nodiscard]] const ZShell::settings::Schema& schema() const override { \ + static const auto schema = ZShell::settings::Schema::build( \ + &staticMetaObject, Base::staticMetaObject.propertyCount()); \ + return schema; \ + } \ + \ + private: \ + using Self = Class; // For use in the below macros -#define SETTINGS_NODE(Class, Base) \ - SETTINGS_NODE_NO_CTOR(Class, Base) \ - QML_ANONYMOUS \ - \ -public: \ - explicit Class(Class* fallback = nullptr, QObject* parent = nullptr, bool globalOnly = false) \ - : Base(fallback, parent, globalOnly) {} \ - \ -private: +#define SETTINGS_NODE(Class, Base) \ + SETTINGS_NODE_NO_CTOR(Class, Base) \ + QML_ANONYMOUS \ + \ + public: \ + explicit Class( \ + Class* fallback = nullptr, \ + QObject* parent = nullptr, \ + bool globalOnly = false) \ + : Base(fallback, parent, globalOnly) {} \ + \ + private: -// Defines a property on a node. -#define SETTINGS_PROPERTY_IMPL(Type, name, global, defaultVal, ...) \ - Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ - \ -public: \ - [[nodiscard]] Type name() const { \ - if (global || m_globalOnly) \ - warnGlobalRead(QStringLiteral(#name)); \ - return m_##name; \ - } \ - \ - void set_##name(const Type& value) { \ - if (!true /* TODO: validation */) \ - return; \ - \ - if (rejectInvalidWrite(QStringLiteral(#name), value)) \ - return; /* Skip writes of the wrong type */ \ - \ - if (rejectGlobalWrite(QStringLiteral(#name))) \ - return; /* Skip writes to global only keys, they should be sent to the global layer */ \ - \ - const auto needsNotify = !ZShell::settings::detail::compare(value, m_##name); \ - m_##name = value; \ - if (recordWrite(QStringLiteral(#name), needsNotify)) \ - Q_EMIT name##Changed(); \ - } \ - \ - Q_SIGNAL void name##Changed(); \ - \ -private: \ - Type m_##name = fallbackValue(&Self::m_##name, defaultVal); \ - inline static const bool s_register_##name = \ - (ZShell::settings::Schema::annotate(&staticMetaObject, QStringLiteral(#name), \ - { .defaultValue = QVariant::fromValue(defaultVal), .globalOnly = global, __VA_ARGS__ }), \ - true); +#define SETTINGS_PROPERTY_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ + \ + public: \ + [[nodiscard]] Type name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + void set_##name(const Type& value) { \ + if (!true /* TODO: validation */) return; \ + \ + if (rejectInvalidWrite(QStringLiteral(#name), value)) \ + return; /* Skip writes of the wrong type */ \ + \ + if (rejectGlobalWrite(QStringLiteral(#name))) \ + return; /* Skip writes to global only keys */ \ + \ + const auto needsNotify = \ + !ZShell::settings::detail::compare(value, m_##name); \ + m_##name = value; \ + if (recordWrite(QStringLiteral(#name), needsNotify)) \ + Q_EMIT name##Changed(); \ + } \ + \ + Q_SIGNAL void name##Changed(); \ + \ + private: \ + Type m_##name = fallbackValue(&Self::m_##name, defaultVal); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = QVariant::fromValue(defaultVal), \ + .globalOnly = global, \ + __VA_ARGS__}), \ + true); -#define SETTINGS_PROPERTY(Type, name, defaultVal, ...) \ - SETTINGS_PROPERTY_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define SETTINGS_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL( \ + Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) // Defines a global property on a node. Shorthand for .globalOnly = true. -#define SETTINGS_GLOBAL_PROPERTY(Type, name, defaultVal, ...) \ - SETTINGS_PROPERTY_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define SETTINGS_GLOBAL_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL( \ + Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) // Defines a subobject property on a node. Subobject properties are CONSTANT. -#define SETTINGS_SUBOBJECT_IMPL(Type, name, global) \ - Q_PROPERTY(Type* name READ name CONSTANT) \ - \ -public: \ - [[nodiscard]] Type* name() const { \ - if (global || m_globalOnly) \ - warnGlobalRead(QStringLiteral(#name)); \ - return m_##name; \ - } \ - \ -private: \ - Type* m_##name = new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ - inline static const bool s_register_##name = \ - (ZShell::settings::Schema::annotate( \ - &staticMetaObject, QStringLiteral(#name), { .defaultValue = {}, .globalOnly = global }), \ - true); +#define SETTINGS_SUBOBJECT_IMPL(Type, name, global) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ + public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + private: \ + Type* m_##name = \ + new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = {}, .globalOnly = global}), \ + true); -#define SETTINGS_SUBOBJECT(Type, name) SETTINGS_SUBOBJECT_IMPL(Type, name, false) +#define SETTINGS_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT_IMPL(Type, name, false) -// Defines a global subobject on a node. Everything inside it is global only. -#define SETTINGS_GLOBAL_SUBOBJECT(Type, name) SETTINGS_SUBOBJECT_IMPL(Type, name, true) +#define SETTINGS_GLOBAL_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT_IMPL(Type, name, true) -// Defines a list type for use with SETTINGS_LIST. -#define SETTINGS_LIST_TYPE(Element, Name) \ - class Name : public ZShell::settings::ListNode { \ - Q_OBJECT \ - QML_ANONYMOUS \ - \ - public: \ - explicit Name(Name* fallback = nullptr, QObject* parent = nullptr, bool globalOnly = false) \ - : ZShell::settings::ListNode(fallback, parent, globalOnly) {} \ - \ - [[nodiscard]] Q_INVOKABLE Element* at(qsizetype index) const { /* Format ugh */ \ - return static_cast(elementAt(index)); \ - } \ - [[nodiscard]] Q_INVOKABLE Element* insert(const QVariantMap& props, qsizetype index = -1) { \ - return static_cast(insertElement(props, index)); \ - } \ - \ - protected: \ - [[nodiscard]] ZShell::settings::Node* createElement(ZShell::settings::Node* fallback) override { \ - return new Element(static_cast(fallback), this); \ - } \ - }; +#define SETTINGS_LIST_TYPE(Element, Name) \ + class Name : public ZShell::settings::ListNode { \ + Q_OBJECT \ + QML_ANONYMOUS \ + \ + public: \ + explicit Name( \ + Name* fallback = nullptr, \ + QObject* parent = nullptr, \ + bool globalOnly = false) \ + : ZShell::settings::ListNode(fallback, parent, globalOnly) {} \ + \ + [[nodiscard]] Q_INVOKABLE Element* at( \ + qsizetype index) const { /* Format ugh */ \ + return static_cast(elementAt(index)); \ + } \ + [[nodiscard]] Q_INVOKABLE Element* insert( \ + const QVariantMap& props, qsizetype index = -1) { \ + return static_cast(insertElement(props, index)); \ + } \ + \ + protected: \ + [[nodiscard]] ZShell::settings::Node* createElement( \ + ZShell::settings::Node* fallback) override { \ + return new Element(static_cast(fallback), this); \ + } \ + }; -// Defines a list property on a node. List properties are CONSTANT. -#define SETTINGS_LIST_IMPL(Type, name, global, defaultVal, ...) \ - Q_PROPERTY(Type* name READ name CONSTANT) \ - \ -public: \ - [[nodiscard]] Type* name() const { \ - if (global || m_globalOnly) \ - warnGlobalRead(QStringLiteral(#name)); \ - return m_##name; \ - } \ - \ -private: \ - Type* m_##name = new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ - inline static const bool s_register_##name = \ - (ZShell::settings::Schema::annotate(&staticMetaObject, QStringLiteral(#name), \ - { .defaultValue = QVariant::fromValue>(defaultVal), \ - .globalOnly = global, \ - __VA_ARGS__ }), \ - true); +#define SETTINGS_LIST_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ + public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + private: \ + Type* m_##name = \ + new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = \ + QVariant::fromValue>(defaultVal), \ + .globalOnly = global, \ + __VA_ARGS__}), \ + true); -#define SETTINGS_LIST(Type, name, defaultVal, ...) \ - SETTINGS_LIST_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define SETTINGS_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) -// Defines a global list on a node. Everything inside it is global only. -#define SETTINGS_GLOBAL_LIST(Type, name, defaultVal, ...) \ - SETTINGS_LIST_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define SETTINGS_GLOBAL_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) From 133d453f6fd6e752b391e7c33a0e249b5afab5ad Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 12:32:36 +0200 Subject: [PATCH 09/21] fix: fix nvidia update interval on config change + more robust read path for generic --- Helpers/NetworkUsage.qml | 242 --------- Modules/Resources/Cards/NetworkCard.qml | 4 +- Plugins/ZShell/Config/services.hpp | 3 +- Plugins/ZShell/Services/gpu.cpp | 590 ++++++++++++--------- Plugins/ZShell/Services/gpu.hpp | 70 ++- Plugins/ZShell/Services/tickingservice.cpp | 1 + Plugins/ZShell/Services/tickingservice.hpp | 1 + 7 files changed, 390 insertions(+), 521 deletions(-) delete mode 100644 Helpers/NetworkUsage.qml diff --git a/Helpers/NetworkUsage.qml b/Helpers/NetworkUsage.qml deleted file mode 100644 index b78c540..0000000 --- a/Helpers/NetworkUsage.qml +++ /dev/null @@ -1,242 +0,0 @@ -pragma Singleton - -import QtQuick -import Quickshell -import Quickshell.Io -import ZShell.Internal -import ZShell.Config - -Singleton { - id: root - - property real _downloadSpeed: 0 - property real _downloadTotal: 0 - - property real _initialRxBytes: 0 - property real _initialTxBytes: 0 - property bool _initialized: false - - property real _prevRxBytes: 0 - property real _prevTimestamp: 0 - property real _prevTxBytes: 0 - property real _uploadSpeed: 0 - property real _uploadTotal: 0 - - readonly property CircularBuffer downloadBuffer: _downloadBuffer - - readonly property real downloadSpeed: _downloadSpeed - - readonly property real downloadTotal: _downloadTotal - readonly property int historyLength: Config.dashboard.performance.showVram ? 70 : 30 - property int refCount: 0 - readonly property CircularBuffer uploadBuffer: _uploadBuffer - readonly property real uploadSpeed: _uploadSpeed - readonly property real uploadTotal: _uploadTotal - -function resetSampling(): void { - netDevFile.reload(); - - const content = netDevFile.text(); - if (!content) - return; - - const data = root.parseNetDev(content); - const now = Date.now(); - - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; -} - - function formatBytes(bytes: real): var { - // Handle negative or invalid values - if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { - return { - value: 0, - unit: "B/s" - }; - } - - if (bytes < 1024) { - return { - value: bytes, - unit: "B/s" - }; - } else if (bytes < 1024 * 1024) { - return { - value: bytes / 1024, - unit: "KB/s" - }; - } else if (bytes < 1024 * 1024 * 1024) { - return { - value: bytes / (1024 * 1024), - unit: "MB/s" - }; - } else { - return { - value: bytes / (1024 * 1024 * 1024), - unit: "GB/s" - }; - } - } - - function formatBytesTotal(bytes: real): var { - // Handle negative or invalid values - if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { - return { - value: 0, - unit: "B" - }; - } - - if (bytes < 1024) { - return { - value: bytes, - unit: "B" - }; - } else if (bytes < 1024 * 1024) { - return { - value: bytes / 1024, - unit: "KB" - }; - } else if (bytes < 1024 * 1024 * 1024) { - return { - value: bytes / (1024 * 1024), - unit: "MB" - }; - } else { - return { - value: bytes / (1024 * 1024 * 1024), - unit: "GB" - }; - } - } - - function parseNetDev(content: string): var { - const lines = content.split("\n"); - let totalRx = 0; - let totalTx = 0; - - for (let i = 2; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) - continue; - - const parts = line.split(/\s+/); - if (parts.length < 10) - continue; - - const iface = parts[0].replace(":", ""); - // Skip loopback interface - if (iface === "lo") - continue; - - const rxBytes = parseFloat(parts[1]) || 0; - const txBytes = parseFloat(parts[9]) || 0; - - totalRx += rxBytes; - totalTx += txBytes; - } - - return { - rx: totalRx, - tx: totalTx - }; - } - - CircularBuffer { - id: _downloadBuffer - - capacity: root.historyLength + 1 - } - - CircularBuffer { - id: _uploadBuffer - - capacity: root.historyLength + 1 - } - - FileView { - id: netDevFile - - path: "/proc/net/dev" - } - - Timer { - interval: Config.dashboard.resourceUpdateInterval - repeat: true - running: true - triggeredOnStart: true - - onTriggered: { - if (root.refCount <= 0) { - root.resetSampling(); - return; - } - - netDevFile.reload(); - const content = netDevFile.text(); - if (!content) - return; - - const data = root.parseNetDev(content); - const now = Date.now(); - - if (!root._initialized) { - root._initialRxBytes = data.rx; - root._initialTxBytes = data.tx; - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; - root._initialized = true; - return; - } - - const timeDelta = (now - root._prevTimestamp) / 1000; // seconds - if (timeDelta > 0) { - // Calculate byte deltas - let rxDelta = data.rx - root._prevRxBytes; - let txDelta = data.tx - root._prevTxBytes; - - // Handle counter overflow (when counters wrap around from max to 0) - // This happens when counters exceed 32-bit or 64-bit limits - if (rxDelta < 0) { - // Counter wrapped around - assume 64-bit counter - rxDelta += Math.pow(2, 64); - } - if (txDelta < 0) { - txDelta += Math.pow(2, 64); - } - - // Calculate speeds - root._downloadSpeed = rxDelta / timeDelta; - root._uploadSpeed = txDelta / timeDelta; - - if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) - _downloadBuffer.push(root._downloadSpeed); - - if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) - _uploadBuffer.push(root._uploadSpeed); - } - - // Calculate totals with overflow handling - let downTotal = data.rx - root._initialRxBytes; - let upTotal = data.tx - root._initialTxBytes; - - // Handle counter overflow for totals - if (downTotal < 0) { - downTotal += Math.pow(2, 64); - } - if (upTotal < 0) { - upTotal += Math.pow(2, 64); - } - - root._downloadTotal = downTotal; - root._uploadTotal = upTotal; - - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; - } - } -} diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index 445ccf6..ea6474c 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -78,12 +78,12 @@ CustomRect { } Connections { - function onValuesChanged(): void { + function onUpdateTriggered(): void { sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); slideAnim.restart(); } - target: NetworkUsage.downloadBuffer + target: NetworkUsage } NumberAnimation { diff --git a/Plugins/ZShell/Config/services.hpp b/Plugins/ZShell/Config/services.hpp index 1f341f8..8e6891c 100644 --- a/Plugins/ZShell/Config/services.hpp +++ b/Plugins/ZShell/Config/services.hpp @@ -1,4 +1,5 @@ #pragma once + #include "common.hpp" #include "enums.hpp" @@ -20,7 +21,7 @@ class Services : public settings::ObjectNode { CONFIG_PROPERTY(qreal, brightnessIncrement, 0.1) CONFIG_PROPERTY(bool, ddcutilService, true) CONFIG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) - CONFIG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) + CONFIG_ENUM_PROPERTY(GpuType, gpuType, GpuType::Auto) CONFIG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) CONFIG_PROPERTY(qreal, maxVolume, 1) CONFIG_PROPERTY(qreal, minBrightness, 0.01) diff --git a/Plugins/ZShell/Services/gpu.cpp b/Plugins/ZShell/Services/gpu.cpp index c12d896..81235cb 100644 --- a/Plugins/ZShell/Services/gpu.cpp +++ b/Plugins/ZShell/Services/gpu.cpp @@ -2,97 +2,187 @@ #include "sensorslib.hpp" -#include -#include +#include "config/rootnodes.hpp" +#include "config/services.hpp" + #include #include -#include +#include #include -#include -#include -#include -#include -#include namespace ZShell::services { +using Qt::StringLiterals::operator""_s; + namespace { -constexpr const char* kTypeDetectScript = - "if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null " - "2>&1; then echo NVIDIA;" - " elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep " - "-q .; then echo GENERIC;" - " else echo NONE; fi"; +QStringList cardFiles(const QString& relative) { + static const QRegularExpression k_cardRe(u"^card\\d+$"_s); -constexpr const char* kNameDetectScript = - "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null" - " || glxinfo -B 2>/dev/null | grep 'Device:' | cut -d':' -f2 | cut -d'(' " - "-f1" - " || lspci 2>/dev/null | grep -i 'vga\\|3d controller\\|display' | head -1"; + QStringList files; + QDirIterator it(u"/sys/class/drm"_s, QDir::Dirs | QDir::NoDotAndDotDot); + while (it.hasNext()) { + const QString path = it.next(); + if (!k_cardRe.match(it.fileName()).hasMatch()) continue; + const QString file = path + relative; + if (QFile::exists(file)) files << file; + } + return files; +} + +std::optional readNumber(const QString& path) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return std::nullopt; + bool ok = false; + const qreal v = f.readAll().trimmed().toDouble(&ok); + if (!ok) return std::nullopt; + return v; +} + +QString cleanName(QString s) { + static const QRegularExpression k_noise( + u"\\(R\\)|\\(TM\\)|Graphics"_s, + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression k_spaces(u"\\s+"_s); + s.replace(k_noise, {}); + s.replace(k_spaces, u" "_s); + return s.trimmed(); +} + +QString parseNvidiaName(const QByteArray& out) { + const QString first = + QString::fromUtf8(out).split(u'\n').value(0).trimmed(); + return first.isEmpty() ? QString() : cleanName(first); +} + +QString parseGlxinfoName(const QByteArray& out) { + const QStringList lines = QString::fromUtf8(out).split(u'\n'); + for (const QString& line : lines) { + const qsizetype idx = line.indexOf(u"Device:"_s); + if (idx < 0) { + continue; + } + + QString rest = line.mid(idx + 7); + const qsizetype paren = rest.indexOf(u'('); + if (paren >= 0) { + rest = rest.left(paren); + } + + const QString cleaned = cleanName(rest); + if (!cleaned.isEmpty()) { + return cleaned; + } + } + + return {}; +} + +QString parseLspciName(const QByteArray& out) { + static const QRegularExpression k_lineRe( + u"vga|3d controller|display"_s, + QRegularExpression::CaseInsensitiveOption); + + const QStringList lines = QString::fromUtf8(out).split(u'\n'); + QString match; + for (const QString& line : lines) { + if (k_lineRe.match(line).hasMatch()) { + match = line; + break; + } + } + + if (match.isEmpty()) { + return {}; + } + + static const QRegularExpression k_bracketRe(u"\\[([^\\]]+)\\][^\\[]*$"_s); + const auto bracket = k_bracketRe.match(match); + if (bracket.hasMatch()) { + return cleanName(bracket.captured(1)); + } + + static const QRegularExpression k_colonRe(u":\\s+(.+)"_s); + const auto colon = k_colonRe.match(match); + if (colon.hasMatch()) { + return cleanName(colon.captured(1)); + } + + return {}; +} + +struct NameSource { + QString program; + QStringList args; + QString (*parse)(const QByteArray&); +}; + +const std::array& nameSources() { + static const std::array k_sources = {{ + { + .program = u"nvidia-smi"_s, + .args = {u"--query-gpu=name"_s, u"--format=csv,noheader"_s}, + .parse = &parseNvidiaName, + }, + { + .program = u"glxinfo"_s, + .args = {u"-B"_s}, + .parse = &parseGlxinfoName, + }, + { + .program = u"lspci"_s, + .args = {}, + .parse = &parseLspciName, + }, + }}; + return k_sources; +} + +constexpr int k_nvidiaSource = 0; +constexpr int k_firstGenericSource = 1; } // namespace Gpu::Gpu(QObject* parent) : TickingService(parent) { - QString configPath = - QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); + auto* svc = config::ConfigSingleton::instance()->services(); + m_userType = svc->gpuType(); + QObject::connect(svc, &config::Services::gpuTypeChanged, this, [this, svc] { + const GpuType value = svc->gpuType(); + if (value == m_userType) return; + m_userType = value; + resolveGpu(); + }); - auto reloadConfig = [this, configPath]() { - QFile file(configPath); - if (file.open(QIODevice::ReadOnly)) { - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (!doc.isNull()) { - QJsonObject services = - doc.object().value("services").toObject(); - setUserType(parseType(services.value("gpuType").toString())); - } - } - }; + resolveGpu(); - reloadConfig(); + m_restartTimer = new QTimer(this); + m_restartTimer->setSingleShot(true); + m_restartTimer->setInterval(500); + QObject::connect(m_restartTimer, &QTimer::timeout, this, [this] { + if (!m_nvidiaProc || m_type != GpuType::Nvidia) return; - static QFileSystemWatcher* watcher = new QFileSystemWatcher(); - if (!watcher->files().contains(configPath)) { - QObject::connect( - watcher, - &QFileSystemWatcher::fileChanged, - this, - [this, configPath]() { - QTimer::singleShot(100, this, [this, configPath]() { - QFile file(configPath); - if (file.open(QIODevice::ReadOnly)) { - QJsonDocument doc = - QJsonDocument::fromJson(file.readAll()); - if (!doc.isNull()) { - QJsonObject services = - doc.object().value("services").toObject(); - setUserType(parseType( - services.value("gpuType").toString())); - } - } - }); - }); - watcher->addPath(configPath); + stopNvidiaUsage(); + startNvidiaUsage(); + }); + + QObject::connect(this, &TickingService::updateIntervalChanged, this, [this] { + if (!m_nvidiaProc) return; + m_restartTimer->start(); + }); +} + +Gpu::~Gpu() { + if (!m_nvidiaProc) return; + m_nvidiaProc->disconnect(this); + m_nvidiaProc->terminate(); + if (!m_nvidiaProc->waitForFinished(500)) { + m_nvidiaProc->kill(); + m_nvidiaProc->waitForFinished(200); } - - // Detection must run before any ServiceRef appears: callers may gate the ref on - // `type !== Gpu.None`, which would otherwise deadlock the detection. - if (m_userType == Auto) { - detectTypeOnce(); - } - detectNameOnce(); } -Gpu::Type Gpu::type() const { - return m_userType == Auto ? m_autoType : m_userType; -} - -Gpu::Type Gpu::userType() const { - return m_userType; -} - -Gpu::Type Gpu::autoType() const { - return m_autoType; +GpuType Gpu::type() const { + return m_type; } QString Gpu::name() const { @@ -115,28 +205,13 @@ qreal Gpu::memoryTotal() const { return m_memoryTotal; } -void Gpu::setUserType(Type value) { - if (value == m_userType) { - return; - } - const Type prevDerived = type(); - m_userType = value; - Q_EMIT userTypeChanged(); - if (type() != prevDerived) { - Q_EMIT typeChanged(); - } -} +void Gpu::setType(GpuType value) { + if (value == m_type) return; -void Gpu::setAutoType(Type value) { - if (value == m_userType) { - return; - } - const Type prevDerived = type(); - m_autoType = value; - Q_EMIT autoTypeChanged(); - if (type() != prevDerived) { - Q_EMIT typeChanged(); - } + m_type = value; + if (m_type != GpuType::Nvidia) stopNvidiaUsage(); + if (m_type == GpuType::None) resetUsage(); + emit typeChanged(); } void Gpu::setName(QString value) { @@ -147,6 +222,12 @@ void Gpu::setName(QString value) { Q_EMIT nameChanged(); } +void Gpu::setDetecting(bool value) { + if (value == m_detecting) return; + m_detecting = value; + emit detectingChanged(); +} + void Gpu::setMemoryUsed(qreal value) { if (std::abs(m_memoryUsed - value) < 0.001) return; m_memoryUsed = value; @@ -160,168 +241,192 @@ void Gpu::setMemoryTotal(qreal value) { } void Gpu::tick() { - const Type t = type(); - if (t == Generic) { + if (m_type == GpuType::Generic) { readGenericUsage(); readGenericMemory(); readGpuTemperature(); - } else if (t == Nvidia) { + } else if (m_type == GpuType::Nvidia) { startNvidiaUsage(); } else { - if (std::abs(m_percentage) > 0.0001) { - m_percentage = 0.0; - Q_EMIT percentageChanged(); - } - if (std::abs(m_temperature) > 0.05) { - m_temperature = 0.0; - Q_EMIT temperatureChanged(); - } + resetUsage(); } } -void Gpu::detectTypeOnce() { - if (m_typeProc) { - return; +void Gpu::refreshSysfsCache() { + m_busyFiles = cardFiles(u"/device/gpu_busy_percent"_s); + + m_vramFiles.clear(); + const QStringList totals = cardFiles(u"/device/mem_info_vram_total"_s); + for (const QString& total : totals) { + QString used = total; + used.replace(u"mem_info_vram_total"_s, u"mem_info_vram_used"_s); + if (QFile::exists(used)) m_vramFiles.append({used, total}); } - m_typeProc = new QProcess(this); - QObject::connect( - m_typeProc, - &QProcess::finished, - this, - [this](int, QProcess::ExitStatus) { - const QByteArray out = - m_typeProc->readAllStandardOutput().trimmed(); - if (!out.isEmpty()) { - setAutoType(parseType(QString::fromLatin1(out))); - } - m_typeProc->deleteLater(); - m_typeProc = nullptr; - }); - m_typeProc->start( - QStringLiteral("sh"), - {QStringLiteral("-c"), QString::fromLatin1(kTypeDetectScript)}); } -void Gpu::detectNameOnce() { - if (m_nameProc) { +void Gpu::resolveGpu() { + refreshSysfsCache(); + const int generation = ++m_generation; + + if (m_userType != GpuType::Auto) setType(m_userType); + + if (m_userType == GpuType::None) { + setName({}); + setDetecting(false); return; } - m_nameProc = new QProcess(this); + + setName({}); + setDetecting(true); + tryNameSource( + m_userType == GpuType::Generic ? k_firstGenericSource : k_nvidiaSource, + generation); +} + +int Gpu::probeEnd() const { + return m_userType == GpuType::Nvidia + ? k_firstGenericSource + : static_cast(nameSources().size()); +} + +void Gpu::tryNameSource(int index, int generation) { + const NameSource& src = nameSources().at(static_cast(index)); + runProcess( + src.program, + src.args, + [this, index, generation, parse = src.parse](const QByteArray& out) { + finishNameSource(index, generation, parse(out)); + }); +} + +void Gpu::finishNameSource(int index, int generation, QString name) { + if (generation != m_generation) { + return; + } + + if (m_userType == GpuType::Auto && index == k_nvidiaSource) { + if (!name.isEmpty()) + setType(GpuType::Nvidia); + else + setType(m_busyFiles.isEmpty() ? GpuType::None : GpuType::Generic); + + if (m_type == GpuType::None) { + setName({}); + setDetecting(false); + return; + } + } + + if (!name.isEmpty()) { + setName(std::move(name)); + setDetecting(false); + return; + } + + const int next = index + 1; + if (next < probeEnd()) { + tryNameSource(next, generation); + } else { + setName({}); + setDetecting(false); + } +} + +void Gpu::runProcess( + const QString& program, + const QStringList& args, + std::function callback) { + auto* proc = new QProcess(this); + proc->setStandardErrorFile(QProcess::nullDevice()); + + const auto finish = + [proc, callback = std::move(callback)](const QByteArray& out) { + callback(out); + proc->deleteLater(); + }; + QObject::connect( - m_nameProc, + proc, &QProcess::finished, this, - [this](int, QProcess::ExitStatus) { - const QString output = - QString::fromUtf8(m_nameProc->readAllStandardOutput()).trimmed(); - if (!output.isEmpty()) { - const QString lower = output.toLower(); - if (lower.contains(QStringLiteral("nvidia")) || - lower.contains(QStringLiteral("geforce")) || - lower.contains(QStringLiteral("rtx")) || - lower.contains(QStringLiteral("gtx")) || - lower.contains(QStringLiteral("rx"))) { - setName(cleanName(output)); - } else { - static const QRegularExpression bracketRe( - QStringLiteral("\\[([^\\]]+)\\][^\\[]*$")); - const auto bracket = bracketRe.match(output); - if (bracket.hasMatch()) { - setName(cleanName(bracket.captured(1))); - } else { - static const QRegularExpression colonRe( - QStringLiteral(":\\s*(.+)")); - const auto colon = colonRe.match(output); - if (colon.hasMatch()) { - setName(cleanName(colon.captured(1))); - } - } - } - } - m_nameProc->deleteLater(); - m_nameProc = nullptr; + [finish, proc](int code, QProcess::ExitStatus status) { + const bool ok = status == QProcess::NormalExit && code == 0; + finish(ok ? proc->readAllStandardOutput() : QByteArray()); }); - m_nameProc->start( - QStringLiteral("sh"), - {QStringLiteral("-c"), QString::fromLatin1(kNameDetectScript)}); + QObject::connect( + proc, + &QProcess::errorOccurred, + this, + [finish](QProcess::ProcessError err) { + if (err == QProcess::FailedToStart) { + finish(QByteArray()); + } + }); + + proc->start(program, args); +} + +void Gpu::onStopped() { + stopNvidiaUsage(); } void Gpu::readGenericMemory() { - const QStringList paths = QDir(QStringLiteral("/sys/class/drm")) - .entryList( - QStringList() << QStringLiteral("card*"), - QDir::Dirs | QDir::NoDotAndDotDot); - - qreal totalMem = 0.0; - qreal usedMem = 0.0; - for (const QString& card : paths) { - QFile total( - QStringLiteral("/sys/class/drm/%1/device/mem_info_vram_total") - .arg(card)); - if (!total.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok = false; - const qreal v = total.readAll().trimmed().toDouble(&ok); - total.close(); - if (ok) { - totalMem += v; - } - - QFile used(QStringLiteral("/sys/class/drm/%1/device/mem_info_vram_used") - .arg(card)); - if (!used.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok1 = false; - const qreal v1 = used.readAll().trimmed().toDouble(&ok1); - used.close(); - if (ok1) { - usedMem += v1; - } + qreal total = 0.0; + qreal used = 0.0; + for (const VramFiles& v : std::as_const(m_vramFiles)) { + const auto t = readNumber(v.total); + const auto u = readNumber(v.used); + if (!t || !u) continue; + total += *t; + used += *u; } - - setMemoryTotal(totalMem); - setMemoryUsed(usedMem); + setMemoryTotal(total); + setMemoryUsed(used); } void Gpu::readGenericUsage() { - const QStringList paths = QDir(QStringLiteral("/sys/class/drm")) - .entryList( - QStringList() << QStringLiteral("card*"), - QDir::Dirs | QDir::NoDotAndDotDot); qreal sum = 0.0; int count = 0; - for (const QString& card : paths) { - QFile f(QStringLiteral("/sys/class/drm/%1/device/gpu_busy_percent") - .arg(card)); - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok = false; - const qreal v = f.readAll().trimmed().toDouble(&ok); - f.close(); - if (ok) { - sum += v; + for (const QString& path : std::as_const(m_busyFiles)) { + if (const auto v = readNumber(path)) { + sum += *v; ++count; } } const qreal newPerc = count > 0 ? sum / count / 100.0 : 0.0; if (std::abs(newPerc - m_percentage) > 0.0001) { m_percentage = newPerc; - Q_EMIT percentageChanged(); + emit percentageChanged(); } } -void Gpu::startNvidiaUsage() { - if (m_nvidiaProc) { +void Gpu::stopNvidiaUsage() { + m_restartTimer->stop(); + + if (!m_nvidiaProc) return; + + QProcess* proc = m_nvidiaProc; + m_nvidiaProc = nullptr; + + proc->disconnect(this); + if (proc->state() == QProcess::NotRunning) { + proc->deleteLater(); return; } - m_nvidiaProc = new QProcess(this); + QObject::connect(proc, &QProcess::finished, proc, &QObject::deleteLater); + proc->terminate(); +} + +void Gpu::startNvidiaUsage() { + if (m_nvidiaProc) return; + + auto* proc = new QProcess(this); + m_nvidiaProc = proc; + QObject::connect( - m_nvidiaProc, &QProcess::readyReadStandardOutput, this, [this]() { - while (m_nvidiaProc->canReadLine()) { - const QByteArray out = m_nvidiaProc->readLine(); + proc, &QProcess::readyReadStandardOutput, this, [this, proc]() { + while (proc->canReadLine()) { + const QByteArray out = proc->readLine(); const QString output = QString::fromUtf8(out).trimmed(); if (output.isEmpty()) continue; @@ -357,21 +462,21 @@ void Gpu::startNvidiaUsage() { } }); - QObject::connect(m_nvidiaProc, &QProcess::finished, this, [this]() { - m_nvidiaProc->deleteLater(); - m_nvidiaProc = nullptr; - }); + auto cleanup = [this, proc]() { + if (m_nvidiaProc == proc) m_nvidiaProc = nullptr; + + proc->deleteLater(); + }; + + QObject::connect(proc, &QProcess::finished, this, cleanup); QObject::connect( - m_nvidiaProc, + proc, &QProcess::errorOccurred, this, - [this](QProcess::ProcessError) { - m_nvidiaProc->deleteLater(); - m_nvidiaProc = nullptr; - }); + [cleanup](QProcess::ProcessError) { cleanup(); }); - m_nvidiaProc->start( + proc->start( QStringLiteral("nvidia-smi"), {QStringLiteral( "--query-gpu=utilization.gpu,temperature.gpu," @@ -386,32 +491,21 @@ void Gpu::readGpuTemperature() { const qreal newTemp = t.value_or(0.0); if (std::abs(newTemp - m_temperature) > 0.05) { m_temperature = newTemp; - Q_EMIT temperatureChanged(); + emit temperatureChanged(); } } -Gpu::Type Gpu::parseType(const QString& s) { - const QString u = s.trimmed().toUpper(); - if (u.isEmpty()) { - return Auto; +void Gpu::resetUsage() { + if (std::abs(m_percentage) > 0.0001) { + m_percentage = 0.0; + emit percentageChanged(); } - if (u == QStringLiteral("NVIDIA")) { - return Nvidia; + if (std::abs(m_temperature) > 0.05) { + m_temperature = 0.0; + emit temperatureChanged(); } - if (u == QStringLiteral("GENERIC")) { - return Generic; - } - return None; -} - -QString Gpu::cleanName(QString s) { - static const QRegularExpression noise( - QStringLiteral("\\(R\\)|\\(TM\\)|Graphics"), - QRegularExpression::CaseInsensitiveOption); - static const QRegularExpression spaces(QStringLiteral("\\s+")); - s.replace(noise, QString()); - s.replace(spaces, QStringLiteral(" ")); - return s.trimmed(); + setMemoryUsed(0.0); + setMemoryTotal(0.0); } } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/gpu.hpp b/Plugins/ZShell/Services/gpu.hpp index dbe567c..e082d1b 100644 --- a/Plugins/ZShell/Services/gpu.hpp +++ b/Plugins/ZShell/Services/gpu.hpp @@ -1,31 +1,29 @@ #pragma once #include "tickingservice.hpp" +#include "config/enums.hpp" #include #include +#include namespace ZShell::services { +using GpuType = config::GpuType::Enum; + +struct VramFiles { + QString used; + QString total; +}; + class Gpu : public TickingService { Q_OBJECT QML_ELEMENT QML_SINGLETON - public: - enum Type { - Auto, // user override is empty (config "") — defer to detected autoType - None, // no usable GPU - Nvidia, // queried via nvidia-smi - Generic, // queried via /sys/class/drm/card*/device/gpu_busy_percent - }; - Q_ENUM(Type) - - private: - Q_PROPERTY(Type type READ type NOTIFY typeChanged) - Q_PROPERTY(Type userType READ userType NOTIFY userTypeChanged) - Q_PROPERTY(Type autoType READ autoType NOTIFY autoTypeChanged) + Q_PROPERTY(GpuType type READ type NOTIFY typeChanged) Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(bool detecting READ detecting NOTIFY detectingChanged) Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) Q_PROPERTY(qreal temperature READ temperature NOTIFY temperatureChanged) Q_PROPERTY(qreal memoryUsed READ memoryUsed NOTIFY memoryUsedChanged) @@ -33,11 +31,11 @@ class Gpu : public TickingService { public: explicit Gpu(QObject* parent = nullptr); + ~Gpu() override; - [[nodiscard]] Type type() const; - [[nodiscard]] Type userType() const; - [[nodiscard]] Type autoType() const; + [[nodiscard]] GpuType type() const; [[nodiscard]] QString name() const; + [[nodiscard]] bool detecting() const; [[nodiscard]] qreal percentage() const; [[nodiscard]] qreal temperature() const; [[nodiscard]] qreal memoryUsed() const; @@ -45,9 +43,8 @@ class Gpu : public TickingService { signals: void typeChanged(); - void userTypeChanged(); - void autoTypeChanged(); void nameChanged(); + void detectingChanged(); void percentageChanged(); void temperatureChanged(); void memoryUsedChanged(); @@ -55,35 +52,52 @@ class Gpu : public TickingService { protected: void tick() override; + void onStopped() override; private: - void detectTypeOnce(); - void detectNameOnce(); + void resolveGpu(); + + [[nodiscard]] int probeEnd() const; + + void tryNameSource(int index, int generation); + void finishNameSource(int index, int generation, QString name); + void readGenericUsage(); + void stopNvidiaUsage(); void startNvidiaUsage(); void readGpuTemperature(); void readGenericMemory(); + void resetUsage(); - void setUserType(Type value); - void setAutoType(Type value); + void runProcess( + const QString& program, + const QStringList& args, + std::function callback); + + void setType(GpuType value); void setName(QString value); + void setDetecting(bool value); void setMemoryUsed(qreal value); void setMemoryTotal(qreal value); - [[nodiscard]] static Type parseType(const QString& s); - [[nodiscard]] static QString cleanName(QString s); + void refreshSysfsCache(); - Type m_userType = Auto; - Type m_autoType = None; + GpuType m_userType = GpuType::Auto; + GpuType m_type = GpuType::None; QString m_name; + bool m_detecting = false; qreal m_percentage = 0.0; qreal m_temperature = 0.0; qreal m_memoryUsed = 0.0; qreal m_memoryTotal = 0.0; - QProcess* m_typeProc = nullptr; - QProcess* m_nameProc = nullptr; + QStringList m_busyFiles; + QList m_vramFiles; + int m_generation = 0; + bool m_nvidiaQuerying = false; + QProcess* m_nvidiaProc = nullptr; + QTimer* m_restartTimer = nullptr; }; } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 0dc67fa..d6f585e 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -37,6 +37,7 @@ void TickingService::start() { void TickingService::stop() { m_running = false; m_timer->stop(); + onStopped(); } void TickingService::applyInterval(int ms) { diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp index 5cd7fe1..87f5676 100644 --- a/Plugins/ZShell/Services/tickingservice.hpp +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -26,6 +26,7 @@ class TickingService : public Service { void stop() final; virtual void tick() = 0; + virtual void onStopped() {} private: void applyInterval(int ms); From 10d763cfe553f41d76cc567ad23e6b90fc1f8bbe Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 13:11:31 +0200 Subject: [PATCH 10/21] fix: define declared method + insert new bar entries to config file list + rename Hyprsunset -> Nightlight --- Helpers/{Hyprsunset.qml => Nightlight.qml} | 0 Modules/Bar/Bar.qml | 7 +- ...rsunsetWidget.qml => NightlightWidget.qml} | 6 +- Modules/Settings/Pages/WallpaperPage.qml | 6 +- Plugins/ZShell/Config/bar.hpp | 5 +- Plugins/ZShell/Services/gpu.cpp | 4 + Plugins/ZShell/Settings/changebatcher.cpp | 17 +- Plugins/ZShell/Settings/changebatcher.hpp | 18 +- Plugins/ZShell/Settings/codecs.cpp | 471 +++++++------ Plugins/ZShell/Settings/codecs.hpp | 102 +-- Plugins/ZShell/Settings/layerregistry.hpp | 76 ++- Plugins/ZShell/Settings/listnode.cpp | 630 ++++++++++-------- Plugins/ZShell/Settings/listnode.hpp | 103 +-- Plugins/ZShell/Settings/node.cpp | 354 +++++----- Plugins/ZShell/Settings/node.hpp | 142 ++-- Plugins/ZShell/Settings/quarantine.cpp | 16 +- Plugins/ZShell/Settings/quarantine.hpp | 30 +- Plugins/ZShell/Settings/rootnode.cpp | 43 +- Plugins/ZShell/Settings/rootnode.hpp | 35 +- Plugins/ZShell/Settings/schema.cpp | 144 ++-- Plugins/ZShell/Settings/schema.hpp | 88 +-- Plugins/ZShell/Settings/settingsfile.cpp | 277 ++++---- Plugins/ZShell/Settings/settingsfile.hpp | 55 +- 23 files changed, 1445 insertions(+), 1184 deletions(-) rename Helpers/{Hyprsunset.qml => Nightlight.qml} (100%) rename Modules/Bar/Components/{HyprsunsetWidget.qml => NightlightWidget.qml} (84%) diff --git a/Helpers/Hyprsunset.qml b/Helpers/Nightlight.qml similarity index 100% rename from Helpers/Hyprsunset.qml rename to Helpers/Nightlight.qml diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index f0f5fb0..bd4df6f 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -89,10 +89,10 @@ GridLayout { role: "id" DelegateChoice { - roleValue: "hyprsunset" + roleValue: "nightlight" delegate: EntryWrapper { - HyprsunsetWidget { + NightlightWidget { horizontal: root.horizontal visible: !root.fullscreen || root.isHovered } @@ -210,8 +210,7 @@ GridLayout { roleValue: "network" delegate: EntryWrapper { - NetworkWidget { - } + NetworkWidget {} } } diff --git a/Modules/Bar/Components/HyprsunsetWidget.qml b/Modules/Bar/Components/NightlightWidget.qml similarity index 84% rename from Modules/Bar/Components/HyprsunsetWidget.qml rename to Modules/Bar/Components/NightlightWidget.qml index f409a29..f875d5b 100644 --- a/Modules/Bar/Components/HyprsunsetWidget.qml +++ b/Modules/Bar/Components/NightlightWidget.qml @@ -8,15 +8,15 @@ import qs.Services WidgetBase { id: root - property bool tempEnabled: Hyprsunset.enabled + property bool tempEnabled: Nightlight.enabled color: root.tempEnabled ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer radius: Tokens.rounding.full StateLayer { onClicked: { - Hyprsunset.toggleNightLight(); - Hyprsunset.manualToggle = true; + Nightlight.toggleNightLight(); + Nightlight.manualToggle = true; } } diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index 5e7035c..ee78ddc 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -158,9 +158,9 @@ PageBase { } onAccepted: { - Config.general.color.scheduleHyprsunsetStart = startCurrent; - Config.general.color.scheduleHyprsunsetEnd = endCurrent; - Hyprsunset.checkStartup(); + Config.display.nightlight.scheduleStart = startCurrent; + Config.display.nightlight.scheduleEnd = endCurrent; + Nightlight.checkStartup(); } } } diff --git a/Plugins/ZShell/Config/bar.hpp b/Plugins/ZShell/Config/bar.hpp index 25f4c66..eb08a50 100644 --- a/Plugins/ZShell/Config/bar.hpp +++ b/Plugins/ZShell/Config/bar.hpp @@ -64,14 +64,15 @@ class Bar : public settings::ObjectNode { LIST_ENTRY(spacer, true), LIST_ENTRY(activeWindow, true), LIST_ENTRY(spacer, true), - LIST_ENTRY(hyprsunset, true), + LIST_ENTRY(nightlight, true), LIST_ENTRY(tray, true), LIST_ENTRY(statusIcons, true), LIST_ENTRY(network, false), LIST_ENTRY(upower, true), LIST_ENTRY(clock, true), LIST_ENTRY(notifBell, true), - })) + }), + .identityKey = QStringLiteral("id")) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Services/gpu.cpp b/Plugins/ZShell/Services/gpu.cpp index 81235cb..25993b7 100644 --- a/Plugins/ZShell/Services/gpu.cpp +++ b/Plugins/ZShell/Services/gpu.cpp @@ -205,6 +205,10 @@ qreal Gpu::memoryTotal() const { return m_memoryTotal; } +bool Gpu::detecting() const { + return m_detecting; +} + void Gpu::setType(GpuType value) { if (value == m_type) return; diff --git a/Plugins/ZShell/Settings/changebatcher.cpp b/Plugins/ZShell/Settings/changebatcher.cpp index ac4b215..30489a0 100644 --- a/Plugins/ZShell/Settings/changebatcher.cpp +++ b/Plugins/ZShell/Settings/changebatcher.cpp @@ -3,23 +3,20 @@ namespace ZShell::settings { ChangeBatcher::ChangeBatcher(QObject* parent) - : QObject(parent) - , m_dirty(false) {} + : QObject(parent), m_dirty(false) {} void ChangeBatcher::dirty() { - if (m_dirty) - return; + if (m_dirty) return; - m_dirty = true; - QMetaObject::invokeMethod(this, &ChangeBatcher::flush, Qt::QueuedConnection); + m_dirty = true; + QMetaObject::invokeMethod(this, &ChangeBatcher::flush, Qt::QueuedConnection); } void ChangeBatcher::flush() { - if (!m_dirty) - return; + if (!m_dirty) return; - m_dirty = false; - emit dirtied(); + m_dirty = false; + emit dirtied(); } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/changebatcher.hpp b/Plugins/ZShell/Settings/changebatcher.hpp index 17ee2b3..ef60e08 100644 --- a/Plugins/ZShell/Settings/changebatcher.hpp +++ b/Plugins/ZShell/Settings/changebatcher.hpp @@ -5,20 +5,20 @@ namespace ZShell::settings { class ChangeBatcher : public QObject { - Q_OBJECT + Q_OBJECT -public: - explicit ChangeBatcher(QObject* parent = nullptr); + public: + explicit ChangeBatcher(QObject* parent = nullptr); - void dirty(); + void dirty(); -signals: - void dirtied(); + signals: + void dirtied(); -private: - bool m_dirty; + private: + bool m_dirty; - void flush(); + void flush(); }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/codecs.cpp b/Plugins/ZShell/Settings/codecs.cpp index 478d34c..6e031ff 100644 --- a/Plugins/ZShell/Settings/codecs.cpp +++ b/Plugins/ZShell/Settings/codecs.cpp @@ -16,352 +16,401 @@ namespace { // We don't know the option here so it isn't set, it should be set by the caller DecodeResult error(DiagnosticType::Type type, const QString& message) { - Diagnostic error; - error.type = type; - error.message = message; - return { .value = QVariant(), .error = error, .indexPath = {} }; + Diagnostic error; + error.type = type; + error.message = message; + return {.value = QVariant(), .error = error, .indexPath = {}}; } DecodeResult mismatch(ExpectedType expected, const QJsonValue& value) { - return { .value = QVariant(), .error = Diagnostic::mismatch(expected, value), .indexPath = {} }; + return { + .value = QVariant(), + .error = Diagnostic::mismatch(expected, value), + .indexPath = {}}; } -DecodeResult mismatch(const QList& expected, const QJsonValue& value) { - return { .value = QVariant(), .error = Diagnostic::mismatch(expected, value), .indexPath = {} }; +DecodeResult mismatch( + const QList& expected, const QJsonValue& value) { + return { + .value = QVariant(), + .error = Diagnostic::mismatch(expected, value), + .indexPath = {}}; } template ValueCodec* makeListCodec(const QMetaType& type) { - const auto* elementCodec = ValueCodec::codecFor(QMetaType::fromType()); - return elementCodec ? new ListCodec(type, elementCodec) : nullptr; + const auto* elementCodec = ValueCodec::codecFor( + QMetaType::fromType()); + return elementCodec ? new ListCodec(type, elementCodec) + : nullptr; } // Unions are keyed by their alternatives in order, since order decides which one wins QString unionKey(const QList& types) { - QStringList ids; - ids.reserve(types.size()); - for (const auto& type : types) - ids << QString::number(type.id()); - return ids.join(u","_s); + QStringList ids; + ids.reserve(types.size()); + for (const auto& type : types) + ids << QString::number(type.id()); + return ids.join(u","_s); } -QList expectedTypesOf(const QList& alternatives) { - QList types; - types.reserve(alternatives.size()); - for (const auto* codec : alternatives) - types << codec->expected(); - return types; +QList expectedTypesOf( + const QList& alternatives) { + QList types; + types.reserve(alternatives.size()); + for (const auto* codec : alternatives) + types << codec->expected(); + return types; } using ListFactory = ValueCodec* (*)(const QMetaType&); const QHash& listFactories() { - static const QHash k_factories{ - { QMetaType::fromType().id(), &makeListCodec }, - { QMetaType::fromType>().id(), &makeListCodec> }, - }; - return k_factories; + static const QHash k_factories{ + {QMetaType::fromType().id(), &makeListCodec}, + {QMetaType::fromType>().id(), + &makeListCodec>}, + }; + return k_factories; } } // namespace ValueCodec::ValueCodec(const QMetaType& type, ExpectedType expected) - : m_type(type) - , m_expected(expected) {} + : m_type(type), m_expected(expected) {} QMetaType ValueCodec::type() const { - return m_type; + return m_type; } ExpectedType ValueCodec::expected() const { - return m_expected; + return m_expected; } ValueCodec* ValueCodec::codecFor(const QMetaType& type) { - // Cache for codecs, keyed by type id - static QHash s_registry; + // Cache for codecs, keyed by type id + static QHash s_registry; - // Cached lookup - if (const auto it = s_registry.constFind(type.id()); it != s_registry.constEnd()) - return *it; + // Cached lookup + if (const auto it = s_registry.constFind(type.id()); + it != s_registry.constEnd()) + return *it; - ValueCodec* codec = nullptr; - switch (type.id()) { - case QMetaType::Bool: - codec = new BoolCodec(type); - break; - case QMetaType::Int: - codec = new IntCodec(type); - break; - case QMetaType::Double: - codec = new RealCodec(type); - break; - case QMetaType::QString: - codec = new StringCodec(type); - break; - case QMetaType::QVariantList: - codec = new VariantListCodec(type); - break; - case QMetaType::QVariantMap: - codec = new VariantMapCodec(type); - break; - default: - if (const auto factory = listFactories().constFind(type.id()); factory != listFactories().constEnd()) - codec = (*factory)(type); - else if (util::isSupportedEnum(type)) - codec = new EnumCodec(type, util::metaEnumFor(type)); - break; - } + ValueCodec* codec = nullptr; + switch (type.id()) { + case QMetaType::Bool: + codec = new BoolCodec(type); + break; + case QMetaType::Int: + codec = new IntCodec(type); + break; + case QMetaType::Double: + codec = new RealCodec(type); + break; + case QMetaType::QString: + codec = new StringCodec(type); + break; + case QMetaType::QVariantList: + codec = new VariantListCodec(type); + break; + case QMetaType::QVariantMap: + codec = new VariantMapCodec(type); + break; + default: + if (const auto factory = listFactories().constFind(type.id()); + factory != listFactories().constEnd()) + codec = (*factory)(type); + else if (util::isSupportedEnum(type)) + codec = new EnumCodec(type, util::metaEnumFor(type)); + break; + } - // Cache codec - if (codec) - s_registry.insert(type.id(), codec); + // Cache codec + if (codec) s_registry.insert(type.id(), codec); - return codec; + return codec; } ValueCodec* ValueCodec::unionFor(const QList& types) { - // Cache for union codecs, keyed by their alternatives - static QHash s_registry; + // Cache for union codecs, keyed by their alternatives + static QHash s_registry; - if (types.size() < 2) { - qCCritical(lcSettings, "A union needs at least two types, got %lld", types.size()); - return nullptr; - } + if (types.size() < 2) { + qCCritical( + lcSettings, + "A union needs at least two types, got %lld", + types.size()); + return nullptr; + } - const auto key = unionKey(types); + const auto key = unionKey(types); - // Cached lookup - if (const auto it = s_registry.constFind(key); it != s_registry.constEnd()) - return *it; + // Cached lookup + if (const auto it = s_registry.constFind(key); it != s_registry.constEnd()) + return *it; - QList alternatives; - alternatives.reserve(types.size()); + QList alternatives; + alternatives.reserve(types.size()); - for (const auto& type : types) { - const auto* codec = codecFor(type); - if (!codec) { - qCCritical(lcSettings, "No codec found for type %s, cannot build union", type.name()); - return nullptr; - } - alternatives << codec; - } + for (const auto& type : types) { + const auto* codec = codecFor(type); + if (!codec) { + qCCritical( + lcSettings, + "No codec found for type %s, cannot build union", + type.name()); + return nullptr; + } + alternatives << codec; + } - auto* const codec = new UnionCodec(alternatives); - s_registry.insert(key, codec); + auto* const codec = new UnionCodec(alternatives); + s_registry.insert(key, codec); - return codec; + return codec; } QJsonValue BoolCodec::encode(const QVariant& value) const { - return value.toBool(); + return value.toBool(); } DecodeResult BoolCodec::decode(const QJsonValue& value) const { - // 1 and "true" are not booleans - if (!value.isBool()) - return mismatch(ExpectedType::Bool, value); + // 1 and "true" are not booleans + if (!value.isBool()) return mismatch(ExpectedType::Bool, value); - return { .value = value.toBool(), .error = std::nullopt, .indexPath = {} }; + return {.value = value.toBool(), .error = std::nullopt, .indexPath = {}}; } QJsonValue IntCodec::encode(const QVariant& value) const { - return value.toInt(); + return value.toInt(); } DecodeResult IntCodec::decode(const QJsonValue& value) const { - if (!value.isDouble()) - return mismatch(ExpectedType::Int, value); + if (!value.isDouble()) return mismatch(ExpectedType::Int, value); - const auto num = value.toDouble(); + const auto num = value.toDouble(); - double integral; - if (std::fpclassify(std::modf(num, &integral)) != FP_ZERO) - return error( - DiagnosticType::InvalidValue, QStringLiteral("Expected an integer, got the real %1").arg(QString::number(num))); + double integral; + if (std::fpclassify(std::modf(num, &integral)) != FP_ZERO) + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Expected an integer, got the real %1") + .arg(QString::number(num))); - constexpr auto k_min = std::numeric_limits::min(); - constexpr auto k_max = std::numeric_limits::max(); - if (num < static_cast(k_min) || num > static_cast(k_max)) { - const auto message = QStringLiteral("Integer %1 is out of range, expected between %2 and %3").arg(QString::number(num, 'f', 0), QString::number(k_min), QString::number(k_max)); - return error(DiagnosticType::InvalidValue, message); - } + constexpr auto k_min = std::numeric_limits::min(); + constexpr auto k_max = std::numeric_limits::max(); + if (num < static_cast(k_min) || num > static_cast(k_max)) { + const auto message = + QStringLiteral( + "Integer %1 is out of range, expected between %2 and %3") + .arg( + QString::number(num, 'f', 0), + QString::number(k_min), + QString::number(k_max)); + return error(DiagnosticType::InvalidValue, message); + } - return { .value = static_cast(num), .error = std::nullopt, .indexPath = {} }; + return { + .value = static_cast(num), .error = std::nullopt, .indexPath = {}}; } QJsonValue RealCodec::encode(const QVariant& value) const { - return value.toDouble(); + return value.toDouble(); } DecodeResult RealCodec::decode(const QJsonValue& value) const { - if (!value.isDouble()) - return mismatch(ExpectedType::Real, value); + if (!value.isDouble()) return mismatch(ExpectedType::Real, value); - return { .value = QVariant::fromValue(value.toDouble()), .error = std::nullopt, .indexPath = {} }; + return { + .value = QVariant::fromValue(value.toDouble()), + .error = std::nullopt, + .indexPath = {}}; } QJsonValue StringCodec::encode(const QVariant& value) const { - return value.toString(); + return value.toString(); } DecodeResult StringCodec::decode(const QJsonValue& value) const { - if (!value.isString()) - return mismatch(ExpectedType::String, value); + if (!value.isString()) return mismatch(ExpectedType::String, value); - return { .value = value.toString(), .error = std::nullopt, .indexPath = {} }; + return {.value = value.toString(), .error = std::nullopt, .indexPath = {}}; } QJsonValue VariantListCodec::encode(const QVariant& value) const { - return QJsonArray::fromVariantList(value.toList()); + return QJsonArray::fromVariantList(value.toList()); } DecodeResult VariantListCodec::decode(const QJsonValue& value) const { - if (!value.isArray()) - return mismatch(ExpectedType::Array, value); + if (!value.isArray()) return mismatch(ExpectedType::Array, value); - return { .value = value.toArray().toVariantList(), .error = std::nullopt, .indexPath = {} }; + return { + .value = value.toArray().toVariantList(), + .error = std::nullopt, + .indexPath = {}}; } QJsonValue VariantMapCodec::encode(const QVariant& value) const { - return QJsonObject::fromVariantMap(value.toMap()); + return QJsonObject::fromVariantMap(value.toMap()); } DecodeResult VariantMapCodec::decode(const QJsonValue& value) const { - if (!value.isObject()) - return mismatch(ExpectedType::Object, value); + if (!value.isObject()) return mismatch(ExpectedType::Object, value); - return { .value = value.toObject().toVariantMap(), .error = std::nullopt, .indexPath = {} }; + return { + .value = value.toObject().toVariantMap(), + .error = std::nullopt, + .indexPath = {}}; } EnumCodec::EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum) - : ValueCodec(type, ExpectedType::String) - , m_metaEnum(metaEnum) {} + : ValueCodec(type, ExpectedType::String), m_metaEnum(metaEnum) {} QJsonValue EnumCodec::encode(const QVariant& value) const { - const auto* key = util::enumKeyFor(m_metaEnum, value); - if (!key) { - qCWarning( - lcSettings, "Cannot encode value %lld of enum %s, no such enumerator", value.toLongLong(), m_type.name()); - return {}; - } + const auto* key = util::enumKeyFor(m_metaEnum, value); + if (!key) { + qCWarning( + lcSettings, + "Cannot encode value %lld of enum %s, no such enumerator", + value.toLongLong(), + m_type.name()); + return {}; + } - return QString::fromUtf8(key); + return QString::fromUtf8(key); } DecodeResult EnumCodec::decode(const QJsonValue& value) const { - if (!value.isString()) - return mismatch(ExpectedType::String, value); + if (!value.isString()) return mismatch(ExpectedType::String, value); - const auto key = value.toString(); + const auto key = value.toString(); - for (int i = 0; i < m_metaEnum.keyCount(); ++i) { - if (QString::compare(QString::fromUtf8(m_metaEnum.key(i)), key, Qt::CaseInsensitive) != 0) - continue; + for (int i = 0; i < m_metaEnum.keyCount(); ++i) { + if (QString::compare( + QString::fromUtf8(m_metaEnum.key(i)), + key, + Qt::CaseInsensitive) != 0) + continue; - const auto raw = m_metaEnum.value(i); - QVariant decoded(m_type); - if (!QMetaType::convert(QMetaType::fromType(), &raw, m_type, decoded.data())) { - qCCritical(lcSettings, "Failed to convert value %d to enum %s", raw, m_type.name()); - return error(DiagnosticType::InvalidValue, - QStringLiteral("Could not convert %1 to %2").arg(key, QString::fromUtf8(m_type.name()))); - } + const auto raw = m_metaEnum.value(i); + QVariant decoded(m_type); + if (!QMetaType::convert( + QMetaType::fromType(), &raw, m_type, decoded.data())) { + qCCritical( + lcSettings, + "Failed to convert value %d to enum %s", + raw, + m_type.name()); + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Could not convert %1 to %2") + .arg(key, QString::fromUtf8(m_type.name()))); + } - return { .value = decoded, .error = std::nullopt, .indexPath = {} }; - } + return {.value = decoded, .error = std::nullopt, .indexPath = {}}; + } - QStringList options; - options.reserve(m_metaEnum.keyCount()); - for (int i = 0; i < m_metaEnum.keyCount(); ++i) - options << QString::fromUtf8(m_metaEnum.key(i)); + QStringList options; + options.reserve(m_metaEnum.keyCount()); + for (int i = 0; i < m_metaEnum.keyCount(); ++i) + options << QString::fromUtf8(m_metaEnum.key(i)); - return error(DiagnosticType::InvalidValue, - QStringLiteral("Invalid enum value %1. Expected one of %2").arg(key, options.join(u", "_s))); + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Invalid enum value %1. Expected one of %2") + .arg(key, options.join(u", "_s))); } template -ListCodec::ListCodec(const QMetaType& type, const ValueCodec* elementCodec) - : ValueCodec(type, ExpectedType::Array) - , m_elementCodec(elementCodec) {} +ListCodec::ListCodec( + const QMetaType& type, const ValueCodec* elementCodec) + : ValueCodec(type, ExpectedType::Array), m_elementCodec(elementCodec) {} -template QJsonValue ListCodec::encode(const QVariant& value) const { - QJsonArray array; - const auto list = value.value(); - for (const auto& element : list) - array.append(m_elementCodec->encode(QVariant::fromValue(element))); - return array; +template +QJsonValue ListCodec::encode(const QVariant& value) const { + QJsonArray array; + const auto list = value.value(); + for (const auto& element : list) + array.append(m_elementCodec->encode(QVariant::fromValue(element))); + return array; } -template DecodeResult ListCodec::decode(const QJsonValue& value) const { - if (!value.isArray()) - return mismatch(ExpectedType::Array, value); +template +DecodeResult ListCodec::decode(const QJsonValue& value) const { + if (!value.isArray()) return mismatch(ExpectedType::Array, value); - const auto array = value.toArray(); - Container list; - list.reserve(array.size()); + const auto array = value.toArray(); + Container list; + list.reserve(array.size()); - for (qsizetype i = 0; i < array.size(); ++i) { - auto result = m_elementCodec->decode(array.at(i)); + for (qsizetype i = 0; i < array.size(); ++i) { + auto result = m_elementCodec->decode(array.at(i)); - // Reject the entire list if any element is invalid - if (result.error) { - result.indexPath.prepend(i); - return result; - } + // Reject the entire list if any element is invalid + if (result.error) { + result.indexPath.prepend(i); + return result; + } - list.append(result.value.value()); - } + list.append(result.value.value()); + } - return { .value = QVariant::fromValue(list), .error = std::nullopt, .indexPath = {} }; + return { + .value = QVariant::fromValue(list), + .error = std::nullopt, + .indexPath = {}}; } UnionCodec::UnionCodec(const QList& alternatives) - : ValueCodec(QMetaType::fromType(), alternatives.first()->expected()) - , m_alternatives(alternatives) - , m_expectedTypes(expectedTypesOf(alternatives)) { - m_byType.reserve(alternatives.size()); + : ValueCodec( + QMetaType::fromType(), alternatives.first()->expected()) + , m_alternatives(alternatives) + , m_expectedTypes(expectedTypesOf(alternatives)) { + m_byType.reserve(alternatives.size()); - for (const auto* codec : alternatives) - m_byType.insert(codec->type().id(), codec); + for (const auto* codec : alternatives) + m_byType.insert(codec->type().id(), codec); } QJsonValue UnionCodec::encode(const QVariant& value) const { - // An unset union is simply absent from the file - if (!value.isValid()) - return QJsonValue::Undefined; + // An unset union is simply absent from the file + if (!value.isValid()) return QJsonValue::Undefined; - const auto* codec = m_byType.value(value.metaType().id()); - if (!codec) { - qCWarning(lcSettings, "Cannot encode value of type %s, it is not one of the allowed types", value.typeName()); - return QJsonValue::Undefined; - } + const auto* codec = m_byType.value(value.metaType().id()); + if (!codec) { + qCWarning( + lcSettings, + "Cannot encode value of type %s, it is not one of the allowed " + "types", + value.typeName()); + return QJsonValue::Undefined; + } - return codec->encode(value); + return codec->encode(value); } DecodeResult UnionCodec::decode(const QJsonValue& value) const { - std::optional best; + std::optional best; - for (const auto* codec : m_alternatives) { - auto result = codec->decode(value); + for (const auto* codec : m_alternatives) { + auto result = codec->decode(value); - // The first alternative to accept the value wins - if (!result.error) - return result; + // The first alternative to accept the value wins + if (!result.error) return result; - // Non-nested type mismatch just means try another alternative - if (result.indexPath.isEmpty() && result.error->type == DiagnosticType::TypeMismatch) - continue; + // Non-nested type mismatch just means try another alternative + if (result.indexPath.isEmpty() && + result.error->type == DiagnosticType::TypeMismatch) + continue; - // Report the deepest error that isn't a type mismatch - if (!best || result.indexPath.size() > best->indexPath.size()) - best = std::move(result); - } + // Report the deepest error that isn't a type mismatch + if (!best || result.indexPath.size() > best->indexPath.size()) + best = std::move(result); + } - if (best) - return *best; + if (best) return *best; - // Nothing matched the shape, so report every alternative - return mismatch(m_expectedTypes, value); + // Nothing matched the shape, so report every alternative + return mismatch(m_expectedTypes, value); } // Instantiated for types as needed diff --git a/Plugins/ZShell/Settings/codecs.hpp b/Plugins/ZShell/Settings/codecs.hpp index 2bbf2b2..f86cfe3 100644 --- a/Plugins/ZShell/Settings/codecs.hpp +++ b/Plugins/ZShell/Settings/codecs.hpp @@ -13,43 +13,46 @@ namespace ZShell::settings { struct DecodeResult { - QVariant value; - std::optional error; - QList indexPath; // Index path of the failing element, outermost list first + QVariant value; + std::optional error; + QList + indexPath; // Index path of the failing element, outermost list first }; class ValueCodec { -public: - explicit ValueCodec(const QMetaType& type, ExpectedType expected); - virtual ~ValueCodec() = default; + public: + explicit ValueCodec(const QMetaType& type, ExpectedType expected); + virtual ~ValueCodec() = default; - // Returns the shared codec for a type, or nullptr if the type is unsupported - static ValueCodec* codecFor(const QMetaType& type); + // Returns the shared codec for a type, or nullptr if the type is unsupported + static ValueCodec* codecFor(const QMetaType& type); - // Returns the shared codec for a union of types, or nullptr if any of them is unsupported - static ValueCodec* unionFor(const QList& types); + // Returns the shared codec for a union of types, or nullptr if any of them is unsupported + static ValueCodec* unionFor(const QList& types); - [[nodiscard]] QMetaType type() const; - [[nodiscard]] ExpectedType expected() const; // The JSON type this decodes from - [[nodiscard]] virtual QJsonValue encode(const QVariant& value) const = 0; - [[nodiscard]] virtual DecodeResult decode(const QJsonValue& value) const = 0; + [[nodiscard]] QMetaType type() const; + [[nodiscard]] ExpectedType expected() + const; // The JSON type this decodes from + [[nodiscard]] virtual QJsonValue encode(const QVariant& value) const = 0; + [[nodiscard]] virtual DecodeResult decode(const QJsonValue& value) const = 0; -protected: - const QMetaType m_type; - const ExpectedType m_expected; + protected: + const QMetaType m_type; + const ExpectedType m_expected; - Q_DISABLE_COPY_MOVE(ValueCodec) + Q_DISABLE_COPY_MOVE(ValueCodec) }; -#define CODEC(Type, Expected) \ - class Type##Codec : public ValueCodec { \ - public: \ - explicit Type##Codec(const QMetaType& type) \ - : ValueCodec(type, ExpectedType::Expected) {} \ - \ - [[nodiscard]] QJsonValue encode(const QVariant& value) const override; \ - [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; \ - }; +#define CODEC(Type, Expected) \ + class Type##Codec : public ValueCodec { \ + public: \ + explicit Type##Codec(const QMetaType& type) \ + : ValueCodec(type, ExpectedType::Expected) {} \ + \ + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; \ + [[nodiscard]] DecodeResult decode( \ + const QJsonValue& value) const override; \ + }; CODEC(Bool, Bool) CODEC(Int, Int) @@ -61,41 +64,44 @@ CODEC(VariantMap, Object) #undef CODEC class EnumCodec : public ValueCodec { -public: - explicit EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum); + public: + explicit EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum); - [[nodiscard]] QJsonValue encode(const QVariant& value) const override; - [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; -private: - const QMetaEnum m_metaEnum; + private: + const QMetaEnum m_metaEnum; }; template class ListCodec : public ValueCodec { - using Value = Container::value_type; + using Value = Container::value_type; -public: - explicit ListCodec(const QMetaType& type, const ValueCodec* elementCodec); + public: + explicit ListCodec(const QMetaType& type, const ValueCodec* elementCodec); - [[nodiscard]] QJsonValue encode(const QVariant& value) const override; - [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; -private: - const ValueCodec* m_elementCodec; + private: + const ValueCodec* m_elementCodec; }; // Decodes any one of several types, for options that accept more than one shape class UnionCodec : public ValueCodec { -public: - explicit UnionCodec(const QList& alternatives); + public: + explicit UnionCodec(const QList& alternatives); - [[nodiscard]] QJsonValue encode(const QVariant& value) const override; - [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; -private: - const QList m_alternatives; // Tried in order, so the first to accept a value wins - const QList m_expectedTypes; // Types of the alternatives, for diagnostics - QHash m_byType; // Type id to alternative, for encoding + private: + const QList + m_alternatives; // Tried in order, so the first to accept a value wins + const QList + m_expectedTypes; // Types of the alternatives, for diagnostics + QHash + m_byType; // Type id to alternative, for encoding }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/layerregistry.hpp b/Plugins/ZShell/Settings/layerregistry.hpp index a41d6b1..4a7cd44 100644 --- a/Plugins/ZShell/Settings/layerregistry.hpp +++ b/Plugins/ZShell/Settings/layerregistry.hpp @@ -10,66 +10,72 @@ namespace ZShell::settings { // Derived from Node and has ctor(path, fallback, parent) template -concept LayerType = std::derived_from && std::constructible_from; +concept LayerType = std::derived_from && + std::constructible_from; template class LayerRegistry { -public: - explicit LayerRegistry(const QString& prefix, const QString& suffix, QObject* parent); + public: + explicit LayerRegistry( + const QString& prefix, const QString& suffix, QObject* parent); - [[nodiscard]] QString pathFor(const QString& name) const; - [[nodiscard]] QString nameFor(T* layer) const; - [[nodiscard]] T* get(const QString& name, T* fallback, bool* created = nullptr); // Created on demand + [[nodiscard]] QString pathFor(const QString& name) const; + [[nodiscard]] QString nameFor(T* layer) const; + [[nodiscard]] T* get( + const QString& name, + T* fallback, + bool* created = nullptr); // Created on demand -private: - const QString m_prefix; - const QString m_suffix; - QObject* const m_parent; - QHash m_layers; + private: + const QString m_prefix; + const QString m_suffix; + QObject* const m_parent; + QHash m_layers; }; namespace detail { inline QString stripTrailingSlashes(QStringView str) { - while (str.endsWith(u'/')) - str = str.left(str.length() - 1); - return str.toString(); + while (str.endsWith(u'/')) + str = str.left(str.length() - 1); + return str.toString(); } inline QString stripLeadingSlashes(QStringView str) { - while (str.startsWith(u'/')) - str = str.mid(1); - return str.toString(); + while (str.startsWith(u'/')) + str = str.mid(1); + return str.toString(); } } // namespace detail template -LayerRegistry::LayerRegistry(const QString& prefix, const QString& suffix, QObject* parent) - : m_prefix(detail::stripTrailingSlashes(prefix)) - , m_suffix(detail::stripLeadingSlashes(suffix)) - , m_parent(parent) {} +LayerRegistry::LayerRegistry( + const QString& prefix, const QString& suffix, QObject* parent) + : m_prefix(detail::stripTrailingSlashes(prefix)) + , m_suffix(detail::stripLeadingSlashes(suffix)) + , m_parent(parent) {} -template QString LayerRegistry::pathFor(const QString& name) const { - return m_prefix + u'/' + name + u'/' + m_suffix; +template +QString LayerRegistry::pathFor(const QString& name) const { + return m_prefix + u'/' + name + u'/' + m_suffix; } template QString LayerRegistry::nameFor(T* layer) const { - return m_layers.key(layer); + return m_layers.key(layer); } -template T* LayerRegistry::get(const QString& name, T* fallback, bool* created) { - if (auto* const layer = m_layers.value(name)) { - if (created) - *created = false; - return layer; - } +template +T* LayerRegistry::get(const QString& name, T* fallback, bool* created) { + if (auto* const layer = m_layers.value(name)) { + if (created) *created = false; + return layer; + } - if (created) - *created = true; + if (created) *created = true; - auto* const layer = new T(pathFor(name), fallback, m_parent); - m_layers.insert(name, layer); - return layer; + auto* const layer = new T(pathFor(name), fallback, m_parent); + m_layers.insert(name, layer); + return layer; } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.cpp b/Plugins/ZShell/Settings/listnode.cpp index f1cd4ea..cf27302 100644 --- a/Plugins/ZShell/Settings/listnode.cpp +++ b/Plugins/ZShell/Settings/listnode.cpp @@ -11,435 +11,525 @@ using Qt::StringLiterals::operator""_s; namespace { QString valuesKey() { - return u"values"_s; + return u"values"_s; } void deleteNode(Node* node) { - node->detachFallback(); - node->setParent(nullptr); - node->deleteLater(); + node->detachFallback(); + node->setParent(nullptr); + node->deleteLater(); } bool isVariantListType(const QVariant& value) { - return value.typeId() == QMetaType::QVariantList || value.typeId() == qMetaTypeId>(); + return value.typeId() == QMetaType::QVariantList || + value.typeId() == qMetaTypeId>(); } QList toMaps(const QVariant& value) { - if (value.typeId() == qMetaTypeId>()) - return value.value>(); + if (value.typeId() == qMetaTypeId>()) + return value.value>(); - const auto list = value.toList(); - QList maps; - maps.reserve(list.count()); + const auto list = value.toList(); + QList maps; + maps.reserve(list.count()); - for (const auto& el : list) { - if (el.typeId() != QMetaType::QVariantMap) { - qCCritical(lcSettings, "Expected a map in list properties, got %s.", el.typeName()); - continue; - } + for (const auto& el : list) { + if (el.typeId() != QMetaType::QVariantMap) { + qCCritical( + lcSettings, + "Expected a map in list properties, got %s.", + el.typeName()); + continue; + } - maps << el.toMap(); - } + maps << el.toMap(); + } - return maps; + return maps; } void syncNodeRecursive(Node* node, const QVariantMap& props) { - const auto& schema = node->schema(); + const auto& schema = node->schema(); - for (const auto& [key, val] : props.asKeyValueRange()) { - const auto* desc = schema.get(key); + for (const auto& [key, val] : props.asKeyValueRange()) { + const auto* desc = schema.get(key); - if (!desc) { - qCCritical(lcSettings, "Unknown key %s in props for %s, something is wrong.", qUtf8Printable(key), - qUtf8Printable(node->path())); - continue; - } + if (!desc) { + qCCritical( + lcSettings, + "Unknown key %s in props for %s, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(node->path())); + continue; + } - // `setValue` warns on type mismatches itself - if (!desc->isNode) { - node->setValue(key, val); - continue; - } + // `setValue` warns on type mismatches itself + if (!desc->isNode) { + node->setValue(key, val); + continue; + } - auto* const child = node->value(key).value(); + auto* const child = node->value(key).value(); - // Nested lists take their elements straight from props - if (auto* const list = qobject_cast(child)) { - if (!isVariantListType(val)) { - qCCritical(lcSettings, "Expected a list for node key %s on %s, got %s.", qUtf8Printable(key), - qUtf8Printable(node->path()), val.typeName()); - continue; - } + // Nested lists take their elements straight from props + if (auto* const list = qobject_cast(child)) { + if (!isVariantListType(val)) { + qCCritical( + lcSettings, + "Expected a list for node key %s on %s, got %s.", + qUtf8Printable(key), + qUtf8Printable(node->path()), + val.typeName()); + continue; + } - list->setValue(valuesKey(), val); - continue; - } + list->setValue(valuesKey(), val); + continue; + } - if (val.typeId() != QMetaType::QVariantMap) { - qCCritical(lcSettings, "Expected a map for node key %s on %s, got %s.", qUtf8Printable(key), - qUtf8Printable(node->path()), val.typeName()); - continue; - } + if (val.typeId() != QMetaType::QVariantMap) { + qCCritical( + lcSettings, + "Expected a map for node key %s on %s, got %s.", + qUtf8Printable(key), + qUtf8Printable(node->path()), + val.typeName()); + continue; + } - syncNodeRecursive(child, val.toMap()); - } + syncNodeRecursive(child, val.toMap()); + } } } // namespace ListNode::ListNode(ListNode* fallback, QObject* parent, bool globalOnly) - : Node(fallback, parent, globalOnly) { - if (fallback) { - // Disconnect generic fallback notify, lists use a custom one - QObject::disconnect(fallback, &ListNode::optionChanged, this, nullptr); + : Node(fallback, parent, globalOnly) { + if (fallback) { + // Disconnect generic fallback notify, lists use a custom one + QObject::disconnect(fallback, &ListNode::optionChanged, this, nullptr); - if (!m_globalOnly) - QObject::connect(fallback, &ListNode::elementsChanged, this, &ListNode::onFallbackListNotify); - } + if (!m_globalOnly) + QObject::connect( + fallback, + &ListNode::elementsChanged, + this, + &ListNode::onFallbackListNotify); + } } qsizetype ListNode::count() const { - return m_elements.count(); + return m_elements.count(); } QVariantList ListNode::values() const { - QVariantList list; - list.reserve(m_elements.count()); - for (auto* const element : m_elements) - list << QVariant::fromValue(element); - return list; + QVariantList list; + list.reserve(m_elements.count()); + for (auto* const element : m_elements) + list << QVariant::fromValue(element); + return list; } void ListNode::remove(qsizetype index) { - if (rejectGlobalMutation()) - return; + if (rejectGlobalMutation()) return; - const WriteScope scope(this, WriteOrigin::Qml); + const WriteScope scope(this, WriteOrigin::Qml); - if (!validIndex(index)) { - qCWarning(lcSettings, "Attempted to remove invalid index %lld from list node %s, ignoring.", index, - qUtf8Printable(path())); + if (!validIndex(index)) { + qCWarning( + lcSettings, + "Attempted to remove invalid index %lld from list node %s, " + "ignoring.", + index, + qUtf8Printable(path())); - recordWrite(valuesKey(), false); - return; - } + recordWrite(valuesKey(), false); + return; + } - deleteNode(m_elements.takeAt(index)); + deleteNode(m_elements.takeAt(index)); - recordWrite(valuesKey(), true); - emit countChanged(); - emit elementsChanged({}, { index }, {}); + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({}, {index}, {}); } void ListNode::move(qsizetype from, qsizetype to) { - if (rejectGlobalMutation()) - return; + if (rejectGlobalMutation()) return; - const WriteScope scope(this, WriteOrigin::Qml); + const WriteScope scope(this, WriteOrigin::Qml); - const auto fromInvalid = !validIndex(from); - if (fromInvalid || !validIndex(to)) { - if (fromInvalid) - qCWarning(lcSettings, "Attempted to move invalid index %lld to index %lld on list node %s, ignoring.", from, - to, qUtf8Printable(path())); - else - qCWarning(lcSettings, "Attempted to move index %lld to invalid index %lld on list node %s, ignoring.", from, - to, qUtf8Printable(path())); + const auto fromInvalid = !validIndex(from); + if (fromInvalid || !validIndex(to)) { + if (fromInvalid) + qCWarning( + lcSettings, + "Attempted to move invalid index %lld to index %lld on list " + "node %s, ignoring.", + from, + to, + qUtf8Printable(path())); + else + qCWarning( + lcSettings, + "Attempted to move index %lld to invalid index %lld on list " + "node %s, ignoring.", + from, + to, + qUtf8Printable(path())); - recordWrite(valuesKey(), false); - return; - } + recordWrite(valuesKey(), false); + return; + } - m_elements.move(from, to); + m_elements.move(from, to); - recordWrite(valuesKey(), true); - emit elementsChanged({}, {}, { { from, to } }); + recordWrite(valuesKey(), true); + emit elementsChanged({}, {}, {{from, to}}); } void ListNode::clear() { - if (rejectGlobalMutation()) - return; + if (rejectGlobalMutation()) return; - const WriteScope scope(this, WriteOrigin::Qml); + const WriteScope scope(this, WriteOrigin::Qml); - if (m_elements.isEmpty()) { - recordWrite(valuesKey(), false); - return; - } + if (m_elements.isEmpty()) { + recordWrite(valuesKey(), false); + return; + } - const auto len = m_elements.count(); + const auto len = m_elements.count(); - for (auto* const element : std::as_const(m_elements)) - deleteNode(element); - m_elements.clear(); + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); - recordWrite(valuesKey(), true); - emit countChanged(); + recordWrite(valuesKey(), true); + emit countChanged(); - NodeChanges removed(len); - std::iota(removed.begin(), removed.end(), 0); - emit elementsChanged({}, removed, {}); + NodeChanges removed(len); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged({}, removed, {}); } QString ListNode::pathFor(const QString& key) const { - return elementPath(path(), key); + return elementPath(path(), key); } const Schema& ListNode::schema() const { - // Offset +1 so count is not registered (allow read only cause values is read only) - static const auto k_schema = Schema::build(&staticMetaObject, Node::staticMetaObject.propertyCount() + 1, true); - return k_schema; + // Offset +1 so count is not registered (allow read only cause values is read only) + static const auto k_schema = Schema::build( + &staticMetaObject, Node::staticMetaObject.propertyCount() + 1, true); + return k_schema; } QVariant ListNode::value(const QString& key) const { - if (key != valuesKey()) { - qCCritical(lcSettings, - "Attempted to read %s on list node %s. List nodes only have a 'values' key, something is wrong.", - qUtf8Printable(key), qUtf8Printable(path())); - return {}; - } + if (key != valuesKey()) { + qCCritical( + lcSettings, + "Attempted to read %s on list node %s. List nodes only have a " + "'values' key, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(path())); + return {}; + } - return QVariant::fromValue(m_elements); + return QVariant::fromValue(m_elements); } bool ListNode::setValue(const QString& key, const QVariant& value) { - return setValue(key, value, nullptr); + return setValue(key, value, nullptr); } -bool ListNode::setValue(const QString& key, const QVariant& value, QList* diagnostics) { - if (key != valuesKey()) { - qCCritical(lcSettings, - "Attempted to write %s on list node %s. List nodes only have a 'values' key, something is wrong.", - qUtf8Printable(key), qUtf8Printable(path())); - return false; - } +bool ListNode::setValue( + const QString& key, const QVariant& value, QList* diagnostics) { + if (key != valuesKey()) { + qCCritical( + lcSettings, + "Attempted to write %s on list node %s. List nodes only have a " + "'values' key, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(path())); + return false; + } - const auto len = m_elements.count(); + const auto len = m_elements.count(); - for (auto* const element : std::as_const(m_elements)) - deleteNode(element); - m_elements.clear(); + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); - if (value.typeId() == qMetaTypeId>()) { - // On reset to fallback - const auto nodes = value.value>(); - for (auto* const node : nodes) - insertNode(m_elements.count(), node); - } else if (isVariantListType(value)) { - // From defaults or QML - const auto list = toMaps(value); - for (qsizetype i = 0; i < list.count(); ++i) - insertNode(i, list.at(i), fallbackFor(i)); - } else if (value.typeId() == QMetaType::QJsonArray) { - // From syncJson - const auto array = value.toJsonArray(); - for (const auto& json : array) - insertNode(m_elements.count(), json.toObject(), *diagnostics); - } else { - qCCritical(lcSettings, "Unexpected type %s for list node %s, something is wrong.", value.typeName(), - qUtf8Printable(path())); - return false; - } + if (value.typeId() == qMetaTypeId>()) { + // On reset to fallback + const auto nodes = value.value>(); + for (auto* const node : nodes) + insertNode(m_elements.count(), node); + } else if (isVariantListType(value)) { + // From defaults or QML + const auto list = toMaps(value); + for (qsizetype i = 0; i < list.count(); ++i) + insertNode(i, list.at(i), fallbackFor(i)); + } else if (value.typeId() == QMetaType::QJsonArray) { + // From syncJson + const auto array = value.toJsonArray(); + for (const auto& json : array) + insertNode(m_elements.count(), json.toObject(), *diagnostics); + insertMissingDefaults(); + } else { + qCCritical( + lcSettings, + "Unexpected type %s for list node %s, something is wrong.", + value.typeName(), + qUtf8Printable(path())); + return false; + } - if (!recordWrite(valuesKey(), true)) - return false; + if (!recordWrite(valuesKey(), true)) return false; - if (len != m_elements.count()) - emit countChanged(); + if (len != m_elements.count()) emit countChanged(); - NodeChanges added(m_elements.count()); - NodeChanges removed(len); - std::iota(added.begin(), added.end(), 0); - std::iota(removed.begin(), removed.end(), 0); - emit elementsChanged(added, removed, {}); + NodeChanges added(m_elements.count()); + NodeChanges removed(len); + std::iota(added.begin(), added.end(), 0); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged(added, removed, {}); - return true; + return true; +} + +void ListNode::insertMissingDefaults() { + if (fallbackNode()) return; + + const auto* desc = getDescriptor(); + if (!desc) return; + + const auto idKey = desc->identityKey(); + if (idKey.isEmpty()) return; + + if (!m_elements.isEmpty() && !m_elements.first()->schema().get(idKey)) { + qCCritical( + lcSettings, + "List %s has identityKey '%s' which its elements don't define.", + qUtf8Printable(path()), + qUtf8Printable(idKey)); + return; + } + + QHash> unmatched; + for (auto* const element : std::as_const(m_elements)) + unmatched[element->value(idKey).toString()] << element; + + Node* anchor = nullptr; + for (const auto& props : defaultValue()) { + const auto id = props.value(idKey).toString(); + if (id.isEmpty()) continue; + + auto& candidates = unmatched[id]; + if (!candidates.isEmpty()) { + anchor = candidates.takeFirst(); + continue; + } + + const auto index = anchor ? m_elements.indexOf(anchor) + 1 : 0; + anchor = insertNode(index, props, nullptr); + } } void ListNode::resetToDefaults() { - if (isNested()) { - qCCritical(lcSettings, "List node %s has a parent list node, resetToDefaults should never be called on it.", - qUtf8Printable(path())); - return; - } + if (isNested()) { + qCCritical( + lcSettings, + "List node %s has a parent list node, resetToDefaults should never " + "be called on it.", + qUtf8Printable(path())); + return; + } - // Don't reset global only list nodes on overlays - if (fallbackNode() && m_globalOnly) - return; + // Don't reset global only list nodes on overlays + if (fallbackNode() && m_globalOnly) return; - const WriteScope scope(this, WriteOrigin::FileReset); - setValue(valuesKey(), fallbackNode() ? fallbackNode()->value(valuesKey()) : QVariant::fromValue(defaultValue())); + const WriteScope scope(this, WriteOrigin::FileReset); + setValue( + valuesKey(), + fallbackNode() ? fallbackNode()->value(valuesKey()) + : QVariant::fromValue(defaultValue())); } QJsonValue ListNode::toJson(bool sparse) const { - Q_UNUSED(sparse) // Lists can't be sparse + Q_UNUSED(sparse) // Lists can't be sparse - QJsonArray array; - for (auto* const element : m_elements) - array << element->toJson(false); - return array; + QJsonArray array; + for (auto* const element : m_elements) + array << element->toJson(false); + return array; } bool ListNode::syncJson(const QJsonValue& json, QList& diagnostics) { - if (!json.isArray()) { - const auto d = Diagnostic::mismatch(ExpectedType::Array, json, path()); - qCWarning(lcSettings, "Error decoding option %s: %s", qUtf8Printable(d.option), - qUtf8Printable(d.message)); - diagnostics << d; - return false; - } + if (!json.isArray()) { + const auto d = Diagnostic::mismatch(ExpectedType::Array, json, path()); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(d.option), + qUtf8Printable(d.message)); + diagnostics << d; + return false; + } - // Refuse syncs to global only list nodes on overlays - if (rejectGlobalSync(diagnostics)) - return false; + // Refuse syncs to global only list nodes on overlays + if (rejectGlobalSync(diagnostics)) return false; - const WriteScope scope(this, WriteOrigin::File); - setValue(valuesKey(), json.toArray(), &diagnostics); + const WriteScope scope(this, WriteOrigin::File); + setValue(valuesKey(), json.toArray(), &diagnostics); - return true; + return true; } bool ListNode::recordWrite(const QString& key, bool changed) { - const auto wasOverride = isOverride(key); - const auto notify = Node::recordWrite(key, changed); + const auto wasOverride = isOverride(key); + const auto notify = Node::recordWrite(key, changed); - // Elements inside overridden lists have no fallbacks, so detach here - if (!wasOverride && isOverride(key)) - for (auto* const element : std::as_const(m_elements)) - element->detachFallback(); + // Elements inside overridden lists have no fallbacks, so detach here + if (!wasOverride && isOverride(key)) + for (auto* const element : std::as_const(m_elements)) + element->detachFallback(); - return notify; + return notify; } QString ListNode::keyOf(const Node* child) const { - return QString::number(m_elements.indexOf(const_cast(child))); + return QString::number(m_elements.indexOf(const_cast(child))); } Node* ListNode::elementAt(qsizetype index) const { - if (!validIndex(index)) - return nullptr; - return m_elements.at(index); + if (!validIndex(index)) return nullptr; + return m_elements.at(index); } Node* ListNode::insertElement(const QVariantMap& props, qsizetype index) { - if (rejectGlobalMutation()) - return nullptr; + if (rejectGlobalMutation()) return nullptr; - const WriteScope scope(this, WriteOrigin::Qml); + const WriteScope scope(this, WriteOrigin::Qml); - if (!validIndex(index)) // Invalid index means append - index = m_elements.count(); + if (!validIndex(index)) // Invalid index means append + index = m_elements.count(); - auto* const element = insertNode(index, props, nullptr); + auto* const element = insertNode(index, props, nullptr); - recordWrite(valuesKey(), true); - emit countChanged(); - emit elementsChanged({ index }, {}, {}); + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({index}, {}, {}); - return element; + return element; } QList ListNode::defaultValue() const { - const auto* desc = getDescriptor(); - if (!desc) - return {}; - return desc->defaultValue().value>(); + const auto* desc = getDescriptor(); + if (!desc) return {}; + return desc->defaultValue().value>(); } bool ListNode::isNested() const { - return qobject_cast(parentNode()); + return qobject_cast(parentNode()); } bool ListNode::rejectGlobalMutation() const { - if (!m_globalOnly || !fallbackNode()) - return false; + if (!m_globalOnly || !fallbackNode()) return false; - qCWarning(lcSettings, - "Attempted to mutate global list %s from an overlay layer, ignoring. " - "This should not be used, mutate global lists from the global layer instead.", - qUtf8Printable(path())); + qCWarning( + lcSettings, + "Attempted to mutate global list %s from an overlay layer, ignoring. " + "This should not be used, mutate global lists from the global layer " + "instead.", + qUtf8Printable(path())); - return true; + return true; } bool ListNode::validIndex(qsizetype index) const { - return index >= 0 && index < m_elements.count(); + return index >= 0 && index < m_elements.count(); } const Descriptor* ListNode::getDescriptor() const { - if (!parentNode()) { // List nodes should not be root nodes - qCCritical(lcSettings, "List node %s has no parent, something is wrong.", qUtf8Printable(path())); - return nullptr; - } + if (!parentNode()) { // List nodes should not be root nodes + qCCritical( + lcSettings, + "List node %s has no parent, something is wrong.", + qUtf8Printable(path())); + return nullptr; + } - const auto* desc = parentNode()->schema().get(key()); + const auto* desc = parentNode()->schema().get(key()); - if (!desc) { - qCCritical(lcSettings, "List node %s is not in parent schema, something is wrong.", qUtf8Printable(path())); - return nullptr; - } + if (!desc) { + qCCritical( + lcSettings, + "List node %s is not in parent schema, something is wrong.", + qUtf8Printable(path())); + return nullptr; + } - return desc; + return desc; } Node* ListNode::fallbackFor(qsizetype index) const { - if (isOverride(valuesKey())) - return nullptr; + if (isOverride(valuesKey())) return nullptr; - const auto* fallback = static_cast(fallbackNode()); - if (!fallback || !fallback->validIndex(index)) - return nullptr; + const auto* fallback = static_cast(fallbackNode()); + if (!fallback || !fallback->validIndex(index)) return nullptr; - return fallback->m_elements.at(index); + return fallback->m_elements.at(index); } -Node* ListNode::insertNode(qsizetype index, const QVariantMap& props, Node* fallback) { - auto* const node = createElement(fallback); - m_elements.insert(index, node); +Node* ListNode::insertNode( + qsizetype index, const QVariantMap& props, Node* fallback) { + auto* const node = createElement(fallback); + m_elements.insert(index, node); - const WriteScope scope(node, WriteOrigin::Init); - syncNodeRecursive(node, props); + const WriteScope scope(node, WriteOrigin::Init); + syncNodeRecursive(node, props); - return node; + return node; } Node* ListNode::insertNode(qsizetype index, Node* node) { - auto* const copy = createElement(node); - m_elements.insert(index, copy); - return copy; + auto* const copy = createElement(node); + m_elements.insert(index, copy); + return copy; } -Node* ListNode::insertNode(qsizetype index, const QJsonObject& json, QList& diagnostics) { - // Write origin will be file/file reset, which is correct. - // This function should only be called from the JSON path of setValue. - auto* const node = createElement(nullptr); // Nodes synced from JSON get no fallback as they are overrides - m_elements.insert(index, node); +Node* ListNode::insertNode( + qsizetype index, const QJsonObject& json, QList& diagnostics) { + // Write origin will be file/file reset, which is correct. + // This function should only be called from the JSON path of setValue. + auto* const node = createElement( + nullptr); // Nodes synced from JSON get no fallback as they are overrides + m_elements.insert(index, node); - node->syncJson(json, diagnostics); + node->syncJson(json, diagnostics); - return node; + return node; } -void ListNode::onFallbackListNotify(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved) { - if (isOverride(valuesKey())) - return; +void ListNode::onFallbackListNotify( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved) { + if (isOverride(valuesKey())) return; - const WriteScope scope(this, WriteOrigin::Layer); + const WriteScope scope(this, WriteOrigin::Layer); - for (const auto index : removed | std::views::reverse) - deleteNode(m_elements.takeAt(index)); - for (const auto index : added) - insertNode(index, fallbackFor(index)); - for (const auto& move : moved) - m_elements.move(move.first, move.second); + for (const auto index : removed | std::views::reverse) + deleteNode(m_elements.takeAt(index)); + for (const auto index : added) + insertNode(index, fallbackFor(index)); + for (const auto& move : moved) + m_elements.move(move.first, move.second); - if (!recordWrite(valuesKey(), true)) - return; + if (!recordWrite(valuesKey(), true)) return; - if (!added.isEmpty() || !removed.isEmpty()) - emit countChanged(); - emit elementsChanged(added, removed, moved); + if (!added.isEmpty() || !removed.isEmpty()) emit countChanged(); + emit elementsChanged(added, removed, moved); } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.hpp b/Plugins/ZShell/Settings/listnode.hpp index e6b51dc..2fa2260 100644 --- a/Plugins/ZShell/Settings/listnode.hpp +++ b/Plugins/ZShell/Settings/listnode.hpp @@ -5,65 +5,82 @@ namespace ZShell::settings { class ListNode : public Node { - Q_OBJECT - QML_ANONYMOUS + Q_OBJECT + QML_ANONYMOUS - Q_PROPERTY(qsizetype count READ count NOTIFY countChanged) - Q_PROPERTY(QVariantList values READ values NOTIFY elementsChanged) + Q_PROPERTY(qsizetype count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY elementsChanged) - using NodeChanges = QList; - using MoveChanges = QList>; + using NodeChanges = QList; + using MoveChanges = QList>; -public: - explicit ListNode(ListNode* fallback, QObject* parent = nullptr, bool globalOnly = false); + public: + explicit ListNode( + ListNode* fallback, QObject* parent = nullptr, bool globalOnly = false); - [[nodiscard]] qsizetype count() const; - [[nodiscard]] QVariantList values() const; + [[nodiscard]] qsizetype count() const; + [[nodiscard]] QVariantList values() const; - Q_INVOKABLE void remove(qsizetype index); - Q_INVOKABLE void move(qsizetype from, qsizetype to); - Q_INVOKABLE void clear(); + Q_INVOKABLE void remove(qsizetype index); + Q_INVOKABLE void move(qsizetype from, qsizetype to); + Q_INVOKABLE void clear(); - [[nodiscard]] QString pathFor(const QString& key) const override; - [[nodiscard]] const Schema& schema() const override; - [[nodiscard]] QVariant value(const QString& key) const override; - bool setValue(const QString& key, const QVariant& value) override; - bool setValue(const QString& key, const QVariant& value, QList* diagnostics); - void resetToDefaults() override; + [[nodiscard]] QString pathFor(const QString& key) const override; + [[nodiscard]] const Schema& schema() const override; + [[nodiscard]] QVariant value(const QString& key) const override; + bool setValue(const QString& key, const QVariant& value) override; + bool setValue( + const QString& key, + const QVariant& value, + QList* diagnostics); + void resetToDefaults() override; - [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; - bool syncJson(const QJsonValue& json, QList& diagnostics) override; + [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; + bool syncJson( + const QJsonValue& json, QList& diagnostics) override; -signals: - void countChanged(); - void elementsChanged(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved); + signals: + void countChanged(); + void elementsChanged( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved); -protected: - bool recordWrite(const QString& key, bool changed) override; - [[nodiscard]] QString keyOf(const Node* child) const override; + protected: + bool recordWrite(const QString& key, bool changed) override; + [[nodiscard]] QString keyOf(const Node* child) const override; - [[nodiscard]] Node* elementAt(qsizetype index) const; - [[nodiscard]] Node* insertElement(const QVariantMap& props, qsizetype index = -1); - [[nodiscard]] virtual Node* createElement(Node* fallback) = 0; + [[nodiscard]] Node* elementAt(qsizetype index) const; + [[nodiscard]] Node* insertElement( + const QVariantMap& props, qsizetype index = -1); + [[nodiscard]] virtual Node* createElement(Node* fallback) = 0; -private: - QList m_elements; + private: + QList m_elements; - [[nodiscard]] QList defaultValue() const; - [[nodiscard]] bool isNested() const; + [[nodiscard]] QList defaultValue() const; + [[nodiscard]] bool isNested() const; - // Returns true if the mutation should be skipped, overlays cannot mutate global lists - [[nodiscard]] bool rejectGlobalMutation() const; + // Returns true if the mutation should be skipped, overlays cannot mutate global lists + [[nodiscard]] bool rejectGlobalMutation() const; - [[nodiscard]] bool validIndex(qsizetype index) const; - [[nodiscard]] const Descriptor* getDescriptor() const; - [[nodiscard]] Node* fallbackFor(qsizetype index) const; + [[nodiscard]] bool validIndex(qsizetype index) const; + [[nodiscard]] const Descriptor* getDescriptor() const; + [[nodiscard]] Node* fallbackFor(qsizetype index) const; - Node* insertNode(qsizetype index, const QVariantMap& props, Node* fallback); - Node* insertNode(qsizetype index, Node* node); - Node* insertNode(qsizetype index, const QJsonObject& json, QList& diagnostics); + void insertMissingDefaults(); - void onFallbackListNotify(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved); + Node* insertNode(qsizetype index, const QVariantMap& props, Node* fallback); + Node* insertNode(qsizetype index, Node* node); + Node* insertNode( + qsizetype index, + const QJsonObject& json, + QList& diagnostics); + + void onFallbackListNotify( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved); }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/node.cpp b/Plugins/ZShell/Settings/node.cpp index 9bacd19..035a0f3 100644 --- a/Plugins/ZShell/Settings/node.cpp +++ b/Plugins/ZShell/Settings/node.cpp @@ -6,275 +6,303 @@ namespace ZShell::settings { using Qt::StringLiterals::operator""_s; Node::Node(Node* fallback, QObject* parent, bool globalOnly) - : QObject(parent) - , m_globalOnly(globalOnly || (parentNode() && parentNode()->m_globalOnly)) - , m_rootNode(parentNode() ? parentNode()->rootNode() : this) - , m_fallbackNode(fallback) - , m_writeOrigin(WriteOrigin::Qml) - , m_internalRead(false) - , m_batcher(m_rootNode == this ? new ChangeBatcher(this) : nullptr) { - if (fallback && !m_globalOnly) - QObject::connect(fallback, &Node::optionChanged, this, &Node::onFallbackNotify); + : QObject(parent) + , m_globalOnly(globalOnly || (parentNode() && parentNode()->m_globalOnly)) + , m_rootNode(parentNode() ? parentNode()->rootNode() : this) + , m_fallbackNode(fallback) + , m_writeOrigin(WriteOrigin::Qml) + , m_internalRead(false) + , m_batcher(m_rootNode == this ? new ChangeBatcher(this) : nullptr) { + if (fallback && !m_globalOnly) + QObject::connect( + fallback, &Node::optionChanged, this, &Node::onFallbackNotify); } QString Node::key() const { - return parentNode() ? parentNode()->keyOf(this) : QString(); + return parentNode() ? parentNode()->keyOf(this) : QString(); } QString Node::path() const { - return parentNode() ? parentNode()->pathFor(key()) : key(); + return parentNode() ? parentNode()->pathFor(key()) : key(); } QString Node::pathFor(const QString& key) const { - const auto p = path(); - return p.isEmpty() ? key : p + u'.' + key; + const auto p = path(); + return p.isEmpty() ? key : p + u'.' + key; } QString Node::elementPath(const QString& path, const QString& index) { - return path + u'[' + index + u']'; + return path + u'[' + index + u']'; } Node* Node::parentNode() const { - return qobject_cast(parent()); + return qobject_cast(parent()); } Node* Node::rootNode() const { - return m_rootNode; + return m_rootNode; } Node* Node::fallbackNode() const { - return m_fallbackNode; + return m_fallbackNode; } void Node::detachFallback() { - if (m_fallbackNode) { - QObject::disconnect(m_fallbackNode, nullptr, this, nullptr); - m_fallbackNode = nullptr; - } + if (m_fallbackNode) { + QObject::disconnect(m_fallbackNode, nullptr, this, nullptr); + m_fallbackNode = nullptr; + } - const auto childNodes = findChildren(Qt::FindDirectChildrenOnly); - for (auto* const child : childNodes) - child->detachFallback(); + const auto childNodes = findChildren(Qt::FindDirectChildrenOnly); + for (auto* const child : childNodes) + child->detachFallback(); } bool Node::isGlobalOnly() const { - return m_globalOnly; + return m_globalOnly; } bool Node::isOverride(const QString& key) const { - return m_overrides.contains(key); + return m_overrides.contains(key); } const QSet& Node::overrides() const { - return m_overrides; + return m_overrides; } bool Node::hasContent() const { - return !m_overrides.isEmpty() || m_quarantine || - std::ranges::any_of(findChildren(Qt::FindDirectChildrenOnly), &Node::hasContent); + return !m_overrides.isEmpty() || m_quarantine || + std::ranges::any_of( + findChildren(Qt::FindDirectChildrenOnly), + &Node::hasContent); } QVariant Node::value(const QString& key) const { - const auto* desc = schema().get(key); - if (!desc) { - qCCritical( - lcSettings, "Attempted to read an unknown key %s, something is wrong.", qUtf8Printable(pathFor(key))); - return {}; - } + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to read an unknown key %s, something is wrong.", + qUtf8Printable(pathFor(key))); + return {}; + } - // Generated getters warn on global reads, this silences them as the warning is only for QML reads - const InternalRead guard(m_rootNode); - return metaObject()->property(desc->metaIndex).read(this); + // Generated getters warn on global reads, this silences them as the warning is only for QML reads + const InternalRead guard(m_rootNode); + return metaObject()->property(desc->metaIndex).read(this); } bool Node::setValue(const QString& key, const QVariant& value) { - const auto* desc = schema().get(key); - if (!desc) { - qCCritical(lcSettings, "Attempted to set an unknown key %s, something is wrong.", qUtf8Printable(pathFor(key))); - return false; - } + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to set an unknown key %s, something is wrong.", + qUtf8Printable(pathFor(key))); + return false; + } - if (desc->isNode) { - qCCritical(lcSettings, "Attempted to set node %s directly, something is wrong.", qUtf8Printable(pathFor(key))); - return false; - } + if (desc->isNode) { + qCCritical( + lcSettings, + "Attempted to set node %s directly, something is wrong.", + qUtf8Printable(pathFor(key))); + return false; + } - // Type mismatch, conversion should happen before this function is called - if (rejectInvalidWrite(key, value)) - return false; + // Type mismatch, conversion should happen before this function is called + if (rejectInvalidWrite(key, value)) return false; - return metaObject()->property(desc->metaIndex).write(this, value); + return metaObject()->property(desc->metaIndex).write(this, value); } void Node::resetToDefaults() { - // Don't reset global only nodes on overlays, the whole subtree belongs to the global layer - if (m_globalOnly && m_fallbackNode) - return; + // Don't reset global only nodes on overlays, the whole subtree belongs to the global layer + if (m_globalOnly && m_fallbackNode) return; - m_quarantine.reset(); // Reset quarantine as well + m_quarantine.reset(); // Reset quarantine as well - // No write scope, callers should create the scope - for (const auto& desc : schema().descriptors()) { - if (desc.isNode) - value(desc.key).value()->resetToDefaults(); - else if (!desc.globalOnly() || !m_fallbackNode) // Skip resetting global options on overlays - setValue(desc.key, m_fallbackNode ? m_fallbackNode->value(desc.key) : desc.defaultValue()); - } + // No write scope, callers should create the scope + for (const auto& desc : schema().descriptors()) { + if (desc.isNode) + value(desc.key).value()->resetToDefaults(); + else if ( + !desc.globalOnly() || + !m_fallbackNode) // Skip resetting global options on overlays + setValue( + desc.key, + m_fallbackNode ? m_fallbackNode->value(desc.key) + : desc.defaultValue()); + } } const Quarantine* Node::quarantine() const { - return m_quarantine.get(); + return m_quarantine.get(); } bool Node::rejectInvalidWrite(const QString& key, const QVariant& value) const { - const auto* desc = schema().get(key); - if (!desc || desc->accepts(value.metaType())) - return false; + const auto* desc = schema().get(key); + if (!desc || desc->accepts(value.metaType())) return false; - qCWarning(lcSettings, "Type mismatch for %s, expected %s got %s", qUtf8Printable(pathFor(key)), - qUtf8Printable(desc->typeString()), value.metaType().name()); - return true; + qCWarning( + lcSettings, + "Type mismatch for %s, expected %s got %s", + qUtf8Printable(pathFor(key)), + qUtf8Printable(desc->typeString()), + value.metaType().name()); + return true; } void Node::warnGlobalRead(const QString& key) const { - if (!m_fallbackNode || m_rootNode->m_internalRead) - return; + if (!m_fallbackNode || m_rootNode->m_internalRead) return; - qCWarning(lcSettings, - "Global option %s was read from an overlay layer. " - "This should not be used, read global options from the global layer instead.", - qUtf8Printable(pathFor(key))); + qCWarning( + lcSettings, + "Global option %s was read from an overlay layer. " + "This should not be used, read global options from the global layer " + "instead.", + qUtf8Printable(pathFor(key))); } bool Node::rejectGlobalWrite(const QString& key) { - const auto* desc = schema().get(key); - if (!desc) { - qCCritical(lcSettings, "Attempted to check a write for an unknown key %s, something is seriously wrong...", - qUtf8Printable(pathFor(key))); - return false; - } + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to check a write for an unknown key %s, something is " + "seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } - const auto origin = m_rootNode->m_writeOrigin; - const auto fromUser = origin == WriteOrigin::Qml || origin == WriteOrigin::QmlReset; + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || + origin == WriteOrigin::QmlReset; - if ((!m_globalOnly && !desc->globalOnly()) || !fromUser || !m_fallbackNode) - return false; + if ((!m_globalOnly && !desc->globalOnly()) || !fromUser || !m_fallbackNode) + return false; - if (origin == WriteOrigin::QmlReset) - qCWarning(lcSettings, - "Attempted to reset global option %s from an overlay layer, ignoring. " - "This should not be used, reset global options from the global layer instead.", - qUtf8Printable(pathFor(key))); - else - qCWarning(lcSettings, - "Attempted to write global option %s from an overlay layer, ignoring. " - "This should not be used, write global options from the global layer instead.", - qUtf8Printable(pathFor(key))); + if (origin == WriteOrigin::QmlReset) + qCWarning( + lcSettings, + "Attempted to reset global option %s from an overlay layer, " + "ignoring. " + "This should not be used, reset global options from the global " + "layer instead.", + qUtf8Printable(pathFor(key))); + else + qCWarning( + lcSettings, + "Attempted to write global option %s from an overlay layer, " + "ignoring. " + "This should not be used, write global options from the global " + "layer instead.", + qUtf8Printable(pathFor(key))); - return true; + return true; } void Node::warnGlobalSync(QList& diagnostics, const QString& path) { - qCWarning(lcSettings, "Global option definition %s found in overlay file, ignoring.", qUtf8Printable(path)); - diagnostics << Diagnostic{ - .type = DiagnosticType::GlobalOption, - .option = path, - .message = QStringLiteral("Global options should not be defined in overlay files"), - }; + qCWarning( + lcSettings, + "Global option definition %s found in overlay file, ignoring.", + qUtf8Printable(path)); + diagnostics << Diagnostic{ + .type = DiagnosticType::GlobalOption, + .option = path, + .message = QStringLiteral( + "Global options should not be defined in overlay files"), + }; } bool Node::rejectGlobalSync(QList& diagnostics) const { - if (!m_globalOnly || !m_fallbackNode) - return false; + if (!m_globalOnly || !m_fallbackNode) return false; - warnGlobalSync(diagnostics, path()); - return true; + warnGlobalSync(diagnostics, path()); + return true; } bool Node::recordWrite(const QString& key, bool changed) { - const auto* desc = schema().get(key); - if (!desc) { - qCCritical(lcSettings, "Attempted to record a write for an unknown key %s, something is seriously wrong...", - qUtf8Printable(pathFor(key))); - return false; - } + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to record a write for an unknown key %s, something is " + "seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } - const auto origin = m_rootNode->m_writeOrigin; - const auto fromUser = origin == WriteOrigin::Qml || origin == WriteOrigin::QmlReset; + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || + origin == WriteOrigin::QmlReset; - bool dirty = changed; - switch (origin) { - // Init does not notify or write to file - case WriteOrigin::Init: - return false; + bool dirty = changed; + switch (origin) { + // Init does not notify or write to file + case WriteOrigin::Init: + return false; - // File and qml both count as overrides - case WriteOrigin::File: - case WriteOrigin::Qml: - dirty |= !m_overrides.contains(key); - m_overrides << key; - break; + // File and qml both count as overrides + case WriteOrigin::File: + case WriteOrigin::Qml: + dirty |= !m_overrides.contains(key); + m_overrides << key; + break; - // Layer is not an override, it is a sync with the fallback value - case WriteOrigin::Layer: - break; + // Layer is not an override, it is a sync with the fallback value + case WriteOrigin::Layer: + break; - // Both resets clear the override - case WriteOrigin::FileReset: - case WriteOrigin::QmlReset: - dirty |= m_overrides.remove(key); - break; - } + // Both resets clear the override + case WriteOrigin::FileReset: + case WriteOrigin::QmlReset: + dirty |= m_overrides.remove(key); + break; + } - // User writes/reset override quarantine - if (fromUser) - dirty |= removeQuarantined(key); + // User writes/reset override quarantine + if (fromUser) dirty |= removeQuarantined(key); - // Both qml and reset write to the file (only write if dirty) - if (fromUser && dirty) - m_rootNode->m_batcher->dirty(); + // Both qml and reset write to the file (only write if dirty) + if (fromUser && dirty) m_rootNode->m_batcher->dirty(); - if (changed) - emit optionChanged(key); + if (changed) emit optionChanged(key); - return changed; + return changed; } bool Node::removeQuarantined(const QString& key) { - if (!m_quarantine) - return false; + if (!m_quarantine) return false; - const auto removed = m_quarantine->remove(key); - if (m_quarantine->isEmpty()) - m_quarantine.reset(); + const auto removed = m_quarantine->remove(key); + if (m_quarantine->isEmpty()) m_quarantine.reset(); - return removed; + return removed; } ChangeBatcher* Node::batcher() const { - return m_rootNode->m_batcher; + return m_rootNode->m_batcher; } QString Node::keyOf(const Node* child) const { - for (const auto& desc : schema().descriptors()) { - if (desc.isNode && child == value(desc.key).value()) - return desc.key; - } + for (const auto& desc : schema().descriptors()) { + if (desc.isNode && child == value(desc.key).value()) + return desc.key; + } - return {}; + return {}; } void Node::onFallbackNotify(const QString& key) { - if (m_overrides.contains(key)) - return; + if (m_overrides.contains(key)) return; - // Don't mirror global options onto overlays - const auto* desc = schema().get(key); - if (desc && desc->globalOnly()) - return; + // Don't mirror global options onto overlays + const auto* desc = schema().get(key); + if (desc && desc->globalOnly()) return; - const WriteScope scope(this, WriteOrigin::Layer); - setValue(key, m_fallbackNode->value(key)); + const WriteScope scope(this, WriteOrigin::Layer); + setValue(key, m_fallbackNode->value(key)); } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/node.hpp b/Plugins/ZShell/Settings/node.hpp index 23237a8..9ed05b8 100644 --- a/Plugins/ZShell/Settings/node.hpp +++ b/Plugins/ZShell/Settings/node.hpp @@ -12,89 +12,105 @@ namespace ZShell::settings { class Node : public QObject { - Q_OBJECT + Q_OBJECT -public: - // Global only nodes are inherited, anything inside one is also global only - explicit Node(Node* fallback, QObject* parent = nullptr, bool globalOnly = false); + public: + // Global only nodes are inherited, anything inside one is also global only + explicit Node( + Node* fallback, QObject* parent = nullptr, bool globalOnly = false); - [[nodiscard]] QString key() const; // The key of this in the parent node - [[nodiscard]] QString path() const; - [[nodiscard]] virtual QString pathFor(const QString& key) const; - [[nodiscard]] static QString elementPath(const QString& path, const QString& index); - [[nodiscard]] Node* parentNode() const; - [[nodiscard]] Node* rootNode() const; - [[nodiscard]] Node* fallbackNode() const; - void detachFallback(); // Recursive, unlinks this and its children from the fallback layer + [[nodiscard]] QString key() const; // The key of this in the parent node + [[nodiscard]] QString path() const; + [[nodiscard]] virtual QString pathFor(const QString& key) const; + [[nodiscard]] static QString elementPath( + const QString& path, const QString& index); + [[nodiscard]] Node* parentNode() const; + [[nodiscard]] Node* rootNode() const; + [[nodiscard]] Node* fallbackNode() const; + void + detachFallback(); // Recursive, unlinks this and its children from the fallback layer - [[nodiscard]] Q_INVOKABLE bool isGlobalOnly() const; - [[nodiscard]] bool isOverride(const QString& key) const; - [[nodiscard]] const QSet& overrides() const; - [[nodiscard]] bool hasContent() const; // Recursive + [[nodiscard]] Q_INVOKABLE bool isGlobalOnly() const; + [[nodiscard]] bool isOverride(const QString& key) const; + [[nodiscard]] const QSet& overrides() const; + [[nodiscard]] bool hasContent() const; // Recursive - [[nodiscard]] virtual const Schema& schema() const = 0; + [[nodiscard]] virtual const Schema& schema() const = 0; - [[nodiscard]] virtual QVariant value(const QString& key) const; - virtual bool setValue(const QString& key, const QVariant& value); // Returns whether the write was successful or not - virtual void resetToDefaults(); // Recursive, resets to fallbacks then defaults if not overridden + [[nodiscard]] virtual QVariant value(const QString& key) const; + virtual bool setValue( + const QString& key, + const QVariant& value); // Returns whether the write was successful or not + virtual void + resetToDefaults(); // Recursive, resets to fallbacks then defaults if not overridden - [[nodiscard]] virtual QJsonValue toJson(bool sparse = true) const = 0; - // Returns false if the entire node was rejected - virtual bool syncJson(const QJsonValue& json, QList& diagnostics) = 0; - [[nodiscard]] const Quarantine* quarantine() const; + [[nodiscard]] virtual QJsonValue toJson(bool sparse = true) const = 0; + // Returns false if the entire node was rejected + virtual bool syncJson( + const QJsonValue& json, QList& diagnostics) = 0; + [[nodiscard]] const Quarantine* quarantine() const; -signals: - void optionChanged(const QString& key); + signals: + void optionChanged(const QString& key); -protected: - // Null means empty, otherwise it has content - std::unique_ptr m_quarantine; - const bool m_globalOnly; // Own flag or inherited from the parent node + protected: + // Null means empty, otherwise it has content + std::unique_ptr m_quarantine; + const bool m_globalOnly; // Own flag or inherited from the parent node - void warnGlobalRead(const QString& key) const; - // Returns true if the write should be skipped afterwards, the value is not one of the allowed types - [[nodiscard]] bool rejectInvalidWrite(const QString& key, const QVariant& value) const; - template [[nodiscard]] bool rejectInvalidWrite(const QString& key, const T& value) const; - // Returns true if the write should be skipped afterwards, overlays cannot write global options - bool rejectGlobalWrite(const QString& key); - static void warnGlobalSync(QList& diagnostics, const QString& path); - bool rejectGlobalSync(QList& diagnostics) const; // Returns true if the sync should be rejected - // Returns true if the notify signal should be emitted - virtual bool recordWrite(const QString& key, bool changed); + void warnGlobalRead(const QString& key) const; + // Returns true if the write should be skipped afterwards, the value is not one of the allowed types + [[nodiscard]] bool rejectInvalidWrite( + const QString& key, const QVariant& value) const; + template + [[nodiscard]] bool rejectInvalidWrite( + const QString& key, const T& value) const; + // Returns true if the write should be skipped afterwards, overlays cannot write global options + bool rejectGlobalWrite(const QString& key); + static void warnGlobalSync( + QList& diagnostics, const QString& path); + bool rejectGlobalSync(QList& diagnostics) + const; // Returns true if the sync should be rejected + // Returns true if the notify signal should be emitted + virtual bool recordWrite(const QString& key, bool changed); - [[nodiscard]] bool removeQuarantined(const QString& key); - [[nodiscard]] ChangeBatcher* batcher() const; + [[nodiscard]] bool removeQuarantined(const QString& key); + [[nodiscard]] ChangeBatcher* batcher() const; - [[nodiscard]] virtual QString keyOf(const Node* child) const; + [[nodiscard]] virtual QString keyOf(const Node* child) const; - template - [[nodiscard]] T fallbackValue(T C::* member, std::type_identity_t defaultValue) const; + template + [[nodiscard]] T fallbackValue( + T C::* member, std::type_identity_t defaultValue) const; -private: - QSet m_overrides; // Overridden keys from file/qml writes - Node* const m_rootNode; - Node* m_fallbackNode; // No fallback node either means global tree or inside overridden list + private: + QSet m_overrides; // Overridden keys from file/qml writes + Node* const m_rootNode; + Node* m_fallbackNode; // No fallback node either means global tree or inside overridden list - // For root node use only - WriteOrigin m_writeOrigin; - bool m_internalRead; - ChangeBatcher* const m_batcher; + // For root node use only + WriteOrigin m_writeOrigin; + bool m_internalRead; + ChangeBatcher* const m_batcher; - void onFallbackNotify(const QString& key); + void onFallbackNotify(const QString& key); - friend class WriteScope; - friend class InternalRead; + friend class WriteScope; + friend class InternalRead; }; -template bool Node::rejectInvalidWrite(const QString& key, const T& value) const { - Q_UNUSED(key) - Q_UNUSED(value) - return false; // Only QVariant unions can be given the wrong type +template +bool Node::rejectInvalidWrite(const QString& key, const T& value) const { + Q_UNUSED(key) + Q_UNUSED(value) + return false; // Only QVariant unions can be given the wrong type } -template T Node::fallbackValue(T C::* member, std::type_identity_t defaultValue) const { - const auto* fallback = static_cast(m_fallbackNode); - return fallback ? fallback->*member : defaultValue; +template +T Node::fallbackValue( + T C::* member, std::type_identity_t defaultValue) const { + const auto* fallback = static_cast(m_fallbackNode); + return fallback ? fallback->*member : defaultValue; } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.cpp b/Plugins/ZShell/Settings/quarantine.cpp index d573c6d..889b02c 100644 --- a/Plugins/ZShell/Settings/quarantine.cpp +++ b/Plugins/ZShell/Settings/quarantine.cpp @@ -5,23 +5,23 @@ namespace ZShell::settings { void ObjectQuarantine::insert(const QString& key, const QJsonValue& value) { - m_quarantine.insert(key, value); + m_quarantine.insert(key, value); } bool ObjectQuarantine::remove(const QString& key) { - return m_quarantine.remove(key); + return m_quarantine.remove(key); } bool ObjectQuarantine::isEmpty() const { - return m_quarantine.isEmpty(); + return m_quarantine.isEmpty(); } QJsonValue ObjectQuarantine::apply(const QJsonValue& json) const { - auto result = json.toObject(); - for (const auto& [key, value] : m_quarantine.asKeyValueRange()) - if (!result.contains(key)) // Don't clobber existing values - result.insert(key, value); - return result; + auto result = json.toObject(); + for (const auto& [key, value] : m_quarantine.asKeyValueRange()) + if (!result.contains(key)) // Don't clobber existing values + result.insert(key, value); + return result; } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.hpp b/Plugins/ZShell/Settings/quarantine.hpp index 979995d..2159278 100644 --- a/Plugins/ZShell/Settings/quarantine.hpp +++ b/Plugins/ZShell/Settings/quarantine.hpp @@ -7,27 +7,27 @@ namespace ZShell::settings { class Quarantine { -public: - explicit Quarantine() = default; - virtual ~Quarantine() = default; + public: + explicit Quarantine() = default; + virtual ~Quarantine() = default; - virtual void insert(const QString& key, const QJsonValue& value) = 0; - [[nodiscard]] virtual bool remove(const QString& key) = 0; - [[nodiscard]] virtual bool isEmpty() const = 0; - [[nodiscard]] virtual QJsonValue apply(const QJsonValue& json) const = 0; + virtual void insert(const QString& key, const QJsonValue& value) = 0; + [[nodiscard]] virtual bool remove(const QString& key) = 0; + [[nodiscard]] virtual bool isEmpty() const = 0; + [[nodiscard]] virtual QJsonValue apply(const QJsonValue& json) const = 0; - Q_DISABLE_COPY_MOVE(Quarantine) + Q_DISABLE_COPY_MOVE(Quarantine) }; class ObjectQuarantine : public Quarantine { -public: - void insert(const QString& key, const QJsonValue& value) override; - [[nodiscard]] bool remove(const QString& key) override; - [[nodiscard]] bool isEmpty() const override; - [[nodiscard]] QJsonValue apply(const QJsonValue& json) const override; + public: + void insert(const QString& key, const QJsonValue& value) override; + [[nodiscard]] bool remove(const QString& key) override; + [[nodiscard]] bool isEmpty() const override; + [[nodiscard]] QJsonValue apply(const QJsonValue& json) const override; -private: - QHash m_quarantine; + private: + QHash m_quarantine; }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.cpp b/Plugins/ZShell/Settings/rootnode.cpp index b9d8868..06c10b9 100644 --- a/Plugins/ZShell/Settings/rootnode.cpp +++ b/Plugins/ZShell/Settings/rootnode.cpp @@ -5,42 +5,43 @@ namespace ZShell::settings { RootNode::RootNode(const QString& path, RootNode* fallback, QObject* parent) - : ObjectNode(fallback, parent) - , m_file(new SettingsFile(path, this)) { - QObject::connect(batcher(), &ChangeBatcher::dirtied, this, &RootNode::saveToFile); - QObject::connect(m_file, &SettingsFile::changed, this, &RootNode::reloadFromFile); - QObject::connect(m_file, &SettingsFile::readFailed, this, [this](const QString& error) { - emit treeLoadFailed(this, error); - }); - QObject::connect(m_file, &SettingsFile::writeFailed, this, [this](const QString& error) { - emit treeSaveFailed(this, error); - }); + : ObjectNode(fallback, parent), m_file(new SettingsFile(path, this)) { + QObject::connect( + batcher(), &ChangeBatcher::dirtied, this, &RootNode::saveToFile); + QObject::connect( + m_file, &SettingsFile::changed, this, &RootNode::reloadFromFile); + QObject::connect( + m_file, &SettingsFile::readFailed, this, [this](const QString& error) { + emit treeLoadFailed(this, error); + }); + QObject::connect( + m_file, &SettingsFile::writeFailed, this, [this](const QString& error) { + emit treeSaveFailed(this, error); + }); } void RootNode::load() { - if (m_file->load() != SettingsFile::LoadResult::Changed) - reloadFromFile(); + if (m_file->load() != SettingsFile::LoadResult::Changed) reloadFromFile(); } QList RootNode::diagnostics() const { - return m_diagnostics; + return m_diagnostics; } void RootNode::reloadFromFile() { - const auto oldDiagnostics = m_diagnostics; - m_diagnostics.clear(); + const auto oldDiagnostics = m_diagnostics; + m_diagnostics.clear(); - const auto data = m_file->read(); - syncJson(data ? data.value() : QJsonObject(), m_diagnostics); + const auto data = m_file->read(); + syncJson(data ? data.value() : QJsonObject(), m_diagnostics); - if (m_diagnostics != oldDiagnostics) - emit diagnosticsChanged(); + if (m_diagnostics != oldDiagnostics) emit diagnosticsChanged(); - emit treeLoaded(this); + emit treeLoaded(this); } void RootNode::saveToFile() { - m_file->write(toJson()); + m_file->write(toJson()); } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.hpp b/Plugins/ZShell/Settings/rootnode.hpp index 71a5ead..240c0be 100644 --- a/Plugins/ZShell/Settings/rootnode.hpp +++ b/Plugins/ZShell/Settings/rootnode.hpp @@ -7,29 +7,32 @@ namespace ZShell::settings { class RootNode : public ObjectNode { - Q_OBJECT + Q_OBJECT - Q_PROPERTY(QList diagnostics READ diagnostics NOTIFY diagnosticsChanged) + Q_PROPERTY( + QList diagnostics READ diagnostics NOTIFY + diagnosticsChanged) -public: - explicit RootNode(const QString& path, RootNode* fallback, QObject* parent = nullptr); + public: + explicit RootNode( + const QString& path, RootNode* fallback, QObject* parent = nullptr); - [[nodiscard]] QList diagnostics() const; + [[nodiscard]] QList diagnostics() const; - void load(); + void load(); -signals: - void diagnosticsChanged(); - void treeLoaded(RootNode* self); - void treeLoadFailed(RootNode* self, const QString& error); - void treeSaveFailed(RootNode* self, const QString& error); + signals: + void diagnosticsChanged(); + void treeLoaded(RootNode* self); + void treeLoadFailed(RootNode* self, const QString& error); + void treeSaveFailed(RootNode* self, const QString& error); -private: - SettingsFile* const m_file; - QList m_diagnostics; + private: + SettingsFile* const m_file; + QList m_diagnostics; - void reloadFromFile(); - void saveToFile(); + void reloadFromFile(); + void saveToFile(); }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.cpp b/Plugins/ZShell/Settings/schema.cpp index 370a695..79ba2c4 100644 --- a/Plugins/ZShell/Settings/schema.cpp +++ b/Plugins/ZShell/Settings/schema.cpp @@ -16,118 +16,122 @@ namespace { // Descriptor annotations are stored here by Schema::annotate, which is called in the CONFIG_XXX macros. // Schema::build then takes them from here. QHash>& annotationCache() { - static QHash> s_cache; - return s_cache; + static QHash> s_cache; + return s_cache; } // Union options carry their alternatives in the annotation, everything else goes by property type const ValueCodec* resolveCodec(Descriptor& desc) { - auto& allowed = desc.annotation.allowedTypes; + auto& allowed = desc.annotation.allowedTypes; - if (allowed.isEmpty()) - return ValueCodec::codecFor(desc.type); + if (allowed.isEmpty()) return ValueCodec::codecFor(desc.type); - if (desc.type.id() != QMetaType::QVariant) { - qCCritical(lcSchema, "Allowed types are only valid for QVariant properties, ignoring them for %s", - qUtf8Printable(desc.key)); - allowed.clear(); - return ValueCodec::codecFor(desc.type); - } + if (desc.type.id() != QMetaType::QVariant) { + qCCritical( + lcSchema, + "Allowed types are only valid for QVariant properties, ignoring " + "them for %s", + qUtf8Printable(desc.key)); + allowed.clear(); + return ValueCodec::codecFor(desc.type); + } - if (const auto* codec = ValueCodec::unionFor(allowed)) - return codec; + if (const auto* codec = ValueCodec::unionFor(allowed)) return codec; - allowed.clear(); - return nullptr; + allowed.clear(); + return nullptr; } bool isNodeType(const QMetaType& type) { - if (!type.flags().testFlag(QMetaType::PointerToQObject)) - return false; - const auto* meta = type.metaObject(); - return meta && meta->inherits(&Node::staticMetaObject); + if (!type.flags().testFlag(QMetaType::PointerToQObject)) return false; + const auto* meta = type.metaObject(); + return meta && meta->inherits(&Node::staticMetaObject); } } // namespace QString Descriptor::typeString() const { - if (annotation.allowedTypes.isEmpty()) - return QString::fromUtf8(type.name()); + if (annotation.allowedTypes.isEmpty()) + return QString::fromUtf8(type.name()); - QStringList names; - names.reserve(annotation.allowedTypes.size()); - for (const auto& allowed : annotation.allowedTypes) - names << QString::fromUtf8(allowed.name()); + QStringList names; + names.reserve(annotation.allowedTypes.size()); + for (const auto& allowed : annotation.allowedTypes) + names << QString::fromUtf8(allowed.name()); - return names.join(QStringLiteral(" | ")); + return names.join(QStringLiteral(" | ")); } bool Descriptor::accepts(const QMetaType& valueType) const { - // Unset is a valid state for QVariant options, they are simply absent from the file - if (type.id() == QMetaType::QVariant && !valueType.isValid()) - return true; + // Unset is a valid state for QVariant options, they are simply absent from the file + if (type.id() == QMetaType::QVariant && !valueType.isValid()) return true; - if (annotation.allowedTypes.isEmpty()) - return type == valueType; + if (annotation.allowedTypes.isEmpty()) return type == valueType; - return annotation.allowedTypes.contains(valueType); + return annotation.allowedTypes.contains(valueType); } -Schema Schema::build(const QMetaObject* meta, int baseOffset, bool includeReadOnly) { - Schema schema; - schema.m_descriptors.reserve(meta->propertyCount() - baseOffset); +Schema Schema::build( + const QMetaObject* meta, int baseOffset, bool includeReadOnly) { + Schema schema; + schema.m_descriptors.reserve(meta->propertyCount() - baseOffset); - // Descriptors are taken from cache since this should be called exactly once per class - const auto annotations = annotationCache().take(meta); + // Descriptors are taken from cache since this should be called exactly once per class + const auto annotations = annotationCache().take(meta); - qCDebug(lcSchema) << "Building schema for" << meta->className(); + qCDebug(lcSchema) << "Building schema for" << meta->className(); - for (int i = baseOffset; i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - const auto isNode = isNodeType(prop.metaType()); + for (int i = baseOffset; i < meta->propertyCount(); ++i) { + const auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + const auto isNode = isNodeType(prop.metaType()); - // Skip read only properties (unless includeReadOnly) - if (!isNode && !includeReadOnly && !prop.isWritable()) { - qCDebug(lcSchema) << " Skipping computed property" << key; - continue; - } + // Skip read only properties (unless includeReadOnly) + if (!isNode && !includeReadOnly && !prop.isWritable()) { + qCDebug(lcSchema) << " Skipping computed property" << key; + continue; + } - qCDebug(lcSchema) << " Adding property" << key; + qCDebug(lcSchema) << " Adding property" << key; - Descriptor desc{ - .key = key, - .type = prop.metaType(), - .metaIndex = i, - .isNode = isNode, - .annotation = annotations.value(key), - }; + Descriptor desc{ + .key = key, + .type = prop.metaType(), + .metaIndex = i, + .isNode = isNode, + .annotation = annotations.value(key), + }; - if (!isNode) { - desc.codec = resolveCodec(desc); - if (!desc.codec) - qCCritical(lcSchema, "No codec for %s of type %s, it will not be loaded or saved", qUtf8Printable(key), - desc.type.name()); - } + if (!isNode) { + desc.codec = resolveCodec(desc); + if (!desc.codec) + qCCritical( + lcSchema, + "No codec for %s of type %s, it will not be loaded or " + "saved", + qUtf8Printable(key), + desc.type.name()); + } - schema.m_descriptors.append(std::move(desc)); - schema.m_keyToIndex.insert(key, schema.m_descriptors.size() - 1); - } + schema.m_descriptors.append(std::move(desc)); + schema.m_keyToIndex.insert(key, schema.m_descriptors.size() - 1); + } - return schema; + return schema; } -void Schema::annotate(const QMetaObject* meta, const QString& key, Annotation annotation) { - annotationCache()[meta].insert(key, std::move(annotation)); +void Schema::annotate( + const QMetaObject* meta, const QString& key, Annotation annotation) { + annotationCache()[meta].insert(key, std::move(annotation)); } const QList& Schema::descriptors() const { - return m_descriptors; + return m_descriptors; } const Descriptor* Schema::get(const QString& key) const { - const auto it = m_keyToIndex.find(key); - return it != m_keyToIndex.end() ? &m_descriptors[it.value()] : nullptr; + const auto it = m_keyToIndex.find(key); + return it != m_keyToIndex.end() ? &m_descriptors[it.value()] : nullptr; } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.hpp b/Plugins/ZShell/Settings/schema.hpp index 2857530..8abe1f2 100644 --- a/Plugins/ZShell/Settings/schema.hpp +++ b/Plugins/ZShell/Settings/schema.hpp @@ -13,69 +13,77 @@ namespace ZShell::settings { class ValueCodec; struct Annotation { - QVariant defaultValue; - bool globalOnly = false; - // NOLINTNEXTLINE(readability-redundant-member-init) callers will get missing field init warnings without - QList allowedTypes = {}; // For QVariant properties + QVariant defaultValue; + bool globalOnly = false; + // NOLINTNEXTLINE(readability-redundant-member-init) callers will get missing field init warnings without + QList allowedTypes = {}; // For QVariant properties + // NOLINTNEXTLINE(readability-redundant-member-init) + QString identityKey = {}; }; namespace detail { // Return trivially copyable types (e.g. bool) by value and others (e.g. QVariant) by const ref template -using AnnotationReturn = std::conditional_t && sizeof(T) <= sizeof(void*), T, const T&>; +using AnnotationReturn = std::conditional_t< + std::is_trivially_copyable_v && sizeof(T) <= sizeof(void*), + T, + const T&>; } // namespace detail -#define ANNOTATION(Type, name) \ - Q_PROPERTY(Type name READ name) \ - \ -public: \ - [[nodiscard]] detail::AnnotationReturn name() const { \ - return annotation.name; \ - } \ - \ -private: +#define ANNOTATION(Type, name) \ + Q_PROPERTY(Type name READ name) \ + \ + public: \ + [[nodiscard]] detail::AnnotationReturn name() const { \ + return annotation.name; \ + } \ + \ + private: struct Descriptor { - Q_GADGET - QML_VALUE_TYPE(descriptor) + Q_GADGET + QML_VALUE_TYPE(descriptor) - Q_PROPERTY(QString key MEMBER key) - Q_PROPERTY(QString type READ typeString) - Q_PROPERTY(int metaIndex MEMBER metaIndex) - Q_PROPERTY(bool isNode MEMBER isNode) + Q_PROPERTY(QString key MEMBER key) + Q_PROPERTY(QString type READ typeString) + Q_PROPERTY(int metaIndex MEMBER metaIndex) + Q_PROPERTY(bool isNode MEMBER isNode) - ANNOTATION(QVariant, defaultValue) - ANNOTATION(bool, globalOnly) + ANNOTATION(QVariant, defaultValue) + ANNOTATION(bool, globalOnly) + ANNOTATION(QString, identityKey) -public: - QString key; - QMetaType type; - int metaIndex; - bool isNode; - Annotation annotation; - const ValueCodec* codec = nullptr; // Null for nodes and unsupported types + public: + QString key; + QMetaType type; + int metaIndex; + bool isNode; + Annotation annotation; + const ValueCodec* codec = nullptr; // Null for nodes and unsupported types - [[nodiscard]] QString typeString() const; - [[nodiscard]] bool accepts(const QMetaType& valueType) const; + [[nodiscard]] QString typeString() const; + [[nodiscard]] bool accepts(const QMetaType& valueType) const; }; #undef ANNOTATION class Schema { -public: - static Schema build(const QMetaObject* meta, int baseOffset, bool includeReadOnly = false); - static void annotate(const QMetaObject* meta, const QString& key, Annotation annotation); + public: + static Schema build( + const QMetaObject* meta, int baseOffset, bool includeReadOnly = false); + static void annotate( + const QMetaObject* meta, const QString& key, Annotation annotation); - [[nodiscard]] const QList& descriptors() const; - [[nodiscard]] const Descriptor* get(const QString& key) const; + [[nodiscard]] const QList& descriptors() const; + [[nodiscard]] const Descriptor* get(const QString& key) const; -private: - explicit Schema() = default; + private: + explicit Schema() = default; - QList m_descriptors; - QHash m_keyToIndex; + QList m_descriptors; + QHash m_keyToIndex; }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.cpp b/Plugins/ZShell/Settings/settingsfile.cpp index abd4883..4ae07f6 100644 --- a/Plugins/ZShell/Settings/settingsfile.cpp +++ b/Plugins/ZShell/Settings/settingsfile.cpp @@ -30,184 +30,221 @@ constexpr int k_maxLoadRetries = 3; } // namespace SettingsFile::SettingsFile(QString path, QObject* parent) - : QObject(parent) - , m_path(std::move(path)) - , m_watcher(new QFileSystemWatcher(this)) - , m_saveDebounce(new QTimer(this)) - , m_loadDebounce(new QTimer(this)) - , m_loadRetries(0) { - m_saveDebounce->setSingleShot(true); - m_saveDebounce->setInterval(500); // Save at most once every 500ms + : QObject(parent) + , m_path(std::move(path)) + , m_watcher(new QFileSystemWatcher(this)) + , m_saveDebounce(new QTimer(this)) + , m_loadDebounce(new QTimer(this)) + , m_loadRetries(0) { + m_saveDebounce->setSingleShot(true); + m_saveDebounce->setInterval(500); // Save at most once every 500ms - m_loadDebounce->setSingleShot(true); - m_loadDebounce->setInterval(50); // Coalesce watcher events within 50ms - QObject::connect(m_loadDebounce, &QTimer::timeout, this, &SettingsFile::onLoadDebounced); + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(50); // Coalesce watcher events within 50ms + QObject::connect( + m_loadDebounce, &QTimer::timeout, this, &SettingsFile::onLoadDebounced); - QObject::connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &SettingsFile::onFileChanged); - QObject::connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &SettingsFile::onDirChanged); + QObject::connect( + m_watcher, + &QFileSystemWatcher::fileChanged, + this, + &SettingsFile::onFileChanged); + QObject::connect( + m_watcher, + &QFileSystemWatcher::directoryChanged, + this, + &SettingsFile::onDirChanged); - initWatcher(); + initWatcher(); } std::optional SettingsFile::read() const { - return m_lastData; + return m_lastData; } void SettingsFile::write(const QJsonValue& json) { - m_pendingWrite = json; - save(); + m_pendingWrite = json; + save(); } void SettingsFile::onFileChanged() { - initWatcher(); // Re-add path in case the file was replaced - scheduleLoad(); + initWatcher(); // Re-add path in case the file was replaced + scheduleLoad(); } void SettingsFile::onDirChanged() { - // Ignore if the file is already watched - if (m_watcher->files().contains(m_path)) - return; + // Ignore if the file is already watched + if (m_watcher->files().contains(m_path)) return; - initWatcher(); + initWatcher(); - if (QFile::exists(m_path)) - scheduleLoad(); + if (QFile::exists(m_path)) scheduleLoad(); } void SettingsFile::initWatcher() { - const QFileInfo info(m_path); - const auto dir = info.absolutePath(); + const QFileInfo info(m_path); + const auto dir = info.absolutePath(); - if (QDir(dir).exists() && !m_watcher->directories().contains(dir)) - m_watcher->addPath(dir); + if (QDir(dir).exists() && !m_watcher->directories().contains(dir)) + m_watcher->addPath(dir); - if (info.exists() && !m_watcher->files().contains(m_path)) - m_watcher->addPath(m_path); + if (info.exists() && !m_watcher->files().contains(m_path)) + m_watcher->addPath(m_path); } void SettingsFile::scheduleLoad() { - // Ignore if a load is already pending - if (m_loadDebounce->isActive()) - return; + // Ignore if a load is already pending + if (m_loadDebounce->isActive()) return; - m_loadRetries = 0; - m_loadDebounce->start(); + m_loadRetries = 0; + m_loadDebounce->start(); } void SettingsFile::onLoadDebounced() { - const auto isFinalTry = m_loadRetries >= k_maxLoadRetries; + const auto isFinalTry = m_loadRetries >= k_maxLoadRetries; - if (load(isFinalTry) == LoadResult::ParseError && !isFinalTry) { - // Likely a partial write, retry after another debounce - ++m_loadRetries; - qCDebug(lcSettingsFile, "Retrying load of %s (%d/%d)", qUtf8Printable(m_path), m_loadRetries, k_maxLoadRetries); - m_loadDebounce->start(); - } + if (load(isFinalTry) == LoadResult::ParseError && !isFinalTry) { + // Likely a partial write, retry after another debounce + ++m_loadRetries; + qCDebug( + lcSettingsFile, + "Retrying load of %s (%d/%d)", + qUtf8Printable(m_path), + m_loadRetries, + k_maxLoadRetries); + m_loadDebounce->start(); + } } SettingsFile::LoadResult SettingsFile::load(bool reportErrors) { - QFile file(m_path); + QFile file(m_path); - if (!file.exists()) { - if (m_lastData) { - m_lastData = std::nullopt; - emit changed(); - return LoadResult::Changed; - } - return LoadResult::Unchanged; - } + if (!file.exists()) { + if (m_lastData) { + m_lastData = std::nullopt; + emit changed(); + return LoadResult::Changed; + } + return LoadResult::Unchanged; + } - if (!file.open(QIODevice::ReadOnly)) { - qCWarning(lcSettingsFile, "Failed to open %s for reading: %s", qUtf8Printable(m_path), - qUtf8Printable(file.errorString())); - emit readFailed(QStringLiteral("Failed to open: %1").arg(file.errorString())); - return LoadResult::Error; - } + if (!file.open(QIODevice::ReadOnly)) { + qCWarning( + lcSettingsFile, + "Failed to open %s for reading: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit readFailed( + QStringLiteral("Failed to open: %1").arg(file.errorString())); + return LoadResult::Error; + } - const auto data = file.readAll(); - file.close(); + const auto data = file.readAll(); + file.close(); - QJsonParseError error; - const auto doc = QJsonDocument::fromJson(data, &error); + QJsonParseError error; + const auto doc = QJsonDocument::fromJson(data, &error); - if (error.error != QJsonParseError::NoError) { - if (reportErrors) { - qCWarning(lcSettingsFile, "Failed to parse %s as JSON: %s", qUtf8Printable(m_path), - qUtf8Printable(error.errorString())); - emit readFailed(QStringLiteral("Failed to parse: %1").arg(error.errorString())); - } else { - qCDebug(lcSettingsFile, "Failed to parse %s as JSON: %s", qUtf8Printable(m_path), - qUtf8Printable(error.errorString())); - } - return LoadResult::ParseError; - } + if (error.error != QJsonParseError::NoError) { + if (reportErrors) { + qCWarning( + lcSettingsFile, + "Failed to parse %s as JSON: %s", + qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + emit readFailed( + QStringLiteral("Failed to parse: %1").arg(error.errorString())); + } else { + qCDebug( + lcSettingsFile, + "Failed to parse %s as JSON: %s", + qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + } + return LoadResult::ParseError; + } - const auto json = doc.isObject() ? QJsonValue(doc.object()) : QJsonValue(doc.array()); + const auto json = doc.isObject() ? QJsonValue(doc.object()) + : QJsonValue(doc.array()); - if (json == m_lastData) - return LoadResult::Unchanged; + if (json == m_lastData) return LoadResult::Unchanged; - // Clear pending write, stuff loaded from file should take precedence - m_pendingWrite = std::nullopt; + // Clear pending write, stuff loaded from file should take precedence + m_pendingWrite = std::nullopt; - m_lastData = json; - emit changed(); + m_lastData = json; + emit changed(); - qCDebug(lcSettingsFile) << "Read JSON from" << m_path; - qCDebug(lcSettingsFile) << " " << json; + qCDebug(lcSettingsFile) << "Read JSON from" << m_path; + qCDebug(lcSettingsFile) << " " << json; - return LoadResult::Changed; + return LoadResult::Changed; } void SettingsFile::save() { - if (!m_pendingWrite) - return; + if (!m_pendingWrite) return; - if (m_saveDebounce->isActive()) { - // Queue save for debounce end - QObject::connect(m_saveDebounce, &QTimer::timeout, this, &SettingsFile::save, - // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) ConnectionType is OR-able by design - static_cast(Qt::UniqueConnection | Qt::SingleShotConnection)); - return; - } + if (m_saveDebounce->isActive()) { + // Queue save for debounce end + QObject::connect( + m_saveDebounce, + &QTimer::timeout, + this, + &SettingsFile::save, + // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) ConnectionType is OR-able by design + static_cast( + Qt::UniqueConnection | Qt::SingleShotConnection)); + return; + } - // Debounce regardless of whether the save actually succeeded - m_saveDebounce->start(); + // Debounce regardless of whether the save actually succeeded + m_saveDebounce->start(); - const auto json = m_pendingWrite.value(); - const auto data = (json.isObject() ? QJsonDocument(json.toObject()) : QJsonDocument(json.toArray())).toJson(); - m_pendingWrite = std::nullopt; + const auto json = m_pendingWrite.value(); + const auto data = (json.isObject() ? QJsonDocument(json.toObject()) + : QJsonDocument(json.toArray())) + .toJson(); + m_pendingWrite = std::nullopt; - const auto dir = QFileInfo(m_path).absolutePath(); + const auto dir = QFileInfo(m_path).absolutePath(); - if (!QDir().mkpath(dir)) { - qCWarning(lcSettingsFile) << "Failed to create dir" << dir; - emit writeFailed(QStringLiteral("Failed to create parent directory")); - return; - } + if (!QDir().mkpath(dir)) { + qCWarning(lcSettingsFile) << "Failed to create dir" << dir; + emit writeFailed(QStringLiteral("Failed to create parent directory")); + return; + } - // Write atomically - QSaveFile file(m_path); + // Write atomically + QSaveFile file(m_path); - if (!file.open(QIODevice::WriteOnly)) { - qCWarning(lcSettingsFile, "Failed to open %s for writing: %s", qUtf8Printable(m_path), - qUtf8Printable(file.errorString())); - emit writeFailed(QStringLiteral("Failed to open: %1").arg(file.errorString())); - return; - } + if (!file.open(QIODevice::WriteOnly)) { + qCWarning( + lcSettingsFile, + "Failed to open %s for writing: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit writeFailed( + QStringLiteral("Failed to open: %1").arg(file.errorString())); + return; + } - file.write(data); + file.write(data); - if (!file.commit()) { - qCWarning(lcSettingsFile, "Failed to write %s: %s", qUtf8Printable(m_path), qUtf8Printable(file.errorString())); - emit writeFailed(QStringLiteral("Failed to write: %1").arg(file.errorString())); - return; - } + if (!file.commit()) { + qCWarning( + lcSettingsFile, + "Failed to write %s: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit writeFailed( + QStringLiteral("Failed to write: %1").arg(file.errorString())); + return; + } - m_lastData = json; - qCDebug(lcSettingsFile) << "Saved" << m_path; + m_lastData = json; + qCDebug(lcSettingsFile) << "Saved" << m_path; - initWatcher(); // The file may have just been created, or replaced by the rename + initWatcher(); // The file may have just been created, or replaced by the rename } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.hpp b/Plugins/ZShell/Settings/settingsfile.hpp index d517d33..d9b82b4 100644 --- a/Plugins/ZShell/Settings/settingsfile.hpp +++ b/Plugins/ZShell/Settings/settingsfile.hpp @@ -8,44 +8,39 @@ namespace ZShell::settings { class SettingsFile : public QObject { - Q_OBJECT + Q_OBJECT -public: - enum class LoadResult : quint8 { - Unchanged, - Changed, - ParseError, - Error - }; + public: + enum class LoadResult : quint8 { Unchanged, Changed, ParseError, Error }; - explicit SettingsFile(QString path, QObject* parent = nullptr); + explicit SettingsFile(QString path, QObject* parent = nullptr); - [[nodiscard]] std::optional read() const; - void write(const QJsonValue& json); + [[nodiscard]] std::optional read() const; + void write(const QJsonValue& json); - LoadResult load(bool reportErrors = true); + LoadResult load(bool reportErrors = true); -signals: - void changed(); // Data changed, not file watcher event - void readFailed(const QString& error); - void writeFailed(const QString& error); + signals: + void changed(); // Data changed, not file watcher event + void readFailed(const QString& error); + void writeFailed(const QString& error); -private: - QString m_path; - QFileSystemWatcher* m_watcher; - QTimer* m_saveDebounce; - QTimer* m_loadDebounce; - int m_loadRetries; - std::optional m_lastData; - std::optional m_pendingWrite; + private: + QString m_path; + QFileSystemWatcher* m_watcher; + QTimer* m_saveDebounce; + QTimer* m_loadDebounce; + int m_loadRetries; + std::optional m_lastData; + std::optional m_pendingWrite; - void onFileChanged(); - void onDirChanged(); + void onFileChanged(); + void onDirChanged(); - void initWatcher(); - void scheduleLoad(); - void onLoadDebounced(); - void save(); + void initWatcher(); + void scheduleLoad(); + void onLoadDebounced(); + void save(); }; } // namespace ZShell::settings From 4a021d81ac3f5b83a83da3cdd759ecf29330ce2c Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 13:14:48 +0200 Subject: [PATCH 11/21] chore: formatter --- Plugins/ZShell/Services/memory.cpp | 15 +++++++-------- Plugins/ZShell/Services/processes.cpp | 19 ++++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Plugins/ZShell/Services/memory.cpp b/Plugins/ZShell/Services/memory.cpp index 1b1c2b9..07e9c55 100644 --- a/Plugins/ZShell/Services/memory.cpp +++ b/Plugins/ZShell/Services/memory.cpp @@ -45,8 +45,7 @@ void Memory::tick() { QStringLiteral("MemTotal: *(\\d+)")); static const QRegularExpression reAvail( QStringLiteral("MemAvailable: *(\\d+)")); - static const QRegularExpression reCached(QStringLiteral( - "Cached: *(\\d+)")); + static const QRegularExpression reCached(QStringLiteral("Cached: *(\\d+)")); const QString text = QString::fromLatin1(data); const auto totalMatch = reTotal.match(text); @@ -78,14 +77,14 @@ void Memory::tick() { const auto pgin = rePgin.match(vtext); const auto pgout = rePgout.match(vtext); if (pgin.hasMatch()) { - readKib = static_cast(static_cast( - pgin.captured(1).toULongLong()) * - pageKib); + readKib = static_cast( + static_cast(pgin.captured(1).toULongLong()) * + pageKib); } if (pgout.hasMatch()) { - writeKib = static_cast(static_cast( - pgout.captured(1).toULongLong()) * - pageKib); + writeKib = static_cast( + static_cast(pgout.captured(1).toULongLong()) * + pageKib); } } } diff --git a/Plugins/ZShell/Services/processes.cpp b/Plugins/ZShell/Services/processes.cpp index 96cef22..5b926a4 100644 --- a/Plugins/ZShell/Services/processes.cpp +++ b/Plugins/ZShell/Services/processes.cpp @@ -793,8 +793,7 @@ void ProcessModel::scanChunk( nowTicks > prevTicks ? nowTicks - prevTicks : 0; entry.cpuPercent = (static_cast(deltaTicks) / - (ctx.deltaSecs * - static_cast(ctx.clockTicksPerSec))) * + (ctx.deltaSecs * static_cast(ctx.clockTicksPerSec))) * 100.0; } else { entry.cpuPercent = 0.0; @@ -839,7 +838,8 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( // syscall-bound (open/read/close per file per process) and serializes // badly on large systems. Keep one pool thread free for this function, // which itself runs inside the global pool. - int nThreads = qMin(16, QThreadPool::globalInstance()->maxThreadCount() - 1); + int nThreads = + qMin(16, QThreadPool::globalInstance()->maxThreadCount() - 1); if (pids.size() < 128 || nThreads < 2) { nThreads = 1; } @@ -860,8 +860,8 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( continue; } chunks[t] = pids.mid(a, b - a); - futures.append(QtConcurrent::run( - [&ctx, &chunks, &outs, &uidParts, t]() { + futures.append( + QtConcurrent::run([&ctx, &chunks, &outs, &uidParts, t]() { scanChunk(ctx, chunks[t], outs[t], uidParts[t]); })); } @@ -880,8 +880,9 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( } } std::sort( - fresh.begin(), fresh.end(), [](const ProcessEntry& a, - const ProcessEntry& b) { + fresh.begin(), + fresh.end(), + [](const ProcessEntry& a, const ProcessEntry& b) { return a.pid < b.pid; }); } @@ -1012,8 +1013,8 @@ void ProcessModel::applyView() { const FuzzyText prep = fuzzyPrepare(src.command); fe.commandChars = std::move(prep.chars); fe.commandClasses = std::move(prep.classes); - fe.hasCommandLine = - !src.commandLine.isEmpty() && src.commandLine != src.command; + fe.hasCommandLine = !src.commandLine.isEmpty() && + src.commandLine != src.command; if (fe.hasCommandLine) { const FuzzyText prepCl = fuzzyPrepare(src.commandLine); fe.commandLineChars = std::move(prepCl.chars); From f6fa2f023088bb4fae932e11c6db7221e39e973e Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 15:48:13 +0200 Subject: [PATCH 12/21] fix: remove clippingrectangle in favor of passing radius to sub-items --- Modules/Dashboard/Content.qml | 151 +++++++++++---------- Modules/Dashboard/Dash.qml | 2 +- Modules/Dashboard/Media.qml | 8 +- Modules/Dashboard/Processes/SystemInfo.qml | 4 +- Modules/Dashboard/ProcessesTab.qml | 3 + Modules/Dashboard/WeatherTab.qml | 5 +- 6 files changed, 95 insertions(+), 78 deletions(-) diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index b4173e7..0ca16cc 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -11,8 +11,8 @@ import qs.Components Item { id: root - readonly property real nonAnimWidth: view.implicitWidth + clipWrapper.anchors.margins * 2 - readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + clipWrapper.anchors.margins + clipWrapper.anchors.topMargin + readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin required property ScreenState screenState @@ -74,115 +74,118 @@ Item { anchors.margins: Tokens.padding.small } - CustomClippingRect { - id: clipWrapper + Item { + id: viewWrapper + + property int radius: Tokens.rounding.extraLarge - anchors.margins + anchors.top: tabs.bottom anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.topMargin: Tokens.spacing.small anchors.margins: Tokens.padding.small - radius: Tokens.rounding.extraLarge - anchors.margins + clip: true - Item { - id: viewWrapper + Flickable { + id: view + + readonly property int currentIndex: root.screenState.dashboardTab + readonly property Item currentItem: { + repeater.count; + return repeater.itemAt(currentIndex); + } anchors.fill: parent - Flickable { - id: view + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 - readonly property int currentIndex: root.screenState.dashboardTab - readonly property Item currentItem: { - repeater.count; - return repeater.itemAt(currentIndex); - } + contentX: currentItem?.x ?? 0 + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight - anchors.fill: parent + interactive: false - implicitWidth: currentItem?.implicitWidth ?? 0 - implicitHeight: currentItem?.implicitHeight ?? 0 + onContentXChanged: { + if (!moving || !currentItem) + return; - contentX: currentItem?.x ?? 0 - contentWidth: row.implicitWidth - contentHeight: row.implicitHeight + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); + } - interactive: false + RowLayout { + id: row - onContentXChanged: { - if (!moving || !currentItem) - return; + Repeater { + id: repeater - const x = contentX - currentItem.x; - if (x > currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); - else if (x < -currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); - } + model: ScriptModel { + values: root.dashboardTabs + } - RowLayout { - id: row + delegate: Loader { + id: paneLoader - Repeater { - id: repeater + required property int index + required property var modelData - model: ScriptModel { - values: root.dashboardTabs - } + Layout.alignment: Qt.AlignTop - delegate: Loader { - id: paneLoader + sourceComponent: modelData.component - required property int index - required property var modelData - - Layout.alignment: Qt.AlignTop - - sourceComponent: modelData.component - - Component.onCompleted: active = Qt.binding(() => { - if (index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) - } + Component.onCompleted: active = Qt.binding(() => { + if (index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) } } + } - Component { - id: dashComponent + Component { + id: dashComponent - Dash { - screenState: root.screenState - } + Dash { + screenState: root.screenState + radius: viewWrapper.radius } + } - Component { - id: weatherComponent + Component { + id: weatherComponent - WeatherTab {} + WeatherTab { + radius: viewWrapper.radius } + } - Component { - id: mediaComponent + Component { + id: mediaComponent - Media { - screenState: root.screenState - } + Media { + screenState: root.screenState + radius: viewWrapper.radius } + } - Component { - id: procsComponent + Component { + id: procsComponent - ProcessesTab {} + ProcessesTab { + radius: viewWrapper.radius } + } - Behavior on contentX { - Anim { - type: Anim.DefaultEffects - } + Behavior on contentX { + Anim { + type: Anim.DefaultEffects } } } diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index 31a78b6..4f61d07 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -12,7 +12,7 @@ GridLayout { id: root readonly property bool dashboardVisible: screenState.dashboard - property int radius: Tokens.rounding.medium + required property int radius required property ScreenState screenState columnSpacing: Tokens.spacing.small diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml index 297e996..acf4399 100644 --- a/Modules/Dashboard/Media.qml +++ b/Modules/Dashboard/Media.qml @@ -11,12 +11,18 @@ Item { id: root property ScreenState screenState + required property int radius implicitWidth: 1000 implicitHeight: 320 - BackgroundShapes { + CustomClippingRect { anchors.fill: parent + radius: root.radius + + BackgroundShapes { + anchors.fill: parent + } } RowLayout { diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml index 086e90b..afaedce 100644 --- a/Modules/Dashboard/Processes/SystemInfo.qml +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -11,6 +11,8 @@ import qs.Services Item { id: root + required property int radius + implicitHeight: layout.implicitHeight ServiceRef { @@ -53,7 +55,7 @@ Item { property list values color: Colors.tPalette.m3surfaceContainer - radius: Tokens.rounding.largeIncreased + radius: root.radius Layout.fillWidth: true Layout.fillHeight: true implicitHeight: mainLayout.implicitHeight + mainLayout.anchors.margins * 2 diff --git a/Modules/Dashboard/ProcessesTab.qml b/Modules/Dashboard/ProcessesTab.qml index 35687fc..646928c 100644 --- a/Modules/Dashboard/ProcessesTab.qml +++ b/Modules/Dashboard/ProcessesTab.qml @@ -9,6 +9,8 @@ import qs.Services Item { id: root + required property int radius + implicitWidth: Tokens.sizes.dashboard.processesWidth implicitHeight: Tokens.sizes.dashboard.processesHeight @@ -47,5 +49,6 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right + radius: root.radius } } diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml index 24538a2..7069abb 100644 --- a/Modules/Dashboard/WeatherTab.qml +++ b/Modules/Dashboard/WeatherTab.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Layouts import ZShell.Config @@ -9,6 +11,7 @@ import qs.Helpers Item { id: root + required property int radius readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 @@ -136,7 +139,7 @@ Item { Layout.fillWidth: true implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.medium * 2 - radius: Tokens.rounding.large + radius: root.radius color: Colors.tPalette.m3surfaceContainer ColumnLayout { From be3c938c3952c6974ac56c15afb18c53fb695630 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 29 Sep 2026 00:02:14 +0200 Subject: [PATCH 13/21] feat: progress on changing interactions to mousearea again --- Drawers/Interactions.qml | 2 +- Drawers/Interactions2.qml | 406 ++++++++++++++--------------- Drawers/Windows.qml | 2 +- Modules/Notifications/Content.qml | 8 +- Plugins/ZShell/Internal/cursor.hpp | 14 +- 5 files changed, 212 insertions(+), 220 deletions(-) diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 327289a..4e0d6a5 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -1,7 +1,7 @@ import Quickshell import QtQuick -import qs.Components import ZShell.Config +import qs.Components import qs.Helpers import qs.Modules.Bar.Popouts diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml index b35cf72..25c3ed7 100644 --- a/Drawers/Interactions2.qml +++ b/Drawers/Interactions2.qml @@ -1,309 +1,299 @@ -import QtQuick -import QtQuick.Controls import Quickshell +import QtQuick import ZShell.Config +import ZShell.Internal import qs.Components -import qs.Modules.Bar as Bar -import qs.Modules.Bar.Popouts as BarPopouts +import qs.Helpers +import qs.Modules.Bar.Popouts CustomMouseArea { id: root - required property ShellScreen screen - required property BarPopouts.Wrapper popouts - required property ScreenState screenState - required property Panels panels - required property Bar.BarLoader bar + required property Item bar required property real borderThickness - required property bool fullscreen + required property ScreenState screenState + required property Drawing drawing + required property EdgeGeometry geometry + required property Panels panels + required property Wrapper popouts + required property ShellScreen screen - property point dragStart property bool dashboardShortcutActive property bool osdShortcutActive + property bool singleGestureTriggered: false property bool utilitiesShortcutActive + property bool setInitialPoint: false + property point dragStart - function withinPanelHeight(panel: Item, x: real, y: real): bool { - const panelY = root.borderThickness + panel.y; - return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding; - } - - function withinPanelWidth(panel: Item, x: real, y: real): bool { - const panelX = bar.implicitWidth + panel.x; - return x >= panelX - Config.border.rounding && x <= panelX + panel.width + Config.border.rounding; + function inBottomPanel(panel: Item, x: real, y: real): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); + return y > root.height - (panelHeight + geometry.insetBottom(borderThickness)) && withinPanelWidth(panel, x, y); } function inLeftPanel(panel: Item, x: real, y: real): bool { - return x < bar.implicitWidth + panel.x + panel.width && withinPanelHeight(panel, x, y); + return x < panel.x + panel.width + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); + } + + function inPopoutArea(x: real, y: real): bool { + const panel = panels.popoutsWrapper; + if (!geometry.horizontal) + return inLeftPanel(panel, x, y); + return withinPanelWidth(panel, x, y) && withinPanelHeight(panel, x, y); } function inRightPanel(panel: Item, x: real, y: real): bool { - return x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panel.x) && withinPanelHeight(panel, x, y); + return x > panel.x + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); } function inTopPanel(panel: Item, x: real, y: real): bool { - const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property - return y < Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) && withinPanelWidth(panel, x, y); + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); + return y < geometry.insetTop(borderThickness) + panelHeight && withinPanelWidth(panel, x, y); } - function inBottomPanel(panel: Item, x: real, y: real, isCorner = false): bool { - const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property - return y > height - Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) - (isCorner ? Config.border.rounding : 0) && withinPanelWidth(panel, x, y); + function withinPanelHeight(panel: Item, x: real, y: real): bool { + const panelY = panel.y + geometry.insetTop(borderThickness); + return y >= panelY && y <= panelY + panel.height; } - function onWheel(event: WheelEvent): void { - if (fullscreen) - return; - if (event.x < bar.implicitWidth) { - bar.handleWheel(event.y, event.angleDelta); - } + function withinPanelWidth(panel: Item, x: real, y: real): bool { + const panelX = panel.x + geometry.insetLeft(borderThickness); + return x >= panelX && x <= panelX + panel.width; } anchors.fill: parent - acceptedButtons: fullscreen ? Qt.NoButton : Qt.AllButtons + acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons hoverEnabled: true - onPressed: event => dragStart = Qt.point(event.x, event.y) + onPressed: event => { + dragStart = Qt.point(event.x, event.y); + + if (screenState.isDrawing) { + if (event.button & Qt.RightButton) { + drawing.content.clear(); + return; + } + + if (!inLeftPanel(panels.drawing, mouseX, mouseY)) + drawing.content.beginStroke(event.x, event.y); + } + } + + onPressedChanged: { + if (!pressed) { + if (screenState.isDrawing) { + setInitialPoint = false; + drawing.content.endStroke(); + if (!inLeftPanel(panels.drawing, mouseX, mouseY)) + drawing.content.showHover(mouseX, mouseY); + } + } else { + if (screenState.isDrawing) { + panels.drawing.collapse(); + } + } + } + onContainsMouseChanged: { if (!containsMouse) { - // Only hide if not activated by shortcut if (!osdShortcutActive) { screenState.osd = false; - root.panels.osd.hovered = false; + panels.osd.hovered = false; } - if (!dashboardShortcutActive) - screenState.dashboard = false; - - if (!utilitiesShortcutActive) - screenState.utilities = false; - - if (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) { + if (!popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) { popouts.hasCurrent = false; - bar.closeTray(); } - if (Config.bar.showOnHover) + if (Config.bar.autoHide || bar.fullscreen) bar.isHovered = false; - if (Config.sidebar.showOnHover) - screenState.sidebar = false; + if (screenState.dashboard && Config.dashboard.showOnHover) + screenState.dashboard = false; } } onPositionChanged: event => { - if (popouts.isDetached) - return; - const x = event.x; const y = event.y; const dragX = x - dragStart.x; const dragY = y - dragStart.y; - if (fullscreen) { - root.panels.osd.hovered = inRightPanel(panels.osdWrapper, x, y); + if (screenState.isDrawing) { + if (pressed) { + drawing.content.appendPoint(x, y); + } else { + if (inLeftPanel(panels.drawing, x, y)) { + if (!(pressedButtons & Qt.LeftButton)) { + panels.drawing.expand(); + drawing.content.hideHover(); + Cursor.clearOverride(); + } + } else { + Cursor.setOverride(Qt.BlankCursor); + drawing.content.showHover(x, y); + } + } + if (drawing.content.hoverVisible) + drawing.content.showHover(x, y); return; } - // Show bar in non-exclusive mode on hover - if (!screenState.bar && Config.bar.showOnHover && x < bar.clampedWidth) - bar.isHovered = true; + if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) + screenState.launcher = true; - // Show/hide bar on drag - if (pressed && dragStart.x < bar.clampedWidth) { - if (dragX > Config.bar.dragThreshold) - screenState.bar = true; - else if (dragX < -Config.bar.dragThreshold) - screenState.bar = false; - } + const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y); - if (panels.sidebar.offsetScale === 1) { - // Show osd on hover - const showOsd = inRightPanel(panels.osdWrapper, x, y); - - // Always update visibility based on hover if not in shortcut mode - if (!osdShortcutActive) { - screenState.osd = showOsd; - root.panels.osd.hovered = showOsd; - } else if (showOsd) { - // If hovering over OSD area while in shortcut mode, transition to hover control - osdShortcutActive = false; - root.panels.osd.hovered = true; - } - - const showSidebar = pressed && dragStart.x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x); - - // Show sidebar on hover (top-right corner, bounded by notification panel height) - if (Config.sidebar.showOnHover) { - const sidebarTriggerY = Math.max(Config.sidebar.minHoverThreshold, panels.notifications.y + panels.notifications.height + borderThickness); - const showSidebarHover = x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x) && y <= sidebarTriggerY; - if (showSidebarHover && !screenState.sidebar) - screenState.sidebar = true; - } - - // Show/hide session on drag - if (pressed && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { - if (dragX < -Config.session.dragThreshold) - screenState.session = true; - else if (dragX > Config.session.dragThreshold) - screenState.session = false; - - // Show sidebar on drag if in session area and session is nearly fully visible - if (showSidebar && panels.session.offsetScale <= 0 && dragX < -Config.sidebar.dragThreshold) - screenState.sidebar = true; - } else if (showSidebar && dragX < -Config.sidebar.dragThreshold) { - // Show sidebar on drag if not in session area - screenState.sidebar = true; - } - } else { - const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); - // Show osd on hover - const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); - - // Always update visibility based on hover if not in shortcut mode - if (!osdShortcutActive) { - screenState.osd = showOsd; - root.panels.osd.hovered = showOsd; - } else if (showOsd) { - // If hovering over OSD area while in shortcut mode, transition to hover control - osdShortcutActive = false; - root.panels.osd.hovered = true; - } - - // Show/hide session on drag - if (pressed && outOfSidebar && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { - if (dragX < -Config.session.dragThreshold) - screenState.session = true; - else if (dragX > Config.session.dragThreshold) - screenState.session = false; - } - - // Show/hide sidebar on hover - if (Config.sidebar.showOnHover && !pressed) { - const sidebarTriggerY = Math.max(Config.sidebar.minHoverThreshold, panels.notifications.y + panels.notifications.height + borderThickness); - const showSidebarHover = x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x) && y <= sidebarTriggerY; - if (showSidebarHover && !screenState.sidebar) { - screenState.sidebar = true; - } else { - const inSidebarArea = inRightPanel(panels.sidebar, x, y) || inRightPanel(panels.sessionWrapper, x, y); - if (!inSidebarArea) - screenState.sidebar = false; - } - } - - // Hide sidebar on drag - if (pressed && inRightPanel(panels.sidebar, dragStart.x, 0) && dragX > Config.sidebar.dragThreshold) - screenState.sidebar = false; - } - - // Show launcher on hover, or show/hide on drag if hover is disabled - if (Config.launcher.showOnHover) { - if (!screenState.launcher && inBottomPanel(panels.launcher, x, y)) - screenState.launcher = true; - } else if (pressed && inBottomPanel(panels.launcher, dragStart.x, dragStart.y) && withinPanelWidth(panels.launcher, x, y)) { - if (dragY < -Config.launcher.dragThreshold) - screenState.launcher = true; - else if (dragY > Config.launcher.dragThreshold) - screenState.launcher = false; - } - - // Show dashboard on hover - const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboard, x, y); - - // Always update visibility based on hover if not in shortcut mode if (!dashboardShortcutActive) { screenState.dashboard = showDashboard; } else if (showDashboard) { - // If hovering over dashboard area while in shortcut mode, transition to hover control dashboardShortcutActive = false; } - // Show/hide dashboard on drag (for touchscreen devices) - if (pressed && inTopPanel(panels.dashboard, dragStart.x, dragStart.y) && withinPanelWidth(panels.dashboard, x, y)) { + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && withinPanelWidth(panels.dashboardWrapper, x, y)) { if (dragY > Config.dashboard.dragThreshold) screenState.dashboard = true; else if (dragY < -Config.dashboard.dragThreshold) screenState.dashboard = false; } - // Show utilities on hover - const showUtilities = inBottomPanel(panels.utilities, x, y, true); - - // Always update visibility based on hover if not in shortcut mode - if (!utilitiesShortcutActive) { - screenState.utilities = showUtilities; - } else if (showUtilities) { - // If hovering over utilities area while in shortcut mode, transition to hover control - utilitiesShortcutActive = false; + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && (Config.dashboard.showOnHover || !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y))) { + if (dragY > 20) { + screenState.settings = true; + } else if (dragY < -20) { + screenState.settings = false; + } } - // Show popouts on hover - if (x < bar.implicitWidth) { - bar.checkPopout(y); - } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) { - popouts.hasCurrent = false; - bar.closeTray(); + if (pressed && dragStart.y > screen.height - geometry.insetBottom(borderThickness) && dragStart.x < screen.width / 5 && dragY < -50) + screenState.clipboard = true; + + if (pressed && !Config.dock.hoverToReveal && dragStart.y > screen.height - geometry.insetTop(borderThickness) && dragStart.x > screen.width / 5 && !screenState.launcher) + if (dragY < -10) + screenState.dock = true; + + if (pressed && dragStart.x > screen.width - Config.bar.border && dragStart.y < (screen.height / 2) && dragX < -Config.sidebar.dragThreshold) + screenState.sidebar = true; + + if (pressed && dragStart.x >= screen.width - Config.bar.border && dragStart.y > (screen.height / 2) && dragX < -20) + Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); + + if (pressed && dragStart.x <= Config.bar.border && dragX > 20) + Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); + + if (!screenState.bar && Config.bar.autoHide && geometry.barContains(x, y, true)) + bar.isHovered = true; + + if (panels.sidebar.offsetScale === 1) { + const showOsd = inRightPanel(panels.osdWrapper, x, y); + + if (showOsd) { + osdShortcutActive = false; + panels.osd.hovered = true; + } + } else { + const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); + const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); + + if (!osdShortcutActive) { + screenState.osd = showOsd; + panels.osd.hovered = showOsd; + } else if (showOsd) { + osdShortcutActive = false; + panels.osd.hovered = true; + } } + + if (Config.dock.enable && Config.dock.hoverToReveal && !screenState.dock && !screenState.launcher && inBottomPanel(panels.dock, x, y)) + screenState.dock = true; + + if (geometry.barContains(x, y)) + bar.checkPopout(geometry.axisPos(x, y)); } - // Monitor individual visibility changes Connections { - function onLauncherChanged() { - // If launcher is hidden, clear shortcut flags for dashboard and OSD + function onDashboardChanged(): void { + if (root.screenState.dashboard) { + const inDashboardArea = root.inTopPanel(root.panels.dashboardWrapper, root.mouseX, root.mouseY); + if (!inDashboardArea) { + root.dashboardShortcutActive = true; + } + + root.screenState.launcher = false; + root.screenState.settings = false; + root.screenState.sidebar = false; + root.screenState.resources = false; + root.popouts.hasCurrent = false; + } else { + root.dashboardShortcutActive = false; + } + } + + function onIsDrawingChanged(): void { + if (root.screenState.isDrawing) { + Cursor.setOverride(Qt.BlankCursor); + } else { + Cursor.clearOverride(); + } + } + + function onLauncherChanged(): void { if (!root.screenState.launcher) { root.dashboardShortcutActive = false; root.osdShortcutActive = false; root.utilitiesShortcutActive = false; - // Also hide dashboard and OSD if they're not being hovered - const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); - const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); - if (!inDashboardArea) { - root.screenState.dashboard = false; - } if (!inOsdArea) { root.screenState.osd = false; root.panels.osd.hovered = false; } } - } - function onDashboardChanged() { - if (root.screenState.dashboard) { - // Dashboard became visible, immediately check if this should be shortcut mode - const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); - if (!inDashboardArea) { - root.dashboardShortcutActive = true; - } - } else { - // Dashboard hidden, clear shortcut flag - root.dashboardShortcutActive = false; + if (root.screenState.launcher) { + root.screenState.dashboard = false; + root.screenState.dock = false; + root.screenState.settings = false; } } - function onOsdChanged() { + function onOsdChanged(): void { if (root.screenState.osd) { - // OSD became visible, immediately check if this should be shortcut mode - const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { root.osdShortcutActive = true; } } else { - // OSD hidden, clear shortcut flag root.osdShortcutActive = false; } } - function onUtilitiesChanged() { - if (root.screenState.utilities) { - // Utilities became visible, immediately check if this should be shortcut mode - const inUtilitiesArea = root.inBottomPanel(root.panels.utilities, root.mouseX, root.mouseY); - if (!inUtilitiesArea) { - root.utilitiesShortcutActive = true; - } - } else { - // Utilities hidden, clear shortcut flag - root.utilitiesShortcutActive = false; + function onResourcesChanged(): void { + if (root.screenState.resources) { + if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates")) + root.popouts.hasCurrent = false; + + root.screenState.settings = false; + root.screenState.dashboard = false; + } + } + + function onSettingsChanged(): void { + if (root.screenState.settings) { + root.screenState.resources = false; + root.screenState.dashboard = false; + root.screenState.sidebar = false; + root.panels.popouts.hasCurrent = false; + root.screenState.launcher = false; + } + } + + function onSidebarChanged(): void { + if (root.screenState.sidebar) { + if (root.panels.dashboardWrapper.x + root.panels.dashboardWrapper.width > root.panels.sidebar.x) + root.screenState.dashboard = false; + root.screenState.settings = false; + root.popouts.hasCurrent = false; } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index b2c9887..2121c27 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -375,7 +375,7 @@ CustomWindow { } } - Interactions { + Interactions2 { id: interactions anchors.fill: parent diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index bad138a..c2b6c48 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -27,7 +27,7 @@ Item { for (let i = 0; i < count; i++) height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; - if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || screenState.dashboard) + if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x) return 0; if (screenState.osd) { @@ -47,8 +47,7 @@ Item { implicitWidth: Config.notifs.sizes.width + padding * 2 Behavior on implicitHeight { - Anim { - } + Anim {} } ClippingWrapperRectangle { @@ -65,8 +64,7 @@ Item { orientation: Qt.Vertical spacing: 0 - delegate: NotifWrapper { - } + delegate: NotifWrapper {} displaced: Transition { Anim { property: "y" diff --git a/Plugins/ZShell/Internal/cursor.hpp b/Plugins/ZShell/Internal/cursor.hpp index 7b1356e..7496195 100644 --- a/Plugins/ZShell/Internal/cursor.hpp +++ b/Plugins/ZShell/Internal/cursor.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -14,23 +16,25 @@ class Cursor : public QObject { public: Q_INVOKABLE void setOverride(Qt::CursorShape shape) { - if (m_override) + if (m_shape == shape) return; + + if (m_shape) QGuiApplication::changeOverrideCursor(QCursor(shape)); else { QGuiApplication::setOverrideCursor(QCursor(shape)); - m_override = true; } + m_shape = shape; } Q_INVOKABLE void clearOverride() { - if (!m_override) return; + if (!m_shape) return; QGuiApplication::restoreOverrideCursor(); - m_override = false; + m_shape.reset(); } private: - bool m_override = false; + std::optional m_shape; }; } // namespace ZShell::internal From 4ebb4ce859a5084efcb359320cb24ef098f2d451 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 00:41:01 +0200 Subject: [PATCH 14/21] feat: wallpaper cropper sdf shader rectangle, better confirmation controls + hide passive tray icons --- Modules/Bar/Components/TrayIcons.qml | 2 +- Modules/Settings/Common/WallpaperCropper.qml | 404 +++++++++++-------- assets/shaders/crop_sdf.frag | 40 ++ assets/shaders/crop_sdf.frag.qsb | Bin 0 -> 1999 bytes shell.qml | 20 +- 5 files changed, 294 insertions(+), 172 deletions(-) create mode 100644 assets/shaders/crop_sdf.frag create mode 100644 assets/shaders/crop_sdf.frag.qsb diff --git a/Modules/Bar/Components/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml index 6fe8ac7..fae91a7 100644 --- a/Modules/Bar/Components/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -42,7 +42,7 @@ WidgetBase { Repeater { id: repeater - model: SystemTray.items + model: SystemTray.items.values.filter(i => i.status !== Status.Passive && i.hasMenu) TrayItem { id: trayItem diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index b2fea36..68076b0 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -26,10 +26,10 @@ Item { const cropRect = cropRectLoader.item; - const cropXPercent = (cropRect.x - cropRect.imageX) / scaledImg.paintedWidth; - const cropYPercent = (cropRect.y - cropRect.imageY) / scaledImg.paintedHeight; - const cropWidthPercent = cropRect.width / scaledImg.paintedWidth; - const cropHeightPercent = cropRect.height / scaledImg.paintedHeight; + const cropXPercent = (cropRect.x - cropRect.imageX) / cropRect.imageW; + const cropYPercent = (cropRect.y - cropRect.imageY) / cropRect.imageH; + const cropWidthPercent = cropRect.width / cropRect.imageW; + const cropHeightPercent = cropRect.height / cropRect.imageH; Wallpapers.setCrop(currentScreen.name, Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent), cropRect.zoom); } @@ -79,16 +79,13 @@ Item { visible: opacity > 0 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } Behavior on y { - Anim { - } + Anim {} } Component.onCompleted: { @@ -99,34 +96,10 @@ Item { Qt.callLater(syncCropToScreen); } - IconButton { - anchors.margins: Tokens.padding.small - anchors.right: parent.right - anchors.top: parent.top - icon: "check" - opacity: wrapper.changesMade ? 1 : 0 - scale: wrapper.changesMade ? 1 : 0 - z: 2 - - Behavior on opacity { - Anim { - } - } - Behavior on scale { - Anim { - } - } - - onClicked: { - wrapper.applyCrop(); - wrapper.changesMade = false; - } - } - ButtonRow { id: screenSelector - anchors.bottom: scaledImg.top + anchors.bottom: imageSlot.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.leftMargin: Tokens.padding.extraLarge * 2 @@ -149,6 +122,7 @@ Item { isRound: true shapeMorph: true text: modelData.name + font: Tokens.font.body.builders.large.weight(Font.DemiBold).build() onClicked: wrapper.selectScreen(index) } @@ -178,16 +152,52 @@ Item { onInteraction: value => { wrapper.zoomClipRect(1 + (value * 4)); - wrapper.changesMade = true; + } + + onPosChanged: wrapper.changesMade = true + } + + ButtonRow { + spacing: Tokens.spacing.medium + + IconTextButton { + isRound: true + icon: "close" + text: qsTr("Cancel") + font: Tokens.font.body.large + shapeMorph: true + horizontalPadding: Tokens.padding.large + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + enabled: wrapper.changesMade + + onClicked: { + wrapper.syncCropToScreen(); + wrapper.changesMade = false; + } + } + + IconTextButton { + isRound: true + icon: "check" + text: qsTr("Apply") + font: Tokens.font.body.large + shapeMorph: true + horizontalPadding: Tokens.padding.large + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + enabled: wrapper.changesMade + + onClicked: { + wrapper.applyCrop(); + wrapper.changesMade = false; + } } } } - Image { - id: scaledImg - - property var displayData - property real monitorScale: 1.0 + Item { + id: imageSlot anchors.bottom: sliderLayout.top anchors.bottomMargin: Tokens.spacing.medium @@ -195,147 +205,225 @@ Item { anchors.right: parent.right anchors.top: parent.top anchors.topMargin: wrapper.buttonRowHeight + Tokens.spacing.medium - // anchors.top: screenSelector.bottom - asynchronous: true - fillMode: Image.PreserveAspectFit - retainWhileLoading: true - source: Wallpapers.current - sourceSize.height: parent.height - sourceSize.width: parent.width - onPaintedWidthChanged: { - if (paintedWidth > 0 && cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + CustomClippingRect { + id: fitBox - wrapper.syncCropToScreen(); - } - } - onSourceChanged: { - if (cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + readonly property real aspect: scaledImg.implicitHeight > 0 ? scaledImg.implicitWidth / scaledImg.implicitHeight : 1 + readonly property real fitW: Math.min(imageSlot.width, imageSlot.height * aspect) - wrapper.syncCropToScreen(); - } - } - onStatusChanged: { - if (scaledImg.status == Image.Ready && cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + implicitHeight: Math.round(fitW / aspect) + implicitWidth: Math.round(fitW) + x: Math.round((imageSlot.width - width) / 2) + y: Math.round((imageSlot.height - height) / 2) + radius: Tokens.rounding.largeIncreased - wrapper.syncCropToScreen(); - } - } + Loader { + anchors.centerIn: parent - Loader { - id: cropRectLoader + opacity: scaledImg.status === Image.Ready ? 0 : 1 + active: opacity > 0 - active: scaledImg.status === Image.Ready + sourceComponent: CustomRect { + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 - sourceComponent: Component { - CustomRect { - id: cropRect + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full - property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 - readonly property real baseHeight: baseWidth / aspectRatio - readonly property real baseWidth: { - let fittedHeight = scaledImg.paintedHeight; - let fittedWidth = fittedHeight * aspectRatio; + LoadingIndicator { + id: loadingIndicator - if (fittedWidth > scaledImg.paintedWidth) { - fittedWidth = scaledImg.paintedWidth; - fittedHeight = fittedWidth / aspectRatio; - } + anchors.centerIn: parent - return fittedWidth; + containsIcon: true + implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3 } - readonly property real imageX: (scaledImg.width - scaledImg.paintedWidth) / 2 - readonly property real imageY: (scaledImg.height - scaledImg.paintedHeight) / 2 - property real imgAspectRatio: scaledImg.paintedWidth / scaledImg.paintedHeight - property real zoom: 1.0 + } - function centerInImage() { - x = imageX + (scaledImg.paintedWidth - width) / 2; - y = imageY + (scaledImg.paintedHeight - height) / 2; + Behavior on opacity { + Anim { + type: Anim.DefaultEffects } - - function clampToBounds() { - x = Math.max(imageX, Math.min(x, imageX + scaledImg.paintedWidth - width)); - y = Math.max(imageY, Math.min(y, imageY + scaledImg.paintedHeight - height)); - } - - function restoreFromData() { - let data = Wallpapers.getCrop(wrapper.currentScreen.name); - - if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { - zoom = data.zoom > 0 ? data.zoom : 1.0; - x = imageX + (data.x * scaledImg.paintedWidth); - y = imageY + (data.y * scaledImg.paintedHeight); - - clampToBounds(); - } else { - zoom = 1.0; - centerInImage(); - } - } - - border.color: Colors.palette.m3primary - border.width: 2 - height: baseHeight / zoom - opacity: 1 - width: baseWidth / zoom - - Behavior on opacity { - Anim { - } - } - - Component.onCompleted: { - restoreFromData(); - } - onHeightChanged: clampToBounds() - onWidthChanged: clampToBounds() } } - } - MouseArea { - id: mouse + Image { + id: scaledImg - function updateCrop(mouseX, mouseY) { - if (!cropRectLoader.item) - return; + anchors.fill: parent + asynchronous: true + fillMode: Image.Stretch + source: Wallpapers.current + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } + retainWhileLoading: true + opacity: status === Image.Ready ? 1 : 0 - const cropRect = cropRectLoader.item; - - let nx = mouseX - cropRect.width * 0.5; - let ny = mouseY - cropRect.height * 0.5; - - nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + scaledImg.paintedWidth - cropRect.width)); - ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + scaledImg.paintedHeight - cropRect.height)); - - cropRect.x = nx; - cropRect.y = ny; + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } } - anchors.fill: parent - hoverEnabled: true - preventStealing: true + Loader { + id: cropRectLoader - onPositionChanged: mouse => { - if (pressed) { + active: scaledImg.status === Image.Ready + + sourceComponent: Component { + CustomRect { + id: cropRect + + property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 + readonly property real baseHeight: baseWidth / aspectRatio + + readonly property real baseWidth: { + let fittedHeight = imageH; + let fittedWidth = fittedHeight * aspectRatio; + + if (fittedWidth > imageW) { + fittedWidth = imageW; + fittedHeight = fittedWidth / aspectRatio; + } + + return fittedWidth; + } + + readonly property real imageH: Math.round(fitBox.height) + readonly property real imageW: Math.round(fitBox.width) + readonly property real imageX: 0 + readonly property real imageY: 0 + property real imgAspectRatio: imageH > 0 ? imageW / imageH : 1 + property real zoom: 1.0 + + function centerInImage() { + x = Math.round(imageX + (imageW - width) / 2); + y = Math.round(imageY + (imageH - height) / 2); + } + + function clampToBounds() { + x = Math.max(imageX, Math.min(x, imageX + imageW - width)); + y = Math.max(imageY, Math.min(y, imageY + imageH - height)); + } + + function restoreFromData() { + let data = Wallpapers.getCrop(wrapper.currentScreen.name); + + if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { + zoom = data.zoom > 0 ? data.zoom : 1.0; + x = Math.round(imageX + (data.x * imageW)); + y = Math.round(imageY + (data.y * imageH)); + + clampToBounds(); + } else { + zoom = 1.0; + centerInImage(); + } + } + + height: Math.round(baseHeight / zoom) + width: Math.round(baseWidth / zoom) + color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent" + + Behavior on color { + CAnim {} + } + + Component.onCompleted: { + restoreFromData(); + } + onHeightChanged: clampToBounds() + onWidthChanged: clampToBounds() + } + } + } + + ShaderEffect { + anchors.fill: parent + + readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 + + function snap(v: real): real { + return Math.round(v * dpr) / dpr; + } + + property color borderColor: Colors.palette.m3tertiary + property vector4d cropBox: cropRectLoader.item ? Qt.vector4d(snap(cropRectLoader.item.x), snap(cropRectLoader.item.y), snap(cropRectLoader.item.width), snap(cropRectLoader.item.height)) : Qt.vector4d(0, 0, 0, 0) + property real borderWidth: 2 + property real cornerRadius: fitBox.radius + property vector2d itemSize: Qt.vector2d(width, height) + property real pixelScale: dpr + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/crop_sdf.frag.qsb`) + } + + MouseArea { + id: mouse + + property real grabX: 0 + property real grabY: 0 + + function insideCropRect(mx, my): bool { + const r = cropRectLoader.item; + if (!r) + return false; + + return mx >= r.x && mx <= r.x + r.width && my >= r.y && my <= r.y + r.height; + } + + function beginGrab(mx, my): void { + const r = cropRectLoader.item; + if (!r) + return; + + const inside = insideCropRect(mx, my); + + if (inside) { + grabX = mx - r.x; + grabY = my - r.y; + } else { + grabX = r.width * 0.5; + grabY = r.height * 0.5; + } + } + + function updateCrop(mx, my): void { + if (!cropRectLoader.item) + return; + + const cropRect = cropRectLoader.item; + + let nx = mx - grabX; + let ny = my - grabY; + + nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + cropRect.imageW - cropRect.width)); + ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + cropRect.imageH - cropRect.height)); + + cropRect.x = nx; + cropRect.y = ny; + } + + anchors.fill: parent + hoverEnabled: true + preventStealing: true + + cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor + + onPositionChanged: mouse => { + if (pressed) { + updateCrop(mouse.x, mouse.y); + wrapper.changesMade = true; + } + } + onPressed: mouse => { + beginGrab(mouse.x, mouse.y); updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; } } - onPressed: mouse => { - updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; - } - onReleased: { - wrapper.changesMade = true; - } } } } diff --git a/assets/shaders/crop_sdf.frag b/assets/shaders/crop_sdf.frag new file mode 100644 index 0000000..bbe2aa0 --- /dev/null +++ b/assets/shaders/crop_sdf.frag @@ -0,0 +1,40 @@ +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 itemSize; + vec4 cropBox; + vec4 borderColor; + float cornerRadius; + float borderWidth; + float pixelScale; +}; + +float sdRoundBox(vec2 p, vec2 halfSize, float r) { + vec2 q = abs(p) - halfSize + r; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +void main() { + vec2 sz = itemSize * pixelScale; + vec2 p = qt_TexCoord0 * sz; + vec4 box = cropBox * pixelScale; + float r = cornerRadius * pixelScale; + float bw = borderWidth * pixelScale; + + float dImage = sdRoundBox(p - sz * 0.5, sz * 0.5, r); + float dCrop = sdRoundBox(p - (box.xy + box.zw * 0.5), box.zw * 0.5, 0.0); + + float d = max(dImage, dCrop); + + // ramp is exactly 1 physical pixel wide + float outer = clamp(0.5 - d, 0.0, 1.0); + float inner = clamp(0.5 - (d + bw), 0.0, 1.0); + float a = outer - inner; + + fragColor = vec4(borderColor.rgb, 1.0) * (borderColor.a * a * qt_Opacity); +} diff --git a/assets/shaders/crop_sdf.frag.qsb b/assets/shaders/crop_sdf.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..786ccc013b2e6be6fb8eca6da93f90d1f49cebc7 GIT binary patch literal 1999 zcmV;=2Qc^m03UyNob6X@R~y$A-5`OeOdKbE1hLX|N%PE^Gh(%4DtVuKr} zwNrT{4KR(Q5u*`Eq)we@>%5vaZQlKZ{RREf$2xcR6_)|hFHKk13bWQM?%8Lb$35rX zGXlVO03-nn02sp>LX>d>F1nb94<7+)C;%7)fPeA9E{`~P7|RIYU_~3(wRz)?yzriz75yKno74@DQTMkh3PY_;X0p zTr{u*51Vkg|I5r{$8!)>1n|+q9DJRW_`PN)Vn4@FVP#zeJ4$tDFS9XJHLa=57U7@< z4;{3*pVnUc*&gpEJk$_km1f~yW=GJ{C3aEA5~^@e*QF36v&aAHV;t7Dpa*Uz22Z9W z<^hc0uS6fhpw8j|hV|b|SC+2i+hHv~JtJoo2a(V`(LIQib}Vnh@tT5{0NmGQPG~$O zK9E4Z-{_=X%QK|qp}w4Adu;3^{~Bz7*sO&O61!|+31ZhQEJ^H+g$)sFS=cbK(85y0 zq&t_8)E9T0xf%H}OO`sEu;lxed>Df|=W!%-p0YlMgpR8KJc6W-H%y&EQeTb~Uy)_F z7|u9`k>+}YujnU`)Yx&xJ&GhAWeiaNsFjzI$605|BS*hdyD=m&3202@6G-dYOp=qL z&e)h&^8GRQBK#s{GyEj4Gt6s%d5An^?f)#-DNBFOs>c)5n|ME=uN32nTp}j=i&nnI z-#pKa=%1lKTmJ&ji;))@&(!%=RK^QNj)v^L3*zth^qpfqBHt%w{JqP%O5NY1?}XKt zk7NALxSw+v(z%*TE}Xw(Q0FSKz$x@3sOO~_qd`!L`lV6JYyAzY07{3ye z-57r@CI>NoJ0^ExaxW&o7L(tid=W$XEKT$L$r&s0zReI9j-03S6=| zo}!)LPa~o0{FLSMX`VYHUtllZru{5A7ieE3COOC%{3&yorw=(l&k{FzUfLzk%fuy5 zv3ZVmml=xtRp`EN9>P^m7 z-XqafC_jmm*2#zXAn%RXRmgjZIun1{+K2RUg?DuUDXm{6Ca?J}8?G1Q{+gJ)gPap) zU$0Ov`+A*No&I03YSds(bt_IY7RM*n+$BzndQ-nO`jmA*y{y~(Zkc+8te5bG*Cx-U z&eVLJv&qOCoHe8L(`Ah^cM${0`@$@&7w=-l6?l#LVn@ zpZD-xtJfb8U%-g|Mtw-^1MB=r&#yDaAIO1)eMD@)!v081Y_B1uWBrNtA5ky+yog~v zBmQjV_OZ3czffo7PdNL{-1wB7Ppo)k-$>ICd z&5=Ol4+=O4FXE9#Bz6m_S_J8s>{A2PR7mhT_2Ugi#E z$P@gZ7}T=#UfRL`i3T{*H3%gCc5>Yf+MeH3X9|UMD{!lxmR7x$)s|YW`%YNg0ej7` zm`=CDpi>Q1rL&xVHLa9V4JVvdYhn4S69!(ls4?w9z=f7m^}=3+uDjJK<%Moz$=h`M z(6p)serwL}T1dqYYHl#^*ZshDSoMRZ8!S3CuhX{ihe%QkY16=YeC$dxLoj8kc6T3I#O=VsM}R%e22lxE(rzQis`%Q^t$iWl=L~XErs@`D#)gI1R3&XYA^|c0-4O&=@{MvM#z-g$GYN<(;X>Y32s*s<_ z#tG#!s-%o|+qux%DOHjRZo#$Z8qSIUWm9%$IHO9^>uovC2Yzdp6w7B#ZqsIA3PM9wQgV!5tS&mhE7&rkL^=%Ah$IIherET@NA)=-1fJ45PHqs zW?)wj4Ms%xr&H?hniTVfl>@%l?h~=I7+BH7Ccn=H%43 hd`|8<5%-7Z{x8k_Uz-2#zciQsH^a04{2PY{g Date: Wed, 30 Sep 2026 00:50:31 +0200 Subject: [PATCH 15/21] fix: normalize zoom slider to linear scale --- Modules/Settings/Common/WallpaperCropper.qml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index 68076b0..bd1e8b0 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -13,13 +13,23 @@ import qs.Services Item { id: wrapper + readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null + readonly property real maxZoom: 5.0 + property int buttonRowHeight: Tokens.padding.large * 3 property bool changesMade: false - readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true + function zoomFromSlider(t: real): real { + return 1 / (1 - t * (1 - 1 / maxZoom)); + } + + function sliderFromZoom(zoom: real): real { + return (1 - 1 / zoom) / (1 - 1 / maxZoom); + } + function applyCrop(): void { if (!cropRectLoader.item || !currentScreen) return; @@ -144,14 +154,12 @@ Item { Layout.leftMargin: Tokens.padding.small Layout.preferredHeight: Tokens.padding.medium * 3 Layout.rightMargin: Tokens.padding.small - from: 1.0 implicitHeight: Tokens.padding.small * 3 insetIcon: "crop" - to: 5.0 - value: cropRectLoader.item ? cropRectLoader.item.zoom : 1.0 + value: cropRectLoader.item ? wrapper.sliderFromZoom(cropRectLoader.item.zoom) : 0 onInteraction: value => { - wrapper.zoomClipRect(1 + (value * 4)); + wrapper.zoomClipRect(wrapper.zoomFromSlider(value)); } onPosChanged: wrapper.changesMade = true From 62e19b5261fab37dd2d9ae95b26d905783fb534c Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 00:57:29 +0200 Subject: [PATCH 16/21] fix: show bar on hover --- Drawers/Interactions2.qml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml index 25c3ed7..001db80 100644 --- a/Drawers/Interactions2.qml +++ b/Drawers/Interactions2.qml @@ -138,6 +138,9 @@ CustomMouseArea { return; } + if (root.bar.fullscreen && !root.bar.isHovered) + unhideTimer.start(); + if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) screenState.launcher = true; @@ -210,6 +213,19 @@ CustomMouseArea { bar.checkPopout(geometry.axisPos(x, y)); } + Timer { + id: unhideTimer + + interval: Config.bar.revealDelay + repeat: false + running: false + + onTriggered: { + if (root.containsMouse) + root.bar.isHovered = true; + } + } + Connections { function onDashboardChanged(): void { if (root.screenState.dashboard) { From a22022e96915f5f368a377a9192aec991e961f92 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 13:58:35 +0200 Subject: [PATCH 17/21] fix: changesMade bool now binding for robustness --- Modules/Settings/Common/WallpaperCropper.qml | 58 ++++++++++++++------ shell.qml | 2 +- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index bd1e8b0..0b5b0d9 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -16,8 +16,20 @@ Item { readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null readonly property real maxZoom: 5.0 + readonly property bool changesMade: { + cropRevision; + + const r = cropRectLoader.item; + if (!r || !currentScreen || scaledImg.status !== Image.Ready) + return false; + + const t = r.savedTarget(); + + return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; + } + + property int cropRevision: 0 property int buttonRowHeight: Tokens.padding.large * 3 - property bool changesMade: false property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true @@ -42,6 +54,7 @@ Item { const cropHeightPercent = cropRect.height / cropRect.imageH; Wallpapers.setCrop(currentScreen.name, Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent), cropRect.zoom); + cropRevision++; } function refreshScreens(): void { @@ -161,8 +174,6 @@ Item { onInteraction: value => { wrapper.zoomClipRect(wrapper.zoomFromSlider(value)); } - - onPosChanged: wrapper.changesMade = true } ButtonRow { @@ -181,7 +192,6 @@ Item { onClicked: { wrapper.syncCropToScreen(); - wrapper.changesMade = false; } } @@ -198,7 +208,6 @@ Item { onClicked: { wrapper.applyCrop(); - wrapper.changesMade = false; } } } @@ -318,19 +327,33 @@ Item { y = Math.max(imageY, Math.min(y, imageY + imageH - height)); } + // Where the rect should be according to saved data (same rules as a restore). + function savedTarget() { + const data = Wallpapers.getCrop(wrapper.currentScreen.name); + const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001); + + const z = hasCrop && data.zoom > 0 ? data.zoom : 1.0; + const w = Math.round(baseWidth / z); + const h = Math.round(baseHeight / z); + + let tx = hasCrop ? Math.round(imageX + data.x * imageW) : Math.round(imageX + (imageW - w) / 2); + let ty = hasCrop ? Math.round(imageY + data.y * imageH) : Math.round(imageY + (imageH - h) / 2); + + tx = Math.max(imageX, Math.min(tx, imageX + imageW - w)); + ty = Math.max(imageY, Math.min(ty, imageY + imageH - h)); + + return { + x: tx, + y: ty, + zoom: z + }; + } + function restoreFromData() { - let data = Wallpapers.getCrop(wrapper.currentScreen.name); - - if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { - zoom = data.zoom > 0 ? data.zoom : 1.0; - x = Math.round(imageX + (data.x * imageW)); - y = Math.round(imageY + (data.y * imageH)); - - clampToBounds(); - } else { - zoom = 1.0; - centerInImage(); - } + const t = savedTarget(); + zoom = t.zoom; + x = t.x; + y = t.y; } height: Math.round(baseHeight / zoom) @@ -424,7 +447,6 @@ Item { onPositionChanged: mouse => { if (pressed) { updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; } } onPressed: mouse => { diff --git a/shell.qml b/shell.qml index 60ec9ed..6df3f3a 100644 --- a/shell.qml +++ b/shell.qml @@ -3,7 +3,7 @@ // @ pragma DefaultEnv QSG_RHI_BACKEND=vulkan //@ pragma DefaultEnv QSG_NO_VSYNC=1 //@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 -// @ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round +//@ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round //@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 //@ pragma DropExpensiveFonts import Quickshell From 60293abe61cbb83f655786b1441a3470673d9160 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 13:11:31 +0200 Subject: [PATCH 18/21] fix: define declared method + insert new bar entries to config file list + rename Hyprsunset -> Nightlight From 08bb221699af516403da3f383a085d665414f361 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 18:43:41 +0200 Subject: [PATCH 19/21] feat: config object migration path and add new options to settings --- Greeter/Services/Colors.qml | 15 +-- Helpers/ModeScheduler.qml | 12 +- Helpers/Nightlight.qml | 14 +- Helpers/Wallpapers.qml | 6 +- Modules/Settings/Pages.qml | 22 ++-- Modules/Settings/Pages/ServicesPage.qml | 4 +- .../Settings/Pages/Wallpaper/ColorsFonts.qml | 14 +- Modules/Settings/Pages/WallpaperPage.qml | 79 ++++++++--- Plugins/ZShell/Config/CMakeLists.txt | 7 +- Plugins/ZShell/Config/migration.cpp | 123 ++++++++++++++++++ Plugins/ZShell/Config/migration.hpp | 38 ++++++ Plugins/ZShell/Services/nightlightmanager.cpp | 2 - Services/Colors.qml | 14 +- cli/src/zshell/subcommands/scheme.py | 4 +- 14 files changed, 274 insertions(+), 80 deletions(-) create mode 100644 Plugins/ZShell/Config/migration.cpp create mode 100644 Plugins/ZShell/Config/migration.hpp diff --git a/Greeter/Services/Colors.qml b/Greeter/Services/Colors.qml index ee9d678..ac4dce6 100644 --- a/Greeter/Services/Colors.qml +++ b/Greeter/Services/Colors.qml @@ -13,21 +13,17 @@ import qs.Paths Singleton { id: root - readonly property M3Palette current: M3Palette { - } + readonly property M3Palette current: M3Palette {} property bool currentLight property string flavour readonly property bool light: showPreview ? previewLight : currentLight readonly property M3Palette palette: showPreview ? preview : current - readonly property M3Palette preview: M3Palette { - } + readonly property M3Palette preview: M3Palette {} property bool previewLight property string scheme property bool showPreview - readonly property M3TPalette tPalette: M3TPalette { - } - readonly property Transparency transparency: Transparency { - } + readonly property M3TPalette tPalette: M3TPalette {} + readonly property Transparency transparency: Transparency {} readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { @@ -112,8 +108,7 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); - Config.general.color.mode = mode; - Config.save(); + Config.colors.mode = mode; } function swapRG(c: color): color { diff --git a/Helpers/ModeScheduler.qml b/Helpers/ModeScheduler.qml index a45e15a..14b6aa8 100644 --- a/Helpers/ModeScheduler.qml +++ b/Helpers/ModeScheduler.qml @@ -11,14 +11,14 @@ import qs.Services Singleton { id: root - readonly property int darkEnd: Config.general.color.scheduleDarkEnd - readonly property int darkStart: Config.general.color.scheduleDarkStart - readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration + readonly property int darkEnd: Config.colors.scheduleDarkEnd + readonly property int darkStart: Config.colors.scheduleDarkStart + readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen function applyDarkMode() { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]); - Config.general.color.mode = "dark"; + Config.colors.mode = "dark"; Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]); @@ -30,13 +30,13 @@ Singleton { function applyLightMode() { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]); - Config.general.color.mode = "light"; + Config.colors.mode = "light"; Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]); Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]); - if (Config.general.color.neovimColors) + if (Config.colors.neovimColors) Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]); } diff --git a/Helpers/Nightlight.qml b/Helpers/Nightlight.qml index 540091d..29857f3 100644 --- a/Helpers/Nightlight.qml +++ b/Helpers/Nightlight.qml @@ -9,13 +9,13 @@ Singleton { id: root readonly property bool enabled: service.enabled - readonly property int end: Config.general.color.scheduleHyprsunsetEnd + readonly property int end: Config.display.nightlight.scheduleEnd property bool manualToggle: false - readonly property int start: Config.general.color.scheduleHyprsunsetStart - readonly property int temp: Config.general.color.hyprsunsetTemp + readonly property int start: Config.display.nightlight.scheduleStart + readonly property int temp: Config.display.nightlight.temp function checkStartup(): void { - if (!Config.general.color.scheduleHyprsunset) + if (!Config.display.nightlight.schedule) return; service.apply(); @@ -29,11 +29,11 @@ Singleton { NightlightManager { id: service - activeAuto: Config.general.color.scheduleHyprsunset + activeAuto: Config.display.nightlight.schedule endTime: root.end startTime: root.start temp: root.temp - fadeDuration: Config.general.color.nativeFadeDuration / 1000 - useNativeNightlight: Config.general.color.useNativeNightlight + fadeDuration: Config.display.nightlight.fadeDuration / 1000 + useNativeNightlight: Config.display.nightlight.useNative } } diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index ba4b64c..d02b01a 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -38,7 +38,7 @@ Searcher { previewPath = path; showPreview = true; - if (Config.general.color.schemeGeneration) + if (Config.colors.schemeGen) previewColorsProc.running = true; } @@ -62,8 +62,8 @@ Searcher { WallpaperPath.currentWallpaperPath = path; Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0)); Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]); - if (Config.general.color.schemeGeneration) - Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]); + if (Config.colors.schemeGen) + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]); } function stopPreview(): void { diff --git a/Modules/Settings/Pages.qml b/Modules/Settings/Pages.qml index ad148b1..4aacc6d 100644 --- a/Modules/Settings/Pages.qml +++ b/Modules/Settings/Pages.qml @@ -43,17 +43,17 @@ Item { Component.onCompleted: root.loadPage(root.sState.currentPageIdx) - CustomRect { - anchors.fill: parent - color: Qt.alpha(Colors.palette.m3shadow, 0.3) - opacity: root.sState.dimmed ? 1 : 0 - visible: opacity > 0 - z: 0 - - Behavior on opacity { - Anim {} - } - } + // CustomRect { + // anchors.fill: parent + // color: Qt.alpha(Colors.palette.m3shadow, 0.3) + // opacity: root.sState.dimmed ? 1 : 0 + // visible: opacity > 0 + // z: 0 + // + // Behavior on opacity { + // Anim {} + // } + // } } Connections { diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index 11b655e..fe827bf 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -202,12 +202,12 @@ PageBase { } ToggleRow { - checked: Config.general.color.smart + checked: Config.colors.smart settingAnchor: "services-smart-color-scheme" subtext: qsTr("Derive theme mode from the wallpaper") text: qsTr("Smart color scheme") - onToggled: Config.general.color.smart = checked + onToggled: Config.colors.smart = checked } SelectRow { diff --git a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml index 44a490e..be09d14 100644 --- a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml +++ b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml @@ -73,15 +73,15 @@ PageBase { ToggleRow { text: qsTr("Dynamic colors") first: true - subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.general.color.schemeGeneration ? "enabled" : "disabled") - checked: Config.general.color.schemeGeneration + subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.colors.schemeGen ? "enabled" : "disabled") + checked: Config.colors.schemeGen settingAnchor: "colors-fonts-dynamic-colors" onToggled: { - Config.general.color.schemeGeneration = checked; + Config.colors.schemeGen = checked; if (checked) - Colors.setMode(Config.general.color.mode); + Colors.setMode(Config.colors.mode); } } @@ -89,7 +89,7 @@ PageBase { settingAnchor: "colors-fonts-preset-scheme" model: root.schemes acceptLabel: qsTr("Confirm") - enabled: !Config.general.color.schemeGeneration + enabled: !Config.colors.schemeGen header: qsTr("Select scheme") rowButton.activeItem: root.schemes.find(s => { var regex = new RegExp(Config.colors.presets.name, "i"); @@ -157,7 +157,7 @@ PageBase { } // qmlformat on acceptLabel: qsTr("Confirm") - enabled: !Config.general.color.schemeGeneration && model.length > 1 + enabled: !Config.colors.schemeGen && model.length > 1 header: qsTr("Select variant") rowButton.activeItem: { const v = Config.colors.presets.variant ?? ""; @@ -210,7 +210,7 @@ PageBase { } // qmlformat on acceptLabel: qsTr("Confirm") - enabled: !Config.general.color.schemeGeneration && model.length > 1 + enabled: !Config.colors.schemeGen && model.length > 1 header: qsTr("Select accent") rowButton.activeItem: { const a = Config.colors.presets.accent ?? ""; diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index ee78ddc..570019f 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -96,20 +96,21 @@ PageBase { TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleDarkEnd); + d.setMinutes(Config.colors.scheduleDarkEnd); return Qt.formatTime(d, "hh:mm AP"); } readonly property string startTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleDarkStart); + d.setMinutes(Config.colors.scheduleDarkStart); return Qt.formatTime(d, "hh:mm AP"); } - rowButton.checked: Config.general.color.scheduleDark - rowButton.onToggled: Config.general.color.scheduleDark = rowButton.checked - start: Config.general.color.scheduleDarkStart - end: Config.general.color.scheduleDarkEnd + rowButton.checked: Config.colors.scheduleDark + rowButton.onToggled: Config.colors.scheduleDark = rowButton.checked + start: Config.colors.scheduleDarkStart + end: Config.colors.scheduleDarkEnd first: true + last: true settingAnchor: "style-schedule-dark-mode" acceptLabel: qsTr("Apply") rootParent: root.flickable @@ -122,8 +123,8 @@ PageBase { } onAccepted: { - Config.general.color.scheduleDarkStart = startCurrent; - Config.general.color.scheduleDarkEnd = endCurrent; + Config.colors.scheduleDarkStart = startCurrent; + Config.colors.scheduleDarkEnd = endCurrent; ModeScheduler.checkStartup(); } } @@ -131,27 +132,26 @@ PageBase { TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleHyprsunsetEnd); + d.setMinutes(Config.display.nightlight.scheduleEnd); return Qt.formatTime(d, "hh:mm AP"); } readonly property string startTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleHyprsunsetStart); + d.setMinutes(Config.display.nightlight.scheduleStart); return Qt.formatTime(d, "hh:mm AP"); } - Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing - rowButton.checked: Config.general.color.scheduleHyprsunset - rowButton.onToggled: Config.general.color.scheduleHyprsunset = rowButton.checked - start: Config.general.color.scheduleHyprsunsetStart - end: Config.general.color.scheduleHyprsunsetEnd - last: true - settingAnchor: "style-schedule-hyprsunset" + rowButton.checked: Config.display.nightlight.schedule + rowButton.onToggled: Config.display.nightlight.schedule = rowButton.checked + start: Config.display.nightlight.scheduleStart + end: Config.display.nightlight.scheduleEnd + first: true + settingAnchor: "style-schedule-nightlight" acceptLabel: qsTr("Apply") rootParent: root.flickable header: qsTr("Select time") - subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - label: qsTr("Schedule hyprsunset") + subtext: qsTr("Nightlight will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) + label: qsTr("Schedule nightlight") onOpenChanged: { root.sState.dimmed = open; @@ -163,5 +163,46 @@ PageBase { Nightlight.checkStartup(); } } + + ToggleRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + checked: Config.display.nightlight.useNative + settingAnchor: "display-nightlight-useNative" + text: qsTr("Use native") + subtext: qsTr("Use native shader nightlight for more features") + + onToggled: Config.display.nightlight.useNative = checked + } + + SpinRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + from: 2000 + settingAnchor: "display-nightlight-temp" + stepSize: 100 + subtext: qsTr("How warm/orange the screen will be. Lower is warmer (Kelvin)") + text: qsTr("Temperature") + to: 6500 + value: Config.display.nightlight.temp + + onMoved: v => Config.display.nightlight.temp = v + } + + SpinRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + from: 0 + settingAnchor: "display-nightlight-fade-duration" + enabled: Config.display.nightlight.useNative + last: true + stepSize: 1 + subtext: qsTr("How long it takes for the gradual nightlight to finish (s)") + text: qsTr("Fade duration") + to: 60 * 5 + value: Config.display.nightlight.fadeDuration / 1000 + + onMoved: v => Config.display.nightlight.fadeDuration = v * 1000 + } } } diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index cb55a84..350ab9a 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -4,8 +4,10 @@ qml_module(ZShell-config DEPENDENCIES ZShell.Settings SOURCES - common.hpp common.cpp - rootnodes.hpp rootnodes.cpp + confignode.hpp confignode.cpp + configobject.hpp configobject.cpp + configlist.hpp configlist.cpp + config.hpp config.cpp migration.hpp migration.cpp anim.hpp anim.cpp fontbuilder.hpp fontbuilder.cpp @@ -17,6 +19,7 @@ qml_module(ZShell-config clipboard.hpp colors.hpp dashboard.hpp + display.hpp dock.hpp enums.hpp general.hpp diff --git a/Plugins/ZShell/Config/migration.cpp b/Plugins/ZShell/Config/migration.cpp new file mode 100644 index 0000000..da78206 --- /dev/null +++ b/Plugins/ZShell/Config/migration.cpp @@ -0,0 +1,123 @@ +#include "migration.hpp" + +#include + +namespace ZShell::config { + +namespace { + +// A stored value is "missing" if the path does not resolve to an entry. +// Note: default-constructed QJsonValue is Null, not Undefined. +bool isMissing(const QJsonValue& v) { + return v.isUndefined() || v.isNull(); +} + +QJsonValue valueAt(const QJsonObject& obj, const QStringList& path) { + QJsonObject cur = obj; + + for (int i = 0; i < path.size() - 1; ++i) { + if (!cur.value(path.at(i)).isObject()) return QJsonValue::Undefined; + cur = cur.value(path.at(i)).toObject(); + } + + const QString key = path.last(); + if (!cur.contains(key)) return QJsonValue::Undefined; + return cur.value(key); +} + +QJsonObject placeAt(QJsonObject obj, QStringList path, const QJsonValue& value) { + const QString head = path.takeFirst(); + + if (path.isEmpty()) { + obj[head] = value; + return obj; + } + + QJsonObject child = obj.value(head).isObject() ? obj.value(head).toObject() + : QJsonObject(); + obj[head] = placeAt(child, path, value); + return obj; +} + +// Removes `path` from `obj`. Returns true if a value was removed. +// Parent objects left empty are removed as well. +bool removeAt(QJsonObject& obj, const QStringList& path) { + const QString head = path.first(); + + if (path.size() == 1) { + if (!obj.contains(head)) return false; + obj.remove(head); + return true; + } + + if (!obj.value(head).isObject()) return false; + + QJsonObject child = obj.value(head).toObject(); + const bool removed = removeAt(child, path.mid(1)); + + if (child.isEmpty()) + obj.remove(head); + else + obj[head] = child; + + return removed; +} + +} // namespace + +const QList& ConfigMigrations::rules() { + static const QList s_rules = { + migrate( + "general.color.scheduleHyprsunset", "display.nightlight.schedule"), + migrate( + "general.color.scheduleHyprsunsetStart", + "display.nightlight.scheduleStart"), + migrate( + "general.color.scheduleHyprsunsetEnd", + "display.nightlight.scheduleEnd"), + migrate("general.color.scheduleDark", "colors.scheduleDark"), + migrate("general.color.scheduleDarkStart", "colors.scheduleDarkStart"), + migrate("general.color.scheduleDarkEnd", "colors.scheduleDarkEnd"), + migrate("general.color.schemeGeneration", "colors.schemeGen"), + migrate( + "general.color.useNativeNightlight", + "display.nightlight.useNative"), + migrate( + "general.color.nativeFadeDuration", + "display.nightlight.fadeDuration"), + migrate("general.color.hyprsunsetTemp", "display.nightlight.temp"), + migrate("general.color.neovimColors", "colors.neovimColors"), + migrate("general.color.mode", "colors.mode"), + migrate("general.color.smart", "colors.smart"), + }; + return s_rules; +} + +QJsonObject ConfigMigrations::apply(const QJsonObject& json) { + QJsonObject obj = json; + + for (const auto& rule : rules()) { + const QStringList from = + rule.from.split(QLatin1Char('.'), Qt::SkipEmptyParts); + const QStringList to = + rule.to.split(QLatin1Char('.'), Qt::SkipEmptyParts); + + if (from.isEmpty() || to.isEmpty()) continue; + + const QJsonValue oldValue = valueAt(obj, from); + if (isMissing(oldValue)) continue; + + const bool targetExists = !isMissing(valueAt(obj, to)); + if (!targetExists) obj = placeAt(obj, to, oldValue); + + if (removeAt(obj, from)) { + qInfo() << "Config migration:" << rule.from << "->" << rule.to + << (targetExists ? "(target already set, old value dropped)" + : "(value moved)"); + } + } + + return obj; +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/migration.hpp b/Plugins/ZShell/Config/migration.hpp new file mode 100644 index 0000000..1c03505 --- /dev/null +++ b/Plugins/ZShell/Config/migration.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace ZShell::config { + +// A single migration rule: move the value stored at the dotted `from` path +// to the dotted `to` path, then delete the old key (and any parent objects +// left empty). +// +// Rules are applied to the raw JSON file on every load, before the schema +// loads it. They are idempotent: once the old key is gone the rule never +// fires again, so no version stamping is needed. Old rules can stay in the +// table forever. +// +// WARNING: the target path must exist in the schema (i.e. a ConfigObject +// property somewhere in the tree). If it does not, the migrated value is +// loaded as an unknown key and silently dropped on the next save. +struct ConfigMigrationRule { + QString from; + QString to; +}; + +// Helper for writing rules: migrate("general.color.foo", "display.bar.foo") +inline ConfigMigrationRule migrate(const QString& from, const QString& to) { + return {from, to}; +} + +class ConfigMigrations { + public: + [[nodiscard]] static const QList& rules(); + + // Applies all rules to `json`, returning the migrated object. + [[nodiscard]] static QJsonObject apply(const QJsonObject& json); +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Services/nightlightmanager.cpp b/Plugins/ZShell/Services/nightlightmanager.cpp index 78658d4..ea50b1d 100644 --- a/Plugins/ZShell/Services/nightlightmanager.cpp +++ b/Plugins/ZShell/Services/nightlightmanager.cpp @@ -10,7 +10,6 @@ namespace ZShell::services { namespace { constexpr const char* NL_EVENT = "zshell-nightlight"; -constexpr int NEUTRAL_KELVIN = 6500; constexpr int RECONNECT_MS = 2000; } // namespace @@ -404,7 +403,6 @@ void NightlightManager::handleEventLine(const QByteArray& line) { if (line.left(sep) != NL_EVENT) return; - // data = enabled,animating,last,current const auto fields = line.mid(sep + 2).split(','); if (fields.size() < 4) return; diff --git a/Services/Colors.qml b/Services/Colors.qml index 863c03f..970281d 100644 --- a/Services/Colors.qml +++ b/Services/Colors.qml @@ -13,21 +13,17 @@ import qs.Paths Singleton { id: root - readonly property M3Palette current: M3Palette { - } + readonly property M3Palette current: M3Palette {} property bool currentLight property string flavour readonly property bool light: showPreview ? previewLight : currentLight readonly property M3Palette palette: showPreview ? preview : current - readonly property M3Palette preview: M3Palette { - } + readonly property M3Palette preview: M3Palette {} property bool previewLight property string scheme property bool showPreview - readonly property M3TPalette tPalette: M3TPalette { - } - readonly property Transparency transparency: Transparency { - } + readonly property M3TPalette tPalette: M3TPalette {} + readonly property Transparency transparency: Transparency {} readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { @@ -112,7 +108,7 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); - Config.general.color.mode = mode; + Config.colors.mode = mode; } function swapRG(c: color): color { diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index 2a65612..f0c8e72 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -625,8 +625,8 @@ def generate( scheme_type = config["colors"].get("schemeType", "fruit-salad") scheme = scheme or scheme_type assert isinstance(scheme, str) - config_mode = config["general"]["color"]["mode"] - smart = bool(config["general"]["color"].get("smart", False)) + config_mode = config["colors"]["mode"] + smart = bool(config["colors"].get("smart", False)) scheme_class = get_scheme_class(scheme) p_variant = "default" From 7cc673430c17e1365e5487f4d0339558734105fb Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 14:48:38 +0200 Subject: [PATCH 20/21] separate dashboard and settings changes to new branch --- Components/CustomScrollBar.qml | 7 ++----- Drawers/Panels.qml | 11 +++++------ Modules/Settings/Common/DialogSelectButton.qml | 1 + 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index dedd428..f889cac 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -17,12 +17,9 @@ ScrollBar { property bool shouldBeActive readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 - property bool shouldBeActive - property bool inverted - enabled: !ShellState.forActive().isDrawing - implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 - implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 + implicitHeight: Tokens.padding.extraSmall * 2 + implicitWidth: Tokens.padding.extraSmall * 2 contentItem: Item { } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 18ecd64..50dd9c8 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -37,12 +37,12 @@ Item { readonly property alias resources: resources readonly property alias resourcesWrapper: resourcesWrapper required property ShellScreen screen + required property ScreenState screenState readonly property alias settings: settings readonly property alias settingsWrapper: settingsWrapper readonly property alias sidebar: sidebar readonly property alias toasts: toasts readonly property alias utilities: utilities - required property ScreenState screenState anchors.bottomMargin: geometry.insetBottom(borderThickness) anchors.fill: parent @@ -84,8 +84,8 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter screen: root.screen - sidebarOrSessionVisible: sidebar.visible screenState: root.screenState + sidebarOrSessionVisible: sidebar.visible } } @@ -121,8 +121,8 @@ Item { anchors.right: parent.right anchors.top: parent.top panels: root - sidebarPanel: sidebar screenState: root.screenState + sidebarPanel: sidebar } Launcher.Wrapper { @@ -140,9 +140,8 @@ Item { anchors.bottom: parent.bottom anchors.right: parent.right - popouts: popouts - sidebar: sidebar screenState: root.screenState + sidebar: sidebar } Item { @@ -150,8 +149,8 @@ Item { property real offsetScale: dashboard.shouldBeActive ? 0 : 1 - anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top clip: true implicitHeight: dashboard.implicitHeight * (1 - offsetScale) implicitWidth: dashboard.implicitWidth diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 5866d6b..505f58b 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -8,6 +8,7 @@ import qs.Services DialogRowButton { id: root + property var initialSelect required property var model property var selectedItem From e86d1e8220fc58cb38d0475132b36a8f44564969 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 15:18:16 +0200 Subject: [PATCH 21/21] remove unrelated files --- Components/GeminiGlow.qml | 42 ------------- assets/shaders/geminiglow.frag | 93 ----------------------------- assets/shaders/geminiglow.frag.qsb | Bin 3721 -> 0 bytes 3 files changed, 135 deletions(-) delete mode 100755 Components/GeminiGlow.qml delete mode 100755 assets/shaders/geminiglow.frag delete mode 100644 assets/shaders/geminiglow.frag.qsb diff --git a/Components/GeminiGlow.qml b/Components/GeminiGlow.qml deleted file mode 100755 index 443ef07..0000000 --- a/Components/GeminiGlow.qml +++ /dev/null @@ -1,42 +0,0 @@ -import QtQuick -import Quickshell - -ShaderEffect { - id: root - - property Item target: parent - - property real glowSize: 40 - property real innerGlow: 0 - property real lineWidth: 2 - property real intensity: 1.0 - property real speed: 1.0 - property int period: 6000 - property bool running: true - - property color color1: "#4285F4" - property color color2: "#9B72CB" - property color color3: "#D96570" - property color color4: "#F2A54A" - - readonly property size itemSize: Qt.size(width, height) - readonly property size rectSize: target ? Qt.size(target.width, target.height) : Qt.size(0, 0) - readonly property real radius: (target as Rectangle).radius ?? 0 - property real phase: 0 - - anchors.fill: target - anchors.margins: -glowSize - z: -1 - - enabled: false - - fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/geminiglow.frag.qsb`) - - FrameAnimation { - running: root.running && root.visible - onTriggered: { - const dt = frameTime * 1000; - root.phase = (root.phase + dt * root.speed / root.period) % 1.0; - } - } -} diff --git a/assets/shaders/geminiglow.frag b/assets/shaders/geminiglow.frag deleted file mode 100755 index ad9de36..0000000 --- a/assets/shaders/geminiglow.frag +++ /dev/null @@ -1,93 +0,0 @@ -#version 440 - -layout(location = 0) in vec2 qt_TexCoord0; -layout(location = 0) out vec4 fragColor; - -// NOTE: std140 layout. Order/types must match the ShaderEffect properties by name. -layout(std140, binding = 0) uniform buf { - mat4 qt_Matrix; - float qt_Opacity; - vec2 itemSize; // full ShaderEffect size in px (rect + glow padding) - vec2 rectSize; // size of the target rectangle in px - float radius; // corner radius of the target rectangle in px - float glowSize; // how far the glow extends outside the rectangle (px) - float innerGlow; // how far the glow bleeds inside (px), 0 = none - float lineWidth; // width of the bright edge line (px), 0 = none - float phase; // 0..1, loops seamlessly - float intensity; // overall brightness - vec4 color1; - vec4 color2; - vec4 color3; - vec4 color4; -}; - -const float TAU = 6.28318530718; - -// Signed distance to a rounded box centred at the origin. <0 inside, >0 outside. -float sdRoundBox(vec2 p, vec2 halfSize, float r) -{ - vec2 q = abs(p) - (halfSize - r); - return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; -} - -// Cyclic 4-colour gradient, t is wrapped to [0,1) -vec3 palette(float t) -{ - t = fract(t) * 4.0; - float f = smoothstep(0.0, 1.0, fract(t)); - if (t < 1.0) return mix(color1.rgb, color2.rgb, f); - if (t < 2.0) return mix(color2.rgb, color3.rgb, f); - if (t < 3.0) return mix(color3.rgb, color4.rgb, f); - return mix(color4.rgb, color1.rgb, f); -} - -// Cheap hash for dithering (avoids banding in soft gradients) -float hash(vec2 p) -{ - return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); -} - -void main() -{ - // Pixel position relative to the centre of the item - vec2 p = (qt_TexCoord0 - 0.5) * itemSize; - - float r = clamp(radius, 0.0, min(rectSize.x, rectSize.y) * 0.5); - float d = sdRoundBox(p, rectSize * 0.5, r); - - // ---- colour: rotates around the rectangle, slightly wobbling ---- - float ang = atan(p.y, p.x); - float a2 = phase * TAU; - float u = ang / TAU + 0.5 + phase; - u += 0.07 * sin(ang * 2.0 + a2) + 0.04 * sin(ang * 3.0 - a2 * 2.0); - vec3 col = palette(u); - - // ---- intensity falloff ---- - float g = 0.0; - if (d > 0.0) { - float t = clamp(d / max(glowSize, 0.001), 0.0, 1.0); - g = pow(1.0 - t, 3.0); - } else if (innerGlow > 0.0) { - g = exp(d / innerGlow * 3.0); - } - - // Slow "breathing" so the glow feels alive - float pulse = 0.9 + 0.1 * sin(a2 * 2.0 + ang); - g *= pulse; - - // Crisp bright line along the edge - float line = 0.0; - if (lineWidth > 0.0) - line = 1.0 - smoothstep(0.0, lineWidth, abs(d)); - - float a = clamp(g * intensity + line * 0.9, 0.0, 1.0); - - // Brighten the colour a bit where the line is - col = mix(col, vec3(1.0), line * 0.25); - - a += (hash(gl_FragCoord.xy) - 0.5) / 255.0; - a = clamp(a, 0.0, 1.0); - - // Premultiplied alpha output - fragColor = vec4(col * a, a) * qt_Opacity; -} diff --git a/assets/shaders/geminiglow.frag.qsb b/assets/shaders/geminiglow.frag.qsb deleted file mode 100644 index 233240e6fd7552d50ef537e097b4a5c3c2609215..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3721 zcmV;44tDVX06lSdob6e8lv`yLzi-wyEl^5XTBUp~D9JQUU$&XVA#_vf0=8IXk>c<= zNoJy%Bu(D5GiZ-RP&vn?xTB)tj^e%|F1X?bE{H4c;I6oX8|b;;``ygVW1<{AoZ}zK zIj{5X?=IiH_q*SH_q`I)QX(24B8P}hpnc@i1U;TgG)LR1PIYQhkz69`BO>}IezeGs z4XmUxHOZqXt6a6}f%*TaIz)SEmdf^Sg2wEc?@}^HTJwQ_XOTxfHK{^#M6?VNTa@$) zxmg93ozx(YCaFR`&11H)!itq>hIUhhE~XOVeC7L8Zq_C@-!g8CY zB2{RXT5#vQ?(R(4Tpm!?Sz%8%g_TsH8r7&oP1*s~K;@$DDpjjgqI0Q2Me=DHD#dOp z5o*vhdDLn7Urc1Oe17Oi z>Q{>_v4;7Y@hUaOpGQRN9imMRN7Qe5!)%B8>EZ37AF%iQ8?^Lbn;u3cG&Eq$15`HXvoIO_e*dsg`E|^d7X7MKu2S*mx4Z4>206^doOVvO9S*! z_(b3{eQJmvL4An+sO28oB6L2WH?N{TIvsv(!@67>C(r!T=Ml&q+ZhLa1~86m2jUXHcc6|V(0>$Uw<-Qvs2_>zoFENbgnuDwN%;I2B-+yE z&!Db^e+lR28#}iO{R|>H^T1Wx;xuUEy%aKxi_Ei#=(%@}>|h_C3;%}^Bh$|d?Zx51 z$Iary3sp_M2=}Zn{422s%zrufLyCVDYDwt#hid0KBD(FU&$svf6GRl-bGRV%=QzjT zJh{O3zovSR*!ux`gUJ7wi0UgZeVFinsr=!;cXOW0<;_C>g@|sx>866n4>+Cit#pFg zkJFG{_d!ljpK^(v6K!ukbUORF&Ixk54S8mo&$rHV)XG17JIg1WAV(t4G@pN+w;c3G zY0&0a9u_R~7wWS)U|r7bbJQNQ?=K6{Z%{PLUlXF=8j`;@M86|Mzb`~zuV~)0TSNS> zhv;vG=n}zRc%icO z65!8--b_ZlTPqU5dy zZ#fNH`n5RI4}p!V;V<)E2R_HXoR(PZ_3a)I__iK$9M8+Zmz=y&`5-xY6Y%FFCvOIJ zSjoHvb;&lb0nPVsg}lV_HqsM4 z*!wK{`i;uo=R<3L0a#hdd=YDk%uT?~!MSDZX6W9e{P>dc<7TD*Wjs&5q~iDr{QI(6 z_p9h(B6AC{uc-XpqU65;dFFi`be;z7^WvL!ehYdY^KS*l_us-<6Fc8VZ@!)e?cDPn zU@UVt_0xCJ^Z!NzmUkNw{Q~{}Hf87gup>3{L)6F*l+2HSORe9IoJGO=v8u&8VDom> zGk&6a#vSl6kGQ@EzO40A^mN&?pF#GgYW<(X-Wu@BDo4!wEA`pkq&sSAT@wA5`7_33yr6=${eqgTR+q z|BP70pWlXJ|E^m5uc7ntH(>hQ{vGE`YWW|K`@8bx9(7*+q3qp@`RE>{|4+vQqCyVP-s1#mN}& ztN6B5t$Q?bCcYj6Uso!bRZf6QUXOJGy;Vx)IG!Pu%<)cu-L3ZX1SilvUd6MzJ*x(M zeYSblT8%ZhzpZhC`A*h+aA?i7PJr=zybCk*N&IfBE?$>$gLM`L!ULO8Dlw-w=DEo7&6X1u{IgU92&U3oNHp1UaoQ2$N zZ~}~FB{v(Cd>rzU*NrN#@kR2wQRUSQik{Il;dPGEy^j*s7G7`ulY%8_H=W9->M zFXs2?G3vMT`!aRE9QRAnpZKgCrDlDe&Gf9l2KszvhtXf<%&t*#4~A{=|3uiAcZ9XT zPE_v*CqZwm!VK169>=*Fn5@I!fL1HJ{EcWmE`Ihgc8YrTu7iy99G>CjUGfw)zeZtK z&#y7yBC`Q9dJc{Qm*?(AwRQ}A;U(02#76imwZ92?LfLkaLpfJT$T5B|^;uf>eg)>D z;Ctr69{ z+suJ4HVd$M9iC5{f#vah+Nx%c&G>D{}bn}A=tAj|u{8}V&bcApT+)49OT zL7vW6c{&&0g3pJY^HhvaTqMQ|fJ=;ffL+ifPfvo43zdxt;9}z?vLHRD+Ef zWup#UY&3w?yX0wKC{Imba!H-zC1MhT?k~Fu5eYgDSoQu=_N`$2|RvJih&Xec`!y9CC1ide$5Q{$l7|2JBMR zYQt$Y6Xec(#XACE*@esgxxH+yHxk*7wCF=oc|GiIMZahB&dD|6!(vlbZK z+3+SS{(OM$FHNRQ#V^h5u3TJFP_s1YFF=8A(<@eHTN;_F)(?cGDz#dvxuaS?puuXT zRywy*^rto0nD$yBRlihgY2PwtvRlVKng2GU_5GaflO9*NA>D&=}}hI3;k#@z8p zWWU#(uhgc3F!%Wrk1x$_uh*MJ<^^uFijS+$){5Keb5SeQhzG*cUbW28#w8yyq&qNW z3dY;piZ)_qqlt>F*=V`ZW-JzX*ev<8&6FGVZ0`m7I^d~wM)1ygQ%lYZ2XnGuso zxH{2gQ!uTWdflIH`K1Og95+e+M^xZ!rEH?UIgNQS3k1eh95a=<=){X0qvIZq!X@liAT!Hg3jpiBvl6y2&v&m(OL=V_B}6 z5yN-6L^d;;8_Na@@2^*ihTBcFqf#5DV4}M6rAXaG)>`6dI(q^N-W1Ga)thP9_=9#} zOEYdu)`I98T)7?eJR9Z(RFi3VO>f2&Oe2g1Ok3H=W9*NAt;yI~vyHQ^NhqOTkP`YF?)~Z3Y&5HPIi<>@HYJ-oWkw3YD&ePec!${c_S1Xu-GW+>&G2KTLqBexkj2inic{9! z>?qe|kTQ$|wy5nYBi&PS->nX6klXcSy^5a9T->$|rn2IY>OdJ&FjLiuGn(F%ZIX#O zOeJ1$I&C|LD4!sElsB1FHrrl*AtdiUzQOdvJ>R>)z0efw8d z&iT=atNHPa_WYQ3T~liHM#u+jgsjvSn;xs)e0|oBR_l|V&)ZNiZp`p#W-XR&Ayw8k?S#2SO_&!fOqi+Og!zDeq+E2C?2Vnh nubaKEo4v1_y|0_SubaKEo4v1_)&JYCoBz|;+5Y_tl5~t>$k>?V