From 980dd0b2090e9ab6b6a191090c498bf347284100 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Sun, 29 Mar 2026 02:13:12 +1100 Subject: [PATCH 1/6] fix: nix duplicate property set --- nix/default.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/nix/default.nix b/nix/default.nix index 67747b2d..3a153dd0 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -126,8 +126,6 @@ in prePatch = '' substituteInPlace assets/pam.d/fprint \ --replace-fail pam_fprintd.so /run/current-system/sw/lib/security/pam_fprintd.so - substituteInPlace shell.qml \ - --replace-fail 'ShellRoot {' 'ShellRoot { settings.watchFiles: false' ''; postInstall = '' From b5f761666db40b71b15291956fc3ba3e283bd781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chlo=C3=A9=20Legu=C3=A9?= Date: Sat, 28 Mar 2026 11:56:38 -0400 Subject: [PATCH 2/6] fix: weather not using system timezone (#1346) * adjusted weather.qml to ensure that it tracks system local time and date while ignoring API times if using VPN * adjusted weather.qml to ensure that it tracks system local time and date while ignoring API times if using VPN --------- Co-authored-by: Chris <102560999+ItsABigIgloo@users.noreply.github.com> Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> --- services/Weather.qml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/Weather.qml b/services/Weather.qml index b74ae55f..40c4d895 100644 --- a/services/Weather.qml +++ b/services/Weather.qml @@ -121,8 +121,8 @@ Singleton { humidity: json.current.relative_humidity_2m, windSpeed: json.current.wind_speed_10m, isDay: json.current.is_day, - sunrise: json.daily.sunrise[0], - sunset: json.daily.sunset[0] + sunrise: json.daily.sunrise[0].replace("T", " "), + sunset: json.daily.sunset[0].replace("T", " ") }; const forecastList = []; @@ -218,7 +218,6 @@ Singleton { target: Config.services } - // Refresh current location hourly Timer { interval: 3600000 // 1 hour running: true From c588794b20242b23ecdfb6281c68cc265db6802b Mon Sep 17 00:00:00 2001 From: Robin Seger Date: Sun, 29 Mar 2026 06:22:00 +0200 Subject: [PATCH 3/6] feat: fullscreen notification & toasts overlay (#1276) * reworked fullscreen mode as transforming shell * controlcenter configuring of toasts & notifs * fix animation on fs switch * border rounding & shadow animation * change controlcenter notifications layout * stay on WlrLayer.Overlay, use Anim * ci: update action versions * qmlformat * format take two * third time's the charm * fix: special workspace fullscreen * fix: bar width border.thickness on non-persistent behaviour * merge fix * stop layout shift on close * last few conventions sorted * linting * maximized state to fullscreen --------- Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> --- components/controls/Menu.qml | 1 + config/Config.qml | 5 +- config/NotifsConfig.qml | 1 + config/UtilitiesConfig.qml | 1 + modules/Shortcuts.qml | 2 +- modules/bar/Bar.qml | 6 + modules/bar/BarWrapper.qml | 9 +- .../components/workspaces/ActiveIndicator.qml | 1 + .../bar/components/workspaces/Workspaces.qml | 3 + modules/controlcenter/PaneRegistry.qml | 12 +- modules/controlcenter/Panes.qml | 1 + .../notifications/NotificationsPane.qml | 427 ++++++++++++++++++ modules/drawers/Backgrounds.qml | 8 +- modules/drawers/Border.qml | 7 +- modules/drawers/Drawers.qml | 57 ++- modules/drawers/Exclusions.qml | 4 +- modules/drawers/Interactions.qml | 9 +- modules/drawers/Panels.qml | 13 +- modules/notifications/Background.qml | 9 +- modules/osd/Background.qml | 3 +- modules/sidebar/Background.qml | 4 +- modules/utilities/Background.qml | 3 +- modules/utilities/toasts/Toasts.qml | 13 + services/Notifs.qml | 18 +- 24 files changed, 582 insertions(+), 35 deletions(-) create mode 100644 modules/controlcenter/notifications/NotificationsPane.qml diff --git a/components/controls/Menu.qml b/components/controls/Menu.qml index b60a3639..320f318c 100644 --- a/components/controls/Menu.qml +++ b/components/controls/Menu.qml @@ -55,6 +55,7 @@ Elevation { function onClicked(): void { root.itemSelected(item.modelData); root.active = item.modelData; + item.modelData.clicked(); root.expanded = false; } diff --git a/config/Config.qml b/config/Config.qml index 91454b59..bcdcff19 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -267,11 +267,13 @@ Singleton { function serializeNotifs(): var { return { expire: notifs.expire, + fullscreen: notifs.fullscreen, defaultExpireTimeout: notifs.defaultExpireTimeout, clearThreshold: notifs.clearThreshold, expandThreshold: notifs.expandThreshold, actionOnClick: notifs.actionOnClick, - groupPreviewNum: notifs.groupPreviewNum + groupPreviewNum: notifs.groupPreviewNum, + openExpanded: notifs.openExpanded }; } @@ -323,6 +325,7 @@ Singleton { maxToasts: utilities.maxToasts, toasts: { configLoaded: utilities.toasts.configLoaded, + fullscreen: utilities.toasts.fullscreen, chargingChanged: utilities.toasts.chargingChanged, gameModeChanged: utilities.toasts.gameModeChanged, dndChanged: utilities.toasts.dndChanged, diff --git a/config/NotifsConfig.qml b/config/NotifsConfig.qml index fa2db494..bd54b94b 100644 --- a/config/NotifsConfig.qml +++ b/config/NotifsConfig.qml @@ -2,6 +2,7 @@ import Quickshell.Io JsonObject { property bool expire: true + property string fullscreen: "on" property int defaultExpireTimeout: 5000 property real clearThreshold: 0.3 property int expandThreshold: 20 diff --git a/config/UtilitiesConfig.qml b/config/UtilitiesConfig.qml index 017ab4ad..c97e9f60 100644 --- a/config/UtilitiesConfig.qml +++ b/config/UtilitiesConfig.qml @@ -46,6 +46,7 @@ JsonObject { component Toasts: JsonObject { property bool configLoaded: true + property string fullscreen: "off" property bool chargingChanged: true property bool gameModeChanged: true property bool dndChanged: true diff --git a/modules/Shortcuts.qml b/modules/Shortcuts.qml index d73e6351..b4633e8f 100644 --- a/modules/Shortcuts.qml +++ b/modules/Shortcuts.qml @@ -9,7 +9,7 @@ Scope { id: root property bool launcherInterrupted - readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false + readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false // qmllint disable unresolved-type CustomShortcut { diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml index 38ed564b..3b99a44b 100644 --- a/modules/bar/Bar.qml +++ b/modules/bar/Bar.qml @@ -16,6 +16,7 @@ ColumnLayout { required property ShellScreen screen required property DrawerVisibilities visibilities required property BarPopouts.Wrapper popouts + required property bool fullscreen readonly property int vPadding: Appearance.padding.large function closeTray(): void { @@ -128,6 +129,7 @@ ColumnLayout { delegate: WrappedLoader { sourceComponent: Workspaces { screen: root.screen + fullscreen: root.fullscreen } } } @@ -135,6 +137,7 @@ ColumnLayout { roleValue: "activeWindow" delegate: WrappedLoader { Layout.fillWidth: true + visible: !root.fullscreen sourceComponent: ActiveWindow { bar: root monitor: Brightness.getMonitorForScreen(root.screen) @@ -144,18 +147,21 @@ ColumnLayout { DelegateChoice { roleValue: "tray" delegate: WrappedLoader { + visible: !root.fullscreen sourceComponent: Tray {} } } DelegateChoice { roleValue: "clock" delegate: WrappedLoader { + visible: !root.fullscreen sourceComponent: Clock {} } } DelegateChoice { roleValue: "statusIcons" delegate: WrappedLoader { + visible: !root.fullscreen sourceComponent: StatusIcons {} } } diff --git a/modules/bar/BarWrapper.qml b/modules/bar/BarWrapper.qml index df29f0af..f29e23f9 100644 --- a/modules/bar/BarWrapper.qml +++ b/modules/bar/BarWrapper.qml @@ -13,12 +13,13 @@ Item { required property DrawerVisibilities visibilities required property BarPopouts.Wrapper popouts required property bool disabled + required property bool fullscreen readonly property int clampedWidth: Math.max(Config.border.minThickness, implicitWidth) readonly property int padding: Math.max(Appearance.padding.smaller, Config.border.thickness) readonly property int contentWidth: Config.bar.sizes.innerWidth + padding * 2 readonly property int exclusiveZone: !disabled && (Config.bar.persistent || visibilities.bar) ? contentWidth : Config.border.thickness - readonly property bool shouldBeVisible: !disabled && (Config.bar.persistent || visibilities.bar || isHovered) + readonly property bool shouldBeVisible: !fullscreen && !disabled && (Config.bar.persistent || visibilities.bar || isHovered) property bool isHovered function closeTray(): void { @@ -33,8 +34,9 @@ Item { (content.item as Bar)?.handleWheel(y, angleDelta); } - visible: width > Config.border.thickness - implicitWidth: Config.border.thickness + clip: true + visible: width > 0 + implicitWidth: fullscreen ? 0 : Config.border.thickness states: State { name: "visible" @@ -83,6 +85,7 @@ Item { screen: root.screen visibilities: root.visibilities popouts: root.popouts // qmllint disable incompatible-type + fullscreen: root.fullscreen } } } diff --git a/modules/bar/components/workspaces/ActiveIndicator.qml b/modules/bar/components/workspaces/ActiveIndicator.qml index e8a52d4d..d10086fd 100644 --- a/modules/bar/components/workspaces/ActiveIndicator.qml +++ b/modules/bar/components/workspaces/ActiveIndicator.qml @@ -10,6 +10,7 @@ StyledRect { required property int activeWsId required property Repeater workspaces required property Item mask + required property bool fullscreen readonly property int currentWsIdx: { let i = activeWsId - 1; diff --git a/modules/bar/components/workspaces/Workspaces.qml b/modules/bar/components/workspaces/Workspaces.qml index f205dfac..bbbfdfbc 100644 --- a/modules/bar/components/workspaces/Workspaces.qml +++ b/modules/bar/components/workspaces/Workspaces.qml @@ -12,6 +12,7 @@ StyledClippingRect { id: root required property ShellScreen screen + required property bool fullscreen readonly property bool onSpecial: (Config.bar.workspaces.perMonitorWorkspaces ? Hypr.monitorFor(screen) : Hypr.focusedMonitor)?.lastIpcObject.specialWorkspace?.name !== "" readonly property int activeWsId: Config.bar.workspaces.perMonitorWorkspaces ? (Hypr.monitorFor(screen).activeWorkspace?.id ?? 1) : Hypr.activeWsId @@ -36,6 +37,7 @@ StyledClippingRect { anchors.fill: parent scale: root.onSpecial ? 0.8 : 1 opacity: root.onSpecial ? 0.5 : 1 + visible: !root.fullscreen layer.enabled: root.blur > 0 layer.effect: MultiEffect { @@ -86,6 +88,7 @@ StyledClippingRect { activeWsId: root.activeWsId workspaces: workspaces mask: layout + fullscreen: root.fullscreen } } diff --git a/modules/controlcenter/PaneRegistry.qml b/modules/controlcenter/PaneRegistry.qml index ca48551f..4d85969b 100644 --- a/modules/controlcenter/PaneRegistry.qml +++ b/modules/controlcenter/PaneRegistry.qml @@ -36,6 +36,12 @@ QtObject { readonly property string icon: "task_alt" readonly property string component: "taskbar/TaskbarPane.qml" }, + QtObject { + readonly property string id: "notifications" + readonly property string label: "notifications" + readonly property string icon: "notifications" + readonly property string component: "notifications/NotificationsPane.qml" + }, QtObject { readonly property string id: "launcher" readonly property string label: "launcher" @@ -60,7 +66,7 @@ QtObject { return result; } - function getByIndex(index: int): QtObject { + function getByIndex(index: int): var { if (index >= 0 && index < panes.length) { return panes[index]; } @@ -76,12 +82,12 @@ QtObject { return -1; } - function getByLabel(label: string): QtObject { + function getByLabel(label: string): var { const index = getIndexByLabel(label); return getByIndex(index); } - function getById(id: string): QtObject { + function getById(id: string): var { for (let i = 0; i < panes.length; i++) { if (panes[i].id === id) { return panes[i]; diff --git a/modules/controlcenter/Panes.qml b/modules/controlcenter/Panes.qml index 5660ea7d..6a536410 100644 --- a/modules/controlcenter/Panes.qml +++ b/modules/controlcenter/Panes.qml @@ -5,6 +5,7 @@ import "network" import "audio" import "appearance" import "taskbar" +import "notifications" import "launcher" import "dashboard" import QtQuick diff --git a/modules/controlcenter/notifications/NotificationsPane.qml b/modules/controlcenter/notifications/NotificationsPane.qml new file mode 100644 index 00000000..41920f69 --- /dev/null +++ b/modules/controlcenter/notifications/NotificationsPane.qml @@ -0,0 +1,427 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import ".." +import "../components" +import qs.components +import qs.components.controls +import qs.components.effects +import qs.components.containers +import qs.services +import qs.config + +Item { + id: root + + required property Session session + + property bool notificationsExpire: Config.notifs.expire ?? true + property string notificationsFullscreen: Config.notifs.fullscreen ?? "on" + property bool notificationsOpenExpanded: Config.notifs.openExpanded ?? false + property int notificationsDefaultExpireTimeout: Config.notifs.defaultExpireTimeout ?? 5000 + property int notificationsGroupPreviewNum: Config.notifs.groupPreviewNum ?? 3 + + property int maxToasts: Config.utilities.maxToasts ?? 4 + property string toastsFullscreen: Config.utilities.toasts.fullscreen ?? "off" + property bool chargingChanged: Config.utilities.toasts.chargingChanged ?? true + property bool gameModeChanged: Config.utilities.toasts.gameModeChanged ?? true + property bool dndChanged: Config.utilities.toasts.dndChanged ?? true + property bool audioOutputChanged: Config.utilities.toasts.audioOutputChanged ?? true + property bool audioInputChanged: Config.utilities.toasts.audioInputChanged ?? true + property bool capsLockChanged: Config.utilities.toasts.capsLockChanged ?? true + property bool numLockChanged: Config.utilities.toasts.numLockChanged ?? true + property bool kbLayoutChanged: Config.utilities.toasts.kbLayoutChanged ?? true + property bool vpnChanged: Config.utilities.toasts.vpnChanged ?? true + property bool nowPlaying: Config.utilities.toasts.nowPlaying ?? false + + function saveConfig(): void { + Config.notifs.expire = root.notificationsExpire; + Config.notifs.fullscreen = root.notificationsFullscreen; + Config.notifs.openExpanded = root.notificationsOpenExpanded; + Config.notifs.defaultExpireTimeout = root.notificationsDefaultExpireTimeout; + Config.notifs.groupPreviewNum = root.notificationsGroupPreviewNum; + + Config.utilities.maxToasts = root.maxToasts; + Config.utilities.toasts.fullscreen = root.toastsFullscreen; + Config.utilities.toasts.chargingChanged = root.chargingChanged; + Config.utilities.toasts.gameModeChanged = root.gameModeChanged; + Config.utilities.toasts.dndChanged = root.dndChanged; + Config.utilities.toasts.audioOutputChanged = root.audioOutputChanged; + Config.utilities.toasts.audioInputChanged = root.audioInputChanged; + Config.utilities.toasts.capsLockChanged = root.capsLockChanged; + Config.utilities.toasts.numLockChanged = root.numLockChanged; + Config.utilities.toasts.kbLayoutChanged = root.kbLayoutChanged; + Config.utilities.toasts.vpnChanged = root.vpnChanged; + Config.utilities.toasts.nowPlaying = root.nowPlaying; + + Config.save(); + } + + anchors.fill: parent + + ClippingRectangle { + id: notificationsClippingRect + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + anchors.leftMargin: 0 + anchors.rightMargin: Appearance.padding.normal + + color: "transparent" + radius: notificationsBorder.innerRadius + + Loader { + id: notificationsLoader + + anchors.fill: parent + anchors.margins: Appearance.padding.large + Appearance.padding.normal + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + + sourceComponent: notificationsContentComponent + } + } + + InnerBorder { + id: notificationsBorder + + leftThickness: 0 + rightThickness: Appearance.padding.normal + } + + Component { + id: notificationsContentComponent + + StyledFlickable { + id: notificationsFlickable + + flickableDirection: Flickable.VerticalFlick + contentHeight: notificationsLayout.height + + StyledScrollBar.vertical: StyledScrollBar { + flickable: notificationsFlickable + } + + RowLayout { + id: notificationsLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Appearance.spacing.normal + + ColumnLayout { + Layout.fillWidth: true + Layout.maximumWidth: 500 + Layout.alignment: Qt.AlignTop + spacing: Appearance.spacing.normal + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Notifications") + font.pointSize: Appearance.font.size.normal + } + + SplitButtonRow { + id: notificationsFullscreenSelector + + function syncActiveItem(): void { + active = root.notificationsFullscreen === "off" ? notificationsFullscreenOffItem : notificationsFullscreenOnItem; + } + + label: qsTr("Show in fullscreen") + menuItems: [notificationsFullscreenOffItem, notificationsFullscreenOnItem] + + Component.onCompleted: syncActiveItem() + + Connections { + function onNotificationsFullscreenChanged(): void { + notificationsFullscreenSelector.syncActiveItem(); + } + + target: root + } + + MenuItem { + id: notificationsFullscreenOffItem + + text: qsTr("Off") + icon: "notifications_off" + activeText: qsTr("Off") + onClicked: { + root.notificationsFullscreen = "off"; + root.saveConfig(); + } + } + + MenuItem { + id: notificationsFullscreenOnItem + + text: qsTr("On") + icon: "notifications" + activeText: qsTr("On") + onClicked: { + root.notificationsFullscreen = "on"; + root.saveConfig(); + } + } + } + + SwitchRow { + label: qsTr("Expire automatically") + checked: root.notificationsExpire + onToggled: checked => { + root.notificationsExpire = checked; + root.saveConfig(); + } + } + + SwitchRow { + label: qsTr("Open expanded") + checked: root.notificationsOpenExpanded + onToggled: checked => { + root.notificationsOpenExpanded = checked; + root.saveConfig(); + } + } + + SpinBoxRow { + label: qsTr("Default timeout") + value: root.notificationsDefaultExpireTimeout + min: 1000 + max: 60000 + step: 500 + onValueModified: value => { + root.notificationsDefaultExpireTimeout = value; + root.saveConfig(); + } + } + + SpinBoxRow { + label: qsTr("Group preview count") + value: root.notificationsGroupPreviewNum + min: 1 + max: 10 + step: 1 + onValueModified: value => { + root.notificationsGroupPreviewNum = value; + root.saveConfig(); + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: Appearance.spacing.normal + + SectionContainer { + Layout.fillWidth: true + alignTop: true + + StyledText { + text: qsTr("Toast settings") + font.pointSize: Appearance.font.size.normal + } + + SplitButtonRow { + id: toastFullscreenSelector + + function syncActiveItem(): void { + if (root.toastsFullscreen === "all") { + active = toastFullscreenAllItem; + return; + } + + if (root.toastsFullscreen === "important") { + active = toastFullscreenImportantItem; + return; + } + + active = toastFullscreenOffItem; + } + + Layout.fillWidth: true + z: expanded ? 100 : 0 + label: qsTr("Show in fullscreen") + menuItems: [toastFullscreenOffItem, toastFullscreenImportantItem, toastFullscreenAllItem] + + Component.onCompleted: syncActiveItem() + + Connections { + function onToastsFullscreenChanged(): void { + toastFullscreenSelector.syncActiveItem(); + } + + target: root + } + + MenuItem { + id: toastFullscreenOffItem + + text: qsTr("Off") + icon: "notifications_off" + activeText: qsTr("Off") + onClicked: { + root.toastsFullscreen = "off"; + root.saveConfig(); + } + } + + MenuItem { + id: toastFullscreenImportantItem + + text: qsTr("Important") + icon: "priority_high" + activeText: qsTr("Important") + onClicked: { + root.toastsFullscreen = "important"; + root.saveConfig(); + } + } + + MenuItem { + id: toastFullscreenAllItem + + text: qsTr("On") + icon: "notifications" + activeText: qsTr("On") + onClicked: { + root.toastsFullscreen = "all"; + root.saveConfig(); + } + } + } + + SpinBoxRow { + Layout.fillWidth: true + label: qsTr("Visible toasts") + value: root.maxToasts + min: 1 + max: 10 + step: 1 + onValueModified: value => { + root.maxToasts = value; + root.saveConfig(); + } + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: Appearance.spacing.normal + rowSpacing: Appearance.spacing.normal + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Charging changes") + checked: root.chargingChanged + onToggled: checked => { + root.chargingChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Game mode changes") + checked: root.gameModeChanged + onToggled: checked => { + root.gameModeChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Do not disturb") + checked: root.dndChanged + onToggled: checked => { + root.dndChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Audio output changes") + checked: root.audioOutputChanged + onToggled: checked => { + root.audioOutputChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Audio input changes") + checked: root.audioInputChanged + onToggled: checked => { + root.audioInputChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Caps lock changes") + checked: root.capsLockChanged + onToggled: checked => { + root.capsLockChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Num lock changes") + checked: root.numLockChanged + onToggled: checked => { + root.numLockChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Keyboard layout changes") + checked: root.kbLayoutChanged + onToggled: checked => { + root.kbLayoutChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("VPN changes") + checked: root.vpnChanged + onToggled: checked => { + root.vpnChanged = checked; + root.saveConfig(); + } + } + + SwitchRow { + Layout.fillWidth: true + label: qsTr("Now playing") + checked: root.nowPlaying + onToggled: checked => { + root.nowPlaying = checked; + root.saveConfig(); + } + } + } + } + } + } + } + } +} diff --git a/modules/drawers/Backgrounds.qml b/modules/drawers/Backgrounds.qml index b79cd919..7592411c 100644 --- a/modules/drawers/Backgrounds.qml +++ b/modules/drawers/Backgrounds.qml @@ -15,14 +15,17 @@ Shape { required property Panels panels required property Item bar + required property real borderThickness + required property real borderRounding anchors.fill: parent - anchors.margins: Config.border.thickness + anchors.margins: root.borderThickness anchors.leftMargin: bar.implicitWidth preferredRendererType: Shape.CurveRenderer Osd.Background { wrapper: root.panels.osd // qmllint disable incompatible-type + rounding: Config.border.rounding startX: root.width - root.panels.session.width - root.panels.sidebar.width startY: (root.height - wrapper.height) / 2 - rounding @@ -31,6 +34,7 @@ Shape { Notifications.Background { wrapper: root.panels.notifications // qmllint disable incompatible-type sidebar: sidebar + rounding: Config.border.rounding startX: root.width startY: 0 @@ -68,6 +72,7 @@ Shape { Utilities.Background { wrapper: root.panels.utilities // qmllint disable incompatible-type sidebar: sidebar + rounding: root.borderRounding startX: root.width startY: root.height @@ -78,6 +83,7 @@ Shape { wrapper: root.panels.sidebar // qmllint disable incompatible-type panels: root.panels + rounding: root.borderRounding startX: root.width startY: root.panels.notifications.height diff --git a/modules/drawers/Border.qml b/modules/drawers/Border.qml index 13f92a4b..a638479d 100644 --- a/modules/drawers/Border.qml +++ b/modules/drawers/Border.qml @@ -4,12 +4,13 @@ import QtQuick import QtQuick.Effects import qs.components import qs.services -import qs.config Item { id: root required property Item bar + required property real borderThickness + required property real borderRounding anchors.fill: parent @@ -36,9 +37,9 @@ Item { Rectangle { anchors.fill: parent - anchors.margins: Config.border.thickness + anchors.margins: root.borderThickness anchors.leftMargin: root.bar.implicitWidth - radius: Config.border.rounding + radius: root.borderRounding } } } diff --git a/modules/drawers/Drawers.qml b/modules/drawers/Drawers.qml index 864ad5c4..9d3de776 100644 --- a/modules/drawers/Drawers.qml +++ b/modules/drawers/Drawers.qml @@ -25,18 +25,34 @@ Variants { Exclusions { screen: scope.modelData bar: bar + borderThickness: Config.border.thickness } StyledWindow { id: win - readonly property bool hasFullscreen: Hypr.monitorFor(screen)?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false + readonly property var monitor: Hypr.monitorFor(screen) + readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject?.specialWorkspace?.name.length ?? 0) > 0 + readonly property bool hasFullscreen: { + if (hasSpecialWorkspace) { + const specialName = monitor?.lastIpcObject?.specialWorkspace?.name; + if (!specialName) + return false; + const specialWs = Hypr.workspaces.values.find(ws => ws.name === specialName); + return specialWs?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + return monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + property real borderThickness: hasFullscreen ? 0 : Config.border.thickness + readonly property real borderLayoutThickness: hasFullscreen ? 0 : Config.border.thickness + property real borderRounding: hasFullscreen ? 0 : Config.border.rounding + property real shadowOpacity: hasFullscreen ? 0 : 0.7 readonly property int dragMaskPadding: { if (focusGrab.active || panels.popouts.isDetached) return 0; const mon = Hypr.monitorFor(screen); - if (mon?.lastIpcObject.specialWorkspace?.name || mon?.activeWorkspace?.lastIpcObject.windows > 0) + if (mon?.lastIpcObject.specialWorkspace?.name || mon?.activeWorkspace.lastIpcObject.windows > 0) return 0; const thresholds = []; @@ -55,6 +71,7 @@ Variants { screen: scope.modelData name: "drawers" WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session || panels.dashboard.needsKeyboard ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None mask: Region { @@ -72,6 +89,30 @@ Variants { anchors.left: true anchors.right: true + Behavior on borderThickness { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on borderRounding { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on shadowOpacity { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + Variants { id: regions @@ -81,7 +122,7 @@ Variants { required property Item modelData x: modelData.x + bar.implicitWidth - y: modelData.y + Config.border.thickness + y: modelData.y + win.borderLayoutThickness width: modelData.width height: modelData.height intersection: Intersection.Subtract @@ -120,16 +161,20 @@ Variants { layer.effect: MultiEffect { shadowEnabled: true blurMax: 15 - shadowColor: Qt.alpha(Colours.palette.m3shadow, 0.7) + shadowColor: Qt.alpha(Colours.palette.m3shadow, Math.max(0, win.shadowOpacity)) } Border { bar: bar + borderThickness: win.borderThickness + borderRounding: win.borderRounding } Backgrounds { panels: panels bar: bar + borderThickness: win.borderThickness + borderRounding: win.borderRounding } } @@ -145,6 +190,8 @@ Variants { visibilities: visibilities panels: panels bar: bar + borderThickness: win.borderLayoutThickness + fullscreen: win.hasFullscreen Panels { id: panels @@ -152,6 +199,7 @@ Variants { screen: scope.modelData visibilities: visibilities bar: bar + borderThickness: win.borderLayoutThickness } BarWrapper { @@ -165,6 +213,7 @@ Variants { popouts: panels.popouts disabled: scope.barDisabled + fullscreen: win.hasFullscreen Component.onCompleted: Visibilities.bars.set(scope.modelData, this) } diff --git a/modules/drawers/Exclusions.qml b/modules/drawers/Exclusions.qml index f43afb9a..87610ee6 100644 --- a/modules/drawers/Exclusions.qml +++ b/modules/drawers/Exclusions.qml @@ -3,7 +3,6 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import qs.components.containers -import qs.config import qs.modules.bar as Bar Scope { @@ -11,6 +10,7 @@ Scope { required property ShellScreen screen required property Bar.BarWrapper bar + required property real borderThickness ExclusionZone { anchors.left: true @@ -32,7 +32,7 @@ Scope { component ExclusionZone: StyledWindow { screen: root.screen name: "border-exclusion" - exclusiveZone: Config.border.thickness + exclusiveZone: root.borderThickness mask: Region {} implicitWidth: 1 implicitHeight: 1 diff --git a/modules/drawers/Interactions.qml b/modules/drawers/Interactions.qml index fcb128a9..4b670fca 100644 --- a/modules/drawers/Interactions.qml +++ b/modules/drawers/Interactions.qml @@ -15,6 +15,8 @@ CustomMouseArea { required property DrawerVisibilities visibilities required property Panels panels required property Bar.BarWrapper bar + required property real borderThickness + required property bool fullscreen property point dragStart property bool dashboardShortcutActive @@ -22,7 +24,7 @@ CustomMouseArea { property bool utilitiesShortcutActive function withinPanelHeight(panel: Item, x: real, y: real): bool { - const panelY = Config.border.thickness + panel.y; + const panelY = root.borderThickness + panel.y; return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding; } @@ -48,13 +50,16 @@ CustomMouseArea { } function onWheel(event: WheelEvent): void { + if (fullscreen) + return; if (event.x < bar.implicitWidth) { bar.handleWheel(event.y, event.angleDelta); } } anchors.fill: parent - hoverEnabled: true + acceptedButtons: fullscreen ? Qt.NoButton : Qt.AllButtons + hoverEnabled: !fullscreen onPressed: event => dragStart = Qt.point(event.x, event.y) onContainsMouseChanged: { diff --git a/modules/drawers/Panels.qml b/modules/drawers/Panels.qml index f2531cfa..04b83c8d 100644 --- a/modules/drawers/Panels.qml +++ b/modules/drawers/Panels.qml @@ -19,6 +19,7 @@ Item { required property ShellScreen screen required property DrawerVisibilities visibilities required property Bar.BarWrapper bar + required property real borderThickness readonly property alias osd: osd readonly property alias notifications: notifications @@ -31,9 +32,17 @@ Item { readonly property alias sidebar: sidebar anchors.fill: parent - anchors.margins: Config.border.thickness + anchors.margins: root.borderThickness anchors.leftMargin: bar.implicitWidth + Behavior on anchors.margins { + Anim {} + } + + Behavior on anchors.leftMargin { + Anim {} + } + Osd.Wrapper { id: osd @@ -100,7 +109,7 @@ Item { if (isDetached) return (root.height - nonAnimHeight) / 2; - const off = currentCenter - Config.border.thickness - nonAnimHeight / 2; + const off = currentCenter - root.borderThickness - nonAnimHeight / 2; const diff = root.height - Math.floor(off + nonAnimHeight); if (diff < 0) return off + diff; diff --git a/modules/notifications/Background.qml b/modules/notifications/Background.qml index 4d7a5ff7..740cda10 100644 --- a/modules/notifications/Background.qml +++ b/modules/notifications/Background.qml @@ -2,14 +2,13 @@ import QtQuick import QtQuick.Shapes import qs.components import qs.services -import qs.config ShapePath { id: root required property Wrapper wrapper required property var sidebar - readonly property real rounding: Config.border.rounding + required property real rounding readonly property bool flatten: wrapper.height < rounding * 2 readonly property real roundingY: flatten ? wrapper.height / 2 : rounding @@ -31,14 +30,14 @@ ShapePath { relativeY: root.wrapper.height - root.roundingY * 2 } PathArc { - relativeX: root.sidebar.notifsRoundingX + relativeX: root.rounding relativeY: root.roundingY - radiusX: root.sidebar.notifsRoundingX + radiusX: root.rounding radiusY: Math.min(root.rounding, root.wrapper.height) direction: PathArc.Counterclockwise } PathLine { - relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.notifsRoundingX : root.wrapper.width + relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding * 2 : root.wrapper.width relativeY: 0 } PathArc { diff --git a/modules/osd/Background.qml b/modules/osd/Background.qml index a609f460..330c703e 100644 --- a/modules/osd/Background.qml +++ b/modules/osd/Background.qml @@ -2,13 +2,12 @@ import QtQuick import QtQuick.Shapes import qs.components import qs.services -import qs.config ShapePath { id: root required property Wrapper wrapper - readonly property real rounding: Config.border.rounding + required property real rounding readonly property bool flatten: wrapper.width < rounding * 2 readonly property real roundingX: flatten ? wrapper.width / 2 : rounding diff --git a/modules/sidebar/Background.qml b/modules/sidebar/Background.qml index 4cc14262..c7d42123 100644 --- a/modules/sidebar/Background.qml +++ b/modules/sidebar/Background.qml @@ -2,15 +2,13 @@ import QtQuick import QtQuick.Shapes import qs.components import qs.services -import qs.config ShapePath { id: root required property Wrapper wrapper required property var panels - - readonly property real rounding: Config.border.rounding + required property real rounding readonly property real notifsWidthDiff: panels.notifications.width - wrapper.width readonly property real notifsRoundingX: panels.notifications.height > 0 && notifsWidthDiff < rounding * 2 ? notifsWidthDiff / 2 : rounding diff --git a/modules/utilities/Background.qml b/modules/utilities/Background.qml index 975461a5..5b58b41e 100644 --- a/modules/utilities/Background.qml +++ b/modules/utilities/Background.qml @@ -2,14 +2,13 @@ import QtQuick import QtQuick.Shapes import qs.components import qs.services -import qs.config ShapePath { id: root required property Wrapper wrapper required property var sidebar - readonly property real rounding: Config.border.rounding + required property real rounding readonly property bool flatten: wrapper.height < rounding * 2 readonly property real roundingY: flatten ? wrapper.height / 2 : rounding diff --git a/modules/utilities/toasts/Toasts.qml b/modules/utilities/toasts/Toasts.qml index ac8772f5..1ef072e9 100644 --- a/modules/utilities/toasts/Toasts.qml +++ b/modules/utilities/toasts/Toasts.qml @@ -4,6 +4,7 @@ import QtQuick import Quickshell import Caelestia import qs.components +import qs.services import qs.config Item { @@ -12,6 +13,16 @@ Item { readonly property int spacing: Appearance.spacing.small property bool flag + function shouldShowToast(toast: Toast): bool { + if (!Notifs.hasFullscreen()) + return true; + if (Config.utilities.toasts.fullscreen === "all") + return true; + if (Config.utilities.toasts.fullscreen === "important") + return toast.type === Toast.Warning || toast.type === Toast.Error; + return false; + } + implicitWidth: Config.utilities.sizes.toastWidth - Appearance.padding.normal * 2 implicitHeight: { let h = -spacing; @@ -31,6 +42,8 @@ Item { const toasts = []; let count = 0; for (const toast of Toaster.toasts) { + if (!root.shouldShowToast(toast)) + continue; toasts.push(toast); if (!toast.closed) { count++; diff --git a/services/Notifs.qml b/services/Notifs.qml index 4e6c0dd6..92e609be 100644 --- a/services/Notifs.qml +++ b/services/Notifs.qml @@ -21,6 +21,22 @@ Singleton { property bool loaded + function hasFullscreen(): bool { + for (const monitor of Hypr.monitors.values) { + if (monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1)) + return true; + } + return false; + } + + function shouldShowPopup(): bool { + if (props.dnd || [...Visibilities.screens.values()].some(v => v.sidebar)) + return false; + if (Config.notifs.fullscreen === "off" && hasFullscreen()) + return false; + return true; + } + onDndChanged: { if (!Config.utilities.toasts.dndChanged) return; @@ -79,7 +95,7 @@ Singleton { notif.tracked = true; const comp = notifComp.createObject(root, { - popup: !props.dnd && ![...Visibilities.screens.values()].some(v => v.sidebar), + popup: root.shouldShowPopup(), notification: notif }); root.list = [comp, ...root.list]; From 5c59e4490a43eb38b39133d80c47490f0963f216 Mon Sep 17 00:00:00 2001 From: Robin Seger Date: Sun, 29 Mar 2026 06:25:52 +0200 Subject: [PATCH 4/6] feat: VPN fixes & improvements (#1116) * feat: add VPN settings and management UI - Add VPN configuration UI - Update VPN toggle visibility to check enabled providers * controlcenter: VPN modal transitions & cleanup * controlcenter: VPN modal styling * controlcenter: VPN modal scrim * controlcenter: VPN modal padding * controlcenter: VPN modal enter & exit behaviour * vpn: reworked managment & fixes - Switched to primarily use provider status json - Emitting more detailed toasts and errors/states - Authentication prompt button to open in browser - Better resets and provider change * vpn: replace use of qt5compat * fixing providers, status and adding custom option * local qml-lint script lied to me * format * section order * linting --------- Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> --- config/Config.qml | 20 +- config/UtilitiesConfig.qml | 2 +- modules/controlcenter/network/VpnDetails.qml | 149 ++++++++- modules/controlcenter/network/VpnList.qml | 153 ++++++++- modules/utilities/cards/Toggles.qml | 4 +- services/VPN.qml | 334 ++++++++++++++++++- 6 files changed, 624 insertions(+), 38 deletions(-) diff --git a/config/Config.qml b/config/Config.qml index bcdcff19..df684b42 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -320,6 +320,24 @@ Singleton { } function serializeUtilities(): var { + const vpnProviders = []; + for (let i = 0; i < utilities.vpn.provider.length; i++) { + const p = utilities.vpn.provider[i]; + const provider = { + displayName: p.displayName, + enabled: p.enabled, + iface: p.iface, + name: p.name + }; + if (p.connectCmd && p.connectCmd.length > 0) { + provider.connectCmd = p.connectCmd; + } + if (p.disconnectCmd && p.disconnectCmd.length > 0) { + provider.disconnectCmd = p.disconnectCmd; + } + vpnProviders.push(provider); + } + return { enabled: utilities.enabled, maxToasts: utilities.maxToasts, @@ -339,7 +357,7 @@ Singleton { }, vpn: { enabled: utilities.vpn.enabled, - provider: utilities.vpn.provider + provider: vpnProviders }, quickToggles: utilities.quickToggles }; diff --git a/config/UtilitiesConfig.qml b/config/UtilitiesConfig.qml index c97e9f60..61cf1678 100644 --- a/config/UtilitiesConfig.qml +++ b/config/UtilitiesConfig.qml @@ -62,6 +62,6 @@ JsonObject { component Vpn: JsonObject { property bool enabled: false - property list provider: ["netbird"] + property list provider: [] } } diff --git a/modules/controlcenter/network/VpnDetails.qml b/modules/controlcenter/network/VpnDetails.qml index 8d91067c..4d749d8b 100644 --- a/modules/controlcenter/network/VpnDetails.qml +++ b/modules/controlcenter/network/VpnDetails.qml @@ -109,10 +109,13 @@ DeviceDetails { inactiveOnColour: Colours.palette.m3onSecondaryContainer onClicked: { + const provider = Config.utilities.vpn.provider[root.vpnProvider.index]; editVpnDialog.editIndex = root.vpnProvider.index; editVpnDialog.providerName = root.vpnProvider.name; editVpnDialog.displayName = root.vpnProvider.displayName; editVpnDialog.interfaceName = root.vpnProvider.interface; + editVpnDialog.connectCmd = (provider && provider.connectCmd) ? provider.connectCmd.join(" ") : ""; + editVpnDialog.disconnectCmd = (provider && provider.disconnectCmd) ? provider.disconnectCmd.join(" ") : ""; editVpnDialog.open(); } } @@ -136,6 +139,30 @@ DeviceDetails { } } } + + TextButton { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + visible: root.providerEnabled && VPN.status.state === "needs-auth" && VPN.status.authUrl !== "" + text: qsTr("Open Login Page") + inactiveColour: Colours.palette.m3tertiaryContainer + inactiveOnColour: Colours.palette.m3onTertiaryContainer + + onClicked: { + Qt.openUrlExternally(VPN.status.authUrl); + } + } + + StyledText { + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.normal + visible: root.providerEnabled && VPN.status.state === "needs-auth" && VPN.status.authUrl === "" + text: qsTr("Click 'Connect' to generate authentication URL") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } } } }, @@ -176,12 +203,31 @@ DeviceDetails { return qsTr("Disabled"); if (VPN.connecting) return qsTr("Connecting..."); - if (VPN.connected) + + switch (VPN.status.state) { + case "connected": return qsTr("Connected"); - return qsTr("Enabled (Not connected)"); + case "disconnected": + return qsTr("Disconnected"); + case "connecting": + return qsTr("Connecting..."); + case "needs-auth": + return qsTr("Authentication required"); + case "error": + return qsTr("Error"); + default: + return qsTr("Unknown"); + } } } + PropertyRow { + visible: VPN.status.reason !== "" + showTopMargin: true + label: qsTr("Details") + value: VPN.status.reason + } + PropertyRow { showTopMargin: true label: qsTr("Enabled") @@ -200,6 +246,8 @@ DeviceDetails { property string providerName: "" property string displayName: "" property string interfaceName: "" + property string connectCmd: "" + property string disconnectCmd: "" function closeWithAnimation(): void { close(); @@ -349,6 +397,82 @@ DeviceDetails { } } + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller / 2 + visible: editVpnDialog.connectCmd.length > 0 + + StyledText { + text: qsTr("Connect Command") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: connectCmdFieldEdit.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: connectCmdFieldEdit.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: connectCmdFieldEdit + + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: editVpnDialog.connectCmd + onTextChanged: editVpnDialog.connectCmd = text + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller / 2 + visible: editVpnDialog.disconnectCmd.length > 0 + + StyledText { + text: qsTr("Disconnect Command") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: disconnectCmdFieldEdit.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: disconnectCmdFieldEdit.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: disconnectCmdFieldEdit + + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: editVpnDialog.disconnectCmd + onTextChanged: editVpnDialog.disconnectCmd = text + } + } + } + RowLayout { Layout.topMargin: Appearance.spacing.normal Layout.fillWidth: true @@ -376,12 +500,23 @@ DeviceDetails { for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { if (i === editVpnDialog.editIndex) { - providers.push({ - name: editVpnDialog.providerName, + const hasCommands = editVpnDialog.connectCmd.length > 0 && editVpnDialog.disconnectCmd.length > 0; + const newProvider = { displayName: editVpnDialog.displayName || editVpnDialog.interfaceName, - interface: editVpnDialog.interfaceName, - enabled: wasEnabled - }); + enabled: wasEnabled, + iface: editVpnDialog.interfaceName, + name: editVpnDialog.providerName, + connectCmd: hasCommands ? editVpnDialog.connectCmd.split(" ").filter(s => s.length > 0) : undefined, + disconnectCmd: hasCommands ? editVpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0) : undefined + }; + + // Remove undefined properties + if (!hasCommands) { + delete newProvider.connectCmd; + delete newProvider.disconnectCmd; + } + + providers.push(newProvider); } else { providers.push(Config.utilities.vpn.provider[i]); } diff --git a/modules/controlcenter/network/VpnList.qml b/modules/controlcenter/network/VpnList.qml index 3646841c..6dabc2cf 100644 --- a/modules/controlcenter/network/VpnList.qml +++ b/modules/controlcenter/network/VpnList.qml @@ -159,15 +159,38 @@ ColumnLayout { StyledText { Layout.fillWidth: true text: { - if (modelData.enabled && VPN.connected) - return qsTr("Connected"); - if (modelData.enabled && VPN.connecting) + if (!modelData.enabled) + return qsTr("Disabled"); + + if (VPN.connecting) return qsTr("Connecting..."); - if (modelData.enabled) + + switch (VPN.status.state) { + case "connected": + return qsTr("Connected"); + case "disconnected": return qsTr("Enabled"); - return qsTr("Disabled"); + case "connecting": + return qsTr("Connecting..."); + case "needs-auth": + return qsTr("Auth required"); + case "error": + return qsTr("Error"); + default: + return qsTr("Enabled"); + } + } + color: { + if (!modelData.enabled) + return Colours.palette.m3outline; + if (VPN.status.state === "connected") + return Colours.palette.m3primary; + if (VPN.status.state === "error") + return Colours.palette.m3error; + if (VPN.status.state === "needs-auth") + return Colours.palette.m3tertiary; + return Colours.palette.m3onSurface; } - color: modelData.enabled ? (VPN.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface) : Colours.palette.m3outline font.pointSize: Appearance.font.size.small font.weight: modelData.enabled && VPN.connected ? 500 : 400 elide: Text.ElideRight @@ -271,6 +294,8 @@ ColumnLayout { property string providerName: "" property string displayName: "" property string interfaceName: "" + property string connectCmd: "" + property string disconnectCmd: "" function showProviderSelection(): void { currentState = "selection"; @@ -286,6 +311,8 @@ ColumnLayout { providerName = providerType; displayName = defaultDisplayName; interfaceName = ""; + connectCmd = ""; + disconnectCmd = ""; if (currentState === "selection") { transitionToForm.start(); @@ -304,6 +331,8 @@ ColumnLayout { providerName = isObject ? (provider.name || "custom") : String(provider); displayName = isObject ? (provider.displayName || providerName) : providerName; interfaceName = isObject ? (provider.interface || "") : ""; + connectCmd = isObject && provider.connectCmd ? provider.connectCmd.join(" ") : ""; + disconnectCmd = isObject && provider.disconnectCmd ? provider.disconnectCmd.join(" ") : ""; currentState = "form"; open(); @@ -491,7 +520,7 @@ ColumnLayout { TextButton { Layout.fillWidth: true - text: qsTr("WireGuard (Custom)") + text: qsTr("WireGuard") inactiveColour: Colours.tPalette.m3surfaceContainerHigh inactiveOnColour: Colours.palette.m3onSurface onClicked: { @@ -499,6 +528,16 @@ ColumnLayout { } } + TextButton { + Layout.fillWidth: true + text: qsTr("Custom") + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurface + onClicked: { + vpnDialog.showAddForm("custom", "Custom VPN"); + } + } + TextButton { Layout.topMargin: Appearance.spacing.normal Layout.fillWidth: true @@ -604,6 +643,82 @@ ColumnLayout { } } + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller / 2 + visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom") + + StyledText { + text: qsTr("Connect Command (e.g., wg-quick up wg0)") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: connectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: connectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: connectCmdField + + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: vpnDialog.connectCmd + onTextChanged: vpnDialog.connectCmd = text + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.smaller / 2 + visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom") + + StyledText { + text: qsTr("Disconnect Command (e.g., wg-quick down wg0)") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 40 + color: disconnectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) + radius: Appearance.rounding.small + border.width: 1 + border.color: disconnectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } + + StyledTextField { + id: disconnectCmdField + + anchors.centerIn: parent + width: parent.width - Appearance.padding.normal + horizontalAlignment: TextInput.AlignLeft + text: vpnDialog.disconnectCmd + onTextChanged: vpnDialog.disconnectCmd = text + } + } + } + RowLayout { Layout.topMargin: Appearance.spacing.normal Layout.fillWidth: true @@ -620,19 +735,37 @@ ColumnLayout { TextButton { Layout.fillWidth: true text: qsTr("Save") - enabled: vpnDialog.interfaceName.length > 0 + enabled: { + const hasCommands = vpnDialog.connectCmd.length > 0 || vpnDialog.disconnectCmd.length > 0; + if (hasCommands) { + return vpnDialog.interfaceName.length > 0 && vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0; + } + return vpnDialog.interfaceName.length > 0; + } inactiveColour: Colours.palette.m3primaryContainer inactiveOnColour: Colours.palette.m3onPrimaryContainer onClicked: { const providers = []; + const hasCommands = vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0; const newProvider = { - name: vpnDialog.providerName, displayName: vpnDialog.displayName || vpnDialog.interfaceName, - interface: vpnDialog.interfaceName + enabled: false, + interface: vpnDialog.interfaceName, + name: vpnDialog.providerName }; + if (hasCommands) { + newProvider.connectCmd = vpnDialog.connectCmd.split(" ").filter(s => s.length > 0); + newProvider.disconnectCmd = vpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0); + } + if (vpnDialog.editIndex >= 0) { + const oldProvider = Config.utilities.vpn.provider[vpnDialog.editIndex]; + if (typeof oldProvider === "object" && oldProvider.enabled !== undefined) { + newProvider.enabled = oldProvider.enabled; + } + for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { if (i === vpnDialog.editIndex) { providers.push(newProvider); diff --git a/modules/utilities/cards/Toggles.qml b/modules/utilities/cards/Toggles.qml index 996b027c..2b14a9ae 100644 --- a/modules/utilities/cards/Toggles.qml +++ b/modules/utilities/cards/Toggles.qml @@ -141,8 +141,10 @@ StyledRect { roleValue: "vpn" delegate: Toggle { icon: "vpn_key" - checked: VPN.connected + checked: VPN.connected && VPN.status.state !== "needs-auth" && VPN.status.state !== "error" enabled: !VPN.connecting + toggle: VPN.status.state !== "needs-auth" && VPN.status.state !== "error" + inactiveOnColour: Colours.palette.m3onSurfaceVariant onClicked: VPN.toggle() } } diff --git a/services/VPN.qml b/services/VPN.qml index 2b25813b..bfeacea4 100644 --- a/services/VPN.qml +++ b/services/VPN.qml @@ -10,6 +10,12 @@ Singleton { id: root property bool connected: false + property var status: ({ + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }) readonly property bool connecting: connectProc.running || disconnectProc.running readonly property bool enabled: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false) @@ -19,7 +25,7 @@ Singleton { } readonly property bool isCustomProvider: typeof providerInput === "object" readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput) - readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : "" + readonly property string interfaceName: isCustomProvider ? (providerInput.iface || "") : "" readonly property var currentConfig: { const name = providerName; const iface = interfaceName; @@ -30,7 +36,7 @@ Singleton { return { connectCmd: custom.connectCmd || defaults.connectCmd, disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd, - interface: custom.interface || defaults.interface, + interface: custom.iface || defaults.interface, displayName: custom.displayName || defaults.displayName }; } @@ -53,7 +59,7 @@ Singleton { displayName: "Warp" }, "netbird": { - connectCmd: ["netbird", "up"], + connectCmd: ["netbird", "up", "--no-browser"], disconnectCmd: ["netbird", "down"], interface: "wt0", displayName: "NetBird" @@ -75,6 +81,10 @@ Singleton { } function connect(): void { + if (status.state === "needs-auth" && status.authUrl) { + emitStatusToast(status); + return; + } if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) { connectProc.exec(root.currentConfig.connectCmd); } @@ -87,11 +97,7 @@ Singleton { } function toggle(): void { - if (connected) { - disconnect(); - } else { - connect(); - } + connected ? disconnect() : connect(); } function checkStatus(): void { @@ -100,18 +106,223 @@ Singleton { } } - onConnectedChanged: { + function getStatusCommand(): var { + switch (providerName) { + case "tailscale": + return ["tailscale", "status", "--json"]; + case "netbird": + return ["netbird", "status", "--json"]; + case "warp": + return ["warp-cli", "status"]; + case "wireguard": + return ["ip", "link", "show"]; + default: + return ["ip", "link", "show"]; + } + } + + function parseTailscaleStatus(output: string): var { + const status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + + // Handle empty or whitespace-only output + if (!output || output.trim().length === 0) { + return status; + } + + // Check for common non-JSON states first + if (output.includes("Logged out") || output.includes("Stopped") || output.includes("not running") || output.includes("Tailscale is not running")) { + status.state = "disconnected"; + return status; + } + + // Try to parse as JSON + try { + const data = JSON.parse(output); + const backendState = data.BackendState || ""; + + if (backendState === "Running") { + status.connected = true; + status.state = "connected"; + } else if (backendState === "Starting") { + status.state = "connecting"; + } else if (backendState === "NeedsLogin" || backendState === "NeedsMachineAuth") { + status.state = "needs-auth"; + status.reason = backendState === "NeedsLogin" ? "Login required" : "Machine authorization required"; + status.authUrl = data.AuthURL || ""; + } + } catch (e) { + // JSON parsing failed - treat as disconnected unless it looks like an error + if (output.includes("error") || output.includes("Error") || output.includes("failed")) { + status.state = "disconnected"; + status.reason = "Tailscale may not be running"; + } else { + status.state = "disconnected"; + } + } + return status; + } + + function parseNetBirdStatus(output: string): var { + const status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + try { + const data = JSON.parse(output); + const mgmtConnected = data.management?.connected; + const signalConnected = data.signal?.connected; + + if (mgmtConnected && signalConnected) { + status.connected = true; + status.state = "connected"; + } else if (data.management?.error) { + const error = data.management.error; + if (error.includes("auth") || error.includes("login")) { + status.state = "needs-auth"; + status.reason = "Authentication required"; + } else { + status.reason = error; + } + } + } catch (e) { + status.state = "error"; + status.reason = "Failed to parse status"; + } + return status; + } + + function parseWarpStatus(output: string): var { + const status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + + if (output.includes("Connected")) { + status.connected = true; + status.state = "connected"; + } else if (output.includes("Connecting")) { + status.state = "connecting"; + } else if (output.includes("Unable") || output.includes("Registration Missing") || output.includes("registration") || output.includes("register")) { + status.state = "needs-auth"; + status.reason = "WARP registration required"; + } else if (!output.includes("Disconnected")) { + status.state = "error"; + status.reason = "Unknown WARP status"; + } + return status; + } + + function parseWireGuardStatus(output: string): var { + const status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + const iface = root.currentConfig?.interface || ""; + + if (iface && output.includes(iface + ":")) { + status.connected = true; + status.state = "connected"; + } + return status; + } + + function parseStatusOutput(output: string): var { + switch (providerName) { + case "tailscale": + return parseTailscaleStatus(output); + case "netbird": + return parseNetBirdStatus(output); + case "warp": + return parseWarpStatus(output); + case "wireguard": + default: + return parseWireGuardStatus(output); + } + } + + function extractAuthUrl(text: string): string { + const urlMatch = text.match(/(https?:\/\/[^\s]+)/); + return urlMatch ? urlMatch[1] : ""; + } + + function createAuthStatus(authUrl: string): var { + return { + connected: false, + state: "needs-auth", + reason: "Authentication required", + authUrl: authUrl + }; + } + + function updateStatus(newStatus: var): void { + const oldState = status.state; + if (newStatus.state === "needs-auth" && !newStatus.authUrl && status.authUrl) { + newStatus.authUrl = status.authUrl; + } + status = newStatus; + root.connected = newStatus.connected; + + if (oldState !== newStatus.state) { + emitStatusToast(newStatus); + } + } + + function emitStatusToast(statusObj: var): void { if (!Config.utilities.toasts.vpnChanged) return; const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN"; - if (connected) { + + switch (statusObj.state) { + case "connected": Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key"); - } else { - Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off"); + break; + case "disconnected": + if (status.connected) { + Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off"); + } + break; + case "needs-auth": + const authMsg = statusObj.reason || "Authentication required"; + Toaster.toast(qsTr("VPN authentication required"), qsTr("%1: %2").arg(displayName).arg(authMsg), "vpn_lock"); + break; + case "error": + if (status.state === "connected" || status.state === "connecting" || status.state === "needs-auth") { + const errMsg = statusObj.reason || "Unknown error"; + Toaster.toast(qsTr("VPN error"), qsTr("%1: %2").arg(displayName).arg(errMsg), "error"); + } + break; } } + onStatusChanged: { + if (providerName === "warp" && status.state === "needs-auth" && status.reason.includes("registration")) { + warpRegisterProc.exec(["warp-cli", "registration", "new"]); + } + } + + onProviderNameChanged: { + status = { + connected: false, + state: "disconnected", + reason: "", + authUrl: "" + }; + root.connected = false; + statusCheckTimer.start(); + } + Component.onCompleted: root.enabled && statusCheckTimer.start() Process { @@ -127,7 +338,7 @@ Singleton { Process { id: statusProc - command: ["ip", "link", "show"] + command: root.getStatusCommand() // qmllint disable incompatible-type environment: ({ // qmllint enable incompatible-type @@ -136,8 +347,38 @@ Singleton { }) stdout: StdioCollector { onStreamFinished: { - const iface = root.currentConfig ? root.currentConfig.interface : ""; - root.connected = iface && text.includes(iface + ":"); + const newStatus = root.parseStatusOutput(text); + root.updateStatus(newStatus); + } + } + stderr: StdioCollector { + onStreamFinished: { + if (text.trim().length > 0) { + if (text.includes("doesn't appear to be running") || text.includes("failed to connect to local tailscaled") || text.includes("daemon is not running") || text.includes("not running") && (text.includes("netbird") || text.includes("warp"))) { + let cmd = "sudo systemctl start "; + switch (root.providerName) { + case "tailscale": + cmd += "tailscaled"; + break; + case "netbird": + cmd += "netbird"; + break; + case "warp": + cmd += "warp-svc"; + break; + default: + cmd += root.providerName + "d"; + break; + } + const errorStatus = { + connected: false, + state: "disconnected", + reason: `Service not running (run: ${cmd})`, + authUrl: "" + }; + root.updateStatus(errorStatus); + } + } } } } @@ -145,12 +386,59 @@ Singleton { Process { id: connectProc - onExited: statusCheckTimer.start() // qmllint disable signal-handler-parameters + onExited: exitCode => { // qmllint disable signal-handler-parameters + if (exitCode !== 0) { + return; + } + + if (root.providerName === "tailscale") { + Qt.callLater(() => { + if (root.status.state !== "needs-auth") { + statusCheckTimer.start(); + } + }); + } else if (root.status.state !== "needs-auth") { + statusCheckTimer.start(); + } + } + stdout: SplitParser { + onRead: data => { + const authUrl = root.extractAuthUrl(data); + if (authUrl) { + root.updateStatus(root.createAuthStatus(authUrl)); + } + } + } stderr: StdioCollector { onStreamFinished: { const error = text.trim(); - if (error && !error.includes("[#]") && !error.includes("already exists")) { - console.warn("VPN connection error:", error); + + if (error.includes("Access denied") || error.includes("checkprefs access denied")) { + const errorStatus = { + connected: false, + state: "disconnected", + reason: "Permission denied. Run in terminal: sudo tailscale set --operator=$USER", + authUrl: "" + }; + root.updateStatus(errorStatus); + return; + } + + if (error.includes("Unknown device type") || error.includes("Protocol not supported")) { + const errorStatus = { + connected: false, + state: "disconnected", + reason: "WireGuard module not loaded. Run: sudo modprobe wireguard", + authUrl: "" + }; + root.updateStatus(errorStatus); + return; + } + + const authUrl = root.extractAuthUrl(error); + + if (authUrl) { + root.updateStatus(root.createAuthStatus(authUrl)); } else if (error.includes("already exists")) { root.connected = true; } @@ -172,6 +460,16 @@ Singleton { } } + Process { + id: warpRegisterProc + + onExited: exitCode => { // qmllint disable signal-handler-parameters + if (exitCode === 0) { + statusCheckTimer.start(); + } + } + } + Timer { id: statusCheckTimer From 1f3656c2f6f715cbb2851732de4efad4f8f4c5d2 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:41:26 +1100 Subject: [PATCH 5/6] fix: performance cpu/gpu name overlapping usage Fixes #1337 --- modules/dashboard/Performance.qml | 93 +++++++++++++++---------------- 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/modules/dashboard/Performance.qml b/modules/dashboard/Performance.qml index 8d8d2c01..75198254 100644 --- a/modules/dashboard/Performance.qml +++ b/modules/dashboard/Performance.qml @@ -358,67 +358,60 @@ Item { anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom - width: parent.width * heroCard.animatedUsage + implicitWidth: parent.width * heroCard.animatedUsage color: Qt.alpha(heroCard.accentColor, 0.15) } - ColumnLayout { - anchors.fill: parent + CardHeader { + anchors.left: parent.left + anchors.top: parent.top anchors.leftMargin: Appearance.padding.large - anchors.rightMargin: Appearance.padding.large - anchors.topMargin: Appearance.padding.normal - anchors.bottomMargin: Appearance.padding.normal + anchors.topMargin: Math.round(Appearance.padding.large * 1.2) + + width: parent.width - anchors.leftMargin - usageColumn.anchors.rightMargin - usageLabel.width - Appearance.spacing.normal + icon: heroCard.icon + title: heroCard.title + accentColor: heroCard.accentColor + } + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Math.round(Appearance.padding.large * 1.2) + anchors.bottomMargin: Math.round(Appearance.padding.large * 1.3) + spacing: Appearance.spacing.small - CardHeader { - icon: heroCard.icon - title: heroCard.title - accentColor: heroCard.accentColor + Row { + spacing: Appearance.spacing.small + + StyledText { + text: heroCard.secondaryValue + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + } + + StyledText { + text: heroCard.secondaryLabel + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + anchors.baseline: parent.children[0].baseline + } } - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: Appearance.spacing.normal - - Column { - Layout.alignment: Qt.AlignBottom - Layout.fillWidth: true - spacing: Appearance.spacing.small - - Row { - spacing: Appearance.spacing.small - - StyledText { - text: heroCard.secondaryValue - font.pointSize: Appearance.font.size.normal - font.weight: Font.Medium - } - - StyledText { - text: heroCard.secondaryLabel - font.pointSize: Appearance.font.size.small - color: Colours.palette.m3onSurfaceVariant - anchors.baseline: parent.children[0].baseline - } - } - - ProgressBar { - width: parent.width * 0.5 - height: 6 - value: heroCard.tempProgress - fgColor: heroCard.accentColor - bgColor: Qt.alpha(heroCard.accentColor, 0.2) - } - } - - Item { - Layout.fillWidth: true - } + ProgressBar { + implicitWidth: parent.width * 0.5 + implicitHeight: 6 + value: heroCard.tempProgress + fgColor: heroCard.accentColor + bgColor: Qt.alpha(heroCard.accentColor, 0.2) } } Column { + id: usageColumn + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.margins: Appearance.padding.large @@ -426,6 +419,8 @@ Item { spacing: 0 StyledText { + id: usageLabel + anchors.right: parent.right text: heroCard.mainLabel font.pointSize: Appearance.font.size.normal From 3bdffca061f0a2b55728f15f20334b5740858f08 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:54:06 +1100 Subject: [PATCH 6/6] feat: c++ visualiser Closes #1126 --- modules/background/Visualiser.qml | 89 ++------ plugin/src/Caelestia/Internal/CMakeLists.txt | 1 + .../src/Caelestia/Internal/visualiserbars.cpp | 198 ++++++++++++++++++ .../src/Caelestia/Internal/visualiserbars.hpp | 72 +++++++ 4 files changed, 285 insertions(+), 75 deletions(-) create mode 100644 plugin/src/Caelestia/Internal/visualiserbars.cpp create mode 100644 plugin/src/Caelestia/Internal/visualiserbars.hpp diff --git a/modules/background/Visualiser.qml b/modules/background/Visualiser.qml index 780abdf4..2f59a300 100644 --- a/modules/background/Visualiser.qml +++ b/modules/background/Visualiser.qml @@ -3,7 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Effects import Quickshell -import Quickshell.Widgets +import Caelestia.Internal import Caelestia.Services import qs.components import qs.services @@ -55,25 +55,29 @@ Item { service: Audio.cava } - Item { - id: content + VisualiserBars { + id: bars anchors.fill: parent anchors.margins: Config.border.thickness anchors.leftMargin: Visibilities.bars.get(root.screen).exclusiveZone + Appearance.spacing.small * Config.background.visualiser.spacing - Side { - content: content - } - Side { - content: content - isRight: true - } + values: Audio.cava.values + primaryColor: Qt.alpha(Colours.palette.m3primary, 0.7) + secondaryColor: Qt.alpha(Colours.palette.m3inversePrimary, 0.7) + rounding: Appearance.rounding.small * Config.background.visualiser.rounding + spacing: Appearance.spacing.small * Config.background.visualiser.spacing + animationDuration: Appearance.anim.durations.normal Behavior on anchors.leftMargin { Anim {} } } + + FrameAnimation { + running: root.opacity > 0 && !bars.settled + onTriggered: bars.advance(frameTime) + } } } } @@ -85,69 +89,4 @@ Item { Behavior on opacity { Anim {} } - - component Side: Repeater { - id: side - - required property Item content - property bool isRight - - model: Config.services.visualiserBars - - ClippingRectangle { - id: bar - - required property int modelData - property real value: Math.max(0, Math.min(1, Audio.cava.values[side.isRight ? modelData : side.count - modelData - 1])) - - clip: true - - x: modelData * ((side.content.width * 0.4) / Config.services.visualiserBars) + (side.isRight ? side.content.width * 0.6 : 0) - implicitWidth: (side.content.width * 0.4) / Config.services.visualiserBars - Appearance.spacing.small * Config.background.visualiser.spacing - - y: side.content.height - height - implicitHeight: bar.value * side.content.height * 0.4 - - color: "transparent" - topLeftRadius: Appearance.rounding.small * Config.background.visualiser.rounding - topRightRadius: Appearance.rounding.small * Config.background.visualiser.rounding - - Rectangle { - topLeftRadius: parent.topLeftRadius - topRightRadius: parent.topRightRadius - - gradient: Gradient { - orientation: Gradient.Vertical - - GradientStop { - position: 0 - color: Qt.alpha(Colours.palette.m3primary, 0.7) - - Behavior on color { - CAnim {} - } - } - GradientStop { - position: 1 - color: Qt.alpha(Colours.palette.m3inversePrimary, 0.7) - - Behavior on color { - CAnim {} - } - } - } - - anchors.left: parent.left - anchors.right: parent.right - y: parent.height - height - implicitHeight: side.content.height * 0.4 - } - - Behavior on value { - Anim { - duration: Appearance.anim.durations.small - } - } - } - } } diff --git a/plugin/src/Caelestia/Internal/CMakeLists.txt b/plugin/src/Caelestia/Internal/CMakeLists.txt index 85e85c85..bc4a6948 100644 --- a/plugin/src/Caelestia/Internal/CMakeLists.txt +++ b/plugin/src/Caelestia/Internal/CMakeLists.txt @@ -9,6 +9,7 @@ qml_module(caelestia-internal hyprextras.hpp hyprextras.cpp logindmanager.hpp logindmanager.cpp sparklineitem.hpp sparklineitem.cpp + visualiserbars.hpp visualiserbars.cpp LIBRARIES Qt::Gui Qt::Quick diff --git a/plugin/src/Caelestia/Internal/visualiserbars.cpp b/plugin/src/Caelestia/Internal/visualiserbars.cpp new file mode 100644 index 00000000..926468b0 --- /dev/null +++ b/plugin/src/Caelestia/Internal/visualiserbars.cpp @@ -0,0 +1,198 @@ +#include "visualiserbars.hpp" + +#include +#include +#include +#include +#include +#include + +namespace caelestia::internal { + +VisualiserBars::VisualiserBars(QQuickItem* parent) + : QQuickPaintedItem(parent) { + setAntialiasing(true); +} + +void VisualiserBars::advance(qreal dt) { + if (m_displayValues.isEmpty() || m_settled) + return; + + // dt is in seconds (from FrameAnimation.frameTime), convert to ms + const qreal dtMs = dt * 1000.0; + const qreal tau = m_animationDuration / 3.0; + const qreal alpha = 1.0 - std::exp(-dtMs / tau); + + bool allSettled = true; + + for (qsizetype i = 0; i < m_displayValues.size(); ++i) { + const double diff = m_targetValues[i] - m_displayValues[i]; + + if (std::abs(diff) > 0.001) { + m_displayValues[i] += diff * alpha; + allSettled = false; + } else { + m_displayValues[i] = m_targetValues[i]; + } + } + + update(); + + if (allSettled && !m_settled) { + m_settled = true; + emit settledChanged(); + } +} + +void VisualiserBars::paint(QPainter* painter) { + if (m_displayValues.isEmpty()) + return; + + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(Qt::NoPen); + + const qreal h = height(); + const qreal maxBarHeight = h * 0.4; + + QLinearGradient gradient(0, h - maxBarHeight, 0, h); + gradient.setColorAt(0, m_primaryColor); + gradient.setColorAt(1, m_secondaryColor); + painter->setBrush(gradient); + + drawSide(painter, false); + drawSide(painter, true); +} + +void VisualiserBars::drawSide(QPainter* painter, bool rightSide) { + const qreal w = width(); + const qreal h = height(); + const auto count = m_displayValues.size(); + + if (count == 0) + return; + + const qreal sideWidth = w * 0.4; + const qreal slotWidth = sideWidth / static_cast(count); + const qreal barWidth = slotWidth - m_spacing; + + if (barWidth <= 0) + return; + + const qreal sideOffset = rightSide ? w * 0.6 : 0; + const qreal maxBarHeight = h * 0.4; + + for (qsizetype i = 0; i < count; ++i) { + const qsizetype valueIndex = rightSide ? i : (count - i - 1); + const qreal value = std::clamp(m_displayValues[valueIndex], 0.0, 1.0); + const qreal barHeight = value * maxBarHeight; + + if (barHeight <= 0) + continue; + + const qreal x = static_cast(i) * slotWidth + sideOffset; + const qreal y = h - barHeight; + const qreal r = std::min({ m_rounding, barWidth / 2.0, barHeight }); + + QPainterPath path; + path.moveTo(x, h); + path.lineTo(x, y + r); + + if (r > 0) { + path.arcTo(x, y, r * 2, r * 2, 180, -90); + path.lineTo(x + barWidth - r, y); + path.arcTo(x + barWidth - r * 2, y, r * 2, r * 2, 90, -90); + } else { + path.lineTo(x, y); + path.lineTo(x + barWidth, y); + } + + path.lineTo(x + barWidth, h); + path.closeSubpath(); + + painter->drawPath(path); + } +} + +QVector VisualiserBars::values() const { + return m_targetValues; +} + +void VisualiserBars::setValues(const QVector& values) { + m_targetValues = values; + + if (m_displayValues.size() != values.size()) { + m_displayValues.resize(values.size(), 0.0); + } + + if (m_settled) { + m_settled = false; + emit settledChanged(); + } + + emit valuesChanged(); +} + +bool VisualiserBars::settled() const { + return m_settled; +} + +QColor VisualiserBars::primaryColor() const { + return m_primaryColor; +} + +void VisualiserBars::setPrimaryColor(const QColor& color) { + if (m_primaryColor == color) + return; + m_primaryColor = color; + emit primaryColorChanged(); + update(); +} + +QColor VisualiserBars::secondaryColor() const { + return m_secondaryColor; +} + +void VisualiserBars::setSecondaryColor(const QColor& color) { + if (m_secondaryColor == color) + return; + m_secondaryColor = color; + emit secondaryColorChanged(); + update(); +} + +qreal VisualiserBars::rounding() const { + return m_rounding; +} + +void VisualiserBars::setRounding(qreal rounding) { + if (qFuzzyCompare(m_rounding, rounding)) + return; + m_rounding = rounding; + emit roundingChanged(); + update(); +} + +qreal VisualiserBars::spacing() const { + return m_spacing; +} + +void VisualiserBars::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) + return; + m_spacing = spacing; + emit spacingChanged(); + update(); +} + +int VisualiserBars::animationDuration() const { + return m_animationDuration; +} + +void VisualiserBars::setAnimationDuration(int duration) { + if (m_animationDuration == duration) + return; + m_animationDuration = duration; + emit animationDurationChanged(); +} + +} // namespace caelestia::internal diff --git a/plugin/src/Caelestia/Internal/visualiserbars.hpp b/plugin/src/Caelestia/Internal/visualiserbars.hpp new file mode 100644 index 00000000..95c07124 --- /dev/null +++ b/plugin/src/Caelestia/Internal/visualiserbars.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace caelestia::internal { + +class VisualiserBars : public QQuickPaintedItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QVector values READ values WRITE setValues NOTIFY valuesChanged) + Q_PROPERTY(QColor primaryColor READ primaryColor WRITE setPrimaryColor NOTIFY primaryColorChanged) + Q_PROPERTY(QColor secondaryColor READ secondaryColor WRITE setSecondaryColor NOTIFY secondaryColorChanged) + Q_PROPERTY(qreal rounding READ rounding WRITE setRounding NOTIFY roundingChanged) + Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + Q_PROPERTY(int animationDuration READ animationDuration WRITE setAnimationDuration NOTIFY animationDurationChanged) + Q_PROPERTY(bool settled READ settled NOTIFY settledChanged) + +public: + explicit VisualiserBars(QQuickItem* parent = nullptr); + + void paint(QPainter* painter) override; + + Q_INVOKABLE void advance(qreal dt); + + [[nodiscard]] QVector values() const; + void setValues(const QVector& values); + + [[nodiscard]] QColor primaryColor() const; + void setPrimaryColor(const QColor& color); + + [[nodiscard]] QColor secondaryColor() const; + void setSecondaryColor(const QColor& color); + + [[nodiscard]] qreal rounding() const; + void setRounding(qreal rounding); + + [[nodiscard]] qreal spacing() const; + void setSpacing(qreal spacing); + + [[nodiscard]] int animationDuration() const; + void setAnimationDuration(int duration); + + [[nodiscard]] bool settled() const; + +signals: + void valuesChanged(); + void primaryColorChanged(); + void secondaryColorChanged(); + void roundingChanged(); + void spacingChanged(); + void animationDurationChanged(); + void settledChanged(); + +private: + void drawSide(QPainter* painter, bool rightSide); + + QVector m_targetValues; + QVector m_displayValues; + QColor m_primaryColor; + QColor m_secondaryColor; + qreal m_rounding = 0.0; + qreal m_spacing = 0.0; + int m_animationDuration = 200; + bool m_settled = true; +}; + +} // namespace caelestia::internal