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();