diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index d1c48860..aaef3201 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -14,7 +14,7 @@ import "settings/SettingsStore.js" as Store Singleton { id: root - readonly property int settingsConfigVersion: 5 + readonly property int settingsConfigVersion: 7 readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" @@ -186,6 +186,7 @@ Singleton { onPopoutElevationEnabledChanged: saveSettings() property bool barElevationEnabled: true onBarElevationEnabledChanged: saveSettings() + property bool blurEnabled: false onBlurEnabledChanged: saveSettings() property string blurBorderColor: "outline" @@ -198,6 +199,21 @@ Singleton { property bool blurredWallpaperLayer: false property bool blurWallpaperOnOverview: false + property bool frameEnabled: false + onFrameEnabledChanged: saveSettings() + property real frameThickness: 15 + onFrameThicknessChanged: saveSettings() + property real frameRounding: 24 + onFrameRoundingChanged: saveSettings() + property string frameColor: "#2a2a2a" + onFrameColorChanged: saveSettings() + property real frameOpacity: 1.0 + onFrameOpacityChanged: saveSettings() + property bool frameSyncBarColor: true + onFrameSyncBarColorChanged: saveSettings() + property var frameScreenPreferences: ["all"] + onFrameScreenPreferencesChanged: saveSettings() + property bool showLauncherButton: true property bool showWorkspaceSwitcher: true property bool showFocusedWindow: true @@ -1938,6 +1954,47 @@ Singleton { return filtered; } + function getFrameFilteredScreens() { + var prefs = frameScreenPreferences || ["all"]; + if (!prefs || prefs.length === 0 || prefs.includes("all")) { + return Quickshell.screens; + } + return Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs)); + } + + function getActiveBarEdgeForScreen(screen) { + if (!screen) return ""; + for (var i = 0; i < barConfigs.length; i++) { + var bc = barConfigs[i]; + if (!bc.enabled) continue; + var prefs = bc.screenPreferences || ["all"]; + if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs)) continue; + switch (bc.position ?? 0) { + case SettingsData.Position.Top: return "top"; + case SettingsData.Position.Bottom: return "bottom"; + case SettingsData.Position.Left: return "left"; + case SettingsData.Position.Right: return "right"; + } + } + return ""; + } + + function getActiveBarThicknessForScreen(screen) { + if (!screen) return frameThickness; + for (var i = 0; i < barConfigs.length; i++) { + var bc = barConfigs[i]; + if (!bc.enabled) continue; + var prefs = bc.screenPreferences || ["all"]; + if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs)) continue; + const innerPadding = bc.innerPadding ?? 4; + const barT = Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding)); + const spacing = bc.spacing ?? 4; + const bottomGap = bc.bottomGap ?? 0; + return barT + spacing + bottomGap; + } + return frameThickness; + } + function sendTestNotifications() { NotificationService.dismissAllPopups(); sendTestNotification(0); diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 803124c5..1045ddbd 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -547,7 +547,15 @@ var SPEC = { clipboardEnterToPaste: { def: false }, launcherPluginVisibility: { def: {} }, - launcherPluginOrder: { def: [] } + launcherPluginOrder: { def: [] }, + + frameEnabled: { def: false }, + frameThickness: { def: 15 }, + frameRounding: { def: 24 }, + frameColor: { def: "#2a2a2a" }, + frameOpacity: { def: 1.0 }, + frameSyncBarColor: { def: true }, + frameScreenPreferences: { def: ["all"] } }; function getValidKeys() { diff --git a/quickshell/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js index f88bb03b..1e5a72fc 100644 --- a/quickshell/Common/settings/SettingsStore.js +++ b/quickshell/Common/settings/SettingsStore.js @@ -248,6 +248,20 @@ function migrateToVersion(obj, targetVersion) { settings.configVersion = 6; } + if (currentVersion < 7) { + console.info("Migrating settings from version", currentVersion, "to version 7"); + + if (settings.frameEnabled === undefined) settings.frameEnabled = false; + if (settings.frameThickness === undefined) settings.frameThickness = 15; + if (settings.frameRounding === undefined) settings.frameRounding = 24; + if (settings.frameColor === undefined) settings.frameColor = "#2a2a2a"; + if (settings.frameOpacity === undefined) settings.frameOpacity = 1.0; + if (settings.frameSyncBarColor === undefined) settings.frameSyncBarColor = true; + if (settings.frameScreenPreferences === undefined) settings.frameScreenPreferences = ["all"]; + + settings.configVersion = 7; + } + return settings; } diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index bf436f85..c50b5fdd 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -21,6 +21,7 @@ import qs.Modules.OSD import qs.Modules.ProcessList import qs.Modules.DankBar import qs.Modules.DankBar.Popouts +import qs.Modules.Frame import qs.Modules.WorkspaceOverlays import qs.Services @@ -207,6 +208,8 @@ Item { } } + Frame {} + property bool dockEnabled: false Timer { diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 34cd51ae..e16bb637 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -518,5 +518,20 @@ FocusScope { Qt.callLater(() => item.forceActiveFocus()); } } + + Loader { + id: frameLoader + anchors.fill: parent + active: root.currentIndex === 33 + visible: active + focus: active + + sourceComponent: FrameTab {} + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } } } diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 092881c0..066043f7 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -120,6 +120,12 @@ Rectangle { "text": I18n.tr("Widgets"), "icon": "widgets", "tabIndex": 22 + }, + { + "id": "frame", + "text": I18n.tr("Frame"), + "icon": "frame_source", + "tabIndex": 33 } ] }, diff --git a/quickshell/Modules/DankBar/BarCanvas.qml b/quickshell/Modules/DankBar/BarCanvas.qml index df3f17fb..30d67286 100644 --- a/quickshell/Modules/DankBar/BarCanvas.qml +++ b/quickshell/Modules/DankBar/BarCanvas.qml @@ -37,6 +37,8 @@ Item { } property real rt: { + if (SettingsData.frameEnabled) + return Math.max(0, SettingsData.frameRounding - SettingsData.frameThickness); if (barConfig?.squareCorners ?? false) return 0; if (barWindow.hasMaximizedToplevel) @@ -255,11 +257,12 @@ Item { h = h - wing; const r = wing; const cr = rt; + const crE = SettingsData.frameEnabled ? 0 : cr; - let d = `M ${cr} 0`; - d += ` L ${w - cr} 0`; - if (cr > 0) - d += ` A ${cr} ${cr} 0 0 1 ${w} ${cr}`; + let d = `M ${crE} 0`; + d += ` L ${w - crE} 0`; + if (crE > 0) + d += ` A ${crE} ${crE} 0 0 1 ${w} ${crE}`; if (r > 0) { d += ` L ${w} ${h + r}`; d += ` A ${r} ${r} 0 0 0 ${w - r} ${h}`; @@ -273,9 +276,9 @@ Item { if (cr > 0) d += ` A ${cr} ${cr} 0 0 1 0 ${h - cr}`; } - d += ` L 0 ${cr}`; - if (cr > 0) - d += ` A ${cr} ${cr} 0 0 1 ${cr} 0`; + d += ` L 0 ${crE}`; + if (crE > 0) + d += ` A ${crE} ${crE} 0 0 1 ${crE} 0`; d += " Z"; return d; } @@ -285,11 +288,12 @@ Item { h = h - wing; const r = wing; const cr = rt; + const crE = SettingsData.frameEnabled ? 0 : cr; - let d = `M ${cr} ${fullH}`; - d += ` L ${w - cr} ${fullH}`; - if (cr > 0) - d += ` A ${cr} ${cr} 0 0 0 ${w} ${fullH - cr}`; + let d = `M ${crE} ${fullH}`; + d += ` L ${w - crE} ${fullH}`; + if (crE > 0) + d += ` A ${crE} ${crE} 0 0 0 ${w} ${fullH - crE}`; if (r > 0) { d += ` L ${w} 0`; d += ` A ${r} ${r} 0 0 1 ${w - r} ${r}`; @@ -303,9 +307,9 @@ Item { if (cr > 0) d += ` A ${cr} ${cr} 0 0 0 0 ${cr}`; } - d += ` L 0 ${fullH - cr}`; - if (cr > 0) - d += ` A ${cr} ${cr} 0 0 0 ${cr} ${fullH}`; + d += ` L 0 ${fullH - crE}`; + if (crE > 0) + d += ` A ${crE} ${crE} 0 0 0 ${crE} ${fullH}`; d += " Z"; return d; } @@ -314,11 +318,12 @@ Item { w = w - wing; const r = wing; const cr = rt; + const crE = SettingsData.frameEnabled ? 0 : cr; - let d = `M 0 ${cr}`; - d += ` L 0 ${h - cr}`; - if (cr > 0) - d += ` A ${cr} ${cr} 0 0 0 ${cr} ${h}`; + let d = `M 0 ${crE}`; + d += ` L 0 ${h - crE}`; + if (crE > 0) + d += ` A ${crE} ${crE} 0 0 0 ${crE} ${h}`; if (r > 0) { d += ` L ${w + r} ${h}`; d += ` A ${r} ${r} 0 0 1 ${w} ${h - r}`; @@ -332,9 +337,9 @@ Item { if (cr > 0) d += ` A ${cr} ${cr} 0 0 0 ${w - cr} 0`; } - d += ` L ${cr} 0`; - if (cr > 0) - d += ` A ${cr} ${cr} 0 0 0 0 ${cr}`; + d += ` L ${crE} 0`; + if (crE > 0) + d += ` A ${crE} ${crE} 0 0 0 0 ${crE}`; d += " Z"; return d; } @@ -344,11 +349,12 @@ Item { w = w - wing; const r = wing; const cr = rt; + const crE = SettingsData.frameEnabled ? 0 : cr; - let d = `M ${fullW} ${cr}`; - d += ` L ${fullW} ${h - cr}`; - if (cr > 0) - d += ` A ${cr} ${cr} 0 0 1 ${fullW - cr} ${h}`; + let d = `M ${fullW} ${crE}`; + d += ` L ${fullW} ${h - crE}`; + if (crE > 0) + d += ` A ${crE} ${crE} 0 0 1 ${fullW - crE} ${h}`; if (r > 0) { d += ` L 0 ${h}`; d += ` A ${r} ${r} 0 0 0 ${r} ${h - r}`; @@ -362,9 +368,9 @@ Item { if (cr > 0) d += ` A ${cr} ${cr} 0 0 1 ${cr} 0`; } - d += ` L ${fullW - cr} 0`; - if (cr > 0) - d += ` A ${cr} ${cr} 0 0 1 ${fullW} ${cr}`; + d += ` L ${fullW - crE} 0`; + if (crE > 0) + d += ` A ${crE} ${crE} 0 0 1 ${fullW} ${crE}`; d += " Z"; return d; } diff --git a/quickshell/Modules/DankBar/DankBarWindow.qml b/quickshell/Modules/DankBar/DankBarWindow.qml index d8b0216c..26fec7f7 100644 --- a/quickshell/Modules/DankBar/DankBarWindow.qml +++ b/quickshell/Modules/DankBar/DankBarWindow.qml @@ -238,7 +238,9 @@ PanelWindow { readonly property color _surfaceContainer: Theme.surfaceContainer readonly property string _barId: barConfig?.id ?? "default" property real _backgroundAlpha: barConfig?.transparency ?? 1.0 - readonly property color _bgColor: Theme.withAlpha(_surfaceContainer, _backgroundAlpha) + readonly property color _bgColor: (SettingsData.frameEnabled && SettingsData.frameSyncBarColor) + ? SettingsData.frameColor + : Theme.withAlpha(_surfaceContainer, _backgroundAlpha) function _updateBackgroundAlpha() { const live = SettingsData.barConfigs.find(c => c.id === _barId); @@ -384,7 +386,7 @@ PanelWindow { shouldHideForWindows = filtered.length > 0; } - property real effectiveSpacing: hasMaximizedToplevel ? 0 : (barConfig?.spacing ?? 4) + property real effectiveSpacing: SettingsData.frameEnabled ? 0 : (hasMaximizedToplevel ? 0 : (barConfig?.spacing ?? 4)) Behavior on effectiveSpacing { enabled: barWindow.visible diff --git a/quickshell/Modules/Frame/Frame.qml b/quickshell/Modules/Frame/Frame.qml new file mode 100644 index 00000000..ab3febbc --- /dev/null +++ b/quickshell/Modules/Frame/Frame.qml @@ -0,0 +1,17 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common + +Variants { + id: root + + model: SettingsData.frameEnabled ? SettingsData.getFrameFilteredScreens() : [] + + FrameInstance { + required property ShellScreen modelData + + screen: modelData + } +} diff --git a/quickshell/Modules/Frame/FrameBorder.qml b/quickshell/Modules/Frame/FrameBorder.qml new file mode 100644 index 00000000..0f845f64 --- /dev/null +++ b/quickshell/Modules/Frame/FrameBorder.qml @@ -0,0 +1,53 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import qs.Common + +Item { + id: root + + required property string barEdge // "top" | "bottom" | "left" | "right" | "" + required property real barThickness + + anchors.fill: parent + + readonly property real _thickness: SettingsData.frameThickness + readonly property real _rounding: SettingsData.frameRounding + + Rectangle { + id: borderRect + + anchors.fill: parent + color: SettingsData.frameColor + opacity: SettingsData.frameOpacity + + layer.enabled: true + layer.effect: MultiEffect { + maskSource: cutoutMask + maskEnabled: true + maskInverted: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + } + + Item { + id: cutoutMask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + anchors { + fill: parent + topMargin: root.barEdge === "top" ? root.barThickness : root._thickness + bottomMargin: root.barEdge === "bottom" ? root.barThickness : root._thickness + leftMargin: root.barEdge === "left" ? root.barThickness : root._thickness + rightMargin: root.barEdge === "right" ? root.barThickness : root._thickness + } + radius: root._rounding + } + } +} diff --git a/quickshell/Modules/Frame/FrameExclusions.qml b/quickshell/Modules/Frame/FrameExclusions.qml new file mode 100644 index 00000000..9c3d6e0a --- /dev/null +++ b/quickshell/Modules/Frame/FrameExclusions.qml @@ -0,0 +1,80 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common + +Scope { + id: root + + required property ShellScreen screen + + readonly property string barEdge: SettingsData.getActiveBarEdgeForScreen(screen) + + // One thin invisible PanelWindow per edge. + // Skips the edge where the bar already provides its own exclusiveZone. + + Loader { + active: root.barEdge !== "top" + sourceComponent: EdgeExclusion { + screen: root.screen + anchorTop: true + anchorLeft: true + anchorRight: true + } + } + + Loader { + active: root.barEdge !== "bottom" + sourceComponent: EdgeExclusion { + screen: root.screen + anchorBottom: true + anchorLeft: true + anchorRight: true + } + } + + Loader { + active: root.barEdge !== "left" + sourceComponent: EdgeExclusion { + screen: root.screen + anchorLeft: true + anchorTop: true + anchorBottom: true + } + } + + Loader { + active: root.barEdge !== "right" + sourceComponent: EdgeExclusion { + screen: root.screen + anchorRight: true + anchorTop: true + anchorBottom: true + } + } + + component EdgeExclusion: PanelWindow { + required property ShellScreen screen + property bool anchorTop: false + property bool anchorBottom: false + property bool anchorLeft: false + property bool anchorRight: false + + WlrLayershell.namespace: "dms:frame-exclusion" + WlrLayershell.layer: WlrLayer.Top + exclusiveZone: SettingsData.frameThickness + color: "transparent" + mask: Region {} + implicitWidth: 1 + implicitHeight: 1 + + anchors { + top: anchorTop + bottom: anchorBottom + left: anchorLeft + right: anchorRight + } + } +} diff --git a/quickshell/Modules/Frame/FrameInstance.qml b/quickshell/Modules/Frame/FrameInstance.qml new file mode 100644 index 00000000..c883d630 --- /dev/null +++ b/quickshell/Modules/Frame/FrameInstance.qml @@ -0,0 +1,18 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell + +Item { + id: root + + required property ShellScreen screen + + FrameWindow { + screen: root.screen + } + + FrameExclusions { + screen: root.screen + } +} diff --git a/quickshell/Modules/Frame/FrameWindow.qml b/quickshell/Modules/Frame/FrameWindow.qml new file mode 100644 index 00000000..4952a80b --- /dev/null +++ b/quickshell/Modules/Frame/FrameWindow.qml @@ -0,0 +1,34 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common + +PanelWindow { + id: win + + required property ShellScreen screen + + WlrLayershell.namespace: "dms:frame" + WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + anchors { + top: true + bottom: true + left: true + right: true + } + + color: "transparent" + + // No input — pass everything through to apps and bar + mask: Region {} + + FrameBorder { + anchors.fill: parent + barEdge: SettingsData.getActiveBarEdgeForScreen(win.screen) + barThickness: SettingsData.getActiveBarThicknessForScreen(win.screen) + } +} diff --git a/quickshell/Modules/Settings/FrameTab.qml b/quickshell/Modules/Settings/FrameTab.qml new file mode 100644 index 00000000..c4fb4eee --- /dev/null +++ b/quickshell/Modules/Settings/FrameTab.qml @@ -0,0 +1,195 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + topPadding: 4 + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + // ── Enable Frame ────────────────────────────────────────────────── + SettingsCard { + width: parent.width + iconName: "frame_source" + title: I18n.tr("Frame") + settingKey: "frameEnabled" + + SettingsToggleRow { + settingKey: "frameEnable" + tags: ["frame", "border", "outline", "display"] + text: I18n.tr("Enable Frame") + description: I18n.tr("Draw a connected picture-frame border around the entire display") + checked: SettingsData.frameEnabled + onToggled: checked => SettingsData.set("frameEnabled", checked) + } + } + + // ── Border ──────────────────────────────────────────────────────── + SettingsCard { + width: parent.width + iconName: "border_outer" + title: I18n.tr("Border") + settingKey: "frameBorder" + collapsible: true + visible: SettingsData.frameEnabled + + SettingsSliderRow { + id: roundingSlider + settingKey: "frameRounding" + tags: ["frame", "border", "rounding", "radius", "corner"] + text: I18n.tr("Border rounding") + unit: "px" + minimum: 0 + maximum: 100 + step: 1 + defaultValue: 24 + value: SettingsData.frameRounding + onSliderDragFinished: v => SettingsData.set("frameRounding", v) + + Binding { + target: roundingSlider + property: "value" + value: SettingsData.frameRounding + } + } + + SettingsSliderRow { + id: thicknessSlider + settingKey: "frameThickness" + tags: ["frame", "border", "thickness", "size", "width"] + text: I18n.tr("Border thickness") + unit: "px" + minimum: 2 + maximum: 100 + step: 1 + defaultValue: 15 + value: SettingsData.frameThickness + onSliderDragFinished: v => SettingsData.set("frameThickness", v) + + Binding { + target: thicknessSlider + property: "value" + value: SettingsData.frameThickness + } + } + + SettingsSliderRow { + id: opacitySlider + settingKey: "frameOpacity" + tags: ["frame", "border", "opacity", "transparency"] + text: I18n.tr("Border opacity") + unit: "%" + minimum: 0 + maximum: 100 + defaultValue: 100 + value: SettingsData.frameOpacity * 100 + onSliderDragFinished: v => SettingsData.set("frameOpacity", v / 100) + + Binding { + target: opacitySlider + property: "value" + value: SettingsData.frameOpacity * 100 + } + } + + // Color row + Item { + width: parent.width + height: colorRow.height + Theme.spacingM * 2 + + Row { + id: colorRow + width: parent.width - Theme.spacingM * 2 + x: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Border color") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + Rectangle { + id: colorSwatch + anchors.verticalCenter: parent.verticalCenter + width: 32 + height: 32 + radius: 16 + color: SettingsData.frameColor + border.color: Theme.outline + border.width: 1 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + PopoutService.colorPickerModal.selectedColor = SettingsData.frameColor; + PopoutService.colorPickerModal.pickerTitle = I18n.tr("Frame Border Color"); + PopoutService.colorPickerModal.onColorSelectedCallback = function (color) { + SettingsData.set("frameColor", color.toString()); + }; + PopoutService.colorPickerModal.show(); + } + } + } + } + } + } + + // ── Bar Integration ─────────────────────────────────────────────── + SettingsCard { + width: parent.width + iconName: "toolbar" + title: I18n.tr("Bar Integration") + settingKey: "frameBarIntegration" + collapsible: true + visible: SettingsData.frameEnabled + + SettingsToggleRow { + settingKey: "frameSyncBarColor" + tags: ["frame", "bar", "sync", "color", "background"] + text: I18n.tr("Sync bar background to frame") + description: I18n.tr("Sets the bar background color to match the frame border color for a seamless look") + checked: SettingsData.frameSyncBarColor + onToggled: checked => SettingsData.set("frameSyncBarColor", checked) + } + } + + // ── Display Assignment ──────────────────────────────────────────── + SettingsCard { + width: parent.width + iconName: "monitor" + title: I18n.tr("Display Assignment") + settingKey: "frameDisplays" + collapsible: true + visible: SettingsData.frameEnabled + + SettingsDisplayPicker { + displayPreferences: SettingsData.frameScreenPreferences + onPreferencesChanged: prefs => SettingsData.set("frameScreenPreferences", prefs) + } + } + } + } +}