From 6a80961b2b7c4a079018d14c2557e6807d8dadb4 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 23 Jun 2026 22:32:56 +0200 Subject: [PATCH] added audio and apps page in settings --- Components/StateLayer.qml | 3 +- Components/VerticalFadeListView.qml | 72 +++ Config/Config.qml | 2 + Config/Launcher.qml | 2 + Helpers/Strings.qml | 26 + Modules/Launcher/Items/AppItem.qml | 15 + Modules/Launcher/Services/Apps.qml | 3 +- .../SettingsNew/Common/AudioDeviceList.qml | 96 ++++ Modules/SettingsNew/Common/BlobPopup.qml | 127 +++++ Modules/SettingsNew/Common/ItemList.qml | 116 ++++ Modules/SettingsNew/Common/NavRow.qml | 70 +++ Modules/SettingsNew/Common/OverlayRow.qml | 68 +++ Modules/SettingsNew/Common/PageBase.qml | 1 + Modules/SettingsNew/Common/PopupRow.qml | 155 ++++-- Modules/SettingsNew/Common/SectionHeader.qml | 16 + Modules/SettingsNew/Common/SliderRow.qml | 81 +++ Modules/SettingsNew/Common/StackPage.qml | 2 +- .../SettingsNew/Common/WallpaperCropper.qml | 511 +++++++++--------- Modules/SettingsNew/PageCompRegistry.qml | 53 +- Modules/SettingsNew/Pages/Apps/AllApps.qml | 100 ++++ Modules/SettingsNew/Pages/Apps/AppInfo.qml | 172 ++++++ Modules/SettingsNew/Pages/AppsPage.qml | 177 ++++++ .../SettingsNew/Pages/Audio/AppVolumes.qml | 69 +++ Modules/SettingsNew/Pages/AudioPage.qml | 136 +++++ Modules/SettingsNew/Pages/Screenshot.qml | 3 +- Modules/SettingsNew/Pages/Wallpaper.qml | 99 +--- .../Pages/Wallpaper/WallpaperSelect.qml | 158 +++--- Plugins/ZShell/appdb.cpp | 377 +++++++------ Plugins/ZShell/appdb.hpp | 10 + 29 files changed, 2080 insertions(+), 640 deletions(-) create mode 100644 Components/VerticalFadeListView.qml create mode 100644 Helpers/Strings.qml create mode 100644 Modules/SettingsNew/Common/AudioDeviceList.qml create mode 100644 Modules/SettingsNew/Common/BlobPopup.qml create mode 100644 Modules/SettingsNew/Common/ItemList.qml create mode 100644 Modules/SettingsNew/Common/NavRow.qml create mode 100644 Modules/SettingsNew/Common/OverlayRow.qml create mode 100644 Modules/SettingsNew/Common/SectionHeader.qml create mode 100644 Modules/SettingsNew/Common/SliderRow.qml create mode 100644 Modules/SettingsNew/Pages/Apps/AllApps.qml create mode 100644 Modules/SettingsNew/Pages/Apps/AppInfo.qml create mode 100644 Modules/SettingsNew/Pages/AppsPage.qml create mode 100644 Modules/SettingsNew/Pages/Audio/AppVolumes.qml create mode 100644 Modules/SettingsNew/Pages/AudioPage.qml diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 613e810..d3eab5b 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -20,6 +20,7 @@ MouseArea { return (Math.sqrt(Math.max(d1, d2, d3, d4)) + (shapeMorph ? 24 : 0)) * 1.3; } property real endRadiusAtPress + property bool manualHoverOverride property bool manualPressOverride property real pressX: width / 2 property real pressY: height / 2 @@ -27,7 +28,7 @@ MouseArea { readonly property alias rect: base property bool shapeMorph property bool showHoverBackground: true - property real stateOpacity: containsMouse ? 0.08 : 0 + property real stateOpacity: containsMouse || manualHoverOverride ? 0.08 : 0 property alias topLeftRadius: base.topLeftRadius property alias topRightRadius: base.topRightRadius diff --git a/Components/VerticalFadeListView.qml b/Components/VerticalFadeListView.qml new file mode 100644 index 0000000..0c35590 --- /dev/null +++ b/Components/VerticalFadeListView.qml @@ -0,0 +1,72 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Effects + +CustomListView { + id: root + + property real bottomFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 + property real fadeAmount: 0.2 + property real topFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 + + function fadeShouldBeActive(isStart: bool): bool { + // When content is smaller than flickable size, hide fade when rebound starts + if (contentHeight + topMargin + bottomMargin < height && rebound.running && ((isStart ? verticalOvershoot > 0 : verticalOvershoot < 0))) + return false; + + if (isStart) + return visibleArea.yPosition > 0; + return visibleArea.yPosition + visibleArea.heightRatio < 1; + } + + flickableDirection: Flickable.VerticalFlick + layer.enabled: true + orientation: ListView.Vertical + + Behavior on bottomFadeOpacity { + Anim { + type: Anim.SlowEffects + } + } + layer.effect: Mask { + maskSource: mask + + Rectangle { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + color: Qt.rgba(0, 0, 0, root.topFadeOpacity) + position: 0 + } + + GradientStop { + color: Qt.rgba(0, 0, 0, 1) + position: root.fadeAmount + } + + GradientStop { + color: Qt.rgba(0, 0, 0, 1) + position: 1 - root.fadeAmount + } + + GradientStop { + color: Qt.rgba(0, 0, 0, root.bottomFadeOpacity) + position: 1 + } + } + } + } + Behavior on topFadeOpacity { + Anim { + type: Anim.SlowEffects + } + } +} diff --git a/Config/Config.qml b/Config/Config.qml index 44d07da..422d81b 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -245,6 +245,8 @@ Singleton { return { maxAppsShown: launcher.maxAppsShown, maxWallpapers: launcher.maxWallpapers, + hiddenApps: launcher.hiddenApps, + favoriteApps: launcher.favoriteApps, uwsm: launcher.uwsm, actionPrefix: launcher.actionPrefix, specialPrefix: launcher.specialPrefix, diff --git a/Config/Launcher.qml b/Config/Launcher.qml index 5ab58cc..07070e3 100644 --- a/Config/Launcher.qml +++ b/Config/Launcher.qml @@ -84,6 +84,8 @@ JsonObject { dangerous: false }, ] + property list favoriteApps: [] + property list hiddenApps: [] property int maxAppsShown: 10 property int maxWallpapers: 7 property Sizes sizes: Sizes { diff --git a/Helpers/Strings.qml b/Helpers/Strings.qml new file mode 100644 index 0000000..f1980b5 --- /dev/null +++ b/Helpers/Strings.qml @@ -0,0 +1,26 @@ +pragma Singleton + +import Quickshell + +Singleton { + property var _regexCache: ({}) + + function testRegexList(filterList: list, target: string): bool { + const regexChecker = /^\^.*\$$/; + for (const filter of filterList) { + if (regexChecker.test(filter)) { + let re = _regexCache[filter]; + if (!re) { + re = new RegExp(filter); + _regexCache[filter] = re; + } + if (re.test(target)) + return true; + } else { + if (filter === target) + return true; + } + } + return false; + } +} diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index b553649..0123c13 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -65,5 +65,20 @@ Item { width: root.width - icon.width - Appearance.rounding.normal * 2 } } + + Loader { + id: favoriteIcon + + active: root.modelData && Strings.testRegexList(Config.launcher.favoriteApps, root.modelData.id) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + asynchronous: true + + sourceComponent: MaterialIcon { + color: DynamicColors.palette.m3primary + fill: 1 + text: "favorite" + } + } } } diff --git a/Modules/Launcher/Services/Apps.qml b/Modules/Launcher/Services/Apps.qml index dbd10fd..d137223 100644 --- a/Modules/Launcher/Services/Apps.qml +++ b/Modules/Launcher/Services/Apps.qml @@ -74,7 +74,8 @@ Searcher { AppDb { id: appDb - entries: DesktopEntries.applications.values + entries: DesktopEntries.applications.values.filter(a => !Strings.testRegexList(Config.launcher.hiddenApps, a.id)) + favoriteApps: Config.launcher.favoriteApps path: `${Paths.state}/apps.sqlite` } } diff --git a/Modules/SettingsNew/Common/AudioDeviceList.qml b/Modules/SettingsNew/Common/AudioDeviceList.qml new file mode 100644 index 0000000..0c38960 --- /dev/null +++ b/Modules/SettingsNew/Common/AudioDeviceList.qml @@ -0,0 +1,96 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import qs.Components +import qs.Config + +ItemList { + id: root + + property int currentId: -1 + property string iconName: "speaker" + property var nodes: [] + + signal selected(node: PwNode) + + last: true + showList: true + + delegate: Item { + id: device + + readonly property bool active: device.modelData?.id === root.currentId + required property int index + required property PwNode modelData + + anchors.left: root.list.contentItem.left + anchors.right: root.list.contentItem.right + implicitHeight: deviceLayout.implicitHeight + deviceLayout.anchors.margins * 2 + + StateLayer { + bottomLeftRadius: device.index === root?.list.count - 1 ? Appearance.rounding.large : radius + bottomRightRadius: device.index === root?.list.count - 1 ? Appearance.rounding.large : radius + radius: Appearance.rounding.extraSmall + + onClicked: root.selected(device.modelData) + } + + RowLayout { + id: deviceLayout + + anchors.fill: parent + anchors.leftMargin: Appearance.padding.largeIncreased + anchors.margins: Appearance.padding.large + anchors.rightMargin: Appearance.padding.largeIncreased + spacing: Appearance.spacing.normal + + CustomRect { + color: device.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondaryContainer + implicitHeight: devIcon.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: implicitHeight + radius: Appearance.rounding.full + + MaterialIcon { + id: devIcon + + anchors.centerIn: parent + color: device.active ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSecondaryContainer + fill: device.active ? 1 : 0 + font.pointSize: Appearance.font.size.large + text: root.iconName + + Behavior on fill { + Anim { + } + } + } + } + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.smaller + text: device.modelData?.description || device.modelData?.name || qsTr("Unknown") + } + + MaterialIcon { + color: DynamicColors.palette.m3primary + font.pointSize: Appearance.font.size.large + opacity: device.active ? 1 : 0 + text: "check" + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + } + } + model: ScriptModel { + values: [...root.nodes].sort((a, b) => (a.description || a.name || "").localeCompare(b.description || b.name || "")) + } +} diff --git a/Modules/SettingsNew/Common/BlobPopup.qml b/Modules/SettingsNew/Common/BlobPopup.qml new file mode 100644 index 0000000..c4226b4 --- /dev/null +++ b/Modules/SettingsNew/Common/BlobPopup.qml @@ -0,0 +1,127 @@ +import QtQuick +import ZShell.Blobs +import qs.Components +import qs.Config + +Item { + id: root + + property real animDriver + property alias color: blobGroup.color + default required property Item content + property bool hoverOverride + readonly property alias hovered: btn.containsMouse + property alias icon: icon.text + property bool open + property int padding + property bool pressOverride + property int topMovement: Appearance.padding.large + + implicitHeight: btn.implicitHeight * 0.9 + implicitWidth: btn.implicitWidth * 0.9 + + Binding { + property: "opacity" + target: root.content + value: root.animDriver + } + + BlobGroup { + id: blobGroup + + color: DynamicColors.palette.m3surfaceContainerHighest + smoothing: Appearance.rounding.medium + + Behavior on color { + CAnim { + } + } + } + + BlobRect { + id: btnRect + + anchors.fill: parent + anchors.margins: (!(btn.pressed || root.pressOverride) && (btn.containsMouse || root.hoverOverride) ? -Appearance.padding.extraSmall : 0) + (root.open ? -Appearance.padding.extraSmall : 0) + group: blobGroup + radius: root.open ? Appearance.rounding.large : Appearance.rounding.medium + + Behavior on anchors.margins { + Anim { + } + } + Behavior on radius { + Anim { + type: Anim.DefaultEffects + } + } + } + + BlobRect { + id: rect + + anchors.right: parent.right + anchors.top: parent.top + deformScale: 0.00001 + group: blobGroup + implicitHeight: parent.height + implicitWidth: parent.width + radius: Appearance.rounding.large + + states: State { + name: "open" + when: root.open + + PropertyChanges { + rect.anchors.rightMargin: root.width - Appearance.spacing.small + rect.anchors.topMargin: -root.topMovement + rect.implicitHeight: root.content.implicitHeight + root.padding * 2 + rect.implicitWidth: root.content.implicitWidth + root.padding * 2 + root.animDriver: 1 + } + } + transitions: Transition { + Anim { + properties: "rightMargin,implicitWidth" + } + + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + properties: "topMargin,implicitHeight" + } + + Anim { + property: "animDriver" + type: Anim.DefaultEffects + } + } + + MouseArea { // MouseArea to catch inputs + anchors.fill: parent + children: [root.content] + clip: true + } + } + + MouseArea { + id: btn + + anchors.centerIn: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + implicitHeight: icon.implicitHeight + Appearance.padding.extraSmall * 2 + implicitWidth: implicitHeight + + onClicked: root.open = !root.open + + MaterialIcon { + id: icon + + anchors.centerIn: parent + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.medium + text: "view_apps" + } + } +} diff --git a/Modules/SettingsNew/Common/ItemList.qml b/Modules/SettingsNew/Common/ItemList.qml new file mode 100644 index 0000000..850f3aa --- /dev/null +++ b/Modules/SettingsNew/Common/ItemList.qml @@ -0,0 +1,116 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config + +ConnectedRect { + id: root + + property alias delegate: list.delegate + property int extraHeight + readonly property alias list: list + property alias model: list.model + property string placeholderIcon + property string placeholderText + property bool showList + + Layout.fillWidth: true + clip: true + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: (showList && list.count > 0 ? list.contentHeight : placeholder.implicitHeight + Appearance.padding.extraLarge * 2) + extraHeight + + Behavior on implicitHeight { + Anim { + } + } + + Loader { + id: placeholder + + active: opacity > 0 + anchors.centerIn: parent + opacity: root.showList && list.count > 0 ? 0 : 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.extraSmall + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + animate: true + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.large + text: root.placeholderIcon + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + animate: true + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.large + text: root.placeholderText + } + } + } + + ListView { + id: list + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + interactive: false + opacity: root.showList ? 1 : 0 + spacing: 0 + + add: Transition { + Anim { + from: 0 + property: "opacity" + to: 1 + type: Anim.DefaultEffects + } + } + displaced: Transition { + Anim { + property: "opacity" + to: 1 + type: Anim.DefaultEffects + } + + Anim { + property: "y" + } + } + move: Transition { + Anim { + property: "opacity" + to: 1 + type: Anim.DefaultEffects + } + + Anim { + property: "y" + } + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + remove: Transition { + Anim { + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + } + } +} diff --git a/Modules/SettingsNew/Common/NavRow.qml b/Modules/SettingsNew/Common/NavRow.qml new file mode 100644 index 0000000..e7a9cec --- /dev/null +++ b/Modules/SettingsNew/Common/NavRow.qml @@ -0,0 +1,70 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config + +ConnectedRect { + id: root + + property alias icon: icon.text + property alias label: label.text + property alias status: status.text + + signal clicked + + Layout.fillWidth: true + implicitHeight: navLayout.implicitHeight + navLayout.anchors.margins * 2 + + StateLayer { + onClicked: root.clicked() + } + + RowLayout { + id: navLayout + + anchors.fill: parent + anchors.leftMargin: Appearance.padding.largeIncreased + anchors.margins: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.largeIncreased + spacing: Appearance.spacing.small + + MaterialIcon { + id: icon + + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.medium + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + id: label + + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + } + + CustomText { + id: status + + Layout.fillWidth: true + animate: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + visible: text + } + } + + MaterialIcon { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.medium + text: "chevron_right" + } + } +} diff --git a/Modules/SettingsNew/Common/OverlayRow.qml b/Modules/SettingsNew/Common/OverlayRow.qml new file mode 100644 index 0000000..34c2f8d --- /dev/null +++ b/Modules/SettingsNew/Common/OverlayRow.qml @@ -0,0 +1,68 @@ +import QtQuick +import QtQuick.Layouts +import qs.Config +import qs.Components +import qs.Modules.SettingsNew + +ConnectedRect { + id: root + + property alias checked: switchButton.checked + property int horizontalPadding: Appearance.padding.largeIncreased + required property Component popup + property alias subtext: subtext.text + property alias text: text.text + property int verticalPadding: Appearance.padding.normal + + signal clicked(checked: bool) + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + verticalPadding * 2 + + Column { + id: layout + + anchors.left: parent.left + anchors.leftMargin: root.horizontalPadding + anchors.right: icon.left + anchors.rightMargin: Appearance.padding.normal + anchors.verticalCenter: parent.verticalCenter + + CustomText { + id: text + + font.pointSize: Appearance.font.size.smaller + } + + CustomText { + id: subtext + + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.small + wrapMode: Text.WordWrap + } + } + + MaterialIcon { + id: icon + + anchors.right: switchButton.left + anchors.rightMargin: Appearance.spacing.normal + anchors.verticalCenter: parent.verticalCenter + text: "open_in_new" + } + + StateLayer { + onClicked: PopupManager.requestOpen(root.popup) + } + + CustomSwitch { + id: switchButton + + anchors.right: parent.right + anchors.rightMargin: root.horizontalPadding + anchors.verticalCenter: parent.verticalCenter + + onClicked: root.clicked(checked) + } +} diff --git a/Modules/SettingsNew/Common/PageBase.qml b/Modules/SettingsNew/Common/PageBase.qml index 64a5b74..be5ec06 100644 --- a/Modules/SettingsNew/Common/PageBase.qml +++ b/Modules/SettingsNew/Common/PageBase.qml @@ -64,6 +64,7 @@ ColumnLayout { bottomMargin: Appearance.padding.extraLarge contentHeight: root.contentChild?.implicitHeight ?? 0 contentItem.children: [root.contentChild] + fadeAmount: 0.1 topMargin: Appearance.padding.large } } diff --git a/Modules/SettingsNew/Common/PopupRow.qml b/Modules/SettingsNew/Common/PopupRow.qml index 34c2f8d..542a5bb 100644 --- a/Modules/SettingsNew/Common/PopupRow.qml +++ b/Modules/SettingsNew/Common/PopupRow.qml @@ -1,68 +1,121 @@ import QtQuick import QtQuick.Layouts -import qs.Config +import Quickshell import qs.Components -import qs.Modules.SettingsNew +import qs.Config +import qs.Drawers ConnectedRect { id: root - property alias checked: switchButton.checked - property int horizontalPadding: Appearance.padding.largeIncreased - required property Component popup - property alias subtext: subtext.text - property alias text: text.text - property int verticalPadding: Appearance.padding.normal - - signal clicked(checked: bool) + default required property Item content + property alias icon: icon.text + property bool keepPopupAsChild + property alias label: label.text + readonly property alias popup: popup + property alias status: status.text Layout.fillWidth: true - implicitHeight: layout.implicitHeight + verticalPadding * 2 - - Column { - id: layout - - anchors.left: parent.left - anchors.leftMargin: root.horizontalPadding - anchors.right: icon.left - anchors.rightMargin: Appearance.padding.normal - anchors.verticalCenter: parent.verticalCenter - - CustomText { - id: text - - font.pointSize: Appearance.font.size.smaller - } - - CustomText { - id: subtext - - color: DynamicColors.palette.m3outline - font.pointSize: Appearance.font.size.small - wrapMode: Text.WordWrap - } - } - - MaterialIcon { - id: icon - - anchors.right: switchButton.left - anchors.rightMargin: Appearance.spacing.normal - anchors.verticalCenter: parent.verticalCenter - text: "open_in_new" - } + implicitHeight: navLayout.implicitHeight + navLayout.anchors.margins * 2 StateLayer { - onClicked: PopupManager.requestOpen(root.popup) + id: stateLayer + + manualHoverOverride: popup.hovered && !popup.open + + onClicked: popup.open = true } - CustomSwitch { - id: switchButton + RowLayout { + id: navLayout - anchors.right: parent.right - anchors.rightMargin: root.horizontalPadding - anchors.verticalCenter: parent.verticalCenter + anchors.fill: parent + anchors.leftMargin: Appearance.padding.largeIncreased + anchors.margins: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.largeIncreased + spacing: Appearance.spacing.small - onClicked: root.clicked(checked) + MaterialIcon { + id: icon + + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.medium + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + id: label + + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + } + + CustomText { + id: status + + Layout.fillWidth: true + animate: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + visible: text + } + } + + Item { + id: triggerArea + + implicitHeight: popup.implicitHeight + implicitWidth: popup.implicitWidth + + TransformWatcher { + id: tWatcher + + a: area.parent + b: triggerArea + } + + MouseArea { + id: area + + anchors.fill: parent + cursorShape: undefined + enabled: popup.open + hoverEnabled: true + parent: { + if (root.keepPopupAsChild) + return triggerArea; + + const win = QsWindow.window; + const contentWin = win as Windows; // If inside the drawer content window, put it inside the interaction wrapper so hover works + return contentWin ? contentWin.interactionWrapper : (win as QsWindow).contentItem; + } + z: popup.animDriver > 0 ? 1 : 0 + + onClicked: popup.open = false + + BlobPopup { + id: popup + + color: open || hovered || stateLayer.containsMouse ? DynamicColors.palette.m3secondaryContainer : DynamicColors.palette.m3surfaceContainerHighest + content: root.content + hoverOverride: stateLayer.containsMouse + padding: Appearance.padding.small + pressOverride: stateLayer.pressed + x: { + tWatcher.transform; + return triggerArea.mapToItem(area.parent, 0, 0).x; + } + y: { + tWatcher.transform; + return triggerArea.mapToItem(area.parent, 0, 0).y; + } + } + } + } } } diff --git a/Modules/SettingsNew/Common/SectionHeader.qml b/Modules/SettingsNew/Common/SectionHeader.qml new file mode 100644 index 0000000..ffaeb06 --- /dev/null +++ b/Modules/SettingsNew/Common/SectionHeader.qml @@ -0,0 +1,16 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config + +CustomText { + property bool first + + Layout.bottomMargin: Appearance.spacing.extraSmall + Layout.fillWidth: true + Layout.leftMargin: Appearance.padding.small + Layout.topMargin: first ? 0 : Appearance.spacing.large - ((parent as ColumnLayout).spacing ?? 0) + color: DynamicColors.palette.m3onSurfaceVariant + elide: Text.ElideRight + font.pointSize: Appearance.font.size.medium +} diff --git a/Modules/SettingsNew/Common/SliderRow.qml b/Modules/SettingsNew/Common/SliderRow.qml new file mode 100644 index 0000000..0ba872b --- /dev/null +++ b/Modules/SettingsNew/Common/SliderRow.qml @@ -0,0 +1,81 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config + +ConnectedRect { + id: root + + property alias icon: slider.insetIcon + property alias label: label.text + property real value + property alias valueLabel: valueLabel.text + + signal moved(value: real) + + Layout.fillWidth: true + implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins + rowLayout.anchors.topMargin + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.margins: Appearance.padding.largeIncreased + anchors.topMargin: Appearance.padding.large + spacing: Appearance.spacing.small + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + CustomText { + id: label + + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + } + + CustomText { + id: valueLabel + + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.small + } + } + + CustomMouseArea { + function onWheel(event: WheelEvent): void { + const step = Config.services.audioIncrement; + if (event.angleDelta.y > 0) + root.moved(Math.min(1, root.value + step)); + else if (event.angleDelta.y < 0) + root.moved(Math.max(0, root.value - step)); + } + + Layout.fillWidth: true + implicitHeight: Appearance.padding.larger * 3 + + CustomSlider { + id: slider + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + enabled: root.enabled + implicitHeight: parent.implicitHeight + radius: Appearance.rounding.small + value: root.value + + onInteraction: v => root.moved(v) + } + } + } + } +} diff --git a/Modules/SettingsNew/Common/StackPage.qml b/Modules/SettingsNew/Common/StackPage.qml index 867802a..b1e6c8c 100644 --- a/Modules/SettingsNew/Common/StackPage.qml +++ b/Modules/SettingsNew/Common/StackPage.qml @@ -104,7 +104,7 @@ StackView { id: logCat defaultLogLevel: LoggingCategory.Info - name: "caelestia.nexus" + name: "ZShell.settings" } Connections { diff --git a/Modules/SettingsNew/Common/WallpaperCropper.qml b/Modules/SettingsNew/Common/WallpaperCropper.qml index b0888b1..f9703e4 100644 --- a/Modules/SettingsNew/Common/WallpaperCropper.qml +++ b/Modules/SettingsNew/Common/WallpaperCropper.qml @@ -4,7 +4,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Hyprland -import ZShell.Internal +import ZShell.Components import qs.Config import qs.Components import qs.Helpers @@ -13,13 +13,71 @@ Item { id: wrapper property bool changesMade: false + readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null + property var screens: [] + property int selectedScreenIndex: 0 property bool shouldBeActive: true - signal requestCrop + function applyCrop(): void { + if (!cropRectLoader.item || !currentScreen) + return; + + const cropRect = cropRectLoader.item; + + const cropXPercent = (cropRect.x - cropRect.imageX) / scaledImg.paintedWidth; + const cropYPercent = (cropRect.y - cropRect.imageY) / scaledImg.paintedHeight; + const cropWidthPercent = cropRect.width / scaledImg.paintedWidth; + const cropHeightPercent = cropRect.height / scaledImg.paintedHeight; + + Wallpapers.setCrop(currentScreen.name, Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent), cropRect.zoom); + } + + function refreshScreens(): void { + screens = [...Quickshell.screens].sort((a, b) => a.x - b.x); + + if (screens.length === 0) { + selectedScreenIndex = 0; + return; + } + + selectedScreenIndex = Math.max(0, Math.min(selectedScreenIndex, screens.length - 1)); + } + + function selectScreen(index: int): void { + if (index < 0 || index >= screens.length || index === selectedScreenIndex) + return; + + selectedScreenIndex = index; + + if (cropRectLoader.item) + Qt.callLater(syncCropToScreen); + } + + function syncCropToScreen(): void { + if (cropRectLoader.item) + cropRectLoader.item.restoreFromData(); + } + + function zoomClipRect(zoom: real): void { + if (!cropRectLoader.item) + return; + + const cropRect = cropRectLoader.item; + + const centerX = cropRect.x + cropRect.width * 0.5; + const centerY = cropRect.y + cropRect.height * 0.5; + + cropRect.zoom = zoom; + + cropRect.x = centerX - cropRect.width * 0.5; + cropRect.y = centerY - cropRect.height * 0.5; + + cropRect.clampToBounds(); + } anchors.left: parent.left anchors.right: parent.right - implicitHeight: shouldBeActive ? 400 : 0 + implicitHeight: shouldBeActive ? 430 : 0 opacity: shouldBeActive ? 1 : 0 scale: shouldBeActive ? 1 : 0.8 visible: opacity > 0 @@ -37,6 +95,14 @@ Item { } } + Component.onCompleted: { + refreshScreens(); + Qt.callLater(syncCropToScreen); + } + onSelectedScreenIndexChanged: { + Qt.callLater(syncCropToScreen); + } + IconButton { anchors.margins: Appearance.padding.normal anchors.right: parent.right @@ -56,259 +122,216 @@ Item { } onClicked: { - wrapper.requestCrop(); + wrapper.applyCrop(); wrapper.changesMade = false; } } - RowLayout { - id: root + ButtonRow { + id: screenSelector - anchors.fill: parent - spacing: Appearance.spacing.normal + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.extraLarge * 2 + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.extraLarge * 2 + anchors.top: parent.top + implicitHeight: 34 + spacing: Appearance.spacing.small Repeater { - model: ScriptModel { - values: [...Quickshell.screens].sort((a, b) => { - return a.x - b.x; - }) + model: wrapper.screens + + delegate: TextButton { + required property int index + readonly property bool isCurrent: wrapper.selectedScreenIndex === index + required property var modelData + + fillWidth: true + inactiveColor: isCurrent ? DynamicColors.palette.m3primary : Qt.alpha(DynamicColors.palette.m3surfaceContainer, 0.7) + inactiveOnColor: isCurrent ? DynamicColors.palette.m3onPrimary : Qt.alpha(DynamicColors.palette.m3onSurface, 0.7) + isRound: true + shapeMorph: true + text: modelData.name + + onClicked: wrapper.selectScreen(index) + } + } + } + + RowLayout { + id: sliderLayout + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: 30 + + CustomSlider { + id: zoomSlider + + Layout.fillWidth: true + Layout.leftMargin: Appearance.padding.normal + Layout.preferredHeight: Appearance.padding.larger * 3 + Layout.rightMargin: Appearance.padding.normal + from: 1.0 + implicitHeight: Appearance.padding.larger * 3 + insetIcon: "crop" + to: 5.0 + value: cropRectLoader.item ? cropRectLoader.item.zoom : 1.0 + + onInteraction: value => { + wrapper.zoomClipRect(1 + (value * 4)); + wrapper.changesMade = true; + } + } + } + + Image { + id: scaledImg + + property var displayData + property real monitorScale: 1.0 + + anchors.bottom: sliderLayout.top + anchors.bottomMargin: Appearance.spacing.normal + anchors.left: parent.left + anchors.right: parent.right + anchors.top: screenSelector.bottom + anchors.topMargin: Appearance.spacing.normal + asynchronous: true + fillMode: Image.PreserveAspectFit + retainWhileLoading: true + source: Wallpapers.current + sourceSize.height: parent.height + sourceSize.width: parent.width + + onPaintedWidthChanged: { + if (paintedWidth > 0 && cropRectLoader.item) { + cropRectLoader.item.restoreFromData(); + } + } + onSourceChanged: { + if (cropRectLoader.item) { + cropRectLoader.item.restoreFromData(); + } + } + onStatusChanged: { + if (scaledImg.status == Image.Ready && cropRectLoader.item) { + cropRectLoader.item.restoreFromData(); + } + } + + Loader { + id: cropRectLoader + + active: scaledImg.paintedWidth > 0 && wrapper.currentScreen + + sourceComponent: Component { + CustomRect { + id: cropRect + + property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 + readonly property real baseHeight: baseWidth / aspectRatio + readonly property real baseWidth: { + let fittedHeight = scaledImg.paintedHeight; + let fittedWidth = fittedHeight * aspectRatio; + + if (fittedWidth > scaledImg.paintedWidth) { + fittedWidth = scaledImg.paintedWidth; + fittedHeight = fittedWidth / aspectRatio; + } + + return fittedWidth; + } + readonly property real imageX: (scaledImg.width - scaledImg.paintedWidth) / 2 + readonly property real imageY: (scaledImg.height - scaledImg.paintedHeight) / 2 + property real imgAspectRatio: scaledImg.paintedWidth / scaledImg.paintedHeight + property real zoom: 1.0 + + function centerInImage() { + x = imageX + (scaledImg.paintedWidth - width) / 2; + y = imageY + (scaledImg.paintedHeight - height) / 2; + } + + function clampToBounds() { + x = Math.max(imageX, Math.min(x, imageX + scaledImg.paintedWidth - width)); + y = Math.max(imageY, Math.min(y, imageY + scaledImg.paintedHeight - height)); + } + + function restoreFromData() { + if (!wrapper.currentScreen) + return; + + let data = Wallpapers.getCrop(wrapper.currentScreen.name); + + if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { + zoom = data.zoom > 0 ? data.zoom : 1.0; + x = imageX + (data.x * scaledImg.paintedWidth); + y = imageY + (data.y * scaledImg.paintedHeight); + + clampToBounds(); + } else { + zoom = 1.0; + centerInImage(); + } + } + + border.color: DynamicColors.palette.m3primary + border.width: 2 + height: baseHeight / zoom + opacity: 1 + width: baseWidth / zoom + + Behavior on opacity { + Anim { + } + } + + Component.onCompleted: { + restoreFromData(); + } + onHeightChanged: clampToBounds() + onWidthChanged: clampToBounds() + } + } + } + + MouseArea { + id: mouse + + function updateCrop(mouseX, mouseY) { + if (!cropRectLoader.item) + return; + + const cropRect = cropRectLoader.item; + + let nx = mouseX - cropRect.width * 0.5; + let ny = mouseY - cropRect.height * 0.5; + + nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + scaledImg.paintedWidth - cropRect.width)); + ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + scaledImg.paintedHeight - cropRect.height)); + + cropRect.x = nx; + cropRect.y = ny; } - Item { - id: delegate + anchors.fill: parent + hoverEnabled: true + preventStealing: true - required property ShellScreen modelData - - function applyCrop(): void { - if (!cropRectLoader.item) - return; - const cropRect = cropRectLoader.item; - - // We need to calculate the exact percentage coordinates that map perfectly - // to our C++ backend, regardless of current display scaling - const cropXPercent = (cropRect.x - cropRect.imageX) / scaledImg.paintedWidth; - const cropYPercent = (cropRect.y - cropRect.imageY) / scaledImg.paintedHeight; - const cropWidthPercent = cropRect.width / scaledImg.paintedWidth; - const cropHeightPercent = cropRect.height / scaledImg.paintedHeight; - - const finalRect = Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent); - - // We just pass the percentages directly to the backend - Wallpapers.setCrop(delegate.modelData.name, finalRect, cropRect.zoom); - } - - function zoomClipRect(zoom: real): void { - if (!cropRectLoader.item) - return; - const cropRect = cropRectLoader.item; - - let oldCenterX = cropRect.x + cropRect.width * 0.5; - let oldCenterY = cropRect.y + cropRect.height * 0.5; - - cropRect.zoom = zoom; - - cropRect.x = oldCenterX - cropRect.width * 0.5; - cropRect.y = oldCenterY - cropRect.height * 0.5; - - cropRect.clampToBounds(); - } - - Layout.fillHeight: true - Layout.fillWidth: true - - Connections { - function onRequestCrop(): void { - delegate.applyCrop(); - } - - target: wrapper - } - - RowLayout { - id: sliderLayout - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: 30 - - CustomSlider { - id: zoomSlider - - Layout.fillWidth: true - Layout.leftMargin: Appearance.padding.normal - Layout.preferredHeight: Appearance.padding.larger * 3 - Layout.rightMargin: Appearance.padding.normal - from: 1.0 - implicitHeight: Appearance.padding.larger * 3 - insetIcon: "crop" - to: 5.0 - value: cropRectLoader.item ? cropRectLoader.item.zoom : 1.0 - - onInteraction: value => { - delegate.zoomClipRect(1 + (value * 4)); - wrapper.changesMade = true; - } - } - } - - CachingImage { - id: scaledImg - - property var displayData - property real monitorScale: 1.0 - - anchors.bottom: sliderLayout.top - anchors.bottomMargin: Appearance.spacing.normal - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - asynchronous: true - fillMode: Image.PreserveAspectFit - retainWhileLoading: true - source: Wallpapers.current - sourceSize.height: parent.height - sourceSize.width: parent.width - - onPaintedWidthChanged: { - if (paintedWidth > 0 && cropRectLoader.item) { - cropRectLoader.item.restoreFromData(); - } - } - onSourceChanged: { - if (cropRectLoader.item) { - cropRectLoader.item.restoreFromData(); - } - } - onStatusChanged: { - if (scaledImg.status == Image.Ready && cropRectLoader.item) { - cropRectLoader.item.restoreFromData(); - } - } - - CustomText { - id: monitorId - - anchors.centerIn: parent - color: Qt.alpha(DynamicColors.palette.m3surface, 0.85) - font.pointSize: Appearance.font.size.large * 4 - style: Text.Outline - styleColor: DynamicColors.palette.m3onSurface - text: delegate.modelData.name - } - - Loader { - id: cropRectLoader - - active: scaledImg.paintedWidth > 0 - - sourceComponent: Component { - CustomRect { - id: cropRect - - property real aspectRatio: delegate.modelData.width / delegate.modelData.height - readonly property real baseHeight: baseWidth / aspectRatio - readonly property real baseWidth: { - let fittedHeight = scaledImg.paintedHeight; - let fittedWidth = fittedHeight * aspectRatio; - - if (fittedWidth > scaledImg.paintedWidth) { - fittedWidth = scaledImg.paintedWidth; - fittedHeight = fittedWidth / aspectRatio; - } - - return fittedWidth; - } - readonly property real imageX: (scaledImg.width - scaledImg.paintedWidth) / 2 - readonly property real imageY: (scaledImg.height - scaledImg.paintedHeight) / 2 - property real imgAspectRatio: scaledImg.paintedWidth / scaledImg.paintedHeight - property real zoom: 1.0 - - function centerInImage() { - x = imageX + (scaledImg.paintedWidth - width) / 2; - y = imageY + (scaledImg.paintedHeight - height) / 2; - } - - function clampToBounds() { - x = Math.max(imageX, Math.min(x, imageX + scaledImg.paintedWidth - width)); - - y = Math.max(imageY, Math.min(y, imageY + scaledImg.paintedHeight - height)); - } - - function restoreFromData() { - let data = Wallpapers.getCrop(delegate.modelData.name); - - if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { - zoom = data.zoom > 0 ? data.zoom : 1.0; - x = imageX + (data.x * scaledImg.paintedWidth); - y = imageY + (data.y * scaledImg.paintedHeight); - - clampToBounds(); - } else { - zoom = 1.0; - centerInImage(); - } - } - - border.color: DynamicColors.palette.m3primary - border.width: 2 - height: baseHeight / zoom - opacity: 1 - width: baseWidth / zoom - - Behavior on opacity { - Anim { - } - } - - Component.onCompleted: { - restoreFromData(); - } - onHeightChanged: clampToBounds() - onWidthChanged: clampToBounds() - } - } - } - - MouseArea { - id: mouse - - function updateCrop(mouseX, mouseY) { - if (!cropRectLoader.item) - return; - const cropRect = cropRectLoader.item; - - let nx = mouseX - cropRect.width * 0.5; - let ny = mouseY - cropRect.height * 0.5; - - nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + scaledImg.paintedWidth - cropRect.width)); - - ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + scaledImg.paintedHeight - cropRect.height)); - - cropRect.x = nx; - cropRect.y = ny; - } - - anchors.fill: parent - hoverEnabled: true - preventStealing: true - - onPositionChanged: mouse => { - if (pressed) { - updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; - } - } - onPressed: mouse => { - updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; - } - onReleased: { - wrapper.changesMade = true; - } - } + onPositionChanged: mouse => { + if (pressed) { + updateCrop(mouse.x, mouse.y); + wrapper.changesMade = true; } } + onPressed: mouse => { + updateCrop(mouse.x, mouse.y); + wrapper.changesMade = true; + } + onReleased: { + wrapper.changesMade = true; + } } } } diff --git a/Modules/SettingsNew/PageCompRegistry.qml b/Modules/SettingsNew/PageCompRegistry.qml index 0ab4db1..327870d 100644 --- a/Modules/SettingsNew/PageCompRegistry.qml +++ b/Modules/SettingsNew/PageCompRegistry.qml @@ -7,6 +7,8 @@ import qs.Config import qs.Modules.SettingsNew.Common import qs.Modules.SettingsNew.Pages import qs.Modules.SettingsNew.Pages.Wallpaper +import qs.Modules.SettingsNew.Pages.Audio +import qs.Modules.SettingsNew.Pages.Apps QtObject { id: root @@ -27,15 +29,62 @@ QtObject { } } }, - - // Screenshot Component { + // Screenshot StackPage { Component { Screenshot { } } } + }, + + // Connectivity + Component { + PlaceholderComp { + } + }, + Component { + PlaceholderComp { + } + }, + Component { + // Audio + StackPage { + Component { + AudioPage { + } + } + + Component { + AppVolumes { + } + } + } + }, + + // Shell + Component { + PlaceholderComp { + } + }, + Component { + StackPage { + Component { + AppsPage { + } + } + + Component { + AllApps { + } + } + + Component { + AppInfo { + } + } + } } ] readonly property Component placeholderComp: Component { diff --git a/Modules/SettingsNew/Pages/Apps/AllApps.qml b/Modules/SettingsNew/Pages/Apps/AllApps.qml new file mode 100644 index 0000000..4f1a9ed --- /dev/null +++ b/Modules/SettingsNew/Pages/Apps/AllApps.qml @@ -0,0 +1,100 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.Components +import qs.Config +import qs.Helpers +import qs.Modules.SettingsNew.Common + +PageBase { + id: root + + isSubPage: true + title: qsTr("All apps") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Appearance.spacing.extraSmall / 2 + width: root.cappedWidth + + Repeater { + id: list + + model: [...DesktopEntries.applications.values].sort((a, b) => a.name.localeCompare(b.name)) + + ConnectedRect { + id: appItem + + required property int index + required property DesktopEntry modelData + + Layout.fillWidth: true + first: index === 0 + implicitHeight: appRow.implicitHeight + appRow.anchors.margins * 2 + last: index === list.count - 1 + + StateLayer { + onClicked: { + root.sState.selectedApp = appItem.modelData; + root.sState.openSubPage(2); + } + } + + RowLayout { + id: appRow + + anchors.fill: parent + anchors.leftMargin: Appearance.padding.largeIncreased + anchors.margins: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.largeIncreased + spacing: Appearance.spacing.small + + IconImage { + asynchronous: true + implicitSize: Math.round(Appearance.font.size.large * 1.8) + source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: appItem.modelData.name + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: (appItem.modelData.comment || appItem.modelData.genericName) ?? "" + visible: text + } + } + + MaterialIcon { + color: DynamicColors.palette.m3primary + fill: 1 + font.pointSize: Appearance.font.size.small + text: "favorite" + visible: Strings.testRegexList(Config.launcher.favoriteApps, appItem.modelData.id) + } + + MaterialIcon { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.medium + text: "chevron_right" + } + } + } + } + } +} diff --git a/Modules/SettingsNew/Pages/Apps/AppInfo.qml b/Modules/SettingsNew/Pages/Apps/AppInfo.qml new file mode 100644 index 0000000..ba44059 --- /dev/null +++ b/Modules/SettingsNew/Pages/Apps/AppInfo.qml @@ -0,0 +1,172 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.Components +import qs.Config +import qs.Helpers +import qs.Modules.SettingsNew.Common + +PageBase { + id: root + + readonly property DesktopEntry app: sState.selectedApp + readonly property bool favoriteByRegex: app && matchedByRegex(Config.launcher.favoriteApps, app.id) + readonly property bool hiddenByRegex: app && matchedByRegex(Config.launcher.hiddenApps, app.id) + + function isRegexEntry(s: string): bool { + return /^\^.*\$$/.test(s); + } + + function matchedByRegex(filterList: list, id: string): bool { + return filterList.some(f => isRegexEntry(f) && new RegExp(f).test(id)); + } + + isSubPage: true + title: qsTr("App info") + + onAppChanged: { + // Auto close when app lost + if (!app) + sState.closeSubPage(); + } + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Appearance.spacing.extraSmall / 2 + width: root.cappedWidth + + // Header + RowLayout { + Layout.bottomMargin: Appearance.spacing.large + Layout.fillWidth: true + Layout.leftMargin: Appearance.padding.small + spacing: Appearance.spacing.large + + IconImage { + asynchronous: true + implicitSize: Math.round(Appearance.font.size.large * 3) + source: Quickshell.iconPath(root.app?.icon, "image-missing") + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.extraSmall / 2 + + CustomText { + Layout.fillWidth: true + font.pointSize: Appearance.font.size.medium + text: root.app?.name ?? "" + wrapMode: Text.WordWrap + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.small + text: (root.app?.comment || root.app?.genericName) ?? "" + visible: text + wrapMode: Text.WordWrap + } + } + } + + // Launcher + SectionHeader { + first: true + text: qsTr("Launcher") + } + + ToggleRow { + checked: root.app && Strings.testRegexList(Config.launcher.favoriteApps, root.app.id) + enabled: !root.favoriteByRegex + first: true + subtext: root.favoriteByRegex ? qsTr("Matched by a regex in favoriteApps — edit the config file to change") : qsTr("Pin to the top of the launcher") + text: qsTr("Favorite") + + onToggled: { + const apps = Config.launcher.favoriteApps; + Config.launcher.favoriteApps = checked ? [...apps, root.app.id] : apps.filter(a => a !== root.app.id); + } + } + + ToggleRow { + checked: root.app && Strings.testRegexList(Config.launcher.hiddenApps, root.app.id) + enabled: !root.hiddenByRegex + last: true + subtext: root.hiddenByRegex ? qsTr("Matched by a regex in hiddenApps — edit the config file to change") : qsTr("Hide from the launcher") + text: qsTr("Hidden") + + onToggled: { + const apps = Config.launcher.hiddenApps; + Config.launcher.hiddenApps = checked ? [...apps, root.app.id] : apps.filter(a => a !== root.app.id); + } + } + + // Details + SectionHeader { + text: qsTr("Details") + } + + WrapInfoRow { + id: appId + + first: true + label: qsTr("App ID") + labelComp.Layout.preferredWidth: Math.max(labelComp.implicitWidth, command.labelComp.implicitWidth) + value: root.app?.id ?? "" + } + + WrapInfoRow { + id: command + + label: qsTr("Command") + labelComp.Layout.preferredWidth: Math.max(labelComp.implicitWidth, appId.labelComp.implicitWidth) + last: true + value: (root.app?.command ?? []).join(" ") + } + } + + component WrapInfoRow: ConnectedRect { + id: row + + property alias label: label.text + readonly property alias labelComp: label + property alias value: value.text + + Layout.fillWidth: true + implicitHeight: rowLayout.implicitHeight + rowLayout.anchors.margins * 2 + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.leftMargin: Appearance.padding.largeIncreased + anchors.margins: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.largeIncreased + spacing: Appearance.spacing.small + + CustomText { + id: label + + Layout.alignment: Qt.AlignTop + font.pointSize: Appearance.font.size.small + } + + Item { + Layout.fillWidth: true + } + + CustomText { + id: value + + Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + 1 // Whyyyyyyyyy + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + } + } +} diff --git a/Modules/SettingsNew/Pages/AppsPage.qml b/Modules/SettingsNew/Pages/AppsPage.qml new file mode 100644 index 0000000..d33fc1a --- /dev/null +++ b/Modules/SettingsNew/Pages/AppsPage.qml @@ -0,0 +1,177 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import ZShell +import qs.Helpers +import qs.Components +import qs.Config +import qs.Modules.SettingsNew.Common + +PageBase { + id: root + + title: qsTr("Apps") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Appearance.spacing.extraSmall / 2 + width: root.cappedWidth + + // Default applications + SectionHeader { + first: true + text: qsTr("Default applications") + } + + DefaultRow { + first: true + icon: "terminal" + label: qsTr("Terminal") + status: Config.general.apps.terminal.join(" ") + + onSelected: app => Config.general.apps.terminal = app.command + } + + DefaultRow { + icon: "volume_up" + label: qsTr("Audio") + status: Config.general.apps.audio.join(" ") + + onSelected: app => Config.general.apps.audio = app.command + } + + DefaultRow { + icon: "play_circle" + label: qsTr("Media playback") + status: Config.general.apps.playback.join(" ") + + onSelected: app => Config.general.apps.playback = app.command + } + + DefaultRow { + icon: "folder" + label: qsTr("File manager") + last: true + status: Config.general.apps.explorer.join(" ") + + onSelected: app => Config.general.apps.explorer = app.command + } + + // Library + SectionHeader { + text: qsTr("Library") + } + + NavRow { + first: true + icon: "apps" + label: qsTr("All apps") + last: true + status: qsTr("Browse installed apps, set favorites and hidden") + + onClicked: root.sState.openSubPage(1) + } + } + + component DefaultRow: PopupRow { + id: row + + readonly property int popupHeight: root.flickable.height - y + root.flickable.contentY - Appearance.padding.large - Appearance.padding.extraLarge + + signal selected(app: DesktopEntry) + + keepPopupAsChild: { + if (root.sState.animatingContainer || root.opacity < 1) + return true; + + let p = root.parent; + while (p && p.objectName !== "PageContainer") + p = p.parent; + return p?.opacity < 1; + } + popup.topMovement: Math.max(0 - popupHeight, Appearance.padding.large) + + Loader { + active: row.popup.animDriver > 0 + anchors.centerIn: parent + + sourceComponent: VerticalFadeListView { + id: list + + fadeAmount: 0.05 + implicitHeight: ZUtils.clamp(row.popupHeight, 200, 800) + implicitWidth: 300 + model: { + const apps = [...DesktopEntries.applications.values]; + const favorited = new Set(apps.filter(a => Strings.testRegexList(Config.launcher.favoriteApps, a.id))); + return apps.sort((a, b) => (favorited.has(b) - favorited.has(a)) || a.name.localeCompare(b.name)); + } + + delegate: StateLayer { + id: appItem + + required property int index + required property DesktopEntry modelData + + anchors.fill: undefined + anchors.left: list.contentItem.left + anchors.right: list.contentItem.right + implicitHeight: itemLayout.implicitHeight + itemLayout.anchors.margins * 2 + radius: Appearance.rounding.small + + onClicked: { + row.popup.open = false; + row.selected(modelData); + } + + RowLayout { + id: itemLayout + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small + + IconImage { + asynchronous: true + implicitSize: Math.round(Appearance.font.size.large * 1.8) + source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: appItem.modelData.name + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: (appItem.modelData.comment || appItem.modelData.genericName) ?? "" + visible: text + } + } + + MaterialIcon { + color: DynamicColors.palette.m3primary + fill: 1 + font.pointSize: Appearance.font.size.small + text: "favorite" + visible: Strings.testRegexList(Config.launcher.favoriteApps, appItem.modelData.id) + } + } + } + } + } + } +} diff --git a/Modules/SettingsNew/Pages/Audio/AppVolumes.qml b/Modules/SettingsNew/Pages/Audio/AppVolumes.qml new file mode 100644 index 0000000..ab19def --- /dev/null +++ b/Modules/SettingsNew/Pages/Audio/AppVolumes.qml @@ -0,0 +1,69 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import qs.Modules.SettingsNew.Common +import qs.Helpers +import qs.Components +import qs.Config +import qs.Daemons + +PageBase { + id: root + + isSubPage: true + title: qsTr("App volumes") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Appearance.spacing.extraSmall / 2 + width: root.cappedWidth + + CustomText { + Layout.bottomMargin: Appearance.spacing.small + Layout.fillWidth: true + Layout.leftMargin: Appearance.padding.small + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.small + text: qsTr("Adjust the volume of individual apps currently playing audio.") + wrapMode: Text.WordWrap + } + + ItemList { + id: streamList + + color: list.count === 0 ? DynamicColors.tPalette.m3surfaceContainer : "transparent" + first: true + last: true + list.spacing: Appearance.spacing.extraSmall / 2 + placeholderIcon: "music_off" + placeholderText: qsTr("No apps playing audio") + showList: true + + delegate: SliderRow { + id: stream + + required property int index + required property PwNode modelData + + anchors.left: streamList.list.contentItem.left + anchors.right: streamList.list.contentItem.right + enabled: !stream.modelData?.audio?.muted + first: index === 0 + icon: Icons.getVolumeIcon(stream.modelData?.audio?.volume ?? 0, stream.modelData?.audio?.muted ?? false) + label: Audio.getStreamName(stream.modelData) + last: index === streamList.list.count - 1 + value: stream.modelData?.audio?.volume ?? 0 + valueLabel: Math.round(value * 100) + "%" + + onMoved: v => Audio.setStreamVolume(stream.modelData, v) + } + model: ScriptModel { + values: [...Audio.streams] + } + } + } +} diff --git a/Modules/SettingsNew/Pages/AudioPage.qml b/Modules/SettingsNew/Pages/AudioPage.qml new file mode 100644 index 0000000..43cb71c --- /dev/null +++ b/Modules/SettingsNew/Pages/AudioPage.qml @@ -0,0 +1,136 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Daemons +import qs.Config +import qs.Components +import qs.Modules.SettingsNew.Common +import qs.Helpers + +PageBase { + id: root + + title: qsTr("Audio") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Appearance.spacing.extraSmall / 2 + width: root.cappedWidth + + // Output + SliderRow { + enabled: !Audio.muted + first: true + icon: Icons.getVolumeIcon(Audio.volume, Audio.muted) + label: qsTr("Output") + value: Audio.volume + valueLabel: Math.round(value * 100) + "%" + + onMoved: v => Audio.setVolume(v) + } + + ToggleRow { + checked: Audio.muted + text: qsTr("Muted") + + onToggled: Audio.setStreamMuted(Audio.sink, checked) + } + + AudioDeviceList { + currentId: Audio.sink?.id ?? -1 + iconName: "speaker" + nodes: Audio.sinks + placeholderIcon: "speaker" + placeholderText: qsTr("No output devices") + + onSelected: node => Audio.setAudioSink(node) + } + + // Input + SliderRow { + Layout.topMargin: Appearance.spacing.large - parent.spacing + enabled: !Audio.sourceMuted + first: true + icon: Icons.getMicVolumeIcon(Audio.sourceVolume, Audio.sourceMuted) + label: qsTr("Input") + value: Audio.sourceVolume + valueLabel: Math.round(value * 100) + "%" + + onMoved: v => Audio.setSourceVolume(v) + } + + ToggleRow { + checked: Audio.sourceMuted + text: qsTr("Muted") + + onToggled: Audio.setStreamMuted(Audio.source, checked) + } + + AudioDeviceList { + currentId: Audio.source?.id ?? -1 + iconName: "mic" + nodes: Audio.sources + placeholderIcon: "mic_off" + placeholderText: qsTr("No input devices") + + onSelected: node => Audio.setAudioSource(node) + } + + // Per-app volumes + ConnectedRect { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.large - parent.spacing + first: true + implicitHeight: appLayout.implicitHeight + appLayout.anchors.margins * 2 + last: true + + StateLayer { + onClicked: root.sState.openSubPage(1) + } + + RowLayout { + id: appLayout + + anchors.fill: parent + anchors.leftMargin: Appearance.padding.largeIncreased + anchors.margins: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.largeIncreased + spacing: Appearance.spacing.small + + MaterialIcon { + font.pointSize: Appearance.font.size.medium + text: "tune" + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: qsTr("App volumes") + } + + CustomText { + Layout.fillWidth: true + animate: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Appearance.font.size.small + text: Audio.streams.length === 0 ? qsTr("No apps playing audio") : Audio.streams.length === 1 ? qsTr("1 app playing audio") : qsTr("%1 apps playing audio").arg(Audio.streams.length) + } + } + + MaterialIcon { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.medium + text: "chevron_right" + } + } + } + } +} diff --git a/Modules/SettingsNew/Pages/Screenshot.qml b/Modules/SettingsNew/Pages/Screenshot.qml index a4c3cb3..cbc9f42 100644 --- a/Modules/SettingsNew/Pages/Screenshot.qml +++ b/Modules/SettingsNew/Pages/Screenshot.qml @@ -68,6 +68,7 @@ PageBase { Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" && Config.screenshot.rounding from: 0 + last: true stepSize: 1 text: qsTr("Corner radius") to: 50 @@ -80,9 +81,9 @@ PageBase { } ToggleRow { - Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing checked: Config.screenshot.shadow enabled: Config.screenshot.enable_pp && Config.screenshot.mode === "manual" + first: true text: qsTr("Enable shadow") onToggled: Config.screenshot.shadow = checked diff --git a/Modules/SettingsNew/Pages/Wallpaper.qml b/Modules/SettingsNew/Pages/Wallpaper.qml index 4edf637..f576a85 100644 --- a/Modules/SettingsNew/Pages/Wallpaper.qml +++ b/Modules/SettingsNew/Pages/Wallpaper.qml @@ -20,105 +20,18 @@ PageBase { spacing: Appearance.spacing.large width: root.cappedWidth - CustomClippingRect { + Item { id: wallWrapper Layout.alignment: Qt.AlignHCenter - color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: { - const screen = root.sState.screen; - const cWidth = root.cappedWidth; - return Math.min(Math.round(cWidth * 0.4), cWidth / screen.width * screen.height); - } + implicitHeight: cropper.height implicitWidth: { const screen = root.sState.screen; return implicitHeight / screen.height * screen.width; } - radius: Appearance.rounding.large - Loader { - active: opacity > 0 - anchors.centerIn: parent - opacity: Config.background.enabled ? 0 : 1 - - Behavior on opacity { - Anim { - type: Anim.SlowEffects - } - } - sourceComponent: ColumnLayout { - spacing: Appearance.spacing.extraSmall - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3onSurfaceVariant - text: "hide_image" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3onSurfaceVariant - text: qsTr("Wallpaper disabled") - } - } - } - - Item { - anchors.fill: parent - opacity: Config.background.enabled ? 1 : 0 - - Behavior on opacity { - Anim { - type: Anim.SlowEffects - } - } - - Loader { - id: wallIndicatorLoader - - active: opacity > 0 - anchors.fill: parent - opacity: 0 - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - sourceComponent: CustomRect { - color: DynamicColors.palette.m3primaryContainer - radius: Appearance.rounding.normal - } - } - - Timer { - id: wallLoadDebounceTimer - - interval: 100 - - onTriggered: { - if (wallImg.status !== Image.Ready) - wallIndicatorLoader.opacity = 1; - } - } - - FadeImage { - id: wallImg - - anchors.fill: parent - fadeInAnim: Anim.SlowEffects - fadeOutAnim: Anim.DefaultEffects - preventInit: wallIndicatorLoader.opacity > 0 - source: Wallpapers.current - - onSourceChanged: wallLoadDebounceTimer.restart() - onStatusChanged: { - if (status === Image.Ready) { - wallLoadDebounceTimer.stop(); - wallIndicatorLoader.opacity = 0; - } - } - } + WallpaperCropper { + id: cropper } } @@ -162,7 +75,7 @@ PageBase { onToggled: DynamicColors.setMode(checked ? "dark" : "light") } - PopupRow { + OverlayRow { checked: Config.general.color.scheduleDark first: true subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(Config.general.color.scheduleDarkStart).arg(Config.general.color.scheduleDarkEnd) @@ -189,7 +102,7 @@ PageBase { } } - PopupRow { + OverlayRow { Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing checked: Config.general.color.scheduleHyprsunset last: true diff --git a/Modules/SettingsNew/Pages/Wallpaper/WallpaperSelect.qml b/Modules/SettingsNew/Pages/Wallpaper/WallpaperSelect.qml index 2578303..53470a4 100644 --- a/Modules/SettingsNew/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/SettingsNew/Pages/Wallpaper/WallpaperSelect.qml @@ -15,118 +15,102 @@ PageBase { isSubPage: true title: qsTr("Wallpapers") - Item { + ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - implicitHeight: childrenRect.height + spacing: Appearance.spacing.small + width: root.cappedWidth - WallpaperCropper { - id: cropper + CustomText { + Layout.topMargin: Appearance.spacing.large + font.pointSize: Appearance.font.size.large + text: qsTr("Wallpapers") } - ColumnLayout { - anchors.horizontalCenter: parent.horizontalCenter - anchors.margins: Appearance.spacing.normal - anchors.top: cropper.bottom - spacing: Appearance.spacing.small - width: root.cappedWidth + GridLayout { + Layout.fillWidth: true + columnSpacing: Appearance.spacing.extraSmall + columns: 4 + rowSpacing: Appearance.spacing.extraSmall + visible: localWalls.count > 0 - CustomText { - Layout.topMargin: Appearance.spacing.large - font.pointSize: Appearance.font.size.large - text: qsTr("Wallpapers") - } + Repeater { + id: localWalls - GridLayout { - Layout.fillWidth: true - columnSpacing: Appearance.spacing.extraSmall - columns: 4 - rowSpacing: Appearance.spacing.extraSmall - visible: localWalls.count > 0 + model: { + const walls = Wallpapers.list; + var baseDir = Paths.wallsdir; + const categories = {}; + const list = []; + for (const w of walls) { + var parentDir = w.parentDir; + if (!parentDir.endsWith("/")) + parentDir = parentDir + "/"; - Repeater { - id: localWalls + if (!baseDir.endsWith("/")) + baseDir = baseDir + "/"; - model: { - const walls = Wallpapers.list; - var baseDir = Paths.wallsdir; - const categories = {}; - const list = []; - for (const w of walls) { - var parentDir = w.parentDir; - if (!parentDir.endsWith("/")) - parentDir = parentDir + "/"; - - if (!baseDir.endsWith("/")) - baseDir = baseDir + "/"; - - if (parentDir !== baseDir) { - const category = Wallpapers.getCategoryFor(w); - if (category && (!(category in categories) || categories[category].name.localeCompare(w.name) > 0)) - categories[category] = w; - } else { - list.push(w); - } + if (parentDir !== baseDir) { + const category = Wallpapers.getCategoryFor(w); + if (category && (!(category in categories) || categories[category].name.localeCompare(w.name) > 0)) + categories[category] = w; + } else { + list.push(w); } - list.push(...Object.values(categories)); - list.sort((a, b) => ((a.parentDir === baseDir) - (b.parentDir === baseDir)) || a.name.localeCompare(b.name)); - while (list.length < 4) - list.push(null); - - return list; } + list.push(...Object.values(categories)); + list.sort((a, b) => ((a.parentDir === baseDir) - (b.parentDir === baseDir)) || a.name.localeCompare(b.name)); + while (list.length < 4) + list.push(null); - WallItem { - required property FileSystemEntry modelData + return list; + } - enabled: modelData + WallItem { + required property FileSystemEntry modelData - // Empty placeholders for sizing - opacity: modelData ? 1 : 0 - source: String(modelData?.path ?? "") + enabled: modelData - onClicked: { - if (modelData.parentDir !== Paths.wallsdir) { - root.sState.selectedWallpaperCategory = Wallpapers.getCategoryFor(modelData); - root.sState.openSubPage(2); // Category page - } else { - Wallpapers.setWallpaper(modelData.path); - root.sState.closeSubPage(); - } - } + // Empty placeholders for sizing + opacity: modelData ? 1 : 0 + source: String(modelData?.path ?? "") + + onClicked: { + Wallpapers.setWallpaper(modelData.path); + root.sState.closeSubPage(); } } } + } - Loader { - Layout.fillWidth: true - active: localWalls.count === 0 - asynchronous: true - visible: active + Loader { + Layout.fillWidth: true + active: localWalls.count === 0 + asynchronous: true + visible: active - sourceComponent: CustomRect { - color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: noWallsLayout.implicitHeight + Appearance.padding.extraLarge * 3 - radius: Appearance.rounding.large + sourceComponent: CustomRect { + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: noWallsLayout.implicitHeight + Appearance.padding.extraLarge * 3 + radius: Appearance.rounding.large - ColumnLayout { - id: noWallsLayout + ColumnLayout { + id: noWallsLayout - anchors.centerIn: parent - spacing: Appearance.spacing.extraSmall + anchors.centerIn: parent + spacing: Appearance.spacing.extraSmall - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3outline - text: "hide_image" - } + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3outline + text: "hide_image" + } - CustomText { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3outline - text: qsTr("No local wallpapers found") - } + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3outline + text: qsTr("No local wallpapers found") } } } diff --git a/Plugins/ZShell/appdb.cpp b/Plugins/ZShell/appdb.cpp index 8614df3..c66ac59 100644 --- a/Plugins/ZShell/appdb.cpp +++ b/Plugins/ZShell/appdb.cpp @@ -1,265 +1,324 @@ #include "appdb.hpp" +#include #include #include #include +Q_LOGGING_CATEGORY(lcAppDb, "ZShell.appdb", QtInfoMsg) + namespace ZShell { AppEntry::AppEntry(QObject* entry, unsigned int frequency, QObject* parent) - : QObject(parent) - , m_entry(entry) - , m_frequency(frequency) { - const auto mo = m_entry->metaObject(); - const auto tmo = metaObject(); + : QObject(parent) + , m_entry(entry) + , m_frequency(frequency) { + const auto mo = m_entry->metaObject(); + const auto tmo = &AppEntry::staticMetaObject; - for (const auto& prop : - { "name", "comment", "execString", "startupClass", "genericName", "categories", "keywords" }) { - const auto metaProp = mo->property(mo->indexOfProperty(prop)); - const auto thisMetaProp = tmo->property(tmo->indexOfProperty(prop)); - QObject::connect(m_entry, metaProp.notifySignal(), this, thisMetaProp.notifySignal()); - } + for (const auto& prop : + { "name", "comment", "execString", "startupClass", "genericName", "categories", "keywords" }) { + const auto metaProp = mo->property(mo->indexOfProperty(prop)); + const auto thisMetaProp = tmo->property(tmo->indexOfProperty(prop)); + QObject::connect(m_entry, metaProp.notifySignal(), this, thisMetaProp.notifySignal()); + } - QObject::connect(m_entry, &QObject::destroyed, this, [this]() { - m_entry = nullptr; - deleteLater(); - }); + QObject::connect(m_entry, &QObject::destroyed, this, [this]() { + m_entry = nullptr; + deleteLater(); + }); } QObject* AppEntry::entry() const { - return m_entry; + return m_entry; } quint32 AppEntry::frequency() const { - return m_frequency; + return m_frequency; } void AppEntry::setFrequency(unsigned int frequency) { - if (m_frequency != frequency) { - m_frequency = frequency; - emit frequencyChanged(); - } + if (m_frequency != frequency) { + m_frequency = frequency; + emit frequencyChanged(); + } } void AppEntry::incrementFrequency() { - m_frequency++; - emit frequencyChanged(); + m_frequency++; + emit frequencyChanged(); } QString AppEntry::id() const { - if (!m_entry) { - return ""; - } - return m_entry->property("id").toString(); + if (!m_entry) { + return ""; + } + return m_entry->property("id").toString(); } QString AppEntry::name() const { - if (!m_entry) { - return ""; - } - return m_entry->property("name").toString(); + if (!m_entry) { + return ""; + } + return m_entry->property("name").toString(); } QString AppEntry::comment() const { - if (!m_entry) { - return ""; - } - return m_entry->property("comment").toString(); + if (!m_entry) { + return ""; + } + return m_entry->property("comment").toString(); } QString AppEntry::execString() const { - if (!m_entry) { - return ""; - } - return m_entry->property("execString").toString(); + if (!m_entry) { + return ""; + } + return m_entry->property("execString").toString(); } QString AppEntry::startupClass() const { - if (!m_entry) { - return ""; - } - return m_entry->property("startupClass").toString(); + if (!m_entry) { + return ""; + } + return m_entry->property("startupClass").toString(); } QString AppEntry::genericName() const { - if (!m_entry) { - return ""; - } - return m_entry->property("genericName").toString(); + if (!m_entry) { + return ""; + } + return m_entry->property("genericName").toString(); } QString AppEntry::categories() const { - if (!m_entry) { - return ""; - } - return m_entry->property("categories").toStringList().join(" "); + if (!m_entry) { + return ""; + } + return m_entry->property("categories").toStringList().join(" "); } QString AppEntry::keywords() const { - if (!m_entry) { - return ""; - } - return m_entry->property("keywords").toStringList().join(" "); + if (!m_entry) { + return ""; + } + return m_entry->property("keywords").toStringList().join(" "); } AppDb::AppDb(QObject* parent) - : QObject(parent) - , m_timer(new QTimer(this)) - , m_uuid(QUuid::createUuid().toString()) { - m_timer->setSingleShot(true); - m_timer->setInterval(300); - QObject::connect(m_timer, &QTimer::timeout, this, &AppDb::updateApps); + : QObject(parent) + , m_timer(new QTimer(this)) + , m_uuid(QUuid::createUuid().toString()) { + m_timer->setSingleShot(true); + m_timer->setInterval(300); + QObject::connect(m_timer, &QTimer::timeout, this, &AppDb::updateApps); - auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid); - db.setDatabaseName(":memory:"); - db.open(); + auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid); + db.setDatabaseName(":memory:"); + db.open(); - QSqlQuery query(db); - query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)"); + QSqlQuery query(db); + query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)"); } QString AppDb::uuid() const { - return m_uuid; + return m_uuid; } QString AppDb::path() const { - return m_path; + return m_path; } void AppDb::setPath(const QString& path) { - auto newPath = path.isEmpty() ? ":memory:" : path; + auto newPath = path.isEmpty() ? ":memory:" : path; - if (m_path == newPath) { - return; - } + if (m_path == newPath) { + return; + } - m_path = newPath; - emit pathChanged(); + m_path = newPath; + emit pathChanged(); - auto db = QSqlDatabase::database(m_uuid, false); - db.close(); - db.setDatabaseName(newPath); - db.open(); + auto db = QSqlDatabase::database(m_uuid, false); + db.close(); + db.setDatabaseName(newPath); + db.open(); - QSqlQuery query(db); - query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)"); + QSqlQuery query(db); + query.exec("CREATE TABLE IF NOT EXISTS frequencies (id TEXT PRIMARY KEY, frequency INTEGER)"); - updateAppFrequencies(); + updateAppFrequencies(); } QObjectList AppDb::entries() const { - return m_entries; + return m_entries; } void AppDb::setEntries(const QObjectList& entries) { - if (m_entries == entries) { - return; - } + if (m_entries == entries) { + return; + } - m_entries = entries; - emit entriesChanged(); + m_entries = entries; + emit entriesChanged(); - m_timer->start(); + m_timer->start(); +} + +QStringList AppDb::favoriteApps() const { + return m_favoriteApps; +} + +void AppDb::setFavoriteApps(const QStringList& favApps) { + if (m_favoriteApps == favApps) { + return; + } + + m_favoriteApps = favApps; + emit favoriteAppsChanged(); + m_favoriteAppsRegex.clear(); + m_favoriteAppsRegex.reserve(m_favoriteApps.size()); + for (const QString& item : std::as_const(m_favoriteApps)) { + const QRegularExpression re(regexifyString(item)); + if (re.isValid()) { + m_favoriteAppsRegex << re; + } else { + qCWarning(lcAppDb) << "setFavoriteApps: regular expression is not valid:" << re.pattern(); + } + } + + emit appsChanged(); +} + +QString AppDb::regexifyString(const QString& original) const { + if (original.startsWith('^') && original.endsWith('$')) + return original; + + const QString escaped = QRegularExpression::escape(original); + return QStringLiteral("^%1$").arg(escaped); } QQmlListProperty AppDb::apps() { - return QQmlListProperty(this, &getSortedApps()); + return QQmlListProperty(this, &getSortedApps()); } void AppDb::incrementFrequency(const QString& id) { - auto db = QSqlDatabase::database(m_uuid); - QSqlQuery query(db); + auto db = QSqlDatabase::database(m_uuid); + QSqlQuery query(db); - query.prepare("INSERT INTO frequencies (id, frequency) " - "VALUES (:id, 1) " - "ON CONFLICT (id) DO UPDATE SET frequency = frequency + 1"); - query.bindValue(":id", id); - query.exec(); + query.prepare("INSERT INTO frequencies (id, frequency) " + "VALUES (:id, 1) " + "ON CONFLICT (id) DO UPDATE SET frequency = frequency + 1"); + query.bindValue(":id", id); + query.exec(); - auto* app = m_apps.value(id); - if (app) { - const auto before = getSortedApps(); - - app->incrementFrequency(); - - if (before != getSortedApps()) { - emit appsChanged(); - } - } else { - qWarning() << "AppDb::incrementFrequency: could not find app with id" << id; - } + auto* app = m_apps.value(id); + if (app) { + const auto before = getSortedApps(); + app->incrementFrequency(); + getSortedApps(); + if (before != m_sortedApps) { + emit appsChanged(); + } + } else { + qCWarning(lcAppDb) << "incrementFrequency: could not find app with id" << id; + } } QList& AppDb::getSortedApps() const { - m_sortedApps = m_apps.values(); - std::sort(m_sortedApps.begin(), m_sortedApps.end(), [](AppEntry* a, AppEntry* b) { - if (a->frequency() != b->frequency()) { - return a->frequency() > b->frequency(); - } - return a->name().localeAwareCompare(b->name()) < 0; - }); - return m_sortedApps; + m_sortedApps = m_apps.values(); + + // Pre-compute favorite status to avoid repeated regex matching during sort + QSet favSet; + favSet.reserve(m_sortedApps.size()); + for (const auto* app : std::as_const(m_sortedApps)) { + if (isFavorite(app)) + favSet.insert(app->id()); + } + + std::sort(m_sortedApps.begin(), m_sortedApps.end(), [&favSet](AppEntry* a, AppEntry* b) { + const bool aIsFav = favSet.contains(a->id()); + const bool bIsFav = favSet.contains(b->id()); + if (aIsFav != bIsFav) + return aIsFav; + if (a->frequency() != b->frequency()) + return a->frequency() > b->frequency(); + return a->name().localeAwareCompare(b->name()) < 0; + }); + return m_sortedApps; +} + +bool AppDb::isFavorite(const AppEntry* app) const { + for (const QRegularExpression& re : m_favoriteAppsRegex) { + if (re.match(app->id()).hasMatch()) { + return true; + } + } + return false; } quint32 AppDb::getFrequency(const QString& id) const { - auto db = QSqlDatabase::database(m_uuid); - QSqlQuery query(db); + auto db = QSqlDatabase::database(m_uuid); + QSqlQuery query(db); - query.prepare("SELECT frequency FROM frequencies WHERE id = :id"); - query.bindValue(":id", id); + query.prepare("SELECT frequency FROM frequencies WHERE id = :id"); + query.bindValue(":id", id); - if (query.exec() && query.next()) { - return query.value(0).toUInt(); - } + if (query.exec() && query.next()) { + return query.value(0).toUInt(); + } - return 0; + return 0; } void AppDb::updateAppFrequencies() { - const auto before = getSortedApps(); + const auto before = getSortedApps(); - for (auto* app : std::as_const(m_apps)) { - app->setFrequency(getFrequency(app->id())); - } + for (auto* app : std::as_const(m_apps)) { + app->setFrequency(getFrequency(app->id())); + } - if (before != getSortedApps()) { - emit appsChanged(); - } + getSortedApps(); + if (before != m_sortedApps) { + emit appsChanged(); + } } void AppDb::updateApps() { - bool dirty = false; + bool dirty = false; - for (const auto& entry : std::as_const(m_entries)) { - const auto id = entry->property("id").toString(); - if (!m_apps.contains(id)) { - dirty = true; - auto* const newEntry = new AppEntry(entry, getFrequency(id), this); - QObject::connect(newEntry, &QObject::destroyed, this, [id, this]() { - if (m_apps.remove(id)) { - emit appsChanged(); - } - }); - m_apps.insert(id, newEntry); - } - } + for (const auto& entry : std::as_const(m_entries)) { + const auto id = entry->property("id").toString(); + if (!m_apps.contains(id)) { + dirty = true; + auto* const newEntry = new AppEntry(entry, getFrequency(id), this); + QObject::connect(newEntry, &QObject::destroyed, this, [id, this]() { + if (m_apps.remove(id)) { + emit appsChanged(); + } + }); + m_apps.insert(id, newEntry); + } + } - QSet newIds; - for (const auto& entry : std::as_const(m_entries)) { - newIds.insert(entry->property("id").toString()); - } + QSet newIds; + for (const auto& entry : std::as_const(m_entries)) { + newIds.insert(entry->property("id").toString()); + } - for (auto it = m_apps.keyBegin(); it != m_apps.keyEnd(); ++it) { - const auto& id = *it; - if (!newIds.contains(id)) { - dirty = true; - m_apps.take(id)->deleteLater(); - } - } + for (auto it = m_apps.begin(); it != m_apps.end();) { + if (!newIds.contains(it.key())) { + dirty = true; + it.value()->deleteLater(); + it = m_apps.erase(it); + } else { + ++it; + } + } - if (dirty) { - emit appsChanged(); - } + if (dirty) { + emit appsChanged(); + } } } // namespace ZShell diff --git a/Plugins/ZShell/appdb.hpp b/Plugins/ZShell/appdb.hpp index 48c7187..2a5f1fb 100644 --- a/Plugins/ZShell/appdb.hpp +++ b/Plugins/ZShell/appdb.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace ZShell { @@ -66,6 +67,7 @@ QML_ELEMENT Q_PROPERTY(QString uuid READ uuid CONSTANT) Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) Q_PROPERTY(QObjectList entries READ entries WRITE setEntries NOTIFY entriesChanged REQUIRED) +Q_PROPERTY(QStringList favoriteApps READ favoriteApps WRITE setFavoriteApps NOTIFY favoriteAppsChanged REQUIRED) Q_PROPERTY(QQmlListProperty apps READ apps NOTIFY appsChanged) public: @@ -79,6 +81,9 @@ void setPath(const QString& path); [[nodiscard]] QObjectList entries() const; void setEntries(const QObjectList& entries); +[[nodiscard]] QStringList favoriteApps() const; +void setFavoriteApps(const QStringList& favApps); + [[nodiscard]] QQmlListProperty apps(); Q_INVOKABLE void incrementFrequency(const QString& id); @@ -86,6 +91,7 @@ Q_INVOKABLE void incrementFrequency(const QString& id); signals: void pathChanged(); void entriesChanged(); +void favoriteAppsChanged(); void appsChanged(); private: @@ -94,10 +100,14 @@ QTimer* m_timer; const QString m_uuid; QString m_path; QObjectList m_entries; +QStringList m_favoriteApps; +QList m_favoriteAppsRegex; QHash m_apps; mutable QList m_sortedApps; +QString regexifyString(const QString& original) const; QList& getSortedApps() const; +bool isFavorite(const AppEntry* app) const; quint32 getFrequency(const QString& id) const; void updateAppFrequencies(); void updateApps();