diff --git a/core/.pre-commit-config.yaml b/core/.pre-commit-config.yaml index 7eb6228b..a2b5ddaf 100644 --- a/core/.pre-commit-config.yaml +++ b/core/.pre-commit-config.yaml @@ -1,26 +1,13 @@ repos: - - repo: local + - repo: https://github.com/golangci/golangci-lint + rev: v2.10.1 hooks: - id: golangci-lint-fmt - name: golangci-lint-fmt - entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 fmt - language: system require_serial: true - types: [go] - pass_filenames: false - id: golangci-lint-full - name: golangci-lint-full - entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 run --fix - language: system - require_serial: true - types: [go] - pass_filenames: false - id: golangci-lint-config-verify - name: golangci-lint-config-verify - entry: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3 config verify - language: system - files: \.golangci\.(?:yml|yaml|toml|json) - pass_filenames: false + - repo: local + hooks: - id: go-test name: go test entry: go test ./... diff --git a/quickshell/Common/AnimVariants.qml b/quickshell/Common/AnimVariants.qml new file mode 100644 index 00000000..676af6d1 --- /dev/null +++ b/quickshell/Common/AnimVariants.qml @@ -0,0 +1,62 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common + +// AnimVariants — central tuning for animation variants (Material/Fluent/Dynamic) +// and motion effects (Standard/Directional/Depth). Lookups are indexed by enum +// value: animationVariant 0=Material, 1=Fluent, 2=Dynamic; motionEffect +// 0=Standard, 1=Directional, 2=Depth. + +Singleton { + id: root + + readonly property int _variant: (typeof SettingsData === "undefined") ? 0 : SettingsData.animationVariant + readonly property int _effect: (typeof SettingsData === "undefined") ? 0 : SettingsData.motionEffect + + readonly property var _enterCurves: [Anims.expressiveDefaultSpatial, Anims.standardDecel, Anims.expressiveFastSpatial] + readonly property var _exitCurves: [Anims.emphasized, Anims.standard, Anims.emphasized] + readonly property var _directionalExitCurves: [Anims.emphasized, Anims.emphasizedAccel, Anims.emphasizedAccel] + readonly property var _enterDurationFactors: [1.0, 0.9, 1.08] + readonly property var _exitDurationFactors: [1.0, 0.85, 0.92] + readonly property var _cleanupPaddings: [50, 8, 24] + readonly property var _effectScaleCollapsed: [0.96, 1.0, 0.88] + readonly property var _effectAnimOffsets: [16, 144, 56] + + readonly property list variantEnterCurve: _enterCurves[_variant] || _enterCurves[0] + readonly property list variantExitCurve: _exitCurves[_variant] || _exitCurves[0] + + readonly property list variantModalEnterCurve: isDirectionalEffect && _variant !== 0 ? (_enterCurves[_variant] || _enterCurves[0]) : variantEnterCurve + readonly property list variantModalExitCurve: isDirectionalEffect ? (_directionalExitCurves[_variant] || _exitCurves[0]) : variantExitCurve + + readonly property list variantPopoutEnterCurve: isDirectionalEffect ? (_variant === 0 ? Anims.standardDecel : (_enterCurves[_variant] || _enterCurves[0])) : variantEnterCurve + readonly property list variantPopoutExitCurve: isDirectionalEffect ? (_directionalExitCurves[_variant] || _exitCurves[0]) : variantExitCurve + + readonly property real variantEnterDurationFactor: _enterDurationFactors[_variant] !== undefined ? _enterDurationFactors[_variant] : 1.0 + readonly property real variantExitDurationFactor: _exitDurationFactors[_variant] !== undefined ? _exitDurationFactors[_variant] : 1.0 + + // Fluent: opacity at ~55% of duration; Material/Dynamic: 1:1 with position + readonly property real variantOpacityDurationScale: _variant === 1 ? 0.55 : 1.0 + + function variantDuration(baseDuration, entering) { + const factor = entering ? variantEnterDurationFactor : variantExitDurationFactor; + return Math.max(0, Math.round(baseDuration * factor)); + } + + function variantExitCleanupPadding() { + return _cleanupPaddings[_effect] !== undefined ? _cleanupPaddings[_effect] : 50; + } + + function variantCloseInterval(baseDuration) { + return variantDuration(baseDuration, false) + variantExitCleanupPadding(); + } + + readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1 + readonly property bool isDepthEffect: _effect === 2 + readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.connectedFrameModeActive + + readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96 + readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16 +} diff --git a/quickshell/Common/Anims.qml b/quickshell/Common/Anims.qml index 349e9916..f8e0c9a5 100644 --- a/quickshell/Common/Anims.qml +++ b/quickshell/Common/Anims.qml @@ -22,4 +22,9 @@ Singleton { readonly property var standard: [0.20, 0.00, 0.00, 1.00, 1.00, 1.00] readonly property var standardDecel: [0.00, 0.00, 0.00, 1.00, 1.00, 1.00] readonly property var standardAccel: [0.30, 0.00, 1.00, 1.00, 1.00, 1.00] + + // Used by AnimVariants for variant/effect logic + readonly property var expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] + readonly property var expressiveFastSpatial: [0.34, 1.5, 0.2, 1.0, 1.0, 1.0] + readonly property var expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] } diff --git a/quickshell/Common/ConnectedModeState.qml b/quickshell/Common/ConnectedModeState.qml new file mode 100644 index 00000000..e8bbd92c --- /dev/null +++ b/quickshell/Common/ConnectedModeState.qml @@ -0,0 +1,481 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell + +Singleton { + id: root + + readonly property var emptyDockState: ({ + "reveal": false, + "barSide": "bottom", + "bodyX": 0, + "bodyY": 0, + "bodyW": 0, + "bodyH": 0, + "slideX": 0, + "slideY": 0 + }) + + // Popout state (updated by DankPopout when connectedFrameModeActive) + property string popoutOwnerId: "" + property bool popoutVisible: false + property string popoutBarSide: "top" + property real popoutBodyX: 0 + property real popoutBodyY: 0 + property real popoutBodyW: 0 + property real popoutBodyH: 0 + property real popoutAnimX: 0 + property real popoutAnimY: 0 + property string popoutScreen: "" + property bool popoutOmitStartConnector: false + property bool popoutOmitEndConnector: false + + // Dock state (updated by Dock when connectedFrameModeActive), keyed by screen.name + property var dockStates: ({}) + + // Dock slide offsets — hot-path updates separated from full geometry state + property var dockSlides: ({}) + + function _cloneDict(src) { + const next = {}; + for (const k in src) + next[k] = src[k]; + return next; + } + + function hasPopoutOwner(claimId) { + return !!claimId && popoutOwnerId === claimId; + } + + function claimPopout(claimId, state) { + if (!claimId) + return false; + + popoutOwnerId = claimId; + return updatePopout(claimId, state); + } + + function updatePopout(claimId, state) { + if (!hasPopoutOwner(claimId) || !state) + return false; + + if (state.visible !== undefined) + popoutVisible = !!state.visible; + if (state.barSide !== undefined) + popoutBarSide = state.barSide || "top"; + if (state.bodyX !== undefined) + popoutBodyX = Number(state.bodyX); + if (state.bodyY !== undefined) + popoutBodyY = Number(state.bodyY); + if (state.bodyW !== undefined) + popoutBodyW = Number(state.bodyW); + if (state.bodyH !== undefined) + popoutBodyH = Number(state.bodyH); + if (state.animX !== undefined) + popoutAnimX = Number(state.animX); + if (state.animY !== undefined) + popoutAnimY = Number(state.animY); + if (state.screen !== undefined) + popoutScreen = state.screen || ""; + if (state.omitStartConnector !== undefined) + popoutOmitStartConnector = !!state.omitStartConnector; + if (state.omitEndConnector !== undefined) + popoutOmitEndConnector = !!state.omitEndConnector; + + return true; + } + + function releasePopout(claimId) { + if (!hasPopoutOwner(claimId)) + return false; + + popoutOwnerId = ""; + popoutVisible = false; + popoutBarSide = "top"; + popoutBodyX = 0; + popoutBodyY = 0; + popoutBodyW = 0; + popoutBodyH = 0; + popoutAnimX = 0; + popoutAnimY = 0; + popoutScreen = ""; + popoutOmitStartConnector = false; + popoutOmitEndConnector = false; + return true; + } + + function setPopoutAnim(claimId, animX, animY) { + if (!hasPopoutOwner(claimId)) + return false; + if (animX !== undefined) { + const nextX = Number(animX); + if (!isNaN(nextX) && popoutAnimX !== nextX) + popoutAnimX = nextX; + } + if (animY !== undefined) { + const nextY = Number(animY); + if (!isNaN(nextY) && popoutAnimY !== nextY) + popoutAnimY = nextY; + } + return true; + } + + function setPopoutBody(claimId, bodyX, bodyY, bodyW, bodyH) { + if (!hasPopoutOwner(claimId)) + return false; + if (bodyX !== undefined) { + const nextX = Number(bodyX); + if (!isNaN(nextX) && popoutBodyX !== nextX) + popoutBodyX = nextX; + } + if (bodyY !== undefined) { + const nextY = Number(bodyY); + if (!isNaN(nextY) && popoutBodyY !== nextY) + popoutBodyY = nextY; + } + if (bodyW !== undefined) { + const nextW = Number(bodyW); + if (!isNaN(nextW) && popoutBodyW !== nextW) + popoutBodyW = nextW; + } + if (bodyH !== undefined) { + const nextH = Number(bodyH); + if (!isNaN(nextH) && popoutBodyH !== nextH) + popoutBodyH = nextH; + } + return true; + } + + function _normalizeDockState(state) { + return { + "reveal": !!(state && state.reveal), + "barSide": state && state.barSide ? state.barSide : "bottom", + "bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0), + "bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0), + "bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0), + "bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0), + "slideX": Number(state && state.slideX !== undefined ? state.slideX : 0), + "slideY": Number(state && state.slideY !== undefined ? state.slideY : 0) + }; + } + + function _sameDockState(a, b) { + if (!a || !b) + return false; + return a.reveal === b.reveal && a.barSide === b.barSide && Math.abs(a.bodyX - b.bodyX) < 0.5 && Math.abs(a.bodyY - b.bodyY) < 0.5 && Math.abs(a.bodyW - b.bodyW) < 0.5 && Math.abs(a.bodyH - b.bodyH) < 0.5 && Math.abs(a.slideX - b.slideX) < 0.5 && Math.abs(a.slideY - b.slideY) < 0.5; + } + + function setDockState(screenName, state) { + if (!screenName || !state) + return false; + + const normalized = _normalizeDockState(state); + if (_sameDockState(dockStates[screenName], normalized)) + return true; + + const next = _cloneDict(dockStates); + next[screenName] = normalized; + dockStates = next; + return true; + } + + function clearDockState(screenName) { + if (!screenName || !dockStates[screenName]) + return false; + + const next = _cloneDict(dockStates); + delete next[screenName]; + dockStates = next; + + // Also clear corresponding slide + if (dockSlides[screenName]) { + const nextSlides = _cloneDict(dockSlides); + delete nextSlides[screenName]; + dockSlides = nextSlides; + } + return true; + } + + function setDockSlide(screenName, x, y) { + if (!screenName) + return false; + const numX = Number(x); + const numY = Number(y); + const cur = dockSlides[screenName]; + if (cur && Math.abs(cur.x - numX) < 0.5 && Math.abs(cur.y - numY) < 0.5) + return true; + const next = _cloneDict(dockSlides); + next[screenName] = { + "x": numX, + "y": numY + }; + dockSlides = next; + return true; + } + + readonly property var emptyNotificationState: ({ + "visible": false, + "barSide": "top", + "bodyX": 0, + "bodyY": 0, + "bodyW": 0, + "bodyH": 0, + "omitStartConnector": false, + "omitEndConnector": false + }) + + property var notificationStates: ({}) + + function _normalizeNotificationState(state) { + return { + "visible": !!(state && state.visible), + "barSide": state && state.barSide ? state.barSide : "top", + "bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0), + "bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0), + "bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0), + "bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0), + "omitStartConnector": !!(state && state.omitStartConnector), + "omitEndConnector": !!(state && state.omitEndConnector) + }; + } + + function _sameNotificationGeometry(a, b) { + if (!a || !b) + return false; + return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5; + } + + function _sameNotificationState(a, b) { + if (!a || !b) + return false; + return a.visible === b.visible && a.barSide === b.barSide && a.omitStartConnector === b.omitStartConnector && a.omitEndConnector === b.omitEndConnector && _sameNotificationGeometry(a, b); + } + + function setNotificationState(screenName, state) { + if (!screenName || !state) + return false; + + const normalized = _normalizeNotificationState(state); + if (_sameNotificationState(notificationStates[screenName], normalized)) + return true; + + const next = _cloneDict(notificationStates); + next[screenName] = normalized; + notificationStates = next; + return true; + } + + function clearNotificationState(screenName) { + if (!screenName || !notificationStates[screenName]) + return false; + + const next = _cloneDict(notificationStates); + delete next[screenName]; + notificationStates = next; + return true; + } + + // DankModal / DankLauncherV2Modal State + readonly property var emptyModalState: ({ + "visible": false, + "barSide": "bottom", + "bodyX": 0, + "bodyY": 0, + "bodyW": 0, + "bodyH": 0, + "animX": 0, + "animY": 0, + "omitStartConnector": false, + "omitEndConnector": false + }) + + property var modalStates: ({}) + + function _normalizeModalState(state) { + return { + "visible": !!(state && state.visible), + "barSide": state && state.barSide ? state.barSide : "bottom", + "bodyX": Number(state && state.bodyX !== undefined ? state.bodyX : 0), + "bodyY": Number(state && state.bodyY !== undefined ? state.bodyY : 0), + "bodyW": Number(state && state.bodyW !== undefined ? state.bodyW : 0), + "bodyH": Number(state && state.bodyH !== undefined ? state.bodyH : 0), + "animX": Number(state && state.animX !== undefined ? state.animX : 0), + "animY": Number(state && state.animY !== undefined ? state.animY : 0), + "omitStartConnector": !!(state && state.omitStartConnector), + "omitEndConnector": !!(state && state.omitEndConnector) + }; + } + + function _sameModalGeometry(a, b) { + if (!a || !b) + return false; + return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5 && Math.abs(Number(a.animX) - Number(b.animX)) < 0.5 && Math.abs(Number(a.animY) - Number(b.animY)) < 0.5; + } + + function _sameModalState(a, b) { + if (!a || !b) + return false; + return a.visible === b.visible && a.barSide === b.barSide && a.omitStartConnector === b.omitStartConnector && a.omitEndConnector === b.omitEndConnector && _sameModalGeometry(a, b); + } + + function setModalState(screenName, state) { + if (!screenName || !state) + return false; + + const normalized = _normalizeModalState(state); + if (_sameModalState(modalStates[screenName], normalized)) + return true; + + const next = _cloneDict(modalStates); + next[screenName] = normalized; + modalStates = next; + return true; + } + + function clearModalState(screenName) { + if (!screenName || !modalStates[screenName]) + return false; + + const next = _cloneDict(modalStates); + delete next[screenName]; + modalStates = next; + return true; + } + + function setModalAnim(screenName, animX, animY) { + const cur = screenName ? modalStates[screenName] : null; + if (!cur) + return false; + const nax = animX !== undefined ? Number(animX) : cur.animX; + const nay = animY !== undefined ? Number(animY) : cur.animY; + if (Math.abs(nax - cur.animX) < 0.5 && Math.abs(nay - cur.animY) < 0.5) + return false; + const next = _cloneDict(modalStates); + next[screenName] = Object.assign({}, cur, { + "animX": nax, + "animY": nay + }); + modalStates = next; + return true; + } + + function setModalBody(screenName, bodyX, bodyY, bodyW, bodyH) { + const cur = screenName ? modalStates[screenName] : null; + if (!cur) + return false; + const nx = bodyX !== undefined ? Number(bodyX) : cur.bodyX; + const ny = bodyY !== undefined ? Number(bodyY) : cur.bodyY; + const nw = bodyW !== undefined ? Number(bodyW) : cur.bodyW; + const nh = bodyH !== undefined ? Number(bodyH) : cur.bodyH; + if (Math.abs(nx - cur.bodyX) < 0.5 && Math.abs(ny - cur.bodyY) < 0.5 && Math.abs(nw - cur.bodyW) < 0.5 && Math.abs(nh - cur.bodyH) < 0.5) + return false; + const next = _cloneDict(modalStates); + next[screenName] = Object.assign({}, cur, { + "bodyX": nx, + "bodyY": ny, + "bodyW": nw, + "bodyH": nh + }); + modalStates = next; + return true; + } + + property var dockRetractRequests: ({}) + + function requestDockRetract(requesterId, screenName, side) { + if (!requesterId || !screenName || !side) + return false; + const existing = dockRetractRequests[requesterId]; + if (existing && existing.screenName === screenName && existing.side === side) + return true; + const next = _cloneDict(dockRetractRequests); + next[requesterId] = { + "screenName": screenName, + "side": side + }; + dockRetractRequests = next; + return true; + } + + function releaseDockRetract(requesterId) { + if (!requesterId || !dockRetractRequests[requesterId]) + return false; + const next = _cloneDict(dockRetractRequests); + delete next[requesterId]; + dockRetractRequests = next; + return true; + } + + function dockRetractActiveForSide(screenName, side) { + if (!screenName || !side) + return false; + for (const k in dockRetractRequests) { + const r = dockRetractRequests[k]; + if (r && r.screenName === screenName && r.side === side) + return true; + } + return false; + } + + // Prune state for screens that are no longer connected. Stale entries + // accumulate across hotplug cycles otherwise — Frame's per-screen + // FrameInstance doesn't notice when its peer dicts go orphan. + function _pruneToLiveScreens() { + const live = {}; + const screens = Quickshell.screens || []; + for (let i = 0; i < screens.length; i++) { + const s = screens[i]; + if (s && s.name) + live[s.name] = true; + } + + function pruneKeyed(dict) { + let changed = false; + const next = {}; + for (const k in dict) { + if (live[k]) + next[k] = dict[k]; + else + changed = true; + } + return changed ? next : null; + } + + const nextDock = pruneKeyed(dockStates); + if (nextDock !== null) + dockStates = nextDock; + const nextSlides = pruneKeyed(dockSlides); + if (nextSlides !== null) + dockSlides = nextSlides; + const nextNotif = pruneKeyed(notificationStates); + if (nextNotif !== null) + notificationStates = nextNotif; + const nextModal = pruneKeyed(modalStates); + if (nextModal !== null) + modalStates = nextModal; + + let retractChanged = false; + const nextRetract = {}; + for (const k in dockRetractRequests) { + const r = dockRetractRequests[k]; + if (r && live[r.screenName]) + nextRetract[k] = r; + else + retractChanged = true; + } + if (retractChanged) + dockRetractRequests = nextRetract; + + if (popoutOwnerId && popoutScreen && !live[popoutScreen]) + releasePopout(popoutOwnerId); + } + + Connections { + target: Quickshell + function onScreensChanged() { + root._pruneToLiveScreens(); + } + } +} diff --git a/quickshell/Common/ConnectorGeometry.js b/quickshell/Common/ConnectorGeometry.js new file mode 100644 index 00000000..c24bd7db --- /dev/null +++ b/quickshell/Common/ConnectorGeometry.js @@ -0,0 +1,68 @@ +.pragma library + +// Geometry for connected-frame arc connectors. +// `barSide` is one of "top" | "bottom" | "left" | "right" — the edge where the +// host bar/dock sits. `placement` is "left" (start) or "right" (end) of the +// body's far edge. `radius` is the connector's arc radius. `spacing` is the +// gap between the host edge and the body. + +function isVertical(barSide) { + return barSide === "left" || barSide === "right"; +} + +function isHorizontal(barSide) { + return barSide === "top" || barSide === "bottom"; +} + +function connectorWidth(barSide, spacing, radius) { + return isVertical(barSide) ? (spacing + radius) : radius; +} + +function connectorHeight(barSide, spacing, radius) { + return isVertical(barSide) ? radius : (spacing + radius); +} + +function seamX(barSide, baseX, bodyWidth, placement) { + if (!isVertical(barSide)) + return placement === "left" ? baseX : baseX + bodyWidth; + return barSide === "left" ? baseX : baseX + bodyWidth; +} + +function seamY(barSide, baseY, bodyHeight, placement) { + if (barSide === "top") + return baseY; + if (barSide === "bottom") + return baseY + bodyHeight; + return placement === "left" ? baseY : baseY + bodyHeight; +} + +function connectorX(barSide, baseX, bodyWidth, placement, spacing, radius) { + var s = seamX(barSide, baseX, bodyWidth, placement); + var w = connectorWidth(barSide, spacing, radius); + if (!isVertical(barSide)) + return placement === "left" ? s - w : s; + return barSide === "left" ? s : s - w; +} + +function connectorY(barSide, baseY, bodyHeight, placement, spacing, radius) { + var s = seamY(barSide, baseY, bodyHeight, placement); + var h = connectorHeight(barSide, spacing, radius); + if (barSide === "top") + return s; + if (barSide === "bottom") + return s - h; + return placement === "left" ? s - h : s; +} + +// Which corner of the connector's bounding rect hosts the concave arc that +// carves into the body. Used for arc-sweep orientation. +function arcCorner(barSide, placement) { + var left = placement === "left"; + if (barSide === "top") + return left ? "bottomLeft" : "bottomRight"; + if (barSide === "bottom") + return left ? "topLeft" : "topRight"; + if (barSide === "left") + return left ? "topRight" : "bottomRight"; + return left ? "topLeft" : "bottomLeft"; +} diff --git a/quickshell/Common/ElevationShadow.qml b/quickshell/Common/ElevationShadow.qml index dab80cdb..1ccf6b1c 100644 --- a/quickshell/Common/ElevationShadow.qml +++ b/quickshell/Common/ElevationShadow.qml @@ -13,8 +13,13 @@ Item { property color targetColor: "white" property real targetRadius: Theme.cornerRadius + property real topLeftRadius: targetRadius + property real topRightRadius: targetRadius + property real bottomLeftRadius: targetRadius + property real bottomRightRadius: targetRadius property color borderColor: "transparent" property real borderWidth: 0 + property bool useCustomSource: false property bool shadowEnabled: Theme.elevationEnabled property real shadowBlurPx: level && level.blurPx !== undefined ? level.blurPx : 0 @@ -46,7 +51,11 @@ Item { Rectangle { id: sourceRect anchors.fill: parent - radius: root.targetRadius + visible: !root.useCustomSource + topLeftRadius: root.topLeftRadius + topRightRadius: root.topRightRadius + bottomLeftRadius: root.bottomLeftRadius + bottomRightRadius: root.bottomRightRadius color: root.targetColor border.color: root.borderColor border.width: root.borderWidth diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 5969d412..977494fd 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -15,7 +15,7 @@ Singleton { id: root readonly property var log: Log.scoped("SettingsData") - readonly property int settingsConfigVersion: 5 + readonly property int settingsConfigVersion: 11 readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" @@ -38,6 +38,18 @@ Singleton { Custom } + enum AnimationVariant { + Material, + Fluent, + Dynamic + } + + enum AnimationEffect { + Standard, // 0 — M3: scale-in, rises from below + Directional, // 1 — pure large slide, no scale + Depth // 2 — medium slide with deep depth scale pop + } + enum SuspendBehavior { Suspend, Hibernate, @@ -169,6 +181,10 @@ Singleton { property int modalCustomAnimationDuration: 150 property bool enableRippleEffects: true onEnableRippleEffectsChanged: saveSettings() + property int animationVariant: SettingsData.AnimationVariant.Material + onAnimationVariantChanged: saveSettings() + property int motionEffect: SettingsData.AnimationEffect.Standard + onMotionEffectChanged: saveSettings() property bool m3ElevationEnabled: true onM3ElevationEnabledChanged: saveSettings() property int m3ElevationIntensity: 12 @@ -187,6 +203,7 @@ Singleton { onPopoutElevationEnabledChanged: saveSettings() property bool barElevationEnabled: true onBarElevationEnabledChanged: saveSettings() + property bool blurEnabled: false onBlurEnabledChanged: saveSettings() property bool blurForegroundLayers: true @@ -203,6 +220,53 @@ Singleton { property bool blurredWallpaperLayer: false property bool blurWallpaperOnOverview: false + property bool frameEnabled: false + onFrameEnabledChanged: saveSettings() + property real frameThickness: 16 + onFrameThicknessChanged: saveSettings() + property real frameRounding: 23 + onFrameRoundingChanged: saveSettings() + property string frameColor: "" + onFrameColorChanged: saveSettings() + property real frameOpacity: 1.0 + onFrameOpacityChanged: saveSettings() + property var frameScreenPreferences: ["all"] + onFrameScreenPreferencesChanged: saveSettings() + property real frameBarSize: 40 + onFrameBarSizeChanged: saveSettings() + property bool frameShowOnOverview: false + onFrameShowOnOverviewChanged: saveSettings() + property bool frameBlurEnabled: true + onFrameBlurEnabledChanged: saveSettings() + property bool frameCloseGaps: true + onFrameCloseGapsChanged: saveSettings() + property string frameLauncherEmergeSide: "bottom" + onFrameLauncherEmergeSideChanged: saveSettings() + property bool frameLauncherArcExtender: false + onFrameLauncherArcExtenderChanged: saveSettings() + readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top" + property string frameMode: "separate" + onFrameModeChanged: saveSettings() + property var connectedFrameBarStyleBackups: ({}) + onConnectedFrameBarStyleBackupsChanged: saveSettings() + readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected" + onConnectedFrameModeActiveChanged: { + if (_loading) + return; + _reconcileConnectedFrameBarStyles(); + } + + readonly property color effectiveFrameColor: { + const fc = frameColor; + if (!fc || fc === "default") + return Theme.surfaceContainer; + if (fc === "primary") + return Theme.primary; + if (fc === "surface") + return Theme.surface; + return fc; + } + property bool showLauncherButton: true property bool showWorkspaceSwitcher: true property bool showFocusedWindow: true @@ -1276,6 +1340,9 @@ Singleton { Store.parse(root, obj); + if (obj?.directionalAnimationMode === 3 && frameMode !== "connected") + frameMode = "connected"; + if (obj?.weatherLocation !== undefined) _legacyWeatherLocation = obj.weatherLocation; if (obj?.weatherCoordinates !== undefined) @@ -1303,6 +1370,7 @@ Singleton { _loading = false; } loadPluginSettings(); + Qt.callLater(() => _reconcileConnectedFrameBarStyles()); } property var _pendingMigration: null @@ -1416,6 +1484,141 @@ Singleton { pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2)); } + function _connectedFrameBarStyleSnapshot(config) { + return { + "shadowIntensity": config?.shadowIntensity ?? 0, + "squareCorners": config?.squareCorners ?? false, + "gothCornersEnabled": config?.gothCornersEnabled ?? false, + "borderEnabled": config?.borderEnabled ?? false + }; + } + + function _hasConnectedFrameBarStyleBackups() { + return connectedFrameBarStyleBackups && Object.keys(connectedFrameBarStyleBackups).length > 0; + } + + function _captureConnectedFrameBarStyleBackups(configs, overwriteExisting) { + if (!Array.isArray(configs)) + return; + + const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {})); + const validIds = {}; + let changed = false; + + for (let i = 0; i < configs.length; i++) { + const config = configs[i]; + if (!config?.id) + continue; + validIds[config.id] = true; + + if (!overwriteExisting && nextBackups[config.id] !== undefined) + continue; + + const snapshot = _connectedFrameBarStyleSnapshot(config); + if (JSON.stringify(nextBackups[config.id]) !== JSON.stringify(snapshot)) { + nextBackups[config.id] = snapshot; + changed = true; + } + } + + if (overwriteExisting) { + for (const barId in nextBackups) { + if (validIds[barId]) + continue; + delete nextBackups[barId]; + changed = true; + } + } + + if (changed) + connectedFrameBarStyleBackups = nextBackups; + } + + function _restoreConnectedFrameBarStyleBackups() { + if (!_hasConnectedFrameBarStyleBackups()) + return; + + const backups = connectedFrameBarStyleBackups || {}; + const configs = JSON.parse(JSON.stringify(barConfigs)); + let changed = false; + + for (let i = 0; i < configs.length; i++) { + const backup = backups[configs[i].id]; + if (!backup) + continue; + for (const key in backup) { + if (configs[i][key] === backup[key]) + continue; + configs[i][key] = backup[key]; + changed = true; + } + } + + if (changed) + barConfigs = configs; + connectedFrameBarStyleBackups = ({}); + if (changed) + updateBarConfigs(); + } + + // Zeroes out connected-mode-hostile fields (shadow, square/goth corners, border). + // Returns { configs, changed } — `configs` is the same ref when no change. + function _sanitizeBarConfigsForConnectedFrame(configs) { + if (!connectedFrameModeActive || !Array.isArray(configs)) + return { + "configs": configs, + "changed": false + }; + + let anyChanged = false; + const out = configs.map(cfg => { + if (!cfg) + return cfg; + let dirty = false; + const s = Object.assign({}, cfg); + if ((s.shadowIntensity ?? 0) !== 0) { + s.shadowIntensity = 0; + dirty = true; + } + if (s.squareCorners ?? false) { + s.squareCorners = false; + dirty = true; + } + if (s.gothCornersEnabled ?? false) { + s.gothCornersEnabled = false; + dirty = true; + } + if (s.borderEnabled ?? false) { + s.borderEnabled = false; + dirty = true; + } + if (dirty) + anyChanged = true; + return dirty ? s : cfg; + }); + return { + "configs": anyChanged ? out : configs, + "changed": anyChanged + }; + } + + // Single entry point for connected-mode bar-style state. + // active → capture backups (if not yet) and sanitize bar configs + // !active → restore backups + function _reconcileConnectedFrameBarStyles() { + if (!connectedFrameModeActive) { + _restoreConnectedFrameBarStyleBackups(); + return; + } + if (!_hasConnectedFrameBarStyleBackups()) + _captureConnectedFrameBarStyleBackups(barConfigs, true); + const result = _sanitizeBarConfigsForConnectedFrame(barConfigs); + if (result.changed) { + barConfigs = result.configs; + updateBarConfigs(); + } + } + function detectAvailableIconThemes() { const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS") || ""; const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation)); @@ -1563,35 +1766,37 @@ Singleton { const spacing = barSpacing !== undefined ? barSpacing : (defaultBar?.spacing ?? 4); const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top); const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0); - const bottomGap = Math.max(0, rawBottomGap); + const isConnected = connectedFrameModeActive; + const bottomGap = isConnected ? 0 : Math.max(0, rawBottomGap); const useAutoGaps = (barConfig && barConfig.popupGapsAuto !== undefined) ? barConfig.popupGapsAuto : (defaultBar?.popupGapsAuto ?? true); const manualGapValue = (barConfig && barConfig.popupGapsManual !== undefined) ? barConfig.popupGapsManual : (defaultBar?.popupGapsManual ?? 4); - const popupGap = useAutoGaps ? Math.max(4, spacing) : manualGapValue; + const popupGap = isConnected ? 0 : (useAutoGaps ? Math.max(4, spacing) : manualGapValue); + const edgeSpacing = isConnected ? 0 : spacing; switch (position) { case SettingsData.Position.Left: return { - "x": barThickness + spacing + popupGap, + "x": barThickness + edgeSpacing + popupGap, "y": relativeY, "width": widgetWidth }; case SettingsData.Position.Right: return { - "x": (screen?.width || 0) - (barThickness + spacing + popupGap), + "x": (screen?.width || 0) - (barThickness + edgeSpacing + popupGap), "y": relativeY, "width": widgetWidth }; case SettingsData.Position.Bottom: return { "x": relativeX, - "y": (screen?.height || 0) - (barThickness + spacing + bottomGap + popupGap), + "y": (screen?.height || 0) - (barThickness + edgeSpacing + bottomGap + popupGap), "width": widgetWidth }; default: return { "x": relativeX, - "y": barThickness + spacing + bottomGap + popupGap, + "y": barThickness + edgeSpacing + bottomGap + popupGap, "width": widgetWidth }; } @@ -1685,7 +1890,9 @@ Singleton { const screenWidth = screen.width; const screenHeight = screen.height; const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top); - const bottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0); + const isConnected = connectedFrameModeActive; + const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0); + const bottomGap = isConnected ? 0 : rawBottomGap; let topOffset = 0; let bottomOffset = 0; @@ -1707,7 +1914,7 @@ Singleton { const otherSpacing = other.spacing !== undefined ? other.spacing : (defaultBar?.spacing ?? 4); const otherPadding = other.innerPadding !== undefined ? other.innerPadding : (defaultBar?.innerPadding ?? 4); const otherThickness = Math.max(26 + otherPadding * 0.6, Theme.barHeight - 4 - (8 - otherPadding)) + otherSpacing + wingSize; - const otherBottomGap = other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0); + const otherBottomGap = isConnected ? 0 : (other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0)); switch (other.position) { case SettingsData.Position.Top: @@ -1798,7 +2005,9 @@ Singleton { function addBarConfig(config) { const configs = JSON.parse(JSON.stringify(barConfigs)); configs.push(config); - barConfigs = configs; + if (connectedFrameModeActive) + _captureConnectedFrameBarStyleBackups(configs, false); + barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs; updateBarConfigs(); } @@ -1810,7 +2019,7 @@ Singleton { const positionChanged = updates.position !== undefined && configs[index].position !== updates.position; Object.assign(configs[index], updates); - barConfigs = configs; + barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs; updateBarConfigs(); if (positionChanged) { @@ -1864,6 +2073,11 @@ Singleton { return; const configs = barConfigs.filter(cfg => cfg.id !== barId); barConfigs = configs; + if (connectedFrameBarStyleBackups?.[barId] !== undefined) { + const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {})); + delete nextBackups[barId]; + connectedFrameBarStyleBackups = nextBackups; + } updateBarConfigs(); } @@ -1958,6 +2172,95 @@ 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 getActiveBarEdgesForScreen(screen) { + if (!screen) + return []; + var edges = []; + 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: + edges.push("top"); + break; + case SettingsData.Position.Bottom: + edges.push("bottom"); + break; + case SettingsData.Position.Left: + edges.push("left"); + break; + case SettingsData.Position.Right: + edges.push("right"); + break; + } + } + return edges; + } + + function frameEdgeInsetForSide(screen, side) { + if (!frameEnabled || !screen) + return 0; + const edges = getActiveBarEdgesForScreen(screen); + return edges.includes(side) ? frameBarSize : frameThickness; + } + + function getActiveBarThicknessForScreen(screen) { + if (frameEnabled) + return frameBarSize; + 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/Theme.qml b/quickshell/Common/Theme.qml index 875ad38e..cd7cecbd 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -986,6 +986,46 @@ Singleton { "expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1] } + // Theme is the canonical access point for animation variant state. The + // aliases below forward to AnimVariants.qml so consumers don't need two + // imports. ~200 call sites read through Theme.variantEnterCurve / + // Theme.isConnectedEffect / etc. — do NOT migrate to AnimVariants directly. + readonly property list variantEnterCurve: AnimVariants.variantEnterCurve + readonly property list variantExitCurve: AnimVariants.variantExitCurve + readonly property list variantModalEnterCurve: AnimVariants.variantModalEnterCurve + readonly property list variantModalExitCurve: AnimVariants.variantModalExitCurve + readonly property list variantPopoutEnterCurve: AnimVariants.variantPopoutEnterCurve + readonly property list variantPopoutExitCurve: AnimVariants.variantPopoutExitCurve + readonly property real variantEnterDurationFactor: AnimVariants.variantEnterDurationFactor + readonly property real variantExitDurationFactor: AnimVariants.variantExitDurationFactor + readonly property real variantOpacityDurationScale: AnimVariants.variantOpacityDurationScale + readonly property bool isDirectionalEffect: AnimVariants.isDirectionalEffect + readonly property bool isDepthEffect: AnimVariants.isDepthEffect + readonly property bool isConnectedEffect: AnimVariants.isConnectedEffect + readonly property real connectedCornerRadius: { + if (typeof SettingsData === "undefined") + return 12; + return SettingsData.connectedFrameModeActive ? SettingsData.frameRounding : cornerRadius; + } + readonly property color connectedSurfaceColor: { + if (typeof SettingsData === "undefined") + return withAlpha(surfaceContainer, popupTransparency); + return isConnectedEffect ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : withAlpha(surfaceContainer, popupTransparency); + } + readonly property real connectedSurfaceRadius: isConnectedEffect ? connectedCornerRadius : cornerRadius + readonly property bool connectedSurfaceBlurEnabled: (typeof SettingsData === "undefined") ? true : (!isConnectedEffect || SettingsData.frameBlurEnabled) + readonly property real effectScaleCollapsed: AnimVariants.effectScaleCollapsed + readonly property real effectAnimOffset: AnimVariants.effectAnimOffset + function variantDuration(baseDuration, entering) { + return AnimVariants.variantDuration(baseDuration, entering); + } + function variantExitCleanupPadding() { + return AnimVariants.variantExitCleanupPadding(); + } + function variantCloseInterval(baseDuration) { + return AnimVariants.variantCloseInterval(baseDuration); + } + readonly property var animationPresetDurations: { "none": 0, "short": 250, @@ -1061,6 +1101,9 @@ Singleton { return base === 0 ? 0 : Math.round(base * 0.85); } + readonly property int notificationInlineExpandDuration: notificationAnimationBaseDuration === 0 ? 0 : 185 + readonly property int notificationInlineCollapseDuration: notificationAnimationBaseDuration === 0 ? 0 : 150 + readonly property real notificationIconSizeNormal: 56 readonly property real notificationIconSizeCompact: 48 readonly property real notificationExpandedIconSizeNormal: 48 @@ -1151,7 +1194,13 @@ Singleton { property real iconSizeLarge: 32 property real panelTransparency: 0.85 - property real popupTransparency: typeof SettingsData !== "undefined" && SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0 + property real popupTransparency: { + if (typeof SettingsData === "undefined") + return 1.0; + if (isConnectedEffect) + return SettingsData.frameOpacity !== undefined ? SettingsData.frameOpacity : 1.0; + return SettingsData.popupTransparency !== undefined ? SettingsData.popupTransparency : 1.0; + } function screenTransition() { if (CompositorService.isNiri) { @@ -1852,6 +1901,12 @@ Singleton { return Qt.rgba(c.r, c.g, c.b, a); } + function popupLayerColor(baseColor) { + if (isConnectedEffect) + return connectedSurfaceColor; + return withAlpha(baseColor, popupTransparency); + } + function blendAlpha(c, a) { return Qt.rgba(c.r, c.g, c.b, c.a * a); } diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 1ed73cf7..5cf50753 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -49,6 +49,8 @@ var SPEC = { modalAnimationSpeed: { def: 1 }, modalCustomAnimationDuration: { def: 150 }, enableRippleEffects: { def: true }, + animationVariant: { def: 0 }, + motionEffect: { def: 0 }, m3ElevationEnabled: { def: true }, m3ElevationIntensity: { def: 12 }, m3ElevationOpacity: { def: 30 }, @@ -443,6 +445,7 @@ var SPEC = { displayProfileAutoSelect: { def: false }, displayShowDisconnected: { def: false }, displaySnapToEdge: { def: true }, + connectedFrameBarStyleBackups: { def: {} }, barConfigs: { def: [{ @@ -549,7 +552,21 @@ var SPEC = { clipboardEnterToPaste: { def: false }, launcherPluginVisibility: { def: {} }, - launcherPluginOrder: { def: [] } + launcherPluginOrder: { def: [] }, + + frameEnabled: { def: false }, + frameThickness: { def: 16 }, + frameRounding: { def: 23 }, + frameColor: { def: "" }, + frameOpacity: { def: 1.0 }, + frameScreenPreferences: { def: ["all"] }, + frameBarSize: { def: 40 }, + frameShowOnOverview: { def: false }, + frameBlurEnabled: { def: true }, + frameCloseGaps: { def: true }, + frameLauncherEmergeSide: { def: "bottom" }, + frameLauncherArcExtender: { def: false }, + frameMode: { def: "separate" } }; function getValidKeys() { diff --git a/quickshell/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js index f88bb03b..43c017a1 100644 --- a/quickshell/Common/settings/SettingsStore.js +++ b/quickshell/Common/settings/SettingsStore.js @@ -248,6 +248,10 @@ function migrateToVersion(obj, targetVersion) { settings.configVersion = 6; } + if (currentVersion < 11) { + settings.configVersion = 11; + } + return settings; } diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 6ca5d5cd..55a01de3 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -22,6 +22,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 @@ -187,6 +188,8 @@ Item { } } + Frame {} + Repeater { id: dankBarRepeater model: ScriptModel { @@ -331,7 +334,6 @@ Item { sourceComponent: Component { DankDashPopout { id: dankDashPopout - onPopoutClosed: PopoutService.unloadDankDash() } } } diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 37ed3810..ac4a7a73 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -162,37 +162,36 @@ Item { } IpcHandler { + function resolveTabIndex(tab: string): int { + switch ((tab || "").toLowerCase()) { + case "media": + return 1; + case "wallpaper": + return 2; + case "weather": + return SettingsData.weatherEnabled ? 3 : 0; + default: + return 0; + } + } + function open(tab: string): string { const bar = root.getPreferredBar("clockButtonRef"); if (!bar) return "DASH_OPEN_FAILED"; + const tabIndex = resolveTabIndex(tab); const dash = root.dankDashPopoutLoader.item; - const onSameScreen = dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name; - - if (!onSameScreen) { - bar.triggerWallpaperBrowser(); + if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) { + dash.currentTabIndex = tabIndex; + if (dash.updateSurfacePosition) + dash.updateSurfacePosition(); + return "DASH_OPEN_SUCCESS"; } - if (!root.dankDashPopoutLoader.item) + if (!bar.triggerDashTab(tabIndex)) return "DASH_OPEN_FAILED"; - switch (tab.toLowerCase()) { - case "media": - root.dankDashPopoutLoader.item.currentTabIndex = 1; - break; - case "wallpaper": - root.dankDashPopoutLoader.item.currentTabIndex = 2; - break; - case "weather": - root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0; - break; - default: - root.dankDashPopoutLoader.item.currentTabIndex = 0; - break; - } - - root.dankDashPopoutLoader.item.dashVisible = true; return "DASH_OPEN_SUCCESS"; } @@ -212,23 +211,8 @@ Item { const bar = root.getPreferredBar("clockButtonRef"); if (bar) { - bar.triggerWallpaperBrowser(); - if (root.dankDashPopoutLoader.item) { - switch (tab.toLowerCase()) { - case "media": - root.dankDashPopoutLoader.item.currentTabIndex = 1; - break; - case "wallpaper": - root.dankDashPopoutLoader.item.currentTabIndex = 2; - break; - case "weather": - root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0; - break; - default: - root.dankDashPopoutLoader.item.currentTabIndex = 0; - break; - } - } + if (!bar.triggerDashTab(resolveTabIndex(tab))) + return "DASH_TOGGLE_FAILED"; return "DASH_TOGGLE_SUCCESS"; } return "DASH_TOGGLE_FAILED"; diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml index 2f76cc78..2d50403e 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml @@ -64,11 +64,19 @@ DankModal { activeImageLoads = 0; shouldHaveFocus = true; ClipboardService.reset(); - if (clipboardAvailable) - ClipboardService.refresh(); keyboardController.reset(); Qt.callLater(function () { + if (clipboardAvailable) { + if (Theme.isConnectedEffect) { + Qt.callLater(() => { + if (clipboardHistoryModal.shouldBeVisible) + ClipboardService.refresh(); + }); + } else { + ClipboardService.refresh(); + } + } if (contentLoader.item?.searchField) { contentLoader.item.searchField.text = ""; contentLoader.item.searchField.forceActiveFocus(); diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml b/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml index c4f0296f..924a3d18 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml @@ -53,8 +53,6 @@ DankPopout { open(); activeImageLoads = 0; ClipboardService.reset(); - if (clipboardAvailable) - ClipboardService.refresh(); keyboardController.reset(); Qt.callLater(function () { @@ -121,8 +119,16 @@ DankPopout { onShouldBeVisibleChanged: { if (!shouldBeVisible) return; - if (clipboardAvailable) - ClipboardService.refresh(); + if (clipboardAvailable) { + if (Theme.isConnectedEffect) { + Qt.callLater(() => { + if (root.shouldBeVisible) + ClipboardService.refresh(); + }); + } else { + ClipboardService.refresh(); + } + } keyboardController.reset(); Qt.callLater(function () { if (contentLoader.item?.searchField) { diff --git a/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml index 1b6725c3..c9891eb7 100644 --- a/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml +++ b/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml @@ -26,9 +26,7 @@ Rectangle { spacing: 2 StyledText { - text: keyboardHints.enterToPaste - ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") - : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help") + text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText anchors.horizontalCenter: parent.horizontalCenter diff --git a/quickshell/Modals/Common/DankModal.qml b/quickshell/Modals/Common/DankModal.qml index 373243c7..11e74a6d 100644 --- a/quickshell/Modals/Common/DankModal.qml +++ b/quickshell/Modals/Common/DankModal.qml @@ -1,25 +1,18 @@ import QtQuick -import Quickshell -import Quickshell.Wayland import qs.Common import qs.Services -import qs.Widgets Item { id: root readonly property var log: Log.scoped("DankModal") + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab property string layerNamespace: "dms:modal" - property alias content: contentLoader.sourceComponent - property alias contentLoader: contentLoader + property Component content: null property Item directContent: null property real modalWidth: 400 property real modalHeight: 300 property var targetScreen - readonly property var effectiveScreen: contentWindow.screen ?? targetScreen - readonly property real screenWidth: effectiveScreen?.width ?? 1920 - readonly property real screenHeight: effectiveScreen?.height ?? 1080 - readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 property bool showBackground: true property real backgroundOpacity: 0.5 property string positioning: "center" @@ -37,7 +30,6 @@ Item { property real borderWidth: 0 property real cornerRadius: Theme.cornerRadius property bool enableShadow: true - property alias modalFocusScope: focusScope property bool shouldBeVisible: false property bool shouldHaveFocus: shouldBeVisible property bool allowFocusOverride: false @@ -46,452 +38,170 @@ Item { property bool keepPopoutsOpen: false property var customKeyboardFocus: null property bool useOverlayLayer: false - readonly property alias contentWindow: contentWindow - readonly property alias clickCatcher: clickCatcher - readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab - readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground - readonly property bool useSingleWindow: CompositorService.isHyprland || useBackground signal opened signal dialogClosed signal backgroundClicked - property bool animationsEnabled: true + readonly property var contentLoader: impl.item ? impl.item.contentLoader : null + readonly property alias modalFocusScope: _modalFocusScope + + FocusScope { + id: _modalFocusScope + objectName: "modalFocusScope" + focus: true + anchors.fill: parent + } + readonly property var contentWindow: impl.item ? impl.item.contentWindow : null + readonly property var clickCatcher: impl.item ? impl.item.clickCatcher : null + readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null + readonly property real screenWidth: impl.item ? impl.item.screenWidth : 1920 + readonly property real screenHeight: impl.item ? impl.item.screenHeight : 1080 + readonly property real dpr: impl.item ? impl.item.dpr : 1 + readonly property bool isClosing: impl.item ? (impl.item.isClosing ?? false) : false + readonly property real alignedX: impl.item ? impl.item.alignedX : 0 + readonly property real alignedY: impl.item ? impl.item.alignedY : 0 + readonly property real alignedWidth: impl.item ? impl.item.alignedWidth : 0 + readonly property real alignedHeight: impl.item ? impl.item.alignedHeight : 0 function open() { - closeTimer.stop(); - const focusedScreen = CompositorService.getFocusedScreen(); - const screenChanged = focusedScreen && contentWindow.screen !== focusedScreen; - if (focusedScreen) { - if (screenChanged) - contentWindow.visible = false; - contentWindow.screen = focusedScreen; - if (!useSingleWindow) { - if (screenChanged) - clickCatcher.visible = false; - clickCatcher.screen = focusedScreen; - } - } - if (screenChanged) { - Qt.callLater(() => root._finishOpen()); - } else { - _finishOpen(); - } - } - - function _finishOpen() { - ModalManager.openModal(root); - shouldBeVisible = true; - if (!useSingleWindow) - clickCatcher.visible = true; - contentWindow.visible = true; - shouldHaveFocus = false; - Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); + if (impl.item) + impl.item.open(); } function close() { - shouldBeVisible = false; - shouldHaveFocus = false; - ModalManager.closeModal(root); - closeTimer.restart(); + if (impl.item) + impl.item.close(); } function instantClose() { - animationsEnabled = false; - shouldBeVisible = false; - shouldHaveFocus = false; - ModalManager.closeModal(root); - closeTimer.stop(); - contentWindow.visible = false; - if (!useSingleWindow) - clickCatcher.visible = false; - dialogClosed(); - Qt.callLater(() => animationsEnabled = true); + if (impl.item && typeof impl.item.instantClose === "function") + impl.item.instantClose(); } function toggle() { - shouldBeVisible ? close() : open(); + if (impl.item) + impl.item.toggle(); + } + + readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp + property var _resolvedBackend: null + + Component.onCompleted: _resolvedBackend = _desiredBackend + + Connections { + target: SettingsData + function onConnectedFrameModeActiveChanged() { + root._maybeResolveBackend(); + } + } + + // Defer Loader source-component swap until impl is fully closed; avoids + // tearing down a modal mid-animation when frame mode is toggled. + function _maybeResolveBackend() { + if (_resolvedBackend === _desiredBackend) + return; + if (impl.item && (impl.item.shouldBeVisible || impl.item.isClosing)) + return; + _resolvedBackend = _desiredBackend; + } + + Loader { + id: impl + sourceComponent: root._resolvedBackend + onItemChanged: if (item) + root._wireBackend(item) + } + + Component { + id: standaloneComp + DankModalStandalone {} + } + + Component { + id: connectedComp + DankModalConnected {} + } + + function _wireBackend(it) { + if (!it) + return; + + it.modalHandle = root; + it.layerNamespace = Qt.binding(() => root.layerNamespace); + it.content = Qt.binding(() => root.content); + it.directContent = Qt.binding(() => root.directContent); + it.modalWidth = Qt.binding(() => root.modalWidth); + it.modalHeight = Qt.binding(() => root.modalHeight); + it.targetScreen = Qt.binding(() => root.targetScreen); + it.showBackground = Qt.binding(() => root.showBackground); + it.backgroundOpacity = Qt.binding(() => root.backgroundOpacity); + it.positioning = Qt.binding(() => root.positioning); + it.customPosition = Qt.binding(() => root.customPosition); + it.closeOnEscapeKey = Qt.binding(() => root.closeOnEscapeKey); + it.closeOnBackgroundClick = Qt.binding(() => root.closeOnBackgroundClick); + it.animationType = Qt.binding(() => root.animationType); + it.animationDuration = Qt.binding(() => root.animationDuration); + it.animationScaleCollapsed = Qt.binding(() => root.animationScaleCollapsed); + it.animationOffset = Qt.binding(() => root.animationOffset); + it.animationEnterCurve = Qt.binding(() => root.animationEnterCurve); + it.animationExitCurve = Qt.binding(() => root.animationExitCurve); + it.backgroundColor = Qt.binding(() => root.backgroundColor); + it.borderColor = Qt.binding(() => root.borderColor); + it.borderWidth = Qt.binding(() => root.borderWidth); + it.cornerRadius = Qt.binding(() => root.cornerRadius); + it.enableShadow = Qt.binding(() => root.enableShadow); + it.allowFocusOverride = Qt.binding(() => root.allowFocusOverride); + it.allowStacking = Qt.binding(() => root.allowStacking); + it.keepContentLoaded = Qt.binding(() => root.keepContentLoaded); + it.keepPopoutsOpen = Qt.binding(() => root.keepPopoutsOpen); + it.customKeyboardFocus = Qt.binding(() => root.customKeyboardFocus); + it.useOverlayLayer = Qt.binding(() => root.useOverlayLayer); + + it.shouldBeVisible = root.shouldBeVisible; + it.shouldHaveFocus = root.shouldHaveFocus; + + if (it.modalFocusScope) + _modalFocusScope.parent = it.modalFocusScope; } Connections { - target: ModalManager - function onCloseAllModalsExcept(excludedModal) { - if (excludedModal !== root && !allowStacking && shouldBeVisible) - close(); + target: root + function onShouldBeVisibleChanged() { + if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible) + impl.item.shouldBeVisible = root.shouldBeVisible; + } + function onShouldHaveFocusChanged() { + if (impl.item && impl.item.shouldHaveFocus !== root.shouldHaveFocus) + impl.item.shouldHaveFocus = root.shouldHaveFocus; } } Connections { - target: Quickshell - function onScreensChanged() { - if (!contentWindow.screen) - return; - const currentScreenName = contentWindow.screen.name; - let screenStillExists = false; - for (let i = 0; i < Quickshell.screens.length; i++) { - if (Quickshell.screens[i].name === currentScreenName) { - screenStillExists = true; - break; - } - } - if (screenStillExists) - return; - const newScreen = CompositorService.getFocusedScreen(); - if (newScreen) { - contentWindow.screen = newScreen; - if (!useSingleWindow) - clickCatcher.screen = newScreen; - } - } - } + target: impl.item + ignoreUnknownSignals: true - Timer { - id: closeTimer - interval: animationDuration + 50 - onTriggered: { - if (shouldBeVisible) - return; - contentWindow.visible = false; - if (!useSingleWindow) - clickCatcher.visible = false; - dialogClosed(); - } - } - - readonly property var shadowLevel: Theme.elevationLevel3 - readonly property real shadowFallbackOffset: 6 - readonly property real shadowRenderPadding: (root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 - readonly property real shadowMotionPadding: animationType === "slide" ? 30 : Math.max(0, animationOffset) - readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) - readonly property real alignedWidth: Theme.px(modalWidth, dpr) - readonly property real alignedHeight: Theme.px(modalHeight, dpr) - - readonly property real alignedX: Theme.snap((() => { - switch (positioning) { - case "center": - return (screenWidth - alignedWidth) / 2; - case "top-right": - return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL); - case "custom": - return customPosition.x; - default: - return 0; - } - })(), dpr) - - readonly property real alignedY: Theme.snap((() => { - switch (positioning) { - case "center": - return (screenHeight - alignedHeight) / 2; - case "top-right": - return Theme.barHeight + Theme.spacingXS; - case "custom": - return customPosition.y; - default: - return 0; - } - })(), dpr) - - PanelWindow { - id: clickCatcher - visible: false - color: "transparent" - - WlrLayershell.namespace: root.layerNamespace + ":clickcatcher" - WlrLayershell.layer: WlrLayershell.Top - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - - anchors { - top: true - left: true - right: true - bottom: true + function onShouldBeVisibleChanged() { + if (impl.item && root.shouldBeVisible !== impl.item.shouldBeVisible) + root.shouldBeVisible = impl.item.shouldBeVisible; } - mask: Region { - item: Rectangle { - x: root.alignedX - y: root.alignedY - width: root.alignedWidth - height: root.alignedHeight - } - intersection: Intersection.Xor + function onShouldHaveFocusChanged() { + if (impl.item && root.shouldHaveFocus !== impl.item.shouldHaveFocus) + root.shouldHaveFocus = impl.item.shouldHaveFocus; } - MouseArea { - anchors.fill: parent - enabled: root.closeOnBackgroundClick && root.shouldBeVisible - onClicked: root.backgroundClicked() - } - } - - PanelWindow { - id: contentWindow - visible: false - color: "transparent" - - WindowBlur { - targetWindow: contentWindow - readonly property real s: Math.min(1, modalContainer.scaleValue) - blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr) - blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr) - blurWidth: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.width * s : 0 - blurHeight: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.height * s : 0 - blurRadius: root.cornerRadius + function onOpened() { + root.opened(); } - WlrLayershell.namespace: root.layerNamespace - WlrLayershell.layer: { - if (root.useOverlayLayer) - return WlrLayershell.Overlay; - switch (Quickshell.env("DMS_MODAL_LAYER")) { - case "bottom": - log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "background": - log.error("'background' layer is not valid for modals. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "overlay": - return WlrLayershell.Overlay; - default: - return WlrLayershell.Top; - } - } - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: { - if (customKeyboardFocus !== null) - return customKeyboardFocus; - if (!shouldHaveFocus) - return WlrKeyboardFocus.None; - if (root.useHyprlandFocusGrab) - return WlrKeyboardFocus.OnDemand; - return WlrKeyboardFocus.Exclusive; + function onDialogClosed() { + root.dialogClosed(); + root._maybeResolveBackend(); } - anchors { - left: true - top: true - right: root.useSingleWindow - bottom: root.useSingleWindow - } - - WlrLayershell.margins { - left: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr)) - top: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr)) - right: 0 - bottom: 0 - } - - implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2) - implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) - - onVisibleChanged: { - if (visible) { - opened(); - } else { - if (Qt.inputMethod) { - Qt.inputMethod.hide(); - Qt.inputMethod.reset(); - } - } - } - - MouseArea { - anchors.fill: parent - enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible - z: -2 - onClicked: root.backgroundClicked() - } - - Rectangle { - anchors.fill: parent - z: -1 - color: "black" - opacity: root.useBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 - visible: root.useBackground - - Behavior on opacity { - enabled: root.animationsEnabled - DankAnim { - duration: root.animationDuration - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - } - - Item { - id: modalContainer - x: root.useSingleWindow ? root.alignedX : shadowBuffer - y: root.useSingleWindow ? root.alignedY : shadowBuffer - - width: root.alignedWidth - height: root.alignedHeight - - MouseArea { - anchors.fill: parent - enabled: root.useSingleWindow && root.shouldBeVisible - hoverEnabled: false - acceptedButtons: Qt.AllButtons - onPressed: mouse.accepted = true - onClicked: mouse.accepted = true - z: -1 - } - - readonly property bool slide: root.animationType === "slide" - readonly property real offsetX: slide ? 15 : 0 - readonly property real offsetY: slide ? -30 : root.animationOffset - - property real animX: 0 - property real animY: 0 - property real scaleValue: root.animationScaleCollapsed - - onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr) - onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr) - - Connections { - target: root - function onShouldBeVisibleChanged() { - modalContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetX, root.dpr); - modalContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetY, root.dpr); - modalContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed; - } - } - - Behavior on animX { - enabled: root.animationsEnabled - DankAnim { - duration: root.animationDuration - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Behavior on animY { - enabled: root.animationsEnabled - DankAnim { - duration: root.animationDuration - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Behavior on scaleValue { - enabled: root.animationsEnabled - DankAnim { - duration: root.animationDuration - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Item { - id: contentContainer - anchors.centerIn: parent - width: parent.width - height: parent.height - clip: false - - Item { - id: animatedContent - anchors.fill: parent - clip: false - opacity: root.shouldBeVisible ? 1 : 0 - scale: modalContainer.scaleValue - x: Theme.snap(modalContainer.animX, root.dpr) + (parent.width - width) * (1 - modalContainer.scaleValue) * 0.5 - y: Theme.snap(modalContainer.animY, root.dpr) + (parent.height - height) * (1 - modalContainer.scaleValue) * 0.5 - - Behavior on opacity { - enabled: root.animationsEnabled - NumberAnimation { - duration: animationDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - ElevationShadow { - id: modalShadowLayer - anchors.fill: parent - level: root.shadowLevel - fallbackOffset: root.shadowFallbackOffset - targetRadius: root.cornerRadius - targetColor: root.backgroundColor - borderColor: root.borderColor - borderWidth: root.borderWidth - shadowEnabled: root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !BlurService.enabled - } - - Rectangle { - anchors.fill: parent - radius: root.cornerRadius - color: "transparent" - border.color: BlurService.borderColor - border.width: BlurService.borderWidth - z: 100 - } - - FocusScope { - anchors.fill: parent - focus: root.shouldBeVisible - clip: false - - Item { - id: directContentWrapper - anchors.fill: parent - visible: root.directContent !== null - focus: true - clip: false - - Component.onCompleted: { - if (root.directContent) { - root.directContent.parent = directContentWrapper; - root.directContent.anchors.fill = directContentWrapper; - Qt.callLater(() => root.directContent.forceActiveFocus()); - } - } - - Connections { - target: root - function onDirectContentChanged() { - if (root.directContent) { - root.directContent.parent = directContentWrapper; - root.directContent.anchors.fill = directContentWrapper; - Qt.callLater(() => root.directContent.forceActiveFocus()); - } - } - } - } - - Loader { - id: contentLoader - anchors.fill: parent - active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible) - asynchronous: false - focus: true - clip: false - visible: root.directContent === null - - onLoaded: { - if (item) { - Qt.callLater(() => item.forceActiveFocus()); - } - } - } - } - } - } - } - - FocusScope { - id: focusScope - objectName: "modalFocusScope" - anchors.fill: parent - visible: root.shouldBeVisible || contentWindow.visible - focus: root.shouldBeVisible - Keys.onEscapePressed: event => { - if (root.closeOnEscapeKey && shouldHaveFocus) { - root.close(); - event.accepted = true; - } - } + function onBackgroundClicked() { + root.backgroundClicked(); } } } diff --git a/quickshell/Modals/Common/DankModalConnected.qml b/quickshell/Modals/Common/DankModalConnected.qml new file mode 100644 index 00000000..085b9086 --- /dev/null +++ b/quickshell/Modals/Common/DankModalConnected.qml @@ -0,0 +1,838 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + readonly property var log: Log.scoped("DankModalConnected") + + property var modalHandle: root + property string layerNamespace: "dms:modal" + property alias content: contentLoader.sourceComponent + property alias contentLoader: contentLoader + property Item directContent: null + property real modalWidth: 400 + property real modalHeight: 300 + property var targetScreen + readonly property var effectiveScreen: contentWindow.screen ?? targetScreen + readonly property real screenWidth: effectiveScreen?.width ?? 1920 + readonly property real screenHeight: effectiveScreen?.height ?? 1080 + readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 + property bool showBackground: true + property real backgroundOpacity: 0.5 + property string positioning: "center" + property point customPosition: Qt.point(0, 0) + property bool closeOnEscapeKey: true + property bool closeOnBackgroundClick: true + property string animationType: "scale" + + // Opposite side from the launcher by default; subclasses may override + property string preferredConnectedBarSide: SettingsData.frameModalEmergeSide + + readonly property bool frameConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + + readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" + + readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" + + function _dockOccupiesSide(side) { + if (!SettingsData.showDock) + return false; + switch (side) { + case "top": + return SettingsData.dockPosition === SettingsData.Position.Top; + case "bottom": + return SettingsData.dockPosition === SettingsData.Position.Bottom; + case "left": + return SettingsData.dockPosition === SettingsData.Position.Left; + case "right": + return SettingsData.dockPosition === SettingsData.Position.Right; + } + return false; + } + + readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide) + + readonly property bool connectedMotionParity: Theme.isConnectedEffect + property int animationDuration: connectedMotionParity ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration + property real animationScaleCollapsed: Theme.effectScaleCollapsed + property real animationOffset: Theme.effectAnimOffset + property list animationEnterCurve: connectedMotionParity ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve + property list animationExitCurve: connectedMotionParity ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve + property color backgroundColor: Theme.surfaceContainer + property color borderColor: Theme.outlineMedium + property real borderWidth: 0 + property real cornerRadius: Theme.cornerRadius + readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect + readonly property color effectiveBackgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : backgroundColor + readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor + readonly property real effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth + readonly property real effectiveCornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : cornerRadius + readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled + property bool enableShadow: true + property alias modalFocusScope: focusScope + property bool shouldBeVisible: false + property bool shouldHaveFocus: shouldBeVisible + property bool allowFocusOverride: false + property bool allowStacking: false + property bool keepContentLoaded: false + property bool keepPopoutsOpen: false + property var customKeyboardFocus: null + property bool useOverlayLayer: false + property real frozenMotionOffsetX: 0 + property real frozenMotionOffsetY: 0 + readonly property alias contentWindow: contentWindow + readonly property alias clickCatcher: clickCatcher + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab + readonly property bool useBackground: false + readonly property bool useSingleWindow: CompositorService.isHyprland + + signal opened + signal dialogClosed + signal backgroundClicked + + // Coalesce per-channel dirty bits; one ConnectedModeState write per tick. + Timer { + id: _syncTimer + interval: 0 + onTriggered: root._flushSync() + } + + property bool animationsEnabled: true + + property string _chromeClaimId: "" + property bool _fullSyncPending: false + + function _nextChromeClaimId() { + return layerNamespace + ":modal:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000); + } + + function _currentScreenName() { + return effectiveScreen ? effectiveScreen.name : ""; + } + + function _publishModalChromeState() { + const screenName = _currentScreenName(); + if (!screenName) + return; + ConnectedModeState.setModalState(screenName, { + "visible": shouldBeVisible || contentWindow.visible, + "barSide": resolvedConnectedBarSide, + "bodyX": alignedX, + "bodyY": alignedY, + "bodyW": alignedWidth, + "bodyH": alignedHeight, + "animX": modalContainer ? modalContainer.animX : 0, + "animY": modalContainer ? modalContainer.animY : 0, + "omitStartConnector": false, + "omitEndConnector": false + }); + } + + function _syncModalChromeState() { + if (!frameOwnsConnectedChrome) { + _releaseModalChrome(); + return; + } + if (!_chromeClaimId) + _chromeClaimId = _nextChromeClaimId(); + _publishModalChromeState(); + if (_dockBlocksEmergence && (shouldBeVisible || contentWindow.visible)) + ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide); + else + ConnectedModeState.releaseDockRetract(_chromeClaimId); + } + + property bool _animSyncQueued: false + property bool _bodySyncQueued: false + + function _queueFullSync() { + _fullSyncPending = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _queueAnimSync() { + _animSyncQueued = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _queueBodySync() { + _bodySyncQueued = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _flushSync() { + const fullDirty = _fullSyncPending; + const animDirty = _animSyncQueued; + const bodyDirty = _bodySyncQueued; + _fullSyncPending = false; + _animSyncQueued = false; + _bodySyncQueued = false; + if (fullDirty) + _syncModalChromeState(); + if (animDirty) + _syncModalAnim(); + if (bodyDirty) + _syncModalBody(); + } + + function _syncModalAnim() { + if (!frameOwnsConnectedChrome || !_chromeClaimId) + return; + const screenName = _currentScreenName(); + if (!screenName || !modalContainer) + return; + ConnectedModeState.setModalAnim(screenName, modalContainer.animX, modalContainer.animY); + } + + function _syncModalBody() { + if (!frameOwnsConnectedChrome || !_chromeClaimId) + return; + const screenName = _currentScreenName(); + if (!screenName) + return; + ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight); + } + + function _releaseModalChrome() { + if (_chromeClaimId) { + ConnectedModeState.releaseDockRetract(_chromeClaimId); + _chromeClaimId = ""; + } + const screenName = _currentScreenName(); + if (screenName) + ConnectedModeState.clearModalState(screenName); + } + + onFrameOwnsConnectedChromeChanged: _syncModalChromeState() + onResolvedConnectedBarSideChanged: _queueFullSync() + onShouldBeVisibleChanged: _queueFullSync() + onAlignedXChanged: _queueBodySync() + onAlignedYChanged: _queueBodySync() + onAlignedWidthChanged: _queueBodySync() + onAlignedHeightChanged: _queueBodySync() + + Component.onDestruction: _releaseModalChrome() + + Connections { + target: contentWindow + function onVisibleChanged() { + if (contentWindow.visible) + root._syncModalChromeState(); + else + root._releaseModalChrome(); + } + } + + function open() { + closeTimer.stop(); + animationsEnabled = false; + frozenMotionOffsetX = modalContainer ? modalContainer.offsetX : 0; + frozenMotionOffsetY = modalContainer ? modalContainer.offsetY : animationOffset; + + const focusedScreen = CompositorService.getFocusedScreen(); + if (focusedScreen) { + contentWindow.screen = focusedScreen; + if (!useSingleWindow) + clickCatcher.screen = focusedScreen; + } + + if (Theme.isDirectionalEffect || root.useBackground) { + if (!useSingleWindow) + clickCatcher.visible = true; + contentWindow.visible = true; + } + ModalManager.openModal(modalHandle); + + Qt.callLater(() => { + animationsEnabled = true; + shouldBeVisible = true; + if (!useSingleWindow && !clickCatcher.visible) + clickCatcher.visible = true; + if (!contentWindow.visible) + contentWindow.visible = true; + shouldHaveFocus = false; + Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); + }); + } + + function close() { + shouldBeVisible = false; + shouldHaveFocus = false; + ModalManager.closeModal(modalHandle); + closeTimer.restart(); + } + + function instantClose() { + animationsEnabled = false; + shouldBeVisible = false; + shouldHaveFocus = false; + ModalManager.closeModal(modalHandle); + closeTimer.stop(); + contentWindow.visible = false; + if (!useSingleWindow) + clickCatcher.visible = false; + dialogClosed(); + Qt.callLater(() => animationsEnabled = true); + } + + function toggle() { + shouldBeVisible ? close() : open(); + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && !allowStacking && shouldBeVisible) + close(); + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (!contentWindow.screen) + return; + const currentScreenName = contentWindow.screen.name; + let screenStillExists = false; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === currentScreenName) { + screenStillExists = true; + break; + } + } + if (screenStillExists) + return; + const newScreen = CompositorService.getFocusedScreen(); + if (newScreen) { + contentWindow.screen = newScreen; + if (!useSingleWindow) + clickCatcher.screen = newScreen; + } + } + } + + Timer { + id: closeTimer + interval: Theme.variantCloseInterval(animationDuration) + onTriggered: { + if (shouldBeVisible) + return; + contentWindow.visible = false; + if (!useSingleWindow) + clickCatcher.visible = false; + dialogClosed(); + } + } + + // shadowRenderPadding is zeroed when frame owns the chrome + // Wayland then clips any content translating past + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowMotionPadding: { + if (Theme.isConnectedEffect) + return 0; + if (animationType === "slide") + return 30; + if (Theme.isDirectionalEffect) + return Math.max(Math.max(0, animationOffset), Math.max(alignedWidth, alignedHeight) * 0.9); + if (Theme.isDepthEffect) + return Math.max(Math.max(0, animationOffset), Math.max(alignedWidth, alignedHeight) * 0.35); + return Math.max(0, animationOffset); + } + readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) + readonly property real alignedWidth: Theme.px(modalWidth, dpr) + readonly property real alignedHeight: Theme.px(modalHeight, dpr) + + function _frameEdgeInset(side) { + if (!effectiveScreen) + return 0; + return SettingsData.frameEdgeInsetForSide(effectiveScreen, side); + } + + // frameEdgeInsetForSide is the full inset; do not add frameBarSize + readonly property real _connectedAlignedX: { + switch (resolvedConnectedBarSide) { + case "top": + case "bottom": + { + const insetL = _frameEdgeInset("left"); + const insetR = _frameEdgeInset("right"); + const usable = Math.max(0, screenWidth - insetL - insetR); + return insetL + Math.max(0, (usable - alignedWidth) / 2); + } + case "left": + return _frameEdgeInset("left"); + case "right": + return screenWidth - alignedWidth - _frameEdgeInset("right"); + } + return 0; + } + + readonly property real _connectedAlignedY: { + switch (resolvedConnectedBarSide) { + case "top": + return _frameEdgeInset("top"); + case "bottom": + return screenHeight - alignedHeight - _frameEdgeInset("bottom"); + case "left": + case "right": + { + const insetT = _frameEdgeInset("top"); + const insetB = _frameEdgeInset("bottom"); + const usable = Math.max(0, screenHeight - insetT - insetB); + return insetT + Math.max(0, (usable - alignedHeight) / 2); + } + } + return 0; + } + + readonly property real alignedX: Theme.snap(frameOwnsConnectedChrome ? _connectedAlignedX : (() => { + switch (positioning) { + case "center": + return (screenWidth - alignedWidth) / 2; + case "top-right": + return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL); + case "custom": + return customPosition.x; + default: + return 0; + } + })(), dpr) + + readonly property real alignedY: Theme.snap(frameOwnsConnectedChrome ? _connectedAlignedY : (() => { + switch (positioning) { + case "center": + return (screenHeight - alignedHeight) / 2; + case "top-right": + return Theme.barHeight + Theme.spacingXS; + case "custom": + return customPosition.y; + default: + return 0; + } + })(), dpr) + + PanelWindow { + id: clickCatcher + visible: false + color: "transparent" + + WlrLayershell.namespace: root.layerNamespace + ":clickcatcher" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + left: true + right: true + bottom: true + } + + mask: Region { + item: Rectangle { + x: root.alignedX + y: root.alignedY + width: root.alignedWidth + height: root.alignedHeight + } + intersection: Intersection.Xor + } + + MouseArea { + anchors.fill: parent + enabled: !root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible + onClicked: root.backgroundClicked() + } + + Rectangle { + anchors.fill: parent + z: -1 + color: "black" + opacity: (!root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 + visible: opacity > 0 + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + } + + PanelWindow { + id: contentWindow + visible: false + color: "transparent" + + WindowBlur { + targetWindow: contentWindow + blurEnabled: root.effectiveBlurEnabled && !root.frameOwnsConnectedChrome + readonly property real s: Math.min(1, modalContainer.scaleValue) + blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr) + blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr) + blurWidth: (root.shouldBeVisible && !root.frameOwnsConnectedChrome) ? modalContainer.width * s : 0 + blurHeight: (root.shouldBeVisible && !root.frameOwnsConnectedChrome) ? modalContainer.height * s : 0 + blurRadius: root.effectiveCornerRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: { + if (root.useOverlayLayer) + return WlrLayershell.Overlay; + switch (Quickshell.env("DMS_MODAL_LAYER")) { + case "bottom": + log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + log.error("'background' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: { + if (customKeyboardFocus !== null) + return customKeyboardFocus; + if (!shouldHaveFocus) + return WlrKeyboardFocus.None; + if (root.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + + anchors { + left: true + top: true + right: root.useSingleWindow + bottom: root.useSingleWindow + } + + readonly property real actualMarginLeft: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr)) + readonly property real actualMarginTop: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr)) + + WlrLayershell.margins { + left: actualMarginLeft + top: actualMarginTop + right: 0 + bottom: 0 + } + + implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2) + implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) + + onVisibleChanged: { + if (visible) { + opened(); + } else { + if (Qt.inputMethod) { + Qt.inputMethod.hide(); + Qt.inputMethod.reset(); + } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible + z: -2 + onClicked: root.backgroundClicked() + } + + Rectangle { + anchors.fill: parent + z: -1 + color: "black" + opacity: (root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 + visible: opacity > 0 + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + + Item { + id: modalContainer + x: (root.useSingleWindow ? root.alignedX : (root.alignedX - contentWindow.actualMarginLeft)) + Theme.snap(animX, root.dpr) + y: (root.useSingleWindow ? root.alignedY : (root.alignedY - contentWindow.actualMarginTop)) + Theme.snap(animY, root.dpr) + + width: root.alignedWidth + height: root.alignedHeight + + MouseArea { + anchors.fill: parent + enabled: root.useSingleWindow && root.shouldBeVisible + hoverEnabled: false + acceptedButtons: Qt.AllButtons + onPressed: mouse.accepted = true + onClicked: mouse.accepted = true + z: -1 + } + + readonly property bool slide: root.animationType === "slide" + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real directionalTravel: Math.max(root.animationOffset, Math.max(root.alignedWidth, root.alignedHeight) * 0.8) + readonly property real depthTravel: Math.max(root.animationOffset * 0.8, 36) + readonly property real customAnchorX: root.alignedX + root.alignedWidth * 0.5 + readonly property real customAnchorY: root.alignedY + root.alignedHeight * 0.5 + readonly property real customDistLeft: customAnchorX + readonly property real customDistRight: root.screenWidth - customAnchorX + readonly property real customDistTop: customAnchorY + readonly property real customDistBottom: root.screenHeight - customAnchorY + // Connected emergence: travel from the resolved bar edge, matching DankPopout cadence. + readonly property real connectedEmergenceTravelX: Math.max(root.animationOffset, root.alignedWidth + Theme.spacingL) + readonly property real connectedEmergenceTravelY: Math.max(root.animationOffset, root.alignedHeight + Theme.spacingL) + readonly property real offsetX: { + if (root.frameOwnsConnectedChrome) { + switch (root.resolvedConnectedBarSide) { + case "left": + return -connectedEmergenceTravelX; + case "right": + return connectedEmergenceTravelX; + } + return 0; + } + if (slide && !directionalEffect && !depthEffect) + return 15; + if (directionalEffect) { + switch (root.positioning) { + case "top-right": + return 0; + case "custom": + if (customDistLeft <= customDistRight && customDistLeft <= customDistTop && customDistLeft <= customDistBottom) + return -directionalTravel; + if (customDistRight <= customDistTop && customDistRight <= customDistBottom) + return directionalTravel; + return 0; + default: + return 0; + } + } + if (depthEffect) { + switch (root.positioning) { + case "top-right": + return 0; + case "custom": + if (customDistLeft <= customDistRight && customDistLeft <= customDistTop && customDistLeft <= customDistBottom) + return -depthTravel; + if (customDistRight <= customDistTop && customDistRight <= customDistBottom) + return depthTravel; + return 0; + default: + return 0; + } + } + return 0; + } + readonly property real offsetY: { + if (root.frameOwnsConnectedChrome) { + switch (root.resolvedConnectedBarSide) { + case "top": + return -connectedEmergenceTravelY; + case "bottom": + return connectedEmergenceTravelY; + } + return 0; + } + if (slide && !directionalEffect && !depthEffect) + return -30; + if (directionalEffect) { + switch (root.positioning) { + case "top-right": + return -Math.max(directionalTravel * 0.65, 96); + case "custom": + if (customDistTop <= customDistBottom && customDistTop <= customDistLeft && customDistTop <= customDistRight) + return -directionalTravel; + if (customDistBottom <= customDistLeft && customDistBottom <= customDistRight) + return directionalTravel; + return 0; + default: + // Default to sliding down from top when centered + return -Math.max(directionalTravel, root.screenHeight * 0.24); + } + } + if (depthEffect) { + switch (root.positioning) { + case "top-right": + return -depthTravel * 0.75; + case "custom": + if (customDistTop <= customDistBottom && customDistTop <= customDistLeft && customDistTop <= customDistRight) + return -depthTravel; + if (customDistBottom <= customDistLeft && customDistBottom <= customDistRight) + return depthTravel; + return depthTravel * 0.45; + default: + return -depthTravel; + } + } + return root.animationOffset; + } + + readonly property real computedScaleCollapsed: root.animationScaleCollapsed + + // openProgress: 0 = closed (at frozenMotionOffset, scaleCollapsed), 1 = open (at 0, scale 1). + QtObject { + id: morph + property real openProgress: root.shouldBeVisible ? 1 : 0 + Behavior on openProgress { + enabled: root.animationsEnabled + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + + readonly property real animX: root.frozenMotionOffsetX * (1 - morph.openProgress) + readonly property real animY: root.frozenMotionOffsetY * (1 - morph.openProgress) + readonly property real scaleValue: computedScaleCollapsed + (1.0 - computedScaleCollapsed) * morph.openProgress + + onAnimXChanged: if (root.frameOwnsConnectedChrome) + root._queueAnimSync() + onAnimYChanged: if (root.frameOwnsConnectedChrome) + root._queueAnimSync() + + Item { + id: contentContainer + anchors.centerIn: parent + width: parent.width + height: parent.height + clip: false + + Item { + id: animatedContent + anchors.fill: parent + clip: false + + property real publishedOpacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0) + + opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0) + scale: modalContainer.scaleValue + transformOrigin: Item.Center + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Behavior on publishedOpacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + ElevationShadow { + id: modalShadowLayer + anchors.fill: parent + level: root.shadowLevel + fallbackOffset: root.shadowFallbackOffset + targetRadius: root.effectiveCornerRadius + targetColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBackgroundColor + borderColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBorderColor + borderWidth: root.frameOwnsConnectedChrome ? 0 : root.effectiveBorderWidth + shadowEnabled: !root.frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + Rectangle { + anchors.fill: parent + radius: root.effectiveCornerRadius + color: "transparent" + border.color: (root.connectedSurfaceOverride || root.frameOwnsConnectedChrome) ? "transparent" : BlurService.borderColor + border.width: (root.connectedSurfaceOverride || root.frameOwnsConnectedChrome) ? 0 : BlurService.borderWidth + z: 100 + } + + FocusScope { + anchors.fill: parent + focus: root.shouldBeVisible + clip: false + + Item { + id: directContentWrapper + anchors.fill: parent + visible: root.directContent !== null + focus: true + clip: false + + Component.onCompleted: { + if (root.directContent) { + root.directContent.parent = directContentWrapper; + root.directContent.anchors.fill = directContentWrapper; + Qt.callLater(() => root.directContent.forceActiveFocus()); + } + } + + Connections { + target: root + function onDirectContentChanged() { + if (root.directContent) { + root.directContent.parent = directContentWrapper; + root.directContent.anchors.fill = directContentWrapper; + Qt.callLater(() => root.directContent.forceActiveFocus()); + } + } + } + } + + Loader { + id: contentLoader + anchors.fill: parent + active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible) + asynchronous: false + focus: true + clip: false + visible: root.directContent === null + + onLoaded: { + if (item) { + Qt.callLater(() => item.forceActiveFocus()); + } + } + } + } + } + } + } + + FocusScope { + id: focusScope + objectName: "modalFocusScope" + anchors.fill: parent + visible: root.shouldBeVisible || contentWindow.visible + focus: root.shouldBeVisible + Keys.onEscapePressed: event => { + if (root.closeOnEscapeKey && shouldHaveFocus) { + root.close(); + event.accepted = true; + } + } + } + } +} diff --git a/quickshell/Modals/Common/DankModalStandalone.qml b/quickshell/Modals/Common/DankModalStandalone.qml new file mode 100644 index 00000000..defeaab5 --- /dev/null +++ b/quickshell/Modals/Common/DankModalStandalone.qml @@ -0,0 +1,484 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + readonly property var log: Log.scoped("DankModalStandalone") + + property var modalHandle: root + property string layerNamespace: "dms:modal" + property alias content: contentLoader.sourceComponent + property alias contentLoader: contentLoader + property Item directContent: null + property real modalWidth: 400 + property real modalHeight: 300 + property var targetScreen + readonly property var effectiveScreen: contentWindow.screen ?? targetScreen + readonly property real screenWidth: effectiveScreen?.width ?? 1920 + readonly property real screenHeight: effectiveScreen?.height ?? 1080 + readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 + property bool showBackground: true + property real backgroundOpacity: 0.5 + property string positioning: "center" + property point customPosition: Qt.point(0, 0) + property bool closeOnEscapeKey: true + property bool closeOnBackgroundClick: true + property string animationType: "scale" + property int animationDuration: Theme.modalAnimationDuration + property real animationScaleCollapsed: 0.96 + property real animationOffset: Theme.spacingL + property list animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial + property list animationExitCurve: Theme.expressiveCurves.emphasized + property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + property color borderColor: Theme.outlineMedium + property real borderWidth: 0 + property real cornerRadius: Theme.cornerRadius + property bool enableShadow: true + property alias modalFocusScope: focusScope + property bool shouldBeVisible: false + property bool isClosing: false + property bool shouldHaveFocus: shouldBeVisible + property bool allowFocusOverride: false + property bool allowStacking: false + property bool keepContentLoaded: false + property bool keepPopoutsOpen: false + property var customKeyboardFocus: null + property bool useOverlayLayer: false + readonly property alias contentWindow: contentWindow + readonly property alias clickCatcher: clickCatcher + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab + readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground + readonly property bool useSingleWindow: CompositorService.isHyprland || useBackground + + signal opened + signal dialogClosed + signal backgroundClicked + + property bool animationsEnabled: true + + function open() { + closeTimer.stop(); + isClosing = false; + const focusedScreen = CompositorService.getFocusedScreen(); + const screenChanged = focusedScreen && contentWindow.screen !== focusedScreen; + if (focusedScreen) { + if (screenChanged) + contentWindow.visible = false; + contentWindow.screen = focusedScreen; + if (!useSingleWindow) { + if (screenChanged) + clickCatcher.visible = false; + clickCatcher.screen = focusedScreen; + } + } + if (screenChanged) { + Qt.callLater(() => root._finishOpen()); + } else { + _finishOpen(); + } + } + + function _finishOpen() { + ModalManager.openModal(modalHandle); + shouldBeVisible = true; + if (!useSingleWindow) + clickCatcher.visible = true; + contentWindow.visible = true; + shouldHaveFocus = false; + Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); + } + + function close() { + isClosing = true; + shouldBeVisible = false; + shouldHaveFocus = false; + ModalManager.closeModal(modalHandle); + closeTimer.restart(); + } + + function instantClose() { + animationsEnabled = false; + isClosing = false; + shouldBeVisible = false; + shouldHaveFocus = false; + ModalManager.closeModal(modalHandle); + closeTimer.stop(); + contentWindow.visible = false; + if (!useSingleWindow) + clickCatcher.visible = false; + dialogClosed(); + Qt.callLater(() => animationsEnabled = true); + } + + function toggle() { + shouldBeVisible ? close() : open(); + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && !allowStacking && shouldBeVisible) + close(); + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (!contentWindow.screen) + return; + const currentScreenName = contentWindow.screen.name; + let screenStillExists = false; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === currentScreenName) { + screenStillExists = true; + break; + } + } + if (screenStillExists) + return; + const newScreen = CompositorService.getFocusedScreen(); + if (newScreen) { + contentWindow.screen = newScreen; + if (!useSingleWindow) + clickCatcher.screen = newScreen; + } + } + } + + Timer { + id: closeTimer + interval: animationDuration + 50 + onTriggered: { + if (shouldBeVisible) + return; + isClosing = false; + contentWindow.visible = false; + if (!useSingleWindow) + clickCatcher.visible = false; + dialogClosed(); + } + } + + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowMotionPadding: animationType === "slide" ? 30 : Math.max(0, animationOffset) + readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) + readonly property real alignedWidth: Theme.px(modalWidth, dpr) + readonly property real alignedHeight: Theme.px(modalHeight, dpr) + + readonly property real alignedX: Theme.snap((() => { + switch (positioning) { + case "center": + return (screenWidth - alignedWidth) / 2; + case "top-right": + return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL); + case "custom": + return customPosition.x; + default: + return 0; + } + })(), dpr) + + readonly property real alignedY: Theme.snap((() => { + switch (positioning) { + case "center": + return (screenHeight - alignedHeight) / 2; + case "top-right": + return Theme.barHeight + Theme.spacingXS; + case "custom": + return customPosition.y; + default: + return 0; + } + })(), dpr) + + PanelWindow { + id: clickCatcher + visible: false + color: "transparent" + + WlrLayershell.namespace: root.layerNamespace + ":clickcatcher" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + left: true + right: true + bottom: true + } + + mask: Region { + item: Rectangle { + x: root.alignedX + y: root.alignedY + width: root.alignedWidth + height: root.alignedHeight + } + intersection: Intersection.Xor + } + + MouseArea { + anchors.fill: parent + enabled: root.closeOnBackgroundClick && root.shouldBeVisible + onClicked: root.backgroundClicked() + } + } + + PanelWindow { + id: contentWindow + visible: false + color: "transparent" + + WindowBlur { + targetWindow: contentWindow + readonly property real s: Math.min(1, modalContainer.scaleValue) + blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr) + blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr) + blurWidth: shouldBeVisible ? modalContainer.width * s : 0 + blurHeight: shouldBeVisible ? modalContainer.height * s : 0 + blurRadius: root.cornerRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: { + if (root.useOverlayLayer) + return WlrLayershell.Overlay; + switch (Quickshell.env("DMS_MODAL_LAYER")) { + case "bottom": + log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + log.error("'background' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: { + if (customKeyboardFocus !== null) + return customKeyboardFocus; + if (!shouldHaveFocus) + return WlrKeyboardFocus.None; + if (root.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + + anchors { + left: true + top: true + right: root.useSingleWindow + bottom: root.useSingleWindow + } + + WlrLayershell.margins { + left: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr)) + top: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr)) + right: 0 + bottom: 0 + } + + implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2) + implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) + + onVisibleChanged: { + if (visible) { + opened(); + } else { + if (Qt.inputMethod) { + Qt.inputMethod.hide(); + Qt.inputMethod.reset(); + } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible + z: -2 + onClicked: root.backgroundClicked() + } + + Rectangle { + anchors.fill: parent + z: -1 + color: "black" + opacity: root.useBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 + visible: root.useBackground + + Behavior on opacity { + enabled: root.animationsEnabled + NumberAnimation { + easing.type: Easing.BezierSpline + duration: root.animationDuration + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + + Item { + id: modalContainer + x: root.useSingleWindow ? root.alignedX : shadowBuffer + y: root.useSingleWindow ? root.alignedY : shadowBuffer + + width: root.alignedWidth + height: root.alignedHeight + + MouseArea { + anchors.fill: parent + enabled: root.useSingleWindow && root.shouldBeVisible + hoverEnabled: false + acceptedButtons: Qt.AllButtons + onPressed: mouse.accepted = true + onClicked: mouse.accepted = true + z: -1 + } + + readonly property bool slide: root.animationType === "slide" + readonly property real offsetX: slide ? 15 : 0 + readonly property real offsetY: slide ? -30 : root.animationOffset + + // openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1). + QtObject { + id: morph + property real openProgress: root.shouldBeVisible ? 1 : 0 + Behavior on openProgress { + enabled: root.animationsEnabled + DankAnim { + duration: root.animationDuration + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + + readonly property real animX: modalContainer.offsetX * (1 - morph.openProgress) + readonly property real animY: modalContainer.offsetY * (1 - morph.openProgress) + readonly property real scaleValue: root.animationScaleCollapsed + (1.0 - root.animationScaleCollapsed) * morph.openProgress + + Item { + id: contentContainer + anchors.centerIn: parent + width: parent.width + height: parent.height + clip: false + + Item { + id: animatedContent + anchors.fill: parent + clip: false + + opacity: root.shouldBeVisible ? 1 : 0 + scale: modalContainer.scaleValue + x: Theme.snap(modalContainer.animX, root.dpr) + (parent.width - width) * (1 - modalContainer.scaleValue) * 0.5 + y: Theme.snap(modalContainer.animY, root.dpr) + (parent.height - height) * (1 - modalContainer.scaleValue) * 0.5 + + Behavior on opacity { + enabled: root.animationsEnabled + NumberAnimation { + duration: animationDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + ElevationShadow { + id: modalShadowLayer + anchors.fill: parent + level: root.shadowLevel + fallbackOffset: root.shadowFallbackOffset + targetRadius: root.cornerRadius + targetColor: root.backgroundColor + borderColor: root.borderColor + borderWidth: root.borderWidth + shadowEnabled: root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + Rectangle { + anchors.fill: parent + radius: root.cornerRadius + color: "transparent" + border.color: BlurService.borderColor + border.width: BlurService.borderWidth + z: 100 + } + + FocusScope { + anchors.fill: parent + focus: root.shouldBeVisible + clip: false + + Item { + id: directContentWrapper + anchors.fill: parent + visible: root.directContent !== null + focus: true + clip: false + + Component.onCompleted: { + if (root.directContent) { + root.directContent.parent = directContentWrapper; + root.directContent.anchors.fill = directContentWrapper; + Qt.callLater(() => root.directContent.forceActiveFocus()); + } + } + + Connections { + target: root + function onDirectContentChanged() { + if (root.directContent) { + root.directContent.parent = directContentWrapper; + root.directContent.anchors.fill = directContentWrapper; + Qt.callLater(() => root.directContent.forceActiveFocus()); + } + } + } + } + + Loader { + id: contentLoader + anchors.fill: parent + active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible) + asynchronous: false + focus: true + clip: false + visible: root.directContent === null + + onLoaded: { + if (item) { + Qt.callLater(() => item.forceActiveFocus()); + } + } + } + } + } + } + } + + FocusScope { + id: focusScope + objectName: "modalFocusScope" + anchors.fill: parent + visible: root.shouldBeVisible || contentWindow.visible + focus: root.shouldBeVisible + Keys.onEscapePressed: event => { + if (root.closeOnEscapeKey && shouldHaveFocus) { + root.close(); + event.accepted = true; + } + } + } + } +} diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml index c3855a7c..3657e948 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml @@ -1,466 +1,118 @@ import QtQuick -import Quickshell -import Quickshell.Wayland -import Quickshell.Hyprland import qs.Common import qs.Services -import qs.Widgets Item { id: root readonly property var log: Log.scoped("DankLauncherV2Modal") - visible: false - - property bool spotlightOpen: false - property bool keyboardActive: false - property bool contentVisible: false - property var spotlightContent: launcherContentLoader.item - property bool openedFromOverview: false - property bool isClosing: false - property bool _pendingInitialize: false - property string _pendingQuery: "" - property string _pendingMode: "" - readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose - - readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab - readonly property var effectiveScreen: launcherWindow.screen - readonly property real screenWidth: effectiveScreen?.width ?? 1920 - readonly property real screenHeight: effectiveScreen?.height ?? 1080 - readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 - - readonly property int baseWidth: { - switch (SettingsData.dankLauncherV2Size) { - case "micro": - return 500; - case "medium": - return 720; - case "large": - return 860; - default: - return 620; - } - } - readonly property int baseHeight: { - switch (SettingsData.dankLauncherV2Size) { - case "micro": - return 480; - case "medium": - return 720; - case "large": - return 860; - default: - return 600; - } - } - readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100) - readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100) - readonly property real modalX: (screenWidth - modalWidth) / 2 - readonly property real modalY: (screenHeight - modalHeight) / 2 - - readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) - readonly property real cornerRadius: Theme.cornerRadius - readonly property color borderColor: { - if (!SettingsData.dankLauncherV2BorderEnabled) - return Theme.outlineMedium; - switch (SettingsData.dankLauncherV2BorderColor) { - case "primary": - return Theme.primary; - case "secondary": - return Theme.secondary; - case "outline": - return Theme.outline; - case "surfaceText": - return Theme.surfaceText; - default: - return Theme.primary; - } - } - readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0 + readonly property bool spotlightOpen: impl.item ? impl.item.spotlightOpen : false + readonly property bool isClosing: impl.item ? impl.item.isClosing : false + readonly property bool keyboardActive: impl.item ? impl.item.keyboardActive : false + readonly property bool contentVisible: impl.item ? impl.item.contentVisible : false + readonly property var spotlightContent: impl.item ? impl.item.spotlightContent : null + readonly property bool openedFromOverview: impl.item ? impl.item.openedFromOverview : false + readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null + readonly property real screenWidth: impl.item ? impl.item.screenWidth : 1920 + readonly property real screenHeight: impl.item ? impl.item.screenHeight : 1080 + readonly property real dpr: impl.item ? impl.item.dpr : 1 + readonly property int modalWidth: impl.item ? impl.item.modalWidth : 620 + readonly property int modalHeight: impl.item ? impl.item.modalHeight : 600 + readonly property real modalX: impl.item ? impl.item.modalX : 0 + readonly property real modalY: impl.item ? impl.item.modalY : 0 + readonly property bool frameOwnsConnectedChrome: impl.item ? (impl.item.frameOwnsConnectedChrome ?? false) : false + readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : "" + readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false signal dialogClosed - function _ensureContentLoadedAndInitialize(query, mode) { - _pendingQuery = query || ""; - _pendingMode = mode || ""; - _pendingInitialize = true; - contentVisible = true; - launcherContentLoader.active = true; - - if (spotlightContent) { - _initializeAndShow(_pendingQuery, _pendingMode); - _pendingInitialize = false; - } - } - - function _initializeAndShow(query, mode) { - if (!spotlightContent) - return; - contentVisible = true; - spotlightContent.searchField.forceActiveFocus(); - - var targetQuery = ""; - - if (query) { - targetQuery = query; - } else if (SettingsData.rememberLastQuery) { - targetQuery = SessionData.launcherLastQuery || ""; - } - - if (spotlightContent.searchField) { - spotlightContent.searchField.text = targetQuery; - } - if (spotlightContent.controller) { - var targetMode = mode || SessionData.launcherLastMode || "all"; - spotlightContent.controller.searchMode = targetMode; - spotlightContent.controller.activePluginId = ""; - spotlightContent.controller.activePluginName = ""; - spotlightContent.controller.pluginFilter = ""; - spotlightContent.controller.fileSearchType = "all"; - spotlightContent.controller.fileSearchExt = ""; - spotlightContent.controller.fileSearchFolder = ""; - spotlightContent.controller.fileSearchSort = "score"; - spotlightContent.controller.collapsedSections = {}; - spotlightContent.controller.selectedFlatIndex = 0; - spotlightContent.controller.selectedItem = null; - spotlightContent.controller.historyIndex = -1; - spotlightContent.controller.searchQuery = targetQuery; - - spotlightContent.controller.performSearch(); - } - if (spotlightContent.resetScroll) { - spotlightContent.resetScroll(); - } - if (spotlightContent.actionPanel) { - spotlightContent.actionPanel.hide(); - } - } - - function _finishShow(query, mode) { - spotlightOpen = true; - isClosing = false; - openedFromOverview = false; - - keyboardActive = true; - ModalManager.openModal(root); - if (useHyprlandFocusGrab) - focusGrab.active = true; - - _ensureContentLoadedAndInitialize(query || "", mode || ""); - } - function show() { - closeCleanupTimer.stop(); - - var focusedScreen = CompositorService.getFocusedScreen(); - if (focusedScreen && launcherWindow.screen !== focusedScreen) { - spotlightOpen = false; - isClosing = false; - launcherWindow.screen = focusedScreen; - Qt.callLater(() => root._finishShow("", "")); - return; - } - - _finishShow("", ""); + if (impl.item) + impl.item.show(); } function showWithQuery(query) { - closeCleanupTimer.stop(); - - var focusedScreen = CompositorService.getFocusedScreen(); - if (focusedScreen && launcherWindow.screen !== focusedScreen) { - spotlightOpen = false; - isClosing = false; - launcherWindow.screen = focusedScreen; - Qt.callLater(() => root._finishShow(query, "")); - return; - } - - _finishShow(query, ""); - } - - function hide() { - if (!spotlightOpen) - return; - openedFromOverview = false; - isClosing = true; - contentVisible = false; - - keyboardActive = false; - spotlightOpen = false; - focusGrab.active = false; - ModalManager.closeModal(root); - - closeCleanupTimer.start(); - } - - function toggle() { - spotlightOpen ? hide() : show(); + if (impl.item) + impl.item.showWithQuery(query); } function showWithMode(mode) { - closeCleanupTimer.stop(); - - var focusedScreen = CompositorService.getFocusedScreen(); - if (focusedScreen && launcherWindow.screen !== focusedScreen) { - spotlightOpen = false; - isClosing = false; - launcherWindow.screen = focusedScreen; - Qt.callLater(() => root._finishShow("", mode)); - return; - } - - spotlightOpen = true; - isClosing = false; - openedFromOverview = false; - - keyboardActive = true; - ModalManager.openModal(root); - if (useHyprlandFocusGrab) - focusGrab.active = true; - - _ensureContentLoadedAndInitialize("", mode); + if (impl.item) + impl.item.showWithMode(mode); } - function toggleWithMode(mode) { - if (spotlightOpen) { - hide(); - } else { - showWithMode(mode); - } + function hide() { + if (impl.item) + impl.item.hide(); + } + + function toggle() { + if (impl.item) + impl.item.toggle(); } function toggleWithQuery(query) { - if (spotlightOpen) { - hide(); - } else { - showWithQuery(query); + if (impl.item) + impl.item.toggleWithQuery(query); + } + + function toggleWithMode(mode) { + if (impl.item) + impl.item.toggleWithMode(mode); + } + + readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp + property var _resolvedBackend: null + + Component.onCompleted: _resolvedBackend = _desiredBackend + + Connections { + target: SettingsData + function onConnectedFrameModeActiveChanged() { + root._maybeResolveBackend(); } } - Timer { - id: closeCleanupTimer - interval: Theme.modalAnimationDuration + 50 - repeat: false - onTriggered: { - isClosing = false; - if (root.unloadContentOnClose) - launcherContentLoader.active = false; - dialogClosed(); - } + // Defer Loader source-component swap until impl is fully closed; avoids + // tearing down the launcher mid-animation when frame mode is toggled. + function _maybeResolveBackend() { + if (_resolvedBackend === _desiredBackend) + return; + if (impl.item && (impl.item.spotlightOpen || impl.item.isClosing)) + return; + _resolvedBackend = _desiredBackend; + } + + Loader { + id: impl + sourceComponent: root._resolvedBackend + onItemChanged: if (item) + root._wireBackend(item) + } + + Component { + id: standaloneComp + DankLauncherV2ModalStandalone {} + } + + Component { + id: connectedComp + DankLauncherV2ModalConnected {} + } + + function _wireBackend(it) { + if (!it) + return; + it.modalHandle = root; } Connections { - target: spotlightContent?.controller ?? null + target: impl.item + ignoreUnknownSignals: true - function onModeChanged(mode) { - if (spotlightContent.controller.autoSwitchedToFiles) - return; - SessionData.setLauncherLastMode(mode); - } - } - - HyprlandFocusGrab { - id: focusGrab - windows: [launcherWindow] - active: false - - onCleared: { - if (spotlightOpen) { - hide(); - } - } - } - - Connections { - target: ModalManager - function onCloseAllModalsExcept(excludedModal) { - if (excludedModal !== root && spotlightOpen) { - hide(); - } - } - } - - Connections { - target: Quickshell - function onScreensChanged() { - if (Quickshell.screens.length === 0) - return; - - const screenName = launcherWindow.screen?.name; - if (screenName) { - for (let i = 0; i < Quickshell.screens.length; i++) { - if (Quickshell.screens[i].name === screenName) - return; - } - } - - if (spotlightOpen) - hide(); - - const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0]; - if (newScreen) - launcherWindow.screen = newScreen; - } - } - - PanelWindow { - id: launcherWindow - visible: spotlightOpen || isClosing - color: "transparent" - exclusionMode: ExclusionMode.Ignore - - WindowBlur { - targetWindow: launcherWindow - readonly property real s: Math.min(1, modalContainer.scale) - blurX: root.modalX + root.modalWidth * (1 - s) * 0.5 - blurY: root.modalY + root.modalHeight * (1 - s) * 0.5 - blurWidth: (contentVisible && modalContainer.opacity > 0) ? root.modalWidth * s : 0 - blurHeight: (contentVisible && modalContainer.opacity > 0) ? root.modalHeight * s : 0 - blurRadius: root.cornerRadius - } - - WlrLayershell.namespace: "dms:spotlight" - WlrLayershell.layer: { - switch (Quickshell.env("DMS_MODAL_LAYER")) { - case "bottom": - log.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "background": - log.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "overlay": - return WlrLayershell.Overlay; - default: - return WlrLayershell.Top; - } - } - WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None - - anchors { - top: true - bottom: true - left: true - right: true - } - - mask: Region { - item: spotlightOpen ? fullScreenMask : null - } - - Item { - id: fullScreenMask - anchors.fill: parent - } - - Rectangle { - id: backgroundDarken - anchors.fill: parent - color: "black" - opacity: contentVisible && SettingsData.modalDarkenBackground ? 0.5 : 0 - visible: contentVisible || opacity > 0 - - Behavior on opacity { - DankAnim { - duration: Theme.modalAnimationDuration - easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized - } - } - } - - MouseArea { - anchors.fill: parent - enabled: spotlightOpen - onClicked: mouse => { - var contentX = modalContainer.x; - var contentY = modalContainer.y; - var contentW = modalContainer.width; - var contentH = modalContainer.height; - - if (mouse.x < contentX || mouse.x > contentX + contentW || mouse.y < contentY || mouse.y > contentY + contentH) { - root.hide(); - } - } - } - - Item { - id: modalContainer - x: root.modalX - y: root.modalY - width: root.modalWidth - height: root.modalHeight - visible: contentVisible || opacity > 0 - - opacity: contentVisible ? 1 : 0 - scale: contentVisible ? 1 : 0.96 - transformOrigin: Item.Center - - Behavior on opacity { - DankAnim { - duration: Theme.modalAnimationDuration - easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized - } - } - - Behavior on scale { - DankAnim { - duration: Theme.modalAnimationDuration - easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized - } - } - - ElevationShadow { - id: launcherShadowLayer - anchors.fill: parent - level: Theme.elevationLevel3 - fallbackOffset: 6 - targetColor: root.backgroundColor - borderColor: root.borderColor - borderWidth: root.borderWidth - targetRadius: root.cornerRadius - shadowEnabled: Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !BlurService.enabled - } - - MouseArea { - anchors.fill: parent - onPressed: mouse => mouse.accepted = true - } - - FocusScope { - anchors.fill: parent - focus: keyboardActive - - Loader { - id: launcherContentLoader - anchors.fill: parent - active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize - asynchronous: false - sourceComponent: LauncherContent { - focus: true - parentModal: root - } - - onLoaded: { - if (root._pendingInitialize) { - root._initializeAndShow(root._pendingQuery, root._pendingMode); - root._pendingInitialize = false; - } - } - } - - Keys.onEscapePressed: event => { - root.hide(); - event.accepted = true; - } - } - - Rectangle { - anchors.fill: parent - radius: root.cornerRadius - color: "transparent" - border.color: BlurService.borderColor - border.width: BlurService.borderWidth - } + function onDialogClosed() { + root.dialogClosed(); + root._maybeResolveBackend(); } } } diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml new file mode 100644 index 00000000..cf986c6d --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml @@ -0,0 +1,922 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + readonly property var log: Log.scoped("DankLauncherV2ModalConnected") + + property var modalHandle: root + + visible: false + + property bool spotlightOpen: false + property bool keyboardActive: false + property bool contentVisible: false + readonly property bool launcherMotionVisible: Theme.isConnectedEffect ? _motionActive : (Theme.isDirectionalEffect ? spotlightOpen : _motionActive) + property var spotlightContent: launcherContentLoader.item + property bool openedFromOverview: false + property bool isClosing: false + property bool _windowEnabled: true + property bool _pendingInitialize: false + property string _pendingQuery: "" + property string _pendingMode: "" + readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose + + // Animation state — matches DankPopout/DankModal pattern + property bool animationsEnabled: true + property bool _motionActive: false + property real _frozenMotionX: 0 + property real _frozenMotionY: 0 + + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab + readonly property var effectiveScreen: contentWindow.screen + readonly property real screenWidth: effectiveScreen?.width ?? 1920 + readonly property real screenHeight: effectiveScreen?.height ?? 1080 + readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 + + readonly property int baseWidth: { + switch (SettingsData.dankLauncherV2Size) { + case "micro": + return 500; + case "medium": + return 720; + case "large": + return 860; + default: + return 620; + } + } + readonly property int baseHeight: { + switch (SettingsData.dankLauncherV2Size) { + case "micro": + return 480; + case "medium": + return 720; + case "large": + return 860; + default: + return 600; + } + } + readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100) + readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100) + + readonly property string preferredConnectedBarSide: SettingsData.frameLauncherEmergeSide + + readonly property bool frameConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + + readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" + + readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" + readonly property bool launcherArcExtenderActive: frameOwnsConnectedChrome && SettingsData.frameLauncherArcExtender && (resolvedConnectedBarSide === "top" || resolvedConnectedBarSide === "bottom") + + function _dockOccupiesSide(side) { + if (!SettingsData.showDock) + return false; + switch (side) { + case "top": + return SettingsData.dockPosition === SettingsData.Position.Top; + case "bottom": + return SettingsData.dockPosition === SettingsData.Position.Bottom; + case "left": + return SettingsData.dockPosition === SettingsData.Position.Left; + case "right": + return SettingsData.dockPosition === SettingsData.Position.Right; + } + return false; + } + readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide) + + function _frameEdgeInset(side) { + if (!effectiveScreen) + return 0; + return SettingsData.frameEdgeInsetForSide(effectiveScreen, side); + } + + // frameEdgeInsetForSide is the full inset; do not add frameBarSize. + // Positions the modal flush to the emerge side, centered on the cross axis. + readonly property var _connectedModalPos: { + const fallback = { + "x": (screenWidth - modalWidth) / 2, + "y": (screenHeight - modalHeight) / 2 + }; + switch (resolvedConnectedBarSide) { + case "top": + case "bottom": + { + const insetL = _frameEdgeInset("left"); + const insetR = _frameEdgeInset("right"); + const insetT = _frameEdgeInset("top"); + const insetB = _frameEdgeInset("bottom"); + const usable = Math.max(0, screenWidth - insetL - insetR); + const usableH = Math.max(0, screenHeight - insetT - insetB); + return { + "x": insetL + Math.max(0, (usable - modalWidth) / 2), + "y": launcherArcExtenderActive ? insetT + Math.max(0, (usableH - modalHeight) / 2) : (resolvedConnectedBarSide === "top" ? insetT : screenHeight - modalHeight - insetB) + }; + } + case "left": + case "right": + { + const insetT = _frameEdgeInset("top"); + const insetB = _frameEdgeInset("bottom"); + const usable = Math.max(0, screenHeight - insetT - insetB); + return { + "x": resolvedConnectedBarSide === "left" ? _frameEdgeInset("left") : screenWidth - modalWidth - _frameEdgeInset("right"), + "y": insetT + Math.max(0, (usable - modalHeight) / 2) + }; + } + } + return fallback; + } + + readonly property real modalX: frameOwnsConnectedChrome ? _connectedModalPos.x : ((screenWidth - modalWidth) / 2) + readonly property real modalY: frameOwnsConnectedChrome ? _connectedModalPos.y : ((screenHeight - modalHeight) / 2) + + readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect + readonly property int launcherAnimationDuration: Theme.isConnectedEffect ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration + readonly property list launcherEnterCurve: Theme.isConnectedEffect ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve + readonly property list launcherExitCurve: Theme.isConnectedEffect ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve + readonly property color backgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + readonly property real cornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : Theme.cornerRadius + readonly property color borderColor: { + if (!SettingsData.dankLauncherV2BorderEnabled) + return Theme.outlineMedium; + switch (SettingsData.dankLauncherV2BorderColor) { + case "primary": + return Theme.primary; + case "secondary": + return Theme.secondary; + case "outline": + return Theme.outline; + case "surfaceText": + return Theme.surfaceText; + default: + return Theme.primary; + } + } + readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0 + readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor + readonly property int effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth + readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled + + // Shadow padding for the content window (render padding only, no motion padding). + // Zeroed when frame owns the chrome and Wayland clips past the bar edge + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr) + readonly property real alignedWidth: Theme.px(modalWidth, dpr) + readonly property real alignedHeight: Theme.px(modalHeight, dpr) + readonly property real alignedX: Theme.snap(modalX, dpr) + readonly property real alignedY: Theme.snap(modalY, dpr) + readonly property real _connectedChromeX: alignedX + readonly property real _connectedChromeY: { + if (!launcherArcExtenderActive) + return alignedY; + return resolvedConnectedBarSide === "top" ? Theme.snap(_frameEdgeInset("top"), dpr) : alignedY; + } + readonly property real _connectedChromeWidth: alignedWidth + readonly property real _connectedChromeHeight: { + if (!launcherArcExtenderActive) + return alignedHeight; + if (resolvedConnectedBarSide === "top") + return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - _frameEdgeInset("top")), dpr); + if (resolvedConnectedBarSide === "bottom") + return Theme.snap(Math.max(alignedHeight, screenHeight - _frameEdgeInset("bottom") - alignedY), dpr); + return alignedHeight; + } + readonly property real contentSurfaceHeight: launcherArcExtenderActive ? _connectedChromeHeight : alignedHeight + + // For directional/depth: window extends from screen top (content slides within) + // For standard: small window tightly around the modal + shadow padding + readonly property bool _needsExtendedWindow: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) || Theme.isDepthEffect + // Content window geometry + readonly property real _cwMarginLeft: Theme.snap(alignedX - shadowPad, dpr) + readonly property real _cwMarginTop: launcherArcExtenderActive ? _connectedChromeY : (_needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr)) + readonly property real _cwWidth: alignedWidth + shadowPad * 2 + readonly property real _cwHeight: { + if (launcherArcExtenderActive) + return _connectedChromeHeight; + if (Theme.isDirectionalEffect && !Theme.isConnectedEffect) + return screenHeight + shadowPad; + if (Theme.isDepthEffect) + return alignedY + alignedHeight + shadowPad; + return alignedHeight + shadowPad * 2; + } + // Where the content container sits inside the content window + readonly property real _ccX: shadowPad + readonly property real _ccY: launcherArcExtenderActive ? 0 : (_needsExtendedWindow ? alignedY : shadowPad) + + signal dialogClosed + + // Coalesce per-channel dirty bits; one ConnectedModeState write per tick. + Timer { + id: _syncTimer + interval: 0 + onTriggered: root._flushSync() + } + + property string _chromeClaimId: "" + property bool _fullSyncPending: false + + function _nextChromeClaimId() { + return "dms:launcher-v2:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000); + } + + function _currentScreenName() { + return effectiveScreen ? effectiveScreen.name : ""; + } + + function _publishModalChromeState() { + const screenName = _currentScreenName(); + if (!screenName) + return; + ConnectedModeState.setModalState(screenName, { + "visible": spotlightOpen || contentWindow.visible, + "barSide": resolvedConnectedBarSide, + "bodyX": _connectedChromeX, + "bodyY": _connectedChromeY, + "bodyW": _connectedChromeWidth, + "bodyH": _connectedChromeHeight, + "animX": contentContainer ? contentContainer.animX : 0, + "animY": contentContainer ? contentContainer.animY : 0, + "omitStartConnector": false, + "omitEndConnector": false + }); + } + + function _syncModalChromeState() { + if (!frameOwnsConnectedChrome) { + _releaseModalChrome(); + return; + } + if (!_chromeClaimId) + _chromeClaimId = _nextChromeClaimId(); + _publishModalChromeState(); + if (_dockBlocksEmergence && (spotlightOpen || contentWindow.visible)) + ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide); + else + ConnectedModeState.releaseDockRetract(_chromeClaimId); + } + + property bool _animSyncQueued: false + property bool _bodySyncQueued: false + + function _queueFullSync() { + _fullSyncPending = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _queueAnimSync() { + _animSyncQueued = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _queueBodySync() { + _bodySyncQueued = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _flushSync() { + const fullDirty = _fullSyncPending; + const animDirty = _animSyncQueued; + const bodyDirty = _bodySyncQueued; + _fullSyncPending = false; + _animSyncQueued = false; + _bodySyncQueued = false; + if (fullDirty) + _syncModalChromeState(); + if (animDirty) + _syncModalAnim(); + if (bodyDirty) + _syncModalBody(); + } + + function _syncModalAnim() { + if (!frameOwnsConnectedChrome || !_chromeClaimId) + return; + const screenName = _currentScreenName(); + if (!screenName || !contentContainer) + return; + ConnectedModeState.setModalAnim(screenName, contentContainer.animX, contentContainer.animY); + } + + function _syncModalBody() { + if (!frameOwnsConnectedChrome || !_chromeClaimId) + return; + const screenName = _currentScreenName(); + if (!screenName) + return; + ConnectedModeState.setModalBody(screenName, _connectedChromeX, _connectedChromeY, _connectedChromeWidth, _connectedChromeHeight); + } + + function _releaseModalChrome() { + if (_chromeClaimId) { + ConnectedModeState.releaseDockRetract(_chromeClaimId); + _chromeClaimId = ""; + } + const screenName = _currentScreenName(); + if (screenName) + ConnectedModeState.clearModalState(screenName); + } + + onFrameOwnsConnectedChromeChanged: _syncModalChromeState() + onLauncherArcExtenderActiveChanged: _queueFullSync() + onResolvedConnectedBarSideChanged: _queueFullSync() + onSpotlightOpenChanged: _queueFullSync() + onAlignedXChanged: _queueBodySync() + onAlignedYChanged: _queueBodySync() + onAlignedWidthChanged: _queueBodySync() + onAlignedHeightChanged: _queueBodySync() + + Component.onDestruction: _releaseModalChrome() + + Connections { + target: contentWindow + function onVisibleChanged() { + if (contentWindow.visible) + root._syncModalChromeState(); + else + root._releaseModalChrome(); + } + } + + function _ensureContentLoadedAndInitialize(query, mode) { + _pendingQuery = query || ""; + _pendingMode = mode || ""; + _pendingInitialize = true; + contentVisible = true; + launcherContentLoader.active = true; + + if (spotlightContent) { + _initializeAndShow(_pendingQuery, _pendingMode); + _pendingInitialize = false; + } + } + + function _initializeAndShow(query, mode) { + if (!spotlightContent) + return; + contentVisible = true; + // NOTE: forceActiveFocus() is deliberately NOT called here. + // It is deferred to after animation starts to avoid compositor IPC stalls. + + if (spotlightContent.searchField) { + spotlightContent.searchField.text = query; + } + if (spotlightContent.controller) { + var targetMode = mode || SessionData.launcherLastMode || "all"; + spotlightContent.controller.searchMode = targetMode; + spotlightContent.controller.activePluginId = ""; + spotlightContent.controller.activePluginName = ""; + spotlightContent.controller.pluginFilter = ""; + spotlightContent.controller.fileSearchType = "all"; + spotlightContent.controller.fileSearchExt = ""; + spotlightContent.controller.fileSearchFolder = ""; + spotlightContent.controller.fileSearchSort = "score"; + spotlightContent.controller.collapsedSections = {}; + spotlightContent.controller.selectedFlatIndex = 0; + spotlightContent.controller.selectedItem = null; + if (query) { + spotlightContent.controller.setSearchQuery(query); + } else { + spotlightContent.controller.searchQuery = ""; + spotlightContent.controller.performSearch(); + } + } + if (spotlightContent.resetScroll) { + spotlightContent.resetScroll(); + } + if (spotlightContent.actionPanel) { + spotlightContent.actionPanel.hide(); + } + } + + function _openCommon(query, mode) { + closeCleanupTimer.stop(); + isClosing = false; + openedFromOverview = false; + + // Disable animations so the snap is instant + animationsEnabled = false; + + // Freeze the collapsed offsets (they depend on height which could change) + _frozenMotionX = contentContainer ? contentContainer.collapsedMotionX : 0; + _frozenMotionY = contentContainer ? contentContainer.collapsedMotionY : (Theme.isDirectionalEffect ? Math.max(root.screenHeight - root._ccY + root.shadowPad, Theme.effectAnimOffset * 1.1) : -Theme.effectAnimOffset); + + var focusedScreen = CompositorService.getFocusedScreen(); + if (focusedScreen) { + backgroundWindow.screen = focusedScreen; + contentWindow.screen = focusedScreen; + } + + // _motionActive = false ensures motionX/Y snap to frozen collapsed position + _motionActive = false; + + // Make windows visible but do NOT request keyboard focus yet + ModalManager.openModal(modalHandle); + spotlightOpen = true; + backgroundWindow.visible = true; + contentWindow.visible = true; + if (useHyprlandFocusGrab) + focusGrab.active = true; + + // Load content and initialize (but no forceActiveFocus — that's deferred) + _ensureContentLoadedAndInitialize(query || "", mode || ""); + + // Frame 1: enable animations and trigger enter motion + Qt.callLater(() => { + root.animationsEnabled = true; + root._motionActive = true; + + // Frame 2: request keyboard focus + activate search field + // Double-deferred to avoid compositor IPC competing with animation frames + Qt.callLater(() => { + root.keyboardActive = true; + if (root.spotlightContent && root.spotlightContent.searchField) + root.spotlightContent.searchField.forceActiveFocus(); + }); + }); + } + + function show() { + _openCommon("", ""); + } + + function showWithQuery(query) { + _openCommon(query, ""); + } + + function hide() { + if (!spotlightOpen) + return; + openedFromOverview = false; + isClosing = true; + // For directional effects, defer contentVisible=false so content stays rendered during exit slide + if (!Theme.isDirectionalEffect) + contentVisible = false; + + // Trigger exit animation — Behaviors will animate motionX/Y to frozen collapsed position + _motionActive = false; + + keyboardActive = false; + spotlightOpen = false; + focusGrab.active = false; + ModalManager.closeModal(modalHandle); + closeCleanupTimer.start(); + } + + function toggle() { + spotlightOpen ? hide() : show(); + } + + function showWithMode(mode) { + _openCommon("", mode); + } + + function toggleWithMode(mode) { + if (spotlightOpen) { + hide(); + } else { + showWithMode(mode); + } + } + + function toggleWithQuery(query) { + if (spotlightOpen) { + hide(); + } else { + showWithQuery(query); + } + } + + Timer { + id: closeCleanupTimer + interval: Theme.variantCloseInterval(root.launcherAnimationDuration) + repeat: false + onTriggered: { + isClosing = false; + contentVisible = false; + contentWindow.visible = false; + backgroundWindow.visible = false; + if (root.unloadContentOnClose) + launcherContentLoader.active = false; + dialogClosed(); + } + } + + Connections { + target: spotlightContent?.controller ?? null + function onModeChanged(mode) { + if (spotlightContent.controller.autoSwitchedToFiles) + return; + SessionData.setLauncherLastMode(mode); + } + } + + HyprlandFocusGrab { + id: focusGrab + windows: [contentWindow] + active: false + + onCleared: { + if (spotlightOpen) { + hide(); + } + } + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && spotlightOpen) { + hide(); + } + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (Quickshell.screens.length === 0) + return; + + const screen = contentWindow.screen; + const screenName = screen?.name; + + let needsReset = !screen || !screenName; + if (!needsReset) { + needsReset = true; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === screenName) { + needsReset = false; + break; + } + } + } + + if (!needsReset) + return; + + const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0]; + if (!newScreen) + return; + + root._windowEnabled = false; + backgroundWindow.screen = newScreen; + contentWindow.screen = newScreen; + Qt.callLater(() => { + root._windowEnabled = true; + }); + } + } + + PanelWindow { + id: backgroundWindow + visible: false + color: "transparent" + + readonly property real _topMargin: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0) + readonly property real _bottomMargin: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0) + readonly property real _leftMargin: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0) + readonly property real _rightMargin: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0) + + WlrLayershell.namespace: "dms:spotlight:bg" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + WlrLayershell.margins { + top: backgroundWindow._topMargin + bottom: backgroundWindow._bottomMargin + left: backgroundWindow._leftMargin + right: backgroundWindow._rightMargin + } + + anchors { + top: true + bottom: true + left: true + right: true + } + + mask: Region { + item: (spotlightOpen || isClosing) ? bgFullScreenMask : null + + Region { + item: bgContentHole + intersection: Intersection.Subtract + } + } + + Item { + id: bgFullScreenMask + anchors.fill: parent + } + + Item { + id: bgContentHole + visible: false + x: root._cwMarginLeft + contentContainer.x - backgroundWindow._leftMargin + y: root._cwMarginTop + contentContainer.y - backgroundWindow._topMargin + width: root.alignedWidth + height: root.contentSurfaceHeight + } + + Rectangle { + id: backgroundDarken + anchors.fill: parent + color: "black" + opacity: launcherMotionVisible && SettingsData.modalDarkenBackground ? 0.5 : 0 + visible: launcherMotionVisible || opacity > 0 + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + easing.type: Easing.BezierSpline + duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) + easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve + } + } + } + + MouseArea { + anchors.fill: parent + enabled: spotlightOpen + onClicked: root.hide() + } + } + + PanelWindow { + id: contentWindow + visible: false + color: "transparent" + + WindowBlur { + targetWindow: contentWindow + blurEnabled: root.effectiveBlurEnabled && !root.frameOwnsConnectedChrome + readonly property real s: Math.min(1, contentContainer.scaleValue) + blurX: root._ccX + root.alignedWidth * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) + blurY: root._ccY + root.alignedHeight * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) + blurWidth: (root.spotlightOpen || root.isClosing) && !root.frameOwnsConnectedChrome ? root.alignedWidth * s : 0 + blurHeight: (root.spotlightOpen || root.isClosing) && !root.frameOwnsConnectedChrome ? root.alignedHeight * s : 0 + blurRadius: root.cornerRadius + } + + WlrLayershell.namespace: "dms:spotlight" + WlrLayershell.layer: { + switch (Quickshell.env("DMS_MODAL_LAYER")) { + case "bottom": + log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + log.error("'background' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None + + anchors { + left: true + top: true + } + + WlrLayershell.margins { + left: root._cwMarginLeft + top: root._cwMarginTop + } + + implicitWidth: root._cwWidth + implicitHeight: root._cwHeight + + mask: Region { + item: contentInputMask + } + + Item { + id: contentInputMask + visible: false + x: contentContainer.x + y: contentContainer.y + width: root.alignedWidth + height: root.contentSurfaceHeight + } + + Item { + id: contentContainer + + // For directional/depth: contentContainer is at alignedY from window top (window starts at screen top) + // For standard: contentContainer is at shadowPad from window top (window starts near modal) + x: root._ccX + y: root._ccY + width: root.alignedWidth + height: root.contentSurfaceHeight + + readonly property int dockEdge: typeof SettingsData !== "undefined" ? SettingsData.dockPosition : 1 + readonly property bool dockTop: dockEdge === 0 + readonly property bool dockBottom: dockEdge === 1 + readonly property bool dockLeft: dockEdge === 2 + readonly property bool dockRight: dockEdge === 3 + + readonly property real dockThickness: typeof SettingsData !== "undefined" && SettingsData.showDock ? Theme.px(SettingsData.dockIconSize + (SettingsData.dockMargin * 2) + SettingsData.dockSpacing + 8, root.dpr) : Theme.px(60, root.dpr) + + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real _connectedTravelX: Math.max(Theme.effectAnimOffset, root.alignedWidth + Theme.spacingL) + readonly property real _connectedTravelY: root.launcherArcExtenderActive ? root._connectedChromeHeight : Math.max(Theme.effectAnimOffset, root.alignedHeight + Theme.spacingL) + readonly property real collapsedMotionX: { + if (root.frameOwnsConnectedChrome) { + switch (root.resolvedConnectedBarSide) { + case "left": + return -_connectedTravelX; + case "right": + return _connectedTravelX; + } + return 0; + } + if (directionalEffect) { + if (dockLeft) + return -(root._ccX + root.alignedWidth + Theme.effectAnimOffset); + if (dockRight) + return root.screenWidth - root._ccX + Theme.effectAnimOffset; + } + if (depthEffect) + return Theme.effectAnimOffset * 0.25; + return 0; + } + readonly property real collapsedMotionY: { + if (root.frameOwnsConnectedChrome) { + switch (root.resolvedConnectedBarSide) { + case "top": + return -_connectedTravelY; + case "bottom": + return _connectedTravelY; + } + return 0; + } + if (directionalEffect) { + if (dockTop) + return -(root._ccY + root.alignedHeight + Theme.effectAnimOffset); + if (dockBottom) + return root.screenHeight - root._ccY + root.shadowPad + Theme.effectAnimOffset; + return 0; + } + if (depthEffect) + return -Math.max(Theme.effectAnimOffset * 0.85, 34); + return -Math.max((root.shadowPad || 0) + Theme.effectAnimOffset, 40); + } + + // openProgress: 0 = closed (at frozenMotion, scaleCollapsed), 1 = open (at 0, scale 1). + QtObject { + id: morph + property real openProgress: root._motionActive ? 1 : 0 + Behavior on openProgress { + enabled: root.animationsEnabled + DankAnim { + duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive) + easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve + } + } + } + + readonly property real animX: root._frozenMotionX * (1 - morph.openProgress) + readonly property real animY: root._frozenMotionY * (1 - morph.openProgress) + readonly property real scaleValue: Theme.effectScaleCollapsed + (1.0 - Theme.effectScaleCollapsed) * morph.openProgress + + onAnimXChanged: if (root.frameOwnsConnectedChrome) + root._queueAnimSync() + onAnimYChanged: if (root.frameOwnsConnectedChrome) + root._queueAnimSync() + + Item { + id: directionalClipMask + readonly property bool shouldClip: Theme.isDirectionalEffect + readonly property real clipOversize: 2000 + + clip: shouldClip + + x: shouldClip ? (contentContainer.dockRight ? -clipOversize : (contentContainer.dockLeft ? contentContainer.dockThickness - root._ccX : -clipOversize)) : 0 + y: shouldClip ? (contentContainer.dockBottom ? -clipOversize : (contentContainer.dockTop ? contentContainer.dockThickness - root._ccY : -clipOversize)) : 0 + + width: shouldClip ? parent.width + clipOversize + (contentContainer.dockRight ? (root.screenWidth - contentContainer.dockThickness - root._ccX - parent.width) : (contentContainer.dockLeft ? clipOversize : clipOversize)) : parent.width + height: shouldClip ? parent.height + clipOversize + (contentContainer.dockBottom ? (root.screenHeight - contentContainer.dockThickness - root._ccY - parent.height) : (contentContainer.dockTop ? clipOversize : clipOversize)) : parent.height + + Item { + id: aligner + x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0 + y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0 + width: contentContainer.width + height: contentContainer.height + + // Shadow mirrors contentWrapper position/scale/opacity + ElevationShadow { + id: launcherShadowLayer + width: parent.width + height: parent.height + opacity: contentWrapper.publishedOpacity + scale: contentWrapper.scale + x: contentWrapper.x + y: contentWrapper.y + level: root.shadowLevel + fallbackOffset: root.shadowFallbackOffset + targetColor: root.frameOwnsConnectedChrome ? "transparent" : root.backgroundColor + borderColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBorderColor + borderWidth: root.frameOwnsConnectedChrome ? 0 : root.effectiveBorderWidth + targetRadius: root.cornerRadius + shadowEnabled: !root.frameOwnsConnectedChrome && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + // contentWrapper moves inside static contentContainer — DankPopout pattern + Item { + id: contentWrapper + width: parent.width + height: parent.height + + property bool _renderActive: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) || launcherMotionVisible + property real publishedOpacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (launcherMotionVisible ? 1 : 0) + + opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (launcherMotionVisible ? 1 : 0) + visible: _renderActive + scale: contentContainer.scaleValue + x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) + y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + easing.type: Easing.BezierSpline + duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) + easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve + } + } + + Behavior on publishedOpacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + easing.type: Easing.BezierSpline + duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) + easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve + onRunningChanged: if (!running && contentWrapper.publishedOpacity === 0) + contentWrapper._renderActive = false + } + } + + Connections { + target: root + function onLauncherMotionVisibleChanged() { + if (root.launcherMotionVisible) + contentWrapper._renderActive = true; + } + } + + MouseArea { + anchors.fill: parent + onPressed: mouse => mouse.accepted = true + } + + FocusScope { + anchors.fill: parent + focus: keyboardActive + + Loader { + id: launcherContentLoader + anchors.fill: parent + active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize + asynchronous: false + sourceComponent: LauncherContent { + focus: true + parentModal: root + } + + onLoaded: { + if (root._pendingInitialize) { + root._initializeAndShow(root._pendingQuery, root._pendingMode); + root._pendingInitialize = false; + } + } + } + + Keys.onEscapePressed: event => { + root.hide(); + event.accepted = true; + } + } + } // contentWrapper + } // aligner + } // directionalClipMask + } // contentContainer + } // PanelWindow +} diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml new file mode 100644 index 00000000..a352cb35 --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml @@ -0,0 +1,512 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + readonly property var log: Log.scoped("DankLauncherV2ModalStandalone") + + property var modalHandle: root + + visible: false + + property bool spotlightOpen: false + property bool keyboardActive: false + property bool contentVisible: false + property var spotlightContent: launcherContentLoader.item + property bool openedFromOverview: false + property bool isClosing: false + property bool _pendingInitialize: false + property string _pendingQuery: "" + property string _pendingMode: "" + readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose + + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab + readonly property var effectiveScreen: launcherWindow.screen + readonly property real screenWidth: effectiveScreen?.width ?? 1920 + readonly property real screenHeight: effectiveScreen?.height ?? 1080 + readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 + + readonly property bool frameOwnsConnectedChrome: SettingsData.connectedFrameModeActive && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + readonly property string resolvedConnectedBarSide: frameOwnsConnectedChrome ? (SettingsData.frameLauncherEmergeSide || "bottom") : "" + + readonly property int baseWidth: { + switch (SettingsData.dankLauncherV2Size) { + case "micro": + return 500; + case "medium": + return 720; + case "large": + return 860; + default: + return 620; + } + } + readonly property int baseHeight: { + switch (SettingsData.dankLauncherV2Size) { + case "micro": + return 480; + case "medium": + return 720; + case "large": + return 860; + default: + return 600; + } + } + readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100) + readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100) + readonly property real modalX: (screenWidth - modalWidth) / 2 + readonly property real modalY: (screenHeight - modalHeight) / 2 + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr) + readonly property real alignedWidth: Theme.px(modalWidth, dpr) + readonly property real alignedHeight: Theme.px(modalHeight, dpr) + readonly property real alignedX: Theme.snap(modalX, dpr) + readonly property real alignedY: Theme.snap(modalY, dpr) + readonly property real windowX: Math.max(0, Theme.snap(alignedX - shadowPad, dpr)) + readonly property real windowY: Math.max(0, Theme.snap(alignedY - shadowPad, dpr)) + readonly property real contentX: Theme.snap(alignedX - windowX, dpr) + readonly property real contentY: Theme.snap(alignedY - windowY, dpr) + readonly property real windowWidth: alignedWidth + contentX + shadowPad + readonly property real windowHeight: alignedHeight + contentY + shadowPad + + readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + readonly property real cornerRadius: Theme.cornerRadius + readonly property color borderColor: { + if (!SettingsData.dankLauncherV2BorderEnabled) + return Theme.outlineMedium; + switch (SettingsData.dankLauncherV2BorderColor) { + case "primary": + return Theme.primary; + case "secondary": + return Theme.secondary; + case "outline": + return Theme.outline; + case "surfaceText": + return Theme.surfaceText; + default: + return Theme.primary; + } + } + readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0 + + signal dialogClosed + + function _ensureContentLoadedAndInitialize(query, mode) { + _pendingQuery = query || ""; + _pendingMode = mode || ""; + _pendingInitialize = true; + contentVisible = true; + launcherContentLoader.active = true; + + if (spotlightContent) { + _initializeAndShow(_pendingQuery, _pendingMode); + _pendingInitialize = false; + } + } + + function _initializeAndShow(query, mode) { + if (!spotlightContent) + return; + contentVisible = true; + spotlightContent.searchField.forceActiveFocus(); + + var targetQuery = ""; + + if (query) { + targetQuery = query; + } else if (SettingsData.rememberLastQuery) { + targetQuery = SessionData.launcherLastQuery || ""; + } + + if (spotlightContent.searchField) { + spotlightContent.searchField.text = targetQuery; + } + if (spotlightContent.controller) { + var targetMode = mode || SessionData.launcherLastMode || "all"; + spotlightContent.controller.searchMode = targetMode; + spotlightContent.controller.activePluginId = ""; + spotlightContent.controller.activePluginName = ""; + spotlightContent.controller.pluginFilter = ""; + spotlightContent.controller.fileSearchType = "all"; + spotlightContent.controller.fileSearchExt = ""; + spotlightContent.controller.fileSearchFolder = ""; + spotlightContent.controller.fileSearchSort = "score"; + spotlightContent.controller.collapsedSections = {}; + spotlightContent.controller.selectedFlatIndex = 0; + spotlightContent.controller.selectedItem = null; + spotlightContent.controller.historyIndex = -1; + spotlightContent.controller.searchQuery = targetQuery; + + spotlightContent.controller.performSearch(); + } + if (spotlightContent.resetScroll) { + spotlightContent.resetScroll(); + } + if (spotlightContent.actionPanel) { + spotlightContent.actionPanel.hide(); + } + } + + function _finishShow(query, mode) { + spotlightOpen = true; + isClosing = false; + openedFromOverview = false; + + keyboardActive = true; + ModalManager.openModal(modalHandle); + if (useHyprlandFocusGrab) + focusGrab.active = true; + + _ensureContentLoadedAndInitialize(query || "", mode || ""); + } + + function _openCommon(query, mode) { + closeCleanupTimer.stop(); + const focusedScreen = CompositorService.getFocusedScreen(); + if (focusedScreen && launcherWindow.screen !== focusedScreen) { + spotlightOpen = false; + isClosing = false; + launcherWindow.screen = focusedScreen; + Qt.callLater(() => root._finishShow(query, mode)); + return; + } + _finishShow(query, mode); + } + + function show() { + _openCommon("", ""); + } + function showWithQuery(query) { + _openCommon(query, ""); + } + function showWithMode(mode) { + _openCommon("", mode); + } + + function hide() { + if (!spotlightOpen) + return; + openedFromOverview = false; + isClosing = true; + contentVisible = false; + + keyboardActive = false; + spotlightOpen = false; + focusGrab.active = false; + ModalManager.closeModal(modalHandle); + + closeCleanupTimer.start(); + } + + function toggle() { + spotlightOpen ? hide() : show(); + } + + function toggleWithMode(mode) { + if (spotlightOpen) { + hide(); + } else { + showWithMode(mode); + } + } + + function toggleWithQuery(query) { + if (spotlightOpen) { + hide(); + } else { + showWithQuery(query); + } + } + + Timer { + id: closeCleanupTimer + interval: Theme.modalAnimationDuration + 50 + repeat: false + onTriggered: { + isClosing = false; + if (root.unloadContentOnClose) + launcherContentLoader.active = false; + dialogClosed(); + } + } + + Connections { + target: spotlightContent?.controller ?? null + + function onModeChanged(mode) { + if (spotlightContent.controller.autoSwitchedToFiles) + return; + SessionData.setLauncherLastMode(mode); + } + } + + HyprlandFocusGrab { + id: focusGrab + windows: [launcherWindow] + active: false + + onCleared: { + if (spotlightOpen) { + hide(); + } + } + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && spotlightOpen) { + hide(); + } + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (Quickshell.screens.length === 0) + return; + + const screenName = launcherWindow.screen?.name; + if (screenName) { + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === screenName) + return; + } + } + + if (spotlightOpen) + hide(); + + const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0]; + if (newScreen) + launcherWindow.screen = newScreen; + } + } + + PanelWindow { + id: clickCatcher + screen: launcherWindow.screen + visible: spotlightOpen + color: "transparent" + updatesEnabled: false + + WlrLayershell.namespace: "dms:spotlight:clickcatcher" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + bottom: true + left: true + right: true + } + + mask: Region { + item: outsideClickMask + + Region { + item: outsideClickHole + intersection: Intersection.Subtract + } + } + + Item { + id: outsideClickMask + visible: false + anchors.fill: parent + } + + Rectangle { + id: outsideClickHole + visible: false + color: "transparent" + x: root.alignedX + y: root.alignedY + width: root.alignedWidth + height: root.alignedHeight + } + + MouseArea { + anchors.fill: parent + enabled: spotlightOpen + onClicked: root.hide() + } + } + + PanelWindow { + id: launcherWindow + visible: spotlightOpen || isClosing + color: "transparent" + exclusionMode: ExclusionMode.Ignore + + WindowBlur { + targetWindow: launcherWindow + readonly property real s: Math.min(1, modalContainer.publishedScale) + blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + blurWidth: contentVisible ? modalContainer.width * s : 0 + blurHeight: contentVisible ? modalContainer.height * s : 0 + blurRadius: root.cornerRadius + } + + WlrLayershell.namespace: "dms:spotlight" + WlrLayershell.layer: { + switch (Quickshell.env("DMS_MODAL_LAYER")) { + case "bottom": + log.error("'bottom' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + log.error("'background' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None + + anchors { + top: true + left: true + } + + WlrLayershell.margins { + left: root.windowX + top: root.windowY + right: 0 + bottom: 0 + } + + implicitWidth: root.windowWidth + implicitHeight: root.windowHeight + + mask: Region { + item: launcherInputMask + } + + Rectangle { + id: launcherInputMask + visible: false + color: "transparent" + x: modalContainer.x + y: modalContainer.y + width: modalContainer.width + height: modalContainer.height + } + + Item { + id: modalContainer + x: root.contentX + y: root.contentY + width: root.alignedWidth + height: root.alignedHeight + visible: _renderActive + + property bool _renderActive: contentVisible + property real publishedScale: contentVisible ? 1 : 0.96 + + opacity: contentVisible ? 1 : 0 + scale: contentVisible ? 1 : 0.96 + transformOrigin: Item.Center + + Behavior on opacity { + NumberAnimation { + easing.type: Easing.BezierSpline + duration: Theme.modalAnimationDuration + easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + onRunningChanged: if (!running && !root.contentVisible) + modalContainer._renderActive = false + } + } + + Behavior on scale { + NumberAnimation { + easing.type: Easing.BezierSpline + duration: Theme.modalAnimationDuration + easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + } + } + + Behavior on publishedScale { + NumberAnimation { + easing.type: Easing.BezierSpline + duration: Theme.modalAnimationDuration + easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + } + } + + Connections { + target: root + function onContentVisibleChanged() { + if (root.contentVisible) + modalContainer._renderActive = true; + } + } + + ElevationShadow { + id: launcherShadowLayer + anchors.fill: parent + level: root.shadowLevel + fallbackOffset: root.shadowFallbackOffset + targetColor: root.backgroundColor + borderColor: root.borderColor + borderWidth: root.borderWidth + targetRadius: root.cornerRadius + shadowEnabled: Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + MouseArea { + anchors.fill: parent + onPressed: mouse => mouse.accepted = true + } + + FocusScope { + anchors.fill: parent + focus: keyboardActive + + Loader { + id: launcherContentLoader + anchors.fill: parent + active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize + asynchronous: false + sourceComponent: LauncherContent { + focus: true + parentModal: root + } + + onLoaded: { + if (root._pendingInitialize) { + root._initializeAndShow(root._pendingQuery, root._pendingMode); + root._pendingInitialize = false; + } + } + } + + Keys.onEscapePressed: event => { + root.hide(); + event.accepted = true; + } + } + + Rectangle { + anchors.fill: parent + radius: root.cornerRadius + color: "transparent" + border.color: BlurService.borderColor + border.width: BlurService.borderWidth + } + } + } +} diff --git a/quickshell/Modals/DankLauncherV2/LauncherContent.qml b/quickshell/Modals/DankLauncherV2/LauncherContent.qml index 43725151..fa63bd46 100644 --- a/quickshell/Modals/DankLauncherV2/LauncherContent.qml +++ b/quickshell/Modals/DankLauncherV2/LauncherContent.qml @@ -41,7 +41,6 @@ FocusScope { editCommentField.text = existing?.comment || ""; editEnvVarsField.text = existing?.envVars || ""; editExtraFlagsField.text = existing?.extraFlags || ""; - editDgpuToggle.checked = existing?.launchOnDgpu || false; editMode = true; Qt.callLater(() => editNameField.forceActiveFocus()); } @@ -65,8 +64,6 @@ FocusScope { override.envVars = editEnvVarsField.text.trim(); if (editExtraFlagsField.text.trim()) override.extraFlags = editExtraFlagsField.text.trim(); - if (editDgpuToggle.checked) - override.launchOnDgpu = true; SessionData.setAppOverride(editAppId, override); closeEditMode(); } @@ -89,7 +86,7 @@ FocusScope { Controller { id: controller - active: root.parentModal?.spotlightOpen ?? true + active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true viewModeContext: root.viewModeContext onItemExecuted: { @@ -149,18 +146,10 @@ FocusScope { event.accepted = false; return; case Qt.Key_Down: - if (hasCtrl) { - controller.navigateHistory("down"); - } else { - controller.selectNext(); - } + controller.selectNext(); return; case Qt.Key_Up: - if (hasCtrl) { - controller.navigateHistory("up"); - } else { - controller.selectPrevious(); - } + controller.selectPrevious(); return; case Qt.Key_PageDown: controller.selectPageDown(8); @@ -169,10 +158,6 @@ FocusScope { controller.selectPageUp(8); return; case Qt.Key_Right: - if (hasCtrl) { - controller.cycleMode(); - return; - } if (controller.getCurrentSectionViewMode() !== "list") { controller.selectRight(); return; @@ -180,25 +165,12 @@ FocusScope { event.accepted = false; return; case Qt.Key_Left: - if (hasCtrl) { - const reverse = true; - controller.cycleMode(reverse); - return; - } if (controller.getCurrentSectionViewMode() !== "list") { controller.selectLeft(); return; } event.accepted = false; return; - case Qt.Key_H: - if (hasCtrl) { - const reverse = true; - controller.cycleMode(reverse); - return; - } - event.accepted = false; - return; case Qt.Key_J: if (hasCtrl) { controller.selectNext(); @@ -213,13 +185,6 @@ FocusScope { } event.accepted = false; return; - case Qt.Key_L: - if (hasCtrl) { - controller.cycleMode(); - return; - } - event.accepted = false; - return; case Qt.Key_N: if (hasCtrl) { controller.selectNextSection(); @@ -235,19 +200,13 @@ FocusScope { event.accepted = false; return; case Qt.Key_Tab: - if (hasCtrl && actionPanel.hasActions) { + if (actionPanel.hasActions) { actionPanel.expanded ? actionPanel.cycleAction() : actionPanel.show(); - return; } - controller.selectNext(); return; case Qt.Key_Backtab: - if (hasCtrl && actionPanel.expanded) { - const reverse = true; - actionPanel.expanded ? actionPanel.cycleAction(reverse) : actionPanel.show(); - return; - } - controller.selectPrevious(); + if (actionPanel.expanded) + actionPanel.hide(); return; case Qt.Key_Return: case Qt.Key_Enter: @@ -311,24 +270,29 @@ FocusScope { Item { anchors.fill: parent - visible: !editMode && !(root.parentModal?.isClosing ?? false) + visible: !editMode Item { id: footerBar + readonly property bool _connectedBottomEmerge: (root.parentModal?.frameOwnsConnectedChrome ?? false) && (root.parentModal?.resolvedConnectedBarSide === "bottom") + readonly property bool _connectedArcAtFooter: _connectedBottomEmerge && !(root.parentModal?.launcherArcExtenderActive ?? false) + readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter + anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.leftMargin: root.parentModal?.borderWidth ?? 1 anchors.rightMargin: root.parentModal?.borderWidth ?? 1 - anchors.bottomMargin: root.parentModal?.borderWidth ?? 1 - readonly property bool showFooter: SettingsData.dankLauncherV2Size !== "micro" && SettingsData.dankLauncherV2ShowFooter - height: showFooter ? 36 : 0 + anchors.bottomMargin: _connectedBottomEmerge ? Theme.spacingM : (root.parentModal?.borderWidth ?? 1) + height: showFooter ? (_connectedArcAtFooter ? 76 : 36) : 0 visible: showFooter clip: true Rectangle { anchors.fill: parent anchors.topMargin: -Theme.cornerRadius + // In connected mode the launcher provides the surface so update the toolbar for arcs + visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) radius: Theme.cornerRadius } @@ -336,7 +300,7 @@ FocusScope { Row { id: modeButtonsRow anchors.left: parent.left - anchors.leftMargin: Theme.spacingXS + anchors.leftMargin: Theme.spacingM anchors.verticalCenter: parent.verticalCenter layoutDirection: I18n.isRtl ? Qt.RightToLeft : Qt.LeftToRight spacing: 2 @@ -408,7 +372,7 @@ FocusScope { Row { id: hintsRow anchors.right: parent.right - anchors.rightMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingM anchors.verticalCenter: parent.verticalCenter layoutDirection: I18n.isRtl ? Qt.RightToLeft : Qt.LeftToRight spacing: Theme.spacingM @@ -429,7 +393,7 @@ FocusScope { StyledText { anchors.verticalCenter: parent.verticalCenter - text: "Ctrl-Tab " + I18n.tr("actions") + text: "Tab " + I18n.tr("actions") font.pixelSize: Theme.fontSizeSmall - 1 color: Theme.surfaceVariantText visible: actionPanel.hasActions @@ -503,7 +467,7 @@ FocusScope { showClearButton: true textColor: Theme.surfaceText font.pixelSize: Theme.fontSizeLarge - enabled: root.parentModal ? root.parentModal.spotlightOpen : true + enabled: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true placeholderText: "" ignoreUpDownKeys: true ignoreTabKeys: true @@ -737,6 +701,14 @@ FocusScope { Item { width: parent.width height: parent.height - searchField.height - categoryRow.height - fileFilterRow.height - actionPanel.height - Theme.spacingXS * ((categoryRow.visible ? 1 : 0) + (fileFilterRow.visible ? 1 : 0) + 2) + opacity: { + if (!root.parentModal) + return 1; + if (Theme.isDirectionalEffect && root.parentModal.isClosing) + return 1; + return root.parentModal.isClosing ? 0 : 1; + } + ResultsList { id: resultsList anchors.fill: parent @@ -769,7 +741,6 @@ FocusScope { } function onSearchQueryRequested(query) { searchField.text = query; - searchField.cursorPosition = query.length; } function onModeChanged() { extFilterField.text = ""; @@ -980,15 +951,6 @@ FocusScope { keyNavigationBacktab: editEnvVarsField } } - - DankToggle { - id: editDgpuToggle - width: parent.width - text: I18n.tr("Launch on dGPU by default") - visible: SessionService.nvidiaCommand.length > 0 - checked: false - onToggled: checked => editDgpuToggle.checked = checked - } } } diff --git a/quickshell/Modals/FileBrowser/FileInfo.qml b/quickshell/Modals/FileBrowser/FileInfo.qml index c41f039b..df85f152 100644 --- a/quickshell/Modals/FileBrowser/FileInfo.qml +++ b/quickshell/Modals/FileBrowser/FileInfo.qml @@ -1,5 +1,4 @@ import QtQuick -import QtCore import Quickshell.Io import qs.Common import qs.Widgets @@ -22,9 +21,9 @@ Rectangle { onShowFileInfoChanged: { if (showFileInfo && currentFileName && currentPath) { - const fullPath = currentPath + "/" + currentFileName - fileStatProcess.selectedFilePath = fullPath - fileStatProcess.running = true + const fullPath = currentPath + "/" + currentFileName; + fileStatProcess.selectedFilePath = fullPath; + fileStatProcess.running = true; } } @@ -38,14 +37,14 @@ Rectangle { stdout: StdioCollector { onStreamFinished: { if (text && text.trim()) { - const parts = text.trim().split('|') + const parts = text.trim().split('|'); if (parts.length >= 4) { fileStatProcess.fileStats = { "modifiedTime": parts[0], "permissions": parts[1], "size": parseInt(parts[2]) || 0, "fullPath": parts[3] - } + }; } } } @@ -60,31 +59,31 @@ Rectangle { onCurrentFileNameChanged: { if (showFileInfo && currentFileName && currentPath) { - const fullPath = currentPath + "/" + currentFileName + const fullPath = currentPath + "/" + currentFileName; if (fullPath !== fileStatProcess.selectedFilePath) { - fileStatProcess.selectedFilePath = fullPath - fileStatProcess.running = true + fileStatProcess.selectedFilePath = fullPath; + fileStatProcess.running = true; } } } function updateFileInfo(filePath, fileName, isDirectory) { if (filePath && filePath !== fileStatProcess.selectedFilePath) { - fileStatProcess.selectedFilePath = filePath - currentFileName = fileName || "" - currentFileIsDir = isDirectory || false + fileStatProcess.selectedFilePath = filePath; + currentFileName = fileName || ""; + currentFileIsDir = isDirectory || false; - let ext = "" + let ext = ""; if (!isDirectory && fileName) { - const lastDot = fileName.lastIndexOf('.') + const lastDot = fileName.lastIndexOf('.'); if (lastDot > 0) { - ext = fileName.substring(lastDot + 1).toLowerCase() + ext = fileName.substring(lastDot + 1).toLowerCase(); } } - currentFileExtension = ext + currentFileExtension = ext; if (showFileInfo) { - fileStatProcess.running = true + fileStatProcess.running = true; } } } @@ -100,10 +99,10 @@ Rectangle { "permissions": "", "extension": "", "position": "N/A" - } + }; } - const hasValidFile = currentFileName !== "" + const hasValidFile = currentFileName !== ""; return { "exists": hasValidFile, "name": hasValidFile ? currentFileName : "Loading...", @@ -113,7 +112,7 @@ Rectangle { "permissions": fileStatProcess.fileStats ? fileStatProcess.fileStats.permissions : "Loading...", "extension": currentFileExtension, "position": sourceFolderModel ? ((selectedIndex + 1) + " of " + sourceFolderModel.count) : "N/A" - } + }; } Column { @@ -209,23 +208,23 @@ Rectangle { function formatFileSize(bytes) { if (bytes === 0 || !bytes) { - return "0 B" + return "0 B"; } - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function formatDateTime(dateTimeString) { if (!dateTimeString) { - return "Unknown" + return "Unknown"; } - const parts = dateTimeString.split(' ') + const parts = dateTimeString.split(' '); if (parts.length >= 2) { - return parts[0] + " " + parts[1].split('.')[0] + return parts[0] + " " + parts[1].split('.')[0]; } - return dateTimeString + return dateTimeString; } Behavior on opacity { diff --git a/quickshell/Modals/Greeter/GreeterDoctorPage.qml b/quickshell/Modals/Greeter/GreeterDoctorPage.qml index 5d9b904e..73c29c7c 100644 --- a/quickshell/Modals/Greeter/GreeterDoctorPage.qml +++ b/quickshell/Modals/Greeter/GreeterDoctorPage.qml @@ -61,26 +61,22 @@ Item { border.color: Theme.primary opacity: 0 - SequentialAnimation on opacity { + OpacityAnimator on opacity { running: root.isRunning loops: Animation.Infinite - NumberAnimation { - from: 0.8 - to: 0 - duration: 1500 - easing.type: Easing.OutQuad - } + from: 0.8 + to: 0 + duration: 1500 + easing.type: Easing.OutQuad } - SequentialAnimation on scale { + ScaleAnimator on scale { running: root.isRunning loops: Animation.Infinite - NumberAnimation { - from: 0.5 - to: 1.5 - duration: 1500 - easing.type: Easing.OutQuad - } + from: 0.5 + to: 1.5 + duration: 1500 + easing.type: Easing.OutQuad } } @@ -95,26 +91,22 @@ Item { border.color: Theme.secondary opacity: 0 - SequentialAnimation on opacity { + OpacityAnimator on opacity { running: root.isRunning loops: Animation.Infinite - NumberAnimation { - from: 0.8 - to: 0 - duration: 1500 - easing.type: Easing.OutQuad - } + from: 0.8 + to: 0 + duration: 1500 + easing.type: Easing.OutQuad } - SequentialAnimation on scale { + ScaleAnimator on scale { running: root.isRunning loops: Animation.Infinite - NumberAnimation { - from: 0.3 - to: 1.3 - duration: 1500 - easing.type: Easing.OutQuad - } + from: 0.3 + to: 1.3 + duration: 1500 + easing.type: Easing.OutQuad } } diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 34cd51ae..e1d6373c 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -112,7 +112,9 @@ FocusScope { focus: active sourceComponent: Component { - DockTab {} + DockTab { + parentModal: root.parentModal + } } onActiveChanged: { @@ -218,7 +220,9 @@ FocusScope { visible: active focus: active - sourceComponent: ThemeColorsTab {} + sourceComponent: ThemeColorsTab { + parentModal: root.parentModal + } onActiveChanged: { if (active && item) @@ -518,5 +522,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 7654962f..a091c431 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/AppDrawer/AppDrawerPopout.qml b/quickshell/Modules/AppDrawer/AppDrawerPopout.qml index ba07d374..21c30870 100644 --- a/quickshell/Modules/AppDrawer/AppDrawerPopout.qml +++ b/quickshell/Modules/AppDrawer/AppDrawerPopout.qml @@ -8,9 +8,6 @@ DankPopout { layerNamespace: "dms:app-launcher" - readonly property real screenWidth: screen?.width ?? 1920 - readonly property real screenHeight: screen?.height ?? 1080 - property string _pendingMode: "" property string _pendingQuery: "" @@ -44,35 +41,8 @@ DankPopout { openWithQuery(query); } - readonly property int _baseWidth: { - switch (SettingsData.dankLauncherV2Size) { - case "micro": - return 500; - case "medium": - return 720; - case "large": - return 860; - default: - return 620; - } - } - - readonly property int _baseHeight: { - switch (SettingsData.dankLauncherV2Size) { - case "micro": - return 480; - case "medium": - return 720; - case "large": - return 860; - default: - return 600; - } - } - - popupWidth: Math.min(_baseWidth, screenWidth - 100) - popupHeight: Math.min(_baseHeight, screenHeight - 100) - + popupWidth: 560 + popupHeight: 640 triggerWidth: 40 positioning: "" contentHandlesKeys: contentLoader.item?.launcherContent?.editMode ?? false @@ -90,7 +60,7 @@ DankPopout { if (!lc) return; - const query = _pendingQuery || (SettingsData.rememberLastQuery ? SessionData.launcherLastQuery : "") || ""; + const query = _pendingQuery; const mode = _pendingMode || SessionData.appDrawerLastMode || "apps"; _pendingMode = ""; _pendingQuery = ""; @@ -102,9 +72,12 @@ DankPopout { if (lc.controller) { lc.controller.searchMode = mode; lc.controller.pluginFilter = ""; - lc.controller.searchQuery = query; - - lc.controller.performSearch(); + lc.controller.searchQuery = ""; + if (query) { + lc.controller.setSearchQuery(query); + } else { + lc.controller.performSearch(); + } } lc.resetScroll?.(); lc.actionPanel?.hide(); @@ -133,7 +106,7 @@ DankPopout { QtObject { id: modalAdapter property bool spotlightOpen: appDrawerPopout.shouldBeVisible - readonly property bool isClosing: !appDrawerPopout.shouldBeVisible + property bool isClosing: appDrawerPopout.isClosing function hide() { appDrawerPopout.close(); diff --git a/quickshell/Modules/ControlCenter/Components/DetailHost.qml b/quickshell/Modules/ControlCenter/Components/DetailHost.qml index b444fa06..170ebef3 100644 --- a/quickshell/Modules/ControlCenter/Components/DetailHost.qml +++ b/quickshell/Modules/ControlCenter/Components/DetailHost.qml @@ -37,7 +37,7 @@ Item { Loader { id: pluginDetailLoader width: parent.width - height: parent.height - Theme.spacingS + height: Math.max(0, parent.height - Theme.spacingS) y: Theme.spacingS active: false sourceComponent: null @@ -46,7 +46,7 @@ Item { Loader { id: coreDetailLoader width: parent.width - height: parent.height - Theme.spacingS + height: Math.max(0, parent.height - Theme.spacingS) y: Theme.spacingS active: false sourceComponent: null @@ -134,7 +134,7 @@ Item { } pluginDetailLoader.sourceComponent = builtinInstance.ccDetailContent; - pluginDetailLoader.active = parent.height > 0; + pluginDetailLoader.active = true; return; } @@ -155,19 +155,19 @@ Item { } pluginDetailLoader.sourceComponent = pluginDetailInstance.ccDetailContent; - pluginDetailLoader.active = parent.height > 0; + pluginDetailLoader.active = true; return; } if (root.expandedSection.startsWith("diskUsage_")) { coreDetailLoader.sourceComponent = diskUsageDetailComponent; - coreDetailLoader.active = parent.height > 0; + coreDetailLoader.active = true; return; } if (root.expandedSection.startsWith("brightnessSlider_")) { coreDetailLoader.sourceComponent = brightnessDetailComponent; - coreDetailLoader.active = parent.height > 0; + coreDetailLoader.active = true; return; } @@ -195,7 +195,7 @@ Item { return; } - coreDetailLoader.active = parent.height > 0; + coreDetailLoader.active = true; } Component { diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml index 65ebf041..4d2fa7db 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml @@ -52,6 +52,35 @@ Column { return Math.max(100, maxPopoutHeight - totalRowHeight - rowSpacing); } + readonly property real targetImplicitHeight: { + const rows = layoutResult.rows; + let totalHeight = 0; + for (let i = 0; i < rows.length; i++) { + const widgets = rows[i] || []; + const sliderOnly = widgets.length > 0 && widgets.every(w => { + const id = w.id || ""; + return id === "volumeSlider" || id === "brightnessSlider" || id === "inputVolumeSlider"; + }); + totalHeight += sliderOnly ? (editMode ? 56 : 36) : 60; + if (expandedSection !== "" && i === expandedRowIndex) + totalHeight += detailHeightForSection(expandedSection) + Theme.spacingS; + } + totalHeight += Math.max(0, rows.length - 1) * spacing; + return totalHeight; + } + + function detailHeightForSection(section) { + if (!section) + return 0; + if (section === "wifi" || section === "bluetooth" || section === "builtin_vpn") + return Math.min(350, _maxDetailHeight); + if (section.startsWith("brightnessSlider_")) + return Math.min(400, _maxDetailHeight); + if (section.startsWith("plugin_")) + return Math.min(250, _maxDetailHeight); + return Math.min(250, _maxDetailHeight); + } + function calculateRowsAndWidgets() { return LayoutUtils.calculateRowsAndWidgets(root, expandedSection, expandedWidgetIndex); } @@ -182,7 +211,10 @@ Column { id: detailHost width: parent.width maxAvailableHeight: root._maxDetailHeight - height: active ? (getDetailHeight(root.expandedSection) + Theme.spacingS) : 0 + height: active ? (root.detailHeightForSection(root.expandedSection) + Theme.spacingS) : 0 + clip: true + property string retainedSection: "" + property var retainedWidgetData: null property bool active: { if (root.expandedSection === "") return false; @@ -199,14 +231,48 @@ Column { return rowIndex === root.expandedRowIndex; } - visible: active - expandedSection: root.expandedSection - expandedWidgetData: root.expandedWidgetData + visible: active || height > 0.5 + expandedSection: active ? root.expandedSection : retainedSection + expandedWidgetData: active ? root.expandedWidgetData : retainedWidgetData bluetoothCodecSelector: root.bluetoothCodecSelector widgetModel: root.model collapseCallback: root.requestCollapse screenName: root.screenName screenModel: root.screenModel + + function retainActiveDetail() { + if (!active || !root.expandedSection) + return; + retainedSection = root.expandedSection; + retainedWidgetData = root.expandedWidgetData; + } + + onActiveChanged: retainActiveDetail() + onHeightChanged: { + if (!active && height <= 0.5) { + retainedSection = ""; + retainedWidgetData = null; + } + } + + Connections { + target: root + function onExpandedSectionChanged() { + detailHost.retainActiveDetail(); + } + function onExpandedWidgetDataChanged() { + detailHost.retainActiveDetail(); + } + } + + Behavior on height { + enabled: true + NumberAnimation { + duration: Theme.variantDuration(Theme.popoutAnimationDuration, detailHost.active) + easing.type: Easing.BezierSpline + easing.bezierCurve: detailHost.active ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + } + } } } } diff --git a/quickshell/Modules/ControlCenter/Components/DragDropWidgetWrapper.qml b/quickshell/Modules/ControlCenter/Components/DragDropWidgetWrapper.qml index 7cb0e2cc..268b72e1 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropWidgetWrapper.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropWidgetWrapper.qml @@ -23,11 +23,15 @@ Item { signal toggleWidgetSize(int index) width: { - const widgetWidth = widgetData?.width || 50 - if (widgetWidth <= 25) return gridCellWidth - else if (widgetWidth <= 50) return gridCellWidth * 2 - else if (widgetWidth <= 75) return gridCellWidth * 3 - else return gridCellWidth * 4 + const widgetWidth = widgetData?.width || 50; + if (widgetWidth <= 25) + return gridCellWidth; + else if (widgetWidth <= 50) + return gridCellWidth * 2; + else if (widgetWidth <= 75) + return gridCellWidth * 3; + else + return gridCellWidth * 4; } height: isSlider ? 16 : gridCellHeight @@ -42,10 +46,14 @@ Item { z: dragArea.drag.active ? 10000 : 1 Behavior on border.width { - NumberAnimation { duration: 150 } + NumberAnimation { + duration: 150 + } } Behavior on opacity { - NumberAnimation { duration: 150 } + NumberAnimation { + duration: 150 + } } } @@ -58,14 +66,17 @@ Item { property int globalWidgetIndex: root.widgetIndex property int widgetWidth: root.widgetData?.width || 50 - MouseArea { id: editModeBlocker anchors.fill: parent enabled: root.editMode acceptedButtons: Qt.AllButtons - onPressed: function(mouse) { mouse.accepted = true } - onWheel: function(wheel) { wheel.accepted = true } + onPressed: function (mouse) { + mouse.accepted = true; + } + onWheel: function (wheel) { + wheel.accepted = true; + } z: 100 } } @@ -79,19 +90,19 @@ Item { drag.axis: Drag.XAndYAxis drag.smoothed: true - onPressed: function(mouse) { + onPressed: function (mouse) { if (editMode) { - cursorShape = Qt.ClosedHandCursor + cursorShape = Qt.ClosedHandCursor; if (root.gridLayout && root.gridLayout.moveToTop) { - root.gridLayout.moveToTop(root) + root.gridLayout.moveToTop(root); } } } - onReleased: function(mouse) { + onReleased: function (mouse) { if (editMode) { - cursorShape = Qt.OpenHandCursor - root.snapToGrid() + cursorShape = Qt.OpenHandCursor; + root.snapToGrid(); } } } @@ -101,9 +112,11 @@ Item { Drag.hotSpot.y: height / 2 function swapIndices(i, j) { - if (i === j) return; + if (i === j) + return; const arr = SettingsData.controlCenterWidgets; - if (!arr || i < 0 || j < 0 || i >= arr.length || j >= arr.length) return; + if (!arr || i < 0 || j < 0 || i >= arr.length || j >= arr.length) + return; const copy = arr.slice(); const tmp = copy[i]; @@ -114,37 +127,41 @@ Item { } function snapToGrid() { - if (!editMode || !gridLayout) return + if (!editMode || !gridLayout) + return; + const globalPos = root.mapToItem(gridLayout, 0, 0); + const cellWidth = gridLayout.width / gridColumns; + const cellHeight = gridCellHeight + Theme.spacingS; - const globalPos = root.mapToItem(gridLayout, 0, 0) - const cellWidth = gridLayout.width / gridColumns - const cellHeight = gridCellHeight + Theme.spacingS + const centerX = globalPos.x + (root.width / 2); + const centerY = globalPos.y + (root.height / 2); - const centerX = globalPos.x + (root.width / 2) - const centerY = globalPos.y + (root.height / 2) + let targetCol = Math.max(0, Math.floor(centerX / cellWidth)); + let targetRow = Math.max(0, Math.floor(centerY / cellHeight)); - let targetCol = Math.max(0, Math.floor(centerX / cellWidth)) - let targetRow = Math.max(0, Math.floor(centerY / cellHeight)) + targetCol = Math.min(targetCol, gridColumns - 1); - targetCol = Math.min(targetCol, gridColumns - 1) - - const newIndex = findBestInsertionIndex(targetRow, targetCol) + const newIndex = findBestInsertionIndex(targetRow, targetCol); if (newIndex !== widgetIndex && newIndex >= 0 && newIndex < (SettingsData.controlCenterWidgets?.length || 0)) { - swapIndices(widgetIndex, newIndex) + swapIndices(widgetIndex, newIndex); } } function findBestInsertionIndex(targetRow, targetCol) { const widgets = SettingsData.controlCenterWidgets || []; const n = widgets.length; - if (!n || widgetIndex < 0 || widgetIndex >= n) return -1; + if (!n || widgetIndex < 0 || widgetIndex >= n) + return -1; function spanFor(width) { const w = width ?? 50; - if (w <= 25) return 1; - if (w <= 50) return 2; - if (w <= 75) return 3; + if (w <= 25) + return 1; + if (w <= 50) + return 2; + if (w <= 75) + return 3; return 4; } @@ -169,7 +186,13 @@ Item { if (i === widgetIndex) { draggedOrigKey = centerKey; } else { - pos.push({ index: i, row, startCol, span, centerKey }); + pos.push({ + index: i, + row, + startCol, + span, + centerKey + }); } col += span; @@ -179,7 +202,8 @@ Item { } } - if (pos.length === 0) return -1; + if (pos.length === 0) + return -1; const centerColCoord = targetCol + 0.5; const targetKey = targetRow * cols + centerColCoord; @@ -192,15 +216,20 @@ Item { } let lo = 0, hi = pos.length - 1; - if (targetKey <= pos[0].centerKey) return pos[0].index; - if (targetKey >= pos[hi].centerKey) return pos[hi].index; + if (targetKey <= pos[0].centerKey) + return pos[0].index; + if (targetKey >= pos[hi].centerKey) + return pos[hi].index; while (lo <= hi) { const mid = (lo + hi) >> 1; const mk = pos[mid].centerKey; - if (targetKey < mk) hi = mid - 1; - else if (targetKey > mk) lo = mid + 1; - else return pos[mid].index; + if (targetKey < mk) + hi = mid - 1; + else if (targetKey > mk) + lo = mid + 1; + else + return pos[mid].index; } const movingUp = (draggedOrigKey != null) ? (targetKey < draggedOrigKey) : false; return (movingUp ? pos[lo].index : pos[hi].index); @@ -240,11 +269,11 @@ Item { currentSize: root.widgetData?.width || 50 isSlider: root.isSlider widgetIndex: root.widgetIndex - onSizeChanged: (newSize) => { - var widgets = SettingsData.controlCenterWidgets.slice() + onSizeChanged: newSize => { + var widgets = SettingsData.controlCenterWidgets.slice(); if (widgetIndex >= 0 && widgetIndex < widgets.length) { - widgets[widgetIndex].width = newSize - SettingsData.set("controlCenterWidgets", widgets) + widgets[widgetIndex].width = newSize; + SettingsData.set("controlCenterWidgets", widgets); } } } @@ -270,7 +299,9 @@ Item { } Behavior on opacity { - NumberAnimation { duration: 150 } + NumberAnimation { + duration: 150 + } } } @@ -283,7 +314,9 @@ Item { z: -1 Behavior on color { - ColorAnimation { duration: Theme.shortDuration } + ColorAnimation { + duration: Theme.shortDuration + } } } } diff --git a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml index ba92dafa..5898406a 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml @@ -20,19 +20,53 @@ DankPopout { property int expandedWidgetIndex: -1 property var expandedWidgetData: null property bool powerMenuOpen: powerMenuModalLoader?.item?.shouldBeVisible ?? false + property real targetPopupHeight: 400 + property bool _heightUpdatePending: false signal lockRequested + function _maxPopupHeight() { + const screenHeight = (triggerScreen?.height ?? 1080); + return screenHeight - 100; + } + + function _contentTargetHeight() { + const item = contentLoader.item; + if (!item) + return 400; + const naturalHeight = item.targetImplicitHeight !== undefined ? item.targetImplicitHeight : item.implicitHeight; + return Math.max(300, naturalHeight + 20); + } + + function updateTargetPopupHeight() { + const target = Math.min(_maxPopupHeight(), _contentTargetHeight()); + if (Math.abs(targetPopupHeight - target) < 0.5) + return; + targetPopupHeight = target; + } + + function queueTargetPopupHeightUpdate() { + if (_heightUpdatePending) + return; + _heightUpdatePending = true; + Qt.callLater(() => { + _heightUpdatePending = false; + updateTargetPopupHeight(); + }); + } + function collapseAll() { expandedSection = ""; expandedWidgetIndex = -1; expandedWidgetData = null; + queueTargetPopupHeightUpdate(); } onEditModeChanged: { if (editMode) { collapseAll(); } + queueTargetPopupHeightUpdate(); } onVisibleChanged: { @@ -52,12 +86,7 @@ DankPopout { } popupWidth: 550 - popupHeight: { - const screenHeight = (triggerScreen?.height ?? 1080); - const maxHeight = screenHeight - 100; - const contentHeight = contentLoader.item && contentLoader.item.implicitHeight > 0 ? contentLoader.item.implicitHeight + 20 : 400; - return Math.min(maxHeight, contentHeight); - } + popupHeight: targetPopupHeight triggerWidth: 80 positioning: "" screen: triggerScreen @@ -95,6 +124,7 @@ DankPopout { onShouldBeVisibleChanged: { if (shouldBeVisible) { collapseAll(); + queueTargetPopupHeightUpdate(); Qt.callLater(() => { if (NetworkService.activeService) NetworkService.activeService.autoRefreshEnabled = NetworkService.wifiEnabled; @@ -111,6 +141,28 @@ DankPopout { } } + onExpandedSectionChanged: queueTargetPopupHeightUpdate() + onExpandedWidgetIndexChanged: queueTargetPopupHeightUpdate() + onTriggerScreenChanged: queueTargetPopupHeightUpdate() + + Connections { + target: contentLoader + function onLoaded() { + root.queueTargetPopupHeightUpdate(); + } + } + + Connections { + target: contentLoader.item + ignoreUnknownSignals: true + function onTargetImplicitHeightChanged() { + root.queueTargetPopupHeightUpdate(); + } + function onImplicitHeightChanged() { + root.queueTargetPopupHeightUpdate(); + } + } + WidgetModel { id: widgetModel } @@ -122,7 +174,13 @@ DankPopout { LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true - implicitHeight: mainColumn.implicitHeight + Theme.spacingM + readonly property real targetImplicitHeight: { + let total = headerPane.implicitHeight + Theme.spacingS + widgetGrid.targetImplicitHeight; + if (editControls.visible) + total += Theme.spacingS + editControls.height; + return total + Theme.spacingM; + } + implicitHeight: targetImplicitHeight property alias bluetoothCodecSelector: bluetoothCodecSelector color: "transparent" @@ -136,95 +194,107 @@ DankPopout { z: 5000 Behavior on opacity { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: 200 - easing.type: Easing.OutCubic + duration: Theme.shortDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } } - Column { - id: mainColumn - width: parent.width - Theme.spacingL * 2 - x: Theme.spacingL - y: Theme.spacingL - spacing: Theme.spacingS + DankFlickable { + id: contentFlickable + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: Math.max(height, mainColumn.implicitHeight + Theme.spacingM) + interactive: contentHeight > height - HeaderPane { - id: headerPane - width: parent.width - editMode: root.editMode - onEditModeToggled: root.editMode = !root.editMode - onPowerButtonClicked: { - if (powerMenuModalLoader) { - powerMenuModalLoader.active = true; - if (powerMenuModalLoader.item) { - const bounds = Qt.rect(root.alignedX, root.alignedY, root.popupWidth, root.popupHeight); - powerMenuModalLoader.item.openFromControlCenter(bounds, root.screen); + Column { + id: mainColumn + width: contentFlickable.width - Theme.spacingL * 2 + x: Theme.spacingL + y: Theme.spacingL + spacing: Theme.spacingS + + HeaderPane { + id: headerPane + width: parent.width + editMode: root.editMode + onEditModeToggled: root.editMode = !root.editMode + onPowerButtonClicked: { + if (powerMenuModalLoader) { + powerMenuModalLoader.active = true; + if (powerMenuModalLoader.item) { + const bounds = Qt.rect(root.alignedX, root.alignedY, root.popupWidth, root.popupHeight); + powerMenuModalLoader.item.openFromControlCenter(bounds, root.screen); + } } } - } - onLockRequested: { - root.close(); - root.lockRequested(); - } - onSettingsButtonClicked: { - root.close(); - } - } - - DragDropGrid { - id: widgetGrid - width: parent.width - editMode: root.editMode - maxPopoutHeight: { - const screenHeight = (root.triggerScreen?.height ?? 1080); - return screenHeight - 100 - Theme.spacingL - headerPane.height - Theme.spacingS; - } - expandedSection: root.expandedSection - expandedWidgetIndex: root.expandedWidgetIndex - expandedWidgetData: root.expandedWidgetData - model: widgetModel - bluetoothCodecSelector: bluetoothCodecSelector - colorPickerModal: root.colorPickerModal - screenName: root.triggerScreen?.name || "" - screenModel: root.triggerScreen?.model || "" - parentScreen: root.triggerScreen - onExpandClicked: (widgetData, globalIndex) => { - root.expandedWidgetIndex = globalIndex; - root.expandedWidgetData = widgetData; - if (widgetData.id === "diskUsage") { - root.toggleSection("diskUsage_" + (widgetData.instanceId || "default")); - } else if (widgetData.id === "brightnessSlider") { - root.toggleSection("brightnessSlider_" + (widgetData.instanceId || "default")); - } else { - root.toggleSection(widgetData.id); + onLockRequested: { + root.close(); + root.lockRequested(); + } + onSettingsButtonClicked: { + root.close(); } } - onRemoveWidget: index => widgetModel.removeWidget(index) - onMoveWidget: (fromIndex, toIndex) => widgetModel.moveWidget(fromIndex, toIndex) - onToggleWidgetSize: index => widgetModel.toggleWidgetSize(index) - onCollapseRequested: root.collapseAll() - } - EditControls { - width: parent.width - visible: editMode - popupScreen: root.screen - popoutX: root.alignedX - popoutY: root.alignedY - popoutWidth: root.alignedWidth - popoutHeight: root.alignedHeight - availableWidgets: { - if (!editMode) - return []; - const existingIds = (SettingsData.controlCenterWidgets || []).map(w => w.id); - const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets()); - return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id)); + DragDropGrid { + id: widgetGrid + width: parent.width + editMode: root.editMode + maxPopoutHeight: { + const screenHeight = (root.triggerScreen?.height ?? 1080); + return screenHeight - 100 - Theme.spacingL - headerPane.implicitHeight - Theme.spacingS; + } + expandedSection: root.expandedSection + expandedWidgetIndex: root.expandedWidgetIndex + expandedWidgetData: root.expandedWidgetData + model: widgetModel + bluetoothCodecSelector: bluetoothCodecSelector + colorPickerModal: root.colorPickerModal + screenName: root.triggerScreen?.name || "" + screenModel: root.triggerScreen?.model || "" + parentScreen: root.triggerScreen + onExpandClicked: (widgetData, globalIndex) => { + root.expandedWidgetIndex = globalIndex; + root.expandedWidgetData = widgetData; + if (widgetData.id === "diskUsage") { + root.toggleSection("diskUsage_" + (widgetData.instanceId || "default")); + } else if (widgetData.id === "brightnessSlider") { + root.toggleSection("brightnessSlider_" + (widgetData.instanceId || "default")); + } else { + root.toggleSection(widgetData.id); + } + } + onRemoveWidget: index => widgetModel.removeWidget(index) + onMoveWidget: (fromIndex, toIndex) => widgetModel.moveWidget(fromIndex, toIndex) + onToggleWidgetSize: index => widgetModel.toggleWidgetSize(index) + onCollapseRequested: root.collapseAll() + } + + EditControls { + id: editControls + width: parent.width + visible: editMode + popupScreen: root.screen + popoutX: root.alignedX + popoutY: root.alignedY + popoutWidth: root.alignedWidth + popoutHeight: root.alignedHeight + availableWidgets: { + if (!editMode) + return []; + const existingIds = (SettingsData.controlCenterWidgets || []).map(w => w.id); + const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets()); + return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id)); + } + onAddWidget: widgetId => widgetModel.addWidget(widgetId) + onResetToDefault: () => widgetModel.resetToDefault() + onClearAll: () => widgetModel.clearAll() } - onAddWidget: widgetId => widgetModel.addWidget(widgetId) - onResetToDefault: () => widgetModel.resetToDefault() - onClearAll: () => widgetModel.clearAll() } } diff --git a/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml b/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml index 3cd2da2d..b03e2969 100644 --- a/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/BluetoothDetail.qml @@ -449,7 +449,7 @@ Rectangle { size: 24 color: Qt.rgba(Theme.surfaceText.r || 0.8, Theme.surfaceText.g || 0.8, Theme.surfaceText.b || 0.8, 0.4) - RotationAnimation on rotation { + RotationAnimator on rotation { running: parent.visible loops: Animation.Infinite from: 0 diff --git a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml index 3814343c..cc27dcea 100644 --- a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml @@ -178,7 +178,7 @@ Rectangle { size: 32 color: Theme.primary - RotationAnimation on rotation { + RotationAnimator on rotation { running: NetworkService.wifiToggling loops: Animation.Infinite from: 0 @@ -494,7 +494,7 @@ Rectangle { size: 48 color: Qt.rgba(Theme.surfaceText.r || 0.8, Theme.surfaceText.g || 0.8, Theme.surfaceText.b || 0.8, 0.3) - RotationAnimation on rotation { + RotationAnimator on rotation { running: wifiScanningOverlay.visible loops: Animation.Infinite from: 0 diff --git a/quickshell/Modules/DankBar/BarCanvas.qml b/quickshell/Modules/DankBar/BarCanvas.qml index c5d5e75d..04d6feb7 100644 --- a/quickshell/Modules/DankBar/BarCanvas.qml +++ b/quickshell/Modules/DankBar/BarCanvas.qml @@ -10,6 +10,8 @@ Item { required property var axis required property var barConfig + visible: !SettingsData.frameEnabled + anchors.fill: parent anchors.left: parent.left @@ -37,6 +39,8 @@ Item { } property real rt: { + if (SettingsData.frameEnabled) + return SettingsData.frameRounding; if (barConfig?.squareCorners ?? false) return 0; if (barWindow.hasMaximizedToplevel) @@ -255,11 +259,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 +278,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 +290,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 +309,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 +320,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 +339,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 +351,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 +370,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/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index 0c977bf7..2b8ca82b 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -23,6 +23,31 @@ Item { readonly property real innerPadding: barConfig?.innerPadding ?? 4 readonly property real outlineThickness: (barConfig?.widgetOutlineEnabled ?? false) ? (barConfig?.widgetOutlineThickness ?? 1) : 0 + readonly property real _frameLeftInset: { + if (!SettingsData.frameEnabled || barWindow.isVertical) return 0 + return barWindow.hasAdjacentLeftBar + ? SettingsData.frameBarSize + : 0 + } + readonly property real _frameRightInset: { + if (!SettingsData.frameEnabled || barWindow.isVertical) return 0 + return barWindow.hasAdjacentRightBar + ? SettingsData.frameBarSize + : 0 + } + readonly property real _frameTopInset: { + if (!SettingsData.frameEnabled || !barWindow.isVertical) return 0 + return barWindow.hasAdjacentTopBar + ? SettingsData.frameThickness + : 0 + } + readonly property real _frameBottomInset: { + if (!SettingsData.frameEnabled || !barWindow.isVertical) return 0 + return barWindow.hasAdjacentBottomBar + ? SettingsData.frameThickness + : 0 + } + property alias hLeftSection: hLeftSection property alias hCenterSection: hCenterSection property alias hRightSection: hRightSection @@ -31,10 +56,14 @@ Item { property alias vRightSection: vRightSection anchors.fill: parent - anchors.leftMargin: Math.max(Theme.spacingXS, innerPadding * 0.8) - anchors.rightMargin: Math.max(Theme.spacingXS, innerPadding * 0.8) - anchors.topMargin: barWindow.isVertical ? (barWindow.hasAdjacentTopBar ? outlineThickness : Theme.spacingXS) : 0 - anchors.bottomMargin: barWindow.isVertical ? (barWindow.hasAdjacentBottomBar ? outlineThickness : Theme.spacingXS) : 0 + anchors.leftMargin: Math.max(Theme.spacingXS, innerPadding * 0.8) + _frameLeftInset + anchors.rightMargin: Math.max(Theme.spacingXS, innerPadding * 0.8) + _frameRightInset + anchors.topMargin: (barWindow.isVertical + ? (barWindow.hasAdjacentTopBar ? outlineThickness : Theme.spacingXS) + : 0) + _frameTopInset + anchors.bottomMargin: (barWindow.isVertical + ? (barWindow.hasAdjacentBottomBar ? outlineThickness : Theme.spacingXS) + : 0) + _frameBottomInset clip: false property int componentMapRevision: 0 @@ -1156,6 +1185,7 @@ Item { if (!notificationCenterLoader.item) { return; } + notificationCenterLoader.item.triggerScreen = barWindow.screen; const effectiveBarConfig = topBarContent.barConfig; const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); if (notificationCenterLoader.item.setBarContext) { diff --git a/quickshell/Modules/DankBar/DankBarWindow.qml b/quickshell/Modules/DankBar/DankBarWindow.qml index 71e2c27a..c93c1db3 100644 --- a/quickshell/Modules/DankBar/DankBarWindow.qml +++ b/quickshell/Modules/DankBar/DankBarWindow.qml @@ -43,10 +43,10 @@ PanelWindow { } } - function triggerWallpaperBrowser() { + function triggerDashTab(tabIndex) { dankDashPopoutLoader.active = true; if (!dankDashPopoutLoader.item) { - return; + return false; } let section = "center"; @@ -82,7 +82,12 @@ PanelWindow { dankDashPopoutLoader.item.triggerScreen = barWindow.screen; } - PopoutManager.requestPopout(dankDashPopoutLoader.item, 2, (barConfig?.id ?? "default") + "-" + section + "-2"); + PopoutManager.requestPopout(dankDashPopoutLoader.item, tabIndex, (barConfig?.id ?? "default") + "-" + section + "-" + tabIndex); + return true; + } + + function triggerWallpaperBrowser() { + triggerDashTab(2); } readonly property var dBarLayer: { @@ -94,7 +99,9 @@ PanelWindow { case "background": return WlrLayer.background; default: - return WlrLayer.Top; + // Elevate to Overlay when Frame is enabled so the bar stays above + // the FrameWindow (WlrLayer.Top) when it is re-mapped on mode switch. + return SettingsData.frameEnabled ? WlrLayer.Overlay : WlrLayer.Top; } } @@ -134,6 +141,11 @@ PanelWindow { teardown(); if (!BlurService.enabled || !BlurService.available) return; + // In frame mode, FrameWindow owns the blur region for the entire screen edge + // (including the bar area). The bar must not set its own competing blur region + // so that frameBlurEnabled acts as the single control for all blur in frame mode. + if (SettingsData.frameEnabled) + return; const widgets = barWindow._blurWidgetItems.filter(w => w && w.visible && w.width > 0 && w.height > 0); const hasBar = barHasTransparency; @@ -188,6 +200,13 @@ PanelWindow { } } + Connections { + target: SettingsData + function onFrameEnabledChanged() { + barBlur.rebuild(); + } + } + Connections { target: topBarSlide function onXChanged() { @@ -239,7 +258,7 @@ 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 ? Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) : Theme.withAlpha(_surfaceContainer, _backgroundAlpha) function _updateBackgroundAlpha() { const live = SettingsData.barConfigs.find(c => c.id === _barId); @@ -385,7 +404,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 @@ -396,7 +415,8 @@ PanelWindow { } readonly property int notificationCount: NotificationService.notifications.length - readonly property real effectiveBarThickness: Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr) + readonly property real effectiveBarThickness: SettingsData.frameEnabled ? SettingsData.frameBarSize : Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr) + readonly property bool effectiveOpenOnOverview: SettingsData.frameEnabled ? SettingsData.frameShowOnOverview : (barConfig?.openOnOverview ?? false) readonly property real widgetThickness: Theme.snap(Math.max(20, 26 + (barConfig?.innerPadding ?? 4) * 0.6), _dpr) readonly property bool hasAdjacentTopBar: { @@ -645,14 +665,14 @@ PanelWindow { anchors.left: !isVertical ? true : (barPos === SettingsData.Position.Left) anchors.right: !isVertical ? true : (barPos === SettingsData.Position.Right) - exclusiveZone: (!(barConfig?.visible ?? true) || topBarCore.autoHide) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (barConfig?.bottomGap ?? 0)) + exclusiveZone: (!(barConfig?.visible ?? true) || topBarCore.autoHide) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (Theme.isConnectedEffect ? 0 : (barConfig?.bottomGap ?? 0))) Item { id: inputMask readonly property int barThickness: Theme.px(barWindow.effectiveBarThickness + barWindow.effectiveSpacing, barWindow._dpr) - readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && (barConfig?.openOnOverview ?? false) + readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview readonly property bool effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow || !topBarCore.autoHide) @@ -793,7 +813,7 @@ PanelWindow { } property bool reveal: { - const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && (barConfig?.openOnOverview ?? false); + const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview; if (inOverviewWithShow) return true; @@ -890,7 +910,7 @@ PanelWindow { top: barWindow.isVertical ? parent.top : undefined bottom: barWindow.isVertical ? parent.bottom : undefined } - readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && (barConfig?.openOnOverview ?? false) + readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !topBarCore.hasActivePopout acceptedButtons: Qt.NoButton enabled: (barConfig?.autoHide ?? false) && !inOverview @@ -935,6 +955,17 @@ PanelWindow { barConfig: barWindow.barConfig } + MouseArea { + anchors.fill: parent + z: -2 + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: { + const screenName = barWindow.screen?.name; + if (screenName && PopoutManager.currentPopoutsByScreen[screenName]) + PopoutManager.closeAllPopouts(); + } + } + MouseArea { id: scrollArea anchors.fill: parent diff --git a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml index c4434e6c..bce02dd1 100644 --- a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml +++ b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml @@ -148,19 +148,16 @@ DankPopout { opacity: enabled ? 1.0 : 0.5 onClicked: SystemUpdateService.checkForUpdates() - RotationAnimation { - target: refreshButton - property: "rotation" + RotationAnimator on rotation { from: 0 to: 360 duration: 1000 - running: SystemUpdateService.isChecking loops: Animation.Infinite + running: SystemUpdateService.isChecking onRunningChanged: { - if (!running) { + if (!running) refreshButton.rotation = 0; - } } } } diff --git a/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml b/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml index ac53c3fe..db7114da 100644 --- a/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml +++ b/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml @@ -73,20 +73,17 @@ BasePill { return root.isActive ? Theme.primary : Theme.surfaceText; } - RotationAnimation { + RotationAnimator on rotation { id: rotationAnimation - target: statusIcon - property: "rotation" from: 0 to: 360 duration: 1000 - running: root.isChecking loops: Animation.Infinite + running: root.isChecking onRunningChanged: { - if (!running) { + if (!running) statusIcon.rotation = 0; - } } } } @@ -130,20 +127,17 @@ BasePill { return root.isActive ? Theme.primary : Theme.surfaceText; } - RotationAnimation { + RotationAnimator on rotation { id: rotationAnimationHorizontal - target: statusIconHorizontal - property: "rotation" from: 0 to: 360 duration: 1000 - running: root.isChecking loops: Animation.Infinite + running: root.isChecking onRunningChanged: { - if (!running) { + if (!running) statusIconHorizontal.rotation = 0; - } } } } diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index bd7b0767..3136deaa 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -16,7 +16,6 @@ DankPopout { popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 500 triggerWidth: 80 screen: triggerScreen - shouldBeVisible: dashVisible property bool __focusArmed: false property bool __contentReady: false @@ -54,7 +53,8 @@ DankPopout { function __hideDropdowns() { __volumeCloseTimer.stop(); __dropdownType = 0; - __mediaTabRef?.resetDropdownStates(); + if (__mediaTabRef && typeof __mediaTabRef.resetDropdownStates === "function") + __mediaTabRef.resetDropdownStates(); } function __startCloseTimer() { @@ -75,7 +75,11 @@ DankPopout { } } - overlayContent: Component { + overlayContent: shouldBeVisible ? mediaDropdownOverlayComponent : null + + Component { + id: mediaDropdownOverlayComponent + MediaDropdownOverlay { dropdownType: root.__dropdownType anchorPos: root.__dropdownAnchor @@ -183,11 +187,8 @@ DankPopout { Connections { target: root function onShouldBeVisibleChanged() { - if (root.shouldBeVisible) { - Qt.callLater(function () { - mainContainer.forceActiveFocus(); - }); - } + if (root.shouldBeVisible) + mainContainer.forceActiveFocus(); } } @@ -379,6 +380,10 @@ DankPopout { section: root.triggerSection barPosition: root.effectiveBarPosition Component.onCompleted: root.__mediaTabRef = this + Component.onDestruction: { + if (root.__mediaTabRef === this) + root.__mediaTabRef = null; + } onShowVolumeDropdown: (pos, screen, rightEdge, player, players) => { root.__showVolumeDropdown(pos, rightEdge, player, players); } diff --git a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml index 23325409..5524e24d 100644 --- a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml +++ b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml @@ -50,11 +50,9 @@ Item { } function volumeAreaExited() { - __volumeHoverCount--; - Qt.callLater(() => { - if (__volumeHoverCount <= 0) - panelExited(); - }); + __volumeHoverCount = Math.max(0, __volumeHoverCount - 1); + if (__volumeHoverCount === 0) + panelExited(); } readonly property Item __activePanel: { @@ -93,23 +91,25 @@ Item { border.color: Theme.outlineStrong border.width: 1 - opacity: dropdownType === 1 ? 1 : 0 - scale: dropdownType === 1 ? 1 : 0.96 + opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 1 ? 1 : 0) + scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 1 ? 1 : Theme.effectScaleCollapsed) transformOrigin: isRightEdge ? Item.Left : Item.Right Behavior on opacity { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1) * Theme.variantOpacityDurationScale) + easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } Behavior on scale { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } @@ -233,23 +233,25 @@ Item { border.color: Theme.outlineStrong border.width: 2 - opacity: dropdownType === 2 ? 1 : 0 - scale: dropdownType === 2 ? 1 : 0.96 + opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 2 ? 1 : 0) + scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 2 ? 1 : Theme.effectScaleCollapsed) transformOrigin: isRightEdge ? Item.Left : Item.Right Behavior on opacity { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2) * Theme.variantOpacityDurationScale) + easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } Behavior on scale { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } @@ -387,23 +389,25 @@ Item { border.color: Theme.outlineStrong border.width: 2 - opacity: dropdownType === 3 ? 1 : 0 - scale: dropdownType === 3 ? 1 : 0.96 + opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 3 ? 1 : 0) + scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 3 ? 1 : Theme.effectScaleCollapsed) transformOrigin: isRightEdge ? Item.Left : Item.Right Behavior on opacity { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3) * Theme.variantOpacityDurationScale) + easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } Behavior on scale { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } diff --git a/quickshell/Modules/DankDash/WeatherTab.qml b/quickshell/Modules/DankDash/WeatherTab.qml index c699fcf8..510f6746 100644 --- a/quickshell/Modules/DankDash/WeatherTab.qml +++ b/quickshell/Modules/DankDash/WeatherTab.qml @@ -192,7 +192,7 @@ Item { onTriggered: refreshButtonTwo.isRefreshing = false } - NumberAnimation on rotation { + RotationAnimator on rotation { running: refreshButtonTwo.isRefreshing from: 0 to: 360 @@ -930,7 +930,7 @@ Item { onTriggered: refreshButton.isRefreshing = false } - NumberAnimation on rotation { + RotationAnimator on rotation { running: refreshButton.isRefreshing from: 0 to: 360 diff --git a/quickshell/Modules/Dock/Dock.qml b/quickshell/Modules/Dock/Dock.qml index 4b20e8d7..6e621bfd 100644 --- a/quickshell/Modules/Dock/Dock.qml +++ b/quickshell/Modules/Dock/Dock.qml @@ -20,14 +20,16 @@ Variants { WindowBlur { targetWindow: dock + blurEnabled: dock.effectiveBlurEnabled && !SettingsData.connectedFrameModeActive blurX: dockBackground.x + dockContainer.x + dockMouseArea.x + dockCore.x + dockSlide.x blurY: dockBackground.y + dockContainer.y + dockMouseArea.y + dockCore.y + dockSlide.y blurWidth: dock.hasApps && dock.reveal ? dockBackground.width : 0 blurHeight: dock.hasApps && dock.reveal ? dockBackground.height : 0 - blurRadius: Theme.cornerRadius + blurRadius: Theme.isConnectedEffect ? Theme.connectedCornerRadius : dock.surfaceRadius } WlrLayershell.namespace: "dms:dock" + WlrLayershell.layer: SettingsData.frameEnabled ? WlrLayer.Overlay : WlrLayer.Top readonly property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right @@ -43,6 +45,25 @@ Variants { property real backgroundTransparency: SettingsData.dockTransparency property bool groupByApp: SettingsData.dockGroupByApp readonly property int borderThickness: SettingsData.dockBorderEnabled ? SettingsData.dockBorderThickness : 0 + readonly property string connectedBarSide: SettingsData.dockPosition === SettingsData.Position.Top ? "top" : SettingsData.dockPosition === SettingsData.Position.Bottom ? "bottom" : SettingsData.dockPosition === SettingsData.Position.Left ? "left" : "right" + readonly property bool connectedBarActiveOnEdge: Theme.isConnectedEffect && !!(dock.screen || modelData) && SettingsData.getActiveBarEdgesForScreen(dock.screen || modelData).includes(connectedBarSide) + readonly property real connectedJoinInset: { + if (Theme.isConnectedEffect) + return connectedBarActiveOnEdge ? SettingsData.frameBarSize : SettingsData.frameThickness; + if (SettingsData.frameEnabled) + return SettingsData.frameEdgeInsetForSide(dock.screen || modelData, dock.connectedBarSide); + return 0; + } + readonly property real surfaceRadius: Theme.connectedSurfaceRadius + readonly property color surfaceColor: Theme.isConnectedEffect ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, backgroundTransparency) + readonly property color surfaceBorderColor: Theme.isConnectedEffect ? "transparent" : BlurService.borderColor + readonly property real surfaceBorderWidth: Theme.isConnectedEffect ? 0 : BlurService.borderWidth + readonly property real surfaceTopLeftRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Top || SettingsData.dockPosition === SettingsData.Position.Left) ? 0 : surfaceRadius + readonly property real surfaceTopRightRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Top || SettingsData.dockPosition === SettingsData.Position.Right) ? 0 : surfaceRadius + readonly property real surfaceBottomLeftRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Bottom || SettingsData.dockPosition === SettingsData.Position.Left) ? 0 : surfaceRadius + readonly property real surfaceBottomRightRadius: Theme.isConnectedEffect && (SettingsData.dockPosition === SettingsData.Position.Bottom || SettingsData.dockPosition === SettingsData.Position.Right) ? 0 : surfaceRadius + readonly property real horizontalConnectorExtent: Theme.isConnectedEffect && !isVertical ? Theme.connectedCornerRadius : 0 + readonly property real verticalConnectorExtent: Theme.isConnectedEffect && isVertical ? Theme.connectedCornerRadius : 0 readonly property int hasApps: dockApps.implicitWidth > 0 || dockApps.implicitHeight > 0 @@ -114,14 +135,76 @@ Variants { return getBarHeight(leftBar); } - readonly property real dockMargin: SettingsData.dockSpacing - readonly property real positionSpacing: barSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin + readonly property real dockMargin: SettingsData.dockMargin + readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled + readonly property real effectiveDockBottomGap: Theme.isConnectedEffect ? 0 : SettingsData.dockBottomGap + readonly property real effectiveDockMargin: Theme.isConnectedEffect ? 0 : SettingsData.dockMargin + readonly property real positionSpacing: barSpacing + effectiveDockBottomGap + effectiveDockMargin + readonly property real joinedEdgeMargin: Theme.isConnectedEffect ? 0 : (barSpacing + effectiveDockMargin + 1 + dock.borderThickness) readonly property real _dpr: (dock.screen && dock.screen.devicePixelRatio) ? dock.screen.devicePixelRatio : 1 function px(v) { return Math.round(v * _dpr) / _dpr; } - property bool contextMenuOpen: (dockVariants.contextMenu && dockVariants.contextMenu.visible && dockVariants.contextMenu.screen === modelData) || (dockVariants.trashContextMenu && dockVariants.trashContextMenu.visible && dockVariants.trashContextMenu.screen === modelData) + // Dock window origin in screen-relative coordinates (FrameWindow space). + function _dockWindowOriginX() { + if (!dock.isVertical) + return 0; + if (SettingsData.dockPosition === SettingsData.Position.Right) + return (dock.screen ? dock.screen.width : 0) - dock.width; + return 0; + } + function _dockWindowOriginY() { + if (dock.isVertical) + return 0; + if (SettingsData.dockPosition === SettingsData.Position.Bottom) + return (dock.screen ? dock.screen.height : 0) - dock.height; + return 0; + } + + readonly property string _dockScreenName: dock.modelData ? dock.modelData.name : (dock.screen ? dock.screen.name : "") + + function _syncDockChromeState() { + if (!dock._dockScreenName) + return; + if (!SettingsData.connectedFrameModeActive) { + ConnectedModeState.clearDockState(dock._dockScreenName); + return; + } + + ConnectedModeState.setDockState(dock._dockScreenName, { + "reveal": dock.visible && (dock.reveal || slideXAnimation.running || slideYAnimation.running) && dock.hasApps, + "barSide": dock.connectedBarSide, + "bodyX": dock._dockWindowOriginX() + dockBackground.x + dockContainer.x + dockMouseArea.x + dockCore.x, + "bodyY": dock._dockWindowOriginY() + dockBackground.y + dockContainer.y + dockMouseArea.y + dockCore.y, + "bodyW": dock.hasApps ? dockBackground.width : 0, + "bodyH": dock.hasApps ? dockBackground.height : 0, + "slideX": dockSlide.x, + "slideY": dockSlide.y + }); + } + + function _syncDockSlide() { + if (!dock._dockScreenName || !SettingsData.connectedFrameModeActive) + return; + ConnectedModeState.setDockSlide(dock._dockScreenName, dockSlide.x, dockSlide.y); + } + + property bool _slideSyncPending: false + function _queueSlideSync() { + if (!SettingsData.connectedFrameModeActive) + return; + if (_slideSyncPending) + return; + _slideSyncPending = true; + Qt.callLater(dock._flushSlideSync); + } + function _flushSlideSync() { + _slideSyncPending = false; + dock._syncDockSlide(); + } + + property bool contextMenuOpen: (dockVariants.contextMenu && dockVariants.contextMenu.visible && dockVariants.contextMenu.screen === modelData) property bool revealSticky: false readonly property bool shouldHideForWindows: { @@ -131,7 +214,7 @@ Variants { return false; const screenName = dock.modelData?.name ?? ""; - const dockThickness = effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin; + const dockThickness = dock.connectedJoinInset + effectiveBarHeight + SettingsData.dockSpacing + dock.effectiveDockBottomGap + dock.effectiveDockMargin; const screenWidth = dock.screen?.width ?? 0; const screenHeight = dock.screen?.height ?? 0; @@ -259,7 +342,17 @@ Variants { onTriggered: dock.revealSticky = false } + // Flip `reveal` false when a modal claims this edge; reuses the slide animation + readonly property bool _modalRetractActive: { + if (!dock._dockScreenName) + return false; + return ConnectedModeState.dockRetractActiveForSide(dock._dockScreenName, dock.connectedBarSide); + } + property bool reveal: { + if (_modalRetractActive) + return false; + if (CompositorService.isNiri && NiriService.inOverview && SettingsData.dockOpenOnOverview) { return true; } @@ -282,6 +375,23 @@ Variants { } } + Component.onCompleted: Qt.callLater(() => dock._syncDockChromeState()) + Component.onDestruction: ConnectedModeState.clearDockState(dock._dockScreenName) + + onRevealChanged: dock._syncDockChromeState() + onWidthChanged: dock._syncDockChromeState() + onHeightChanged: dock._syncDockChromeState() + onVisibleChanged: dock._syncDockChromeState() + onHasAppsChanged: dock._syncDockChromeState() + onConnectedBarSideChanged: dock._syncDockChromeState() + + Connections { + target: SettingsData + function onConnectedFrameModeActiveChanged() { + dock._syncDockChromeState(); + } + } + Connections { target: SettingsData function onDockTransparencyChanged() { @@ -303,13 +413,13 @@ Variants { return -1; if (barSpacing > 0) return -1; - return px(effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin); + return px(connectedJoinInset + effectiveBarHeight + SettingsData.dockSpacing + effectiveDockBottomGap + effectiveDockMargin); } property real animationHeadroom: Math.ceil(SettingsData.dockIconSize * 0.35) - implicitWidth: isVertical ? (px(effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockMargin + SettingsData.dockIconSize * 0.3) + animationHeadroom) : 0 - implicitHeight: !isVertical ? (px(effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockMargin + SettingsData.dockIconSize * 0.3) + animationHeadroom) : 0 + implicitWidth: isVertical ? (px(connectedJoinInset + effectiveBarHeight + SettingsData.dockSpacing + effectiveDockMargin + SettingsData.dockIconSize * 0.3) + animationHeadroom) : 0 + implicitHeight: !isVertical ? (px(connectedJoinInset + effectiveBarHeight + SettingsData.dockSpacing + effectiveDockMargin + SettingsData.dockIconSize * 0.3) + animationHeadroom) : 0 Item { id: maskItem @@ -319,17 +429,17 @@ Variants { x: { const baseX = dockCore.x + dockMouseArea.x; if (isVertical && SettingsData.dockPosition === SettingsData.Position.Right) - return baseX - (expanded ? animationHeadroom + borderThickness : 0); - return baseX - (expanded ? borderThickness : 0); + return baseX - (expanded ? animationHeadroom + borderThickness + dock.horizontalConnectorExtent : 0); + return baseX - (expanded ? borderThickness + dock.horizontalConnectorExtent : 0); } y: { const baseY = dockCore.y + dockMouseArea.y; if (!isVertical && SettingsData.dockPosition === SettingsData.Position.Bottom) - return baseY - (expanded ? animationHeadroom + borderThickness : 0); - return baseY - (expanded ? borderThickness : 0); + return baseY - (expanded ? animationHeadroom + borderThickness + dock.verticalConnectorExtent : 0); + return baseY - (expanded ? borderThickness + dock.verticalConnectorExtent : 0); } - width: dockMouseArea.width + (isVertical && expanded ? animationHeadroom : 0) + (expanded ? borderThickness * 2 : 0) - height: dockMouseArea.height + (!isVertical && expanded ? animationHeadroom : 0) + (expanded ? borderThickness * 2 : 0) + width: dockMouseArea.width + (isVertical && expanded ? animationHeadroom : 0) + (expanded ? borderThickness * 2 + dock.horizontalConnectorExtent * 2 : 0) + height: dockMouseArea.height + (!isVertical && expanded ? animationHeadroom : 0) + (expanded ? borderThickness * 2 + dock.verticalConnectorExtent * 2 : 0) } mask: Region { @@ -389,7 +499,7 @@ Variants { const screenHeight = dock.screen ? dock.screen.height : 0; const gap = Theme.spacingS; - const bgMargin = barSpacing + SettingsData.dockMargin + 1 + dock.borderThickness; + const bgMargin = dock.joinedEdgeMargin + dock.connectedJoinInset; const btnW = dock.hoveredButton.width; const btnH = dock.hoveredButton.height; @@ -460,11 +570,11 @@ Variants { // Keep the taller hit area regardless of the reveal state to prevent shrinking loop return Math.min(Math.max(dockBackground.height + 64, 200), maxDockHeight); } - return dock.reveal ? px(dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin) : 1; + return dock.reveal ? px(dock.connectedJoinInset + dock.effectiveBarHeight + SettingsData.dockSpacing + dock.effectiveDockBottomGap + dock.effectiveDockMargin) : 1; } width: { if (dock.isVertical) { - return dock.reveal ? px(dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin) : 1; + return dock.reveal ? px(dock.connectedJoinInset + dock.effectiveBarHeight + SettingsData.dockSpacing + dock.effectiveDockBottomGap + dock.effectiveDockMargin) : 1; } // Keep the wider hit area regardless of the reveal state to prevent shrinking loop return Math.min(dockBackground.width + 8 + dock.borderThickness, maxDockWidth); @@ -506,7 +616,11 @@ Variants { return 0; if (dock.reveal) return 0; - const hideDistance = dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin + 10; + if (Theme.isConnectedEffect) { + const retractDist = dockBackground.width + SettingsData.dockSpacing + 10; + return SettingsData.dockPosition === SettingsData.Position.Right ? retractDist : -retractDist; + } + const hideDistance = dock.connectedJoinInset + dock.effectiveBarHeight + SettingsData.dockSpacing + dock.effectiveDockBottomGap + dock.effectiveDockMargin + 10; if (SettingsData.dockPosition === SettingsData.Position.Right) { return hideDistance; } else { @@ -518,7 +632,11 @@ Variants { return 0; if (dock.reveal) return 0; - const hideDistance = dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin + 10; + if (Theme.isConnectedEffect) { + const retractDist = dockBackground.height + SettingsData.dockSpacing + 10; + return SettingsData.dockPosition === SettingsData.Position.Bottom ? retractDist : -retractDist; + } + const hideDistance = dock.connectedJoinInset + dock.effectiveBarHeight + SettingsData.dockSpacing + dock.effectiveDockBottomGap + dock.effectiveDockMargin + 10; if (SettingsData.dockPosition === SettingsData.Position.Bottom) { return hideDistance; } else { @@ -529,18 +647,27 @@ Variants { Behavior on x { NumberAnimation { id: slideXAnimation - duration: Theme.shortDuration - easing.type: Easing.OutCubic + duration: Theme.isConnectedEffect ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration + easing.type: Theme.isConnectedEffect ? Easing.BezierSpline : Easing.OutCubic + easing.bezierCurve: Theme.isConnectedEffect ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : [] + onRunningChanged: if (!running) + dock._syncDockChromeState() } } Behavior on y { NumberAnimation { id: slideYAnimation - duration: Theme.shortDuration - easing.type: Easing.OutCubic + duration: Theme.isConnectedEffect ? Theme.variantDuration(Theme.popoutAnimationDuration, dock.reveal) : Theme.shortDuration + easing.type: Theme.isConnectedEffect ? Easing.BezierSpline : Easing.OutCubic + easing.bezierCurve: Theme.isConnectedEffect ? (dock.reveal ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve) : [] + onRunningChanged: if (!running) + dock._syncDockChromeState() } } + + onXChanged: dock._queueSlideSync() + onYChanged: dock._queueSlideSync() } Item { @@ -554,33 +681,60 @@ Variants { right: dock.isVertical ? (SettingsData.dockPosition === SettingsData.Position.Right ? parent.right : undefined) : undefined verticalCenter: dock.isVertical ? parent.verticalCenter : undefined } - anchors.topMargin: !dock.isVertical && SettingsData.dockPosition === SettingsData.Position.Top ? barSpacing + SettingsData.dockMargin + 1 + dock.borderThickness : 0 - anchors.bottomMargin: !dock.isVertical && SettingsData.dockPosition === SettingsData.Position.Bottom ? barSpacing + SettingsData.dockMargin + 1 + dock.borderThickness : 0 - anchors.leftMargin: dock.isVertical && SettingsData.dockPosition === SettingsData.Position.Left ? barSpacing + SettingsData.dockMargin + 1 + dock.borderThickness : 0 - anchors.rightMargin: dock.isVertical && SettingsData.dockPosition === SettingsData.Position.Right ? barSpacing + SettingsData.dockMargin + 1 + dock.borderThickness : 0 + anchors.topMargin: !dock.isVertical && SettingsData.dockPosition === SettingsData.Position.Top ? (dock.connectedJoinInset + dock.joinedEdgeMargin) : 0 + anchors.bottomMargin: !dock.isVertical && SettingsData.dockPosition === SettingsData.Position.Bottom ? (dock.connectedJoinInset + dock.joinedEdgeMargin) : 0 + anchors.leftMargin: dock.isVertical && SettingsData.dockPosition === SettingsData.Position.Left ? (dock.connectedJoinInset + dock.joinedEdgeMargin) : 0 + anchors.rightMargin: dock.isVertical && SettingsData.dockPosition === SettingsData.Position.Right ? (dock.connectedJoinInset + dock.joinedEdgeMargin) : 0 implicitWidth: dock.isVertical ? (dockApps.implicitHeight + SettingsData.dockSpacing * 2) : (dockApps.implicitWidth + SettingsData.dockSpacing * 2) implicitHeight: dock.isVertical ? (dockApps.implicitWidth + SettingsData.dockSpacing * 2) : (dockApps.implicitHeight + SettingsData.dockSpacing * 2) width: implicitWidth height: implicitHeight - layer.enabled: true + // Avoid an offscreen texture seam where the connected dock meets the frame. + layer.enabled: !Theme.isConnectedEffect clip: false Rectangle { anchors.fill: parent - color: Theme.withAlpha(Theme.surfaceContainer, backgroundTransparency) - radius: Theme.cornerRadius + visible: !SettingsData.connectedFrameModeActive && !(Theme.isConnectedEffect && dock.reveal) + color: dock.surfaceColor + topLeftRadius: dock.surfaceTopLeftRadius + topRightRadius: dock.surfaceTopRightRadius + bottomLeftRadius: dock.surfaceBottomLeftRadius + bottomRightRadius: dock.surfaceBottomRightRadius } Rectangle { anchors.fill: parent + visible: !SettingsData.connectedFrameModeActive && !(Theme.isConnectedEffect && dock.reveal) color: "transparent" - radius: Theme.cornerRadius - border.color: BlurService.borderColor - border.width: BlurService.borderWidth + topLeftRadius: dock.surfaceTopLeftRadius + topRightRadius: dock.surfaceTopRightRadius + bottomLeftRadius: dock.surfaceBottomLeftRadius + bottomRightRadius: dock.surfaceBottomRightRadius + border.color: dock.surfaceBorderColor + border.width: dock.surfaceBorderWidth z: 100 } + + // Sync dockBackground geometry to ConnectedModeState + onXChanged: dock._syncDockChromeState() + onYChanged: dock._syncDockChromeState() + onWidthChanged: dock._syncDockChromeState() + onHeightChanged: dock._syncDockChromeState() + } + + ConnectedShape { + visible: Theme.isConnectedEffect && dock.reveal && !SettingsData.connectedFrameModeActive + barSide: dock.connectedBarSide + bodyWidth: dockBackground.width + bodyHeight: dockBackground.height + connectorRadius: Theme.connectedCornerRadius + surfaceRadius: dock.surfaceRadius + fillColor: dock.surfaceColor + x: dockBackground.x - bodyX + y: dockBackground.y - bodyY } Shape { @@ -589,12 +743,12 @@ Variants { y: dockBackground.y - borderThickness width: dockBackground.width + borderThickness * 2 height: dockBackground.height + borderThickness * 2 - visible: SettingsData.dockBorderEnabled && dock.hasApps + visible: SettingsData.dockBorderEnabled && dock.hasApps && !Theme.isConnectedEffect preferredRendererType: Shape.CurveRenderer readonly property real borderThickness: Math.max(1, dock.borderThickness) readonly property real i: borderThickness / 2 - readonly property real cr: Theme.cornerRadius + readonly property real cr: dock.surfaceRadius readonly property real w: dockBackground.width readonly property real h: dockBackground.height diff --git a/quickshell/Modules/Frame/Frame.qml b/quickshell/Modules/Frame/Frame.qml new file mode 100644 index 00000000..c973ddc7 --- /dev/null +++ b/quickshell/Modules/Frame/Frame.qml @@ -0,0 +1,24 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common + +Variants { + id: root + + model: Quickshell.screens + + delegate: Loader { + id: instanceLoader + + required property var modelData + + active: SettingsData.frameEnabled && SettingsData.isScreenInPreferences(instanceLoader.modelData, SettingsData.frameScreenPreferences) + asynchronous: false + + sourceComponent: FrameInstance { + screen: instanceLoader.modelData + } + } +} diff --git a/quickshell/Modules/Frame/FrameBorder.qml b/quickshell/Modules/Frame/FrameBorder.qml new file mode 100644 index 00000000..31112382 --- /dev/null +++ b/quickshell/Modules/Frame/FrameBorder.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import qs.Common + +Item { + id: root + + anchors.fill: parent + + required property real cutoutTopInset + required property real cutoutBottomInset + required property real cutoutLeftInset + required property real cutoutRightInset + required property real cutoutRadius + property color borderColor: Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) + + Rectangle { + id: borderRect + + anchors.fill: parent + // Bake frameOpacity into the color alpha rather than using the `opacity` property + color: root.borderColor + + 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.cutoutTopInset + bottomMargin: root.cutoutBottomInset + leftMargin: root.cutoutLeftInset + rightMargin: root.cutoutRightInset + } + radius: root.cutoutRadius + } + } +} diff --git a/quickshell/Modules/Frame/FrameExclusions.qml b/quickshell/Modules/Frame/FrameExclusions.qml new file mode 100644 index 00000000..0ca1449b --- /dev/null +++ b/quickshell/Modules/Frame/FrameExclusions.qml @@ -0,0 +1,87 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common + +Scope { + id: root + + required property var screen + + readonly property var barEdges: { + SettingsData.barConfigs; // force re-eval when bar configs change + return SettingsData.getActiveBarEdgesForScreen(screen); + } + + // One thin invisible PanelWindow per edge. + // Skips any edge where a bar already provides its own exclusiveZone. + + readonly property bool screenEnabled: SettingsData.frameEnabled && SettingsData.isScreenInPreferences(root.screen, SettingsData.frameScreenPreferences) + + Loader { + active: root.screenEnabled && !root.barEdges.includes("top") + sourceComponent: EdgeExclusion { + targetScreen: root.screen + anchorTop: true + anchorLeft: true + anchorRight: true + } + } + + Loader { + active: root.screenEnabled && !root.barEdges.includes("bottom") + sourceComponent: EdgeExclusion { + targetScreen: root.screen + anchorBottom: true + anchorLeft: true + anchorRight: true + } + } + + Loader { + active: root.screenEnabled && !root.barEdges.includes("left") + sourceComponent: EdgeExclusion { + targetScreen: root.screen + anchorLeft: true + anchorTop: true + anchorBottom: true + } + } + + Loader { + active: root.screenEnabled && !root.barEdges.includes("right") + sourceComponent: EdgeExclusion { + targetScreen: root.screen + anchorRight: true + anchorTop: true + anchorBottom: true + } + } + + component EdgeExclusion: PanelWindow { + required property var targetScreen + + screen: targetScreen + 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..1afdd92e --- /dev/null +++ b/quickshell/Modules/Frame/FrameInstance.qml @@ -0,0 +1,17 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +Item { + id: root + + required property var screen + + FrameWindow { + targetScreen: 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..65ae1f83 --- /dev/null +++ b/quickshell/Modules/Frame/FrameWindow.qml @@ -0,0 +1,1495 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import qs.Widgets +import "../../Common/ConnectorGeometry.js" as ConnectorGeometry + +PanelWindow { + id: win + + readonly property var log: Log.scoped("FrameWindow") + + required property var targetScreen + + screen: targetScreen + visible: _frameActive + + WlrLayershell.namespace: "dms:frame" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + anchors { + top: true + bottom: true + left: true + right: true + } + + color: "transparent" + mask: Region {} + + readonly property var barEdges: { + SettingsData.barConfigs; + return SettingsData.getActiveBarEdgesForScreen(win.targetScreen); + } + + readonly property real _dpr: CompositorService.getScreenScale(win.targetScreen) + readonly property bool _frameActive: SettingsData.frameEnabled && SettingsData.isScreenInPreferences(win.targetScreen, SettingsData.frameScreenPreferences) + readonly property int _windowRegionWidth: win._regionInt(win.width) + readonly property int _windowRegionHeight: win._regionInt(win.height) + readonly property string _screenName: win.targetScreen ? win.targetScreen.name : "" + readonly property var _dockState: ConnectedModeState.dockStates[win._screenName] || ConnectedModeState.emptyDockState + readonly property var _dockSlide: ConnectedModeState.dockSlides[win._screenName] || ({ + "x": 0, + "y": 0 + }) + readonly property var _notifState: ConnectedModeState.notificationStates[win._screenName] || ConnectedModeState.emptyNotificationState + readonly property var _modalState: ConnectedModeState.modalStates[win._screenName] || ConnectedModeState.emptyModalState + + readonly property bool _connectedActive: win._frameActive && SettingsData.connectedFrameModeActive + readonly property string _barSide: { + const edges = win.barEdges; + if (edges.includes("top")) + return "top"; + if (edges.includes("bottom")) + return "bottom"; + if (edges.includes("left")) + return "left"; + return "right"; + } + readonly property real _ccr: Theme.connectedCornerRadius + + readonly property bool _popoutHorizontal: ConnectedModeState.popoutBarSide === "top" || ConnectedModeState.popoutBarSide === "bottom" + readonly property bool _notifHorizontal: ConnectorGeometry.isHorizontal(win._notifState.barSide) + readonly property bool _modalHorizontal: ConnectorGeometry.isHorizontal(win._modalState.barSide) + readonly property bool _dockHorizontal: ConnectorGeometry.isHorizontal(win._dockState.barSide) + + readonly property real _popoutArcExtent: win._popoutHorizontal ? _popoutBodyBlurAnchor.height : _popoutBodyBlurAnchor.width + readonly property real _modalArcExtent: win._modalHorizontal ? _modalBodyBlurAnchor.height : _modalBodyBlurAnchor.width + readonly property real _popoutConnectorRadiusLeft: win._effectivePopoutStartCcr + readonly property real _popoutConnectorRadiusRight: win._effectivePopoutEndCcr + readonly property real _modalConnectorRadiusLeft: win._effectiveModalStartCcr + readonly property real _modalConnectorRadiusRight: win._effectiveModalEndCcr + readonly property real _notifConnectorRadiusLeft: win._effectiveNotifStartCcr + readonly property real _notifConnectorRadiusRight: win._effectiveNotifEndCcr + readonly property real _dockBodyBlurRadiusValue: _dockBodyBlurAnchor._active ? Math.max(0, Math.min(win._surfaceRadius, _dockBodyBlurAnchor.width / 2, _dockBodyBlurAnchor.height / 2)) : win._surfaceRadius + readonly property real _dockConnectorRadiusValue: { + if (!_dockBodyBlurAnchor._active) + return win._ccr; + const thickness = (win._dockState.barSide === "left" || win._dockState.barSide === "right") ? _dockBodyBlurAnchor.width : _dockBodyBlurAnchor.height; + const bodyRadius = win._dockBodyBlurRadiusValue; + const maxConnectorRadius = Math.max(0, thickness - bodyRadius - win._seamOverlap); + return Math.max(0, Math.min(win._ccr, bodyRadius, maxConnectorRadius)); + } + + readonly property real _popoutFillOverlapXValue: win._popoutHorizontal ? win._seamOverlap : 0 + readonly property real _popoutFillOverlapYValue: (ConnectedModeState.popoutBarSide === "left" || ConnectedModeState.popoutBarSide === "right") ? win._seamOverlap : 0 + readonly property real _dockFillOverlapXValue: win._dockHorizontal ? win._seamOverlap : 0 + readonly property real _dockFillOverlapYValue: (win._dockState.barSide === "left" || win._dockState.barSide === "right") ? win._seamOverlap : 0 + readonly property real _notifSideUnderlapValue: ConnectorGeometry.isVertical(win._notifState.barSide) ? win._seamOverlap : 0 + readonly property real _notifStartUnderlapValue: win._notifState.omitStartConnector ? win._seamOverlap : 0 + readonly property real _notifEndUnderlapValue: win._notifState.omitEndConnector ? win._seamOverlap : 0 + + // Theme.snap rounds to integer pixel: equal rounded values suppress + // downstream Changed during sub-pixel morph jitter. + readonly property real _effectivePopoutCcr: { + const crossSize = win._popoutHorizontal ? _popoutBodyBlurAnchor.width : _popoutBodyBlurAnchor.height; + return Theme.snap(Math.max(0, Math.min(win._ccr, win._popoutArcExtent, crossSize / 2)), win._dpr); + } + readonly property real _effectivePopoutFarCcr: { + const crossSize = win._popoutHorizontal ? _popoutBodyBlurAnchor.width : _popoutBodyBlurAnchor.height; + return Theme.snap(Math.max(0, Math.min(win._ccr, win._surfaceRadius, crossSize / 2)), win._dpr); + } + readonly property real _effectivePopoutStartCcr: ConnectedModeState.popoutOmitStartConnector ? 0 : win._effectivePopoutCcr + readonly property real _effectivePopoutEndCcr: ConnectedModeState.popoutOmitEndConnector ? 0 : win._effectivePopoutCcr + readonly property real _effectivePopoutFarStartCcr: ConnectedModeState.popoutOmitStartConnector ? win._effectivePopoutFarCcr : 0 + readonly property real _effectivePopoutFarEndCcr: ConnectedModeState.popoutOmitEndConnector ? win._effectivePopoutFarCcr : 0 + readonly property real _effectivePopoutMaxCcr: Math.max(win._effectivePopoutStartCcr, win._effectivePopoutEndCcr) + readonly property real _effectivePopoutFarExtent: Math.max(win._effectivePopoutFarStartCcr, win._effectivePopoutFarEndCcr) + readonly property real _effectiveNotifCcr: { + const crossSize = win._notifHorizontal ? _notifBodyBlurAnchor.width : _notifBodyBlurAnchor.height; + const extent = win._notifHorizontal ? _notifBodyBlurAnchor.height : _notifBodyBlurAnchor.width; + return Theme.snap(Math.max(0, Math.min(win._ccr, win._surfaceRadius, extent, crossSize / 2)), win._dpr); + } + readonly property real _effectiveNotifFarCcr: { + const crossSize = win._notifHorizontal ? _notifBodySceneBlurAnchor.width : _notifBodySceneBlurAnchor.height; + return Theme.snap(Math.max(0, Math.min(win._ccr, win._surfaceRadius, crossSize / 2)), win._dpr); + } + readonly property real _effectiveNotifStartCcr: win._notifState.omitStartConnector ? 0 : win._effectiveNotifCcr + readonly property real _effectiveNotifEndCcr: win._notifState.omitEndConnector ? 0 : win._effectiveNotifCcr + readonly property real _effectiveNotifFarStartCcr: win._notifState.omitStartConnector ? win._effectiveNotifFarCcr : 0 + readonly property real _effectiveNotifFarEndCcr: win._notifState.omitEndConnector ? win._effectiveNotifFarCcr : 0 + readonly property real _effectiveNotifMaxCcr: Math.max(win._effectiveNotifStartCcr, win._effectiveNotifEndCcr) + readonly property real _effectiveNotifFarExtent: Math.max(win._effectiveNotifFarStartCcr, win._effectiveNotifFarEndCcr) + readonly property real _effectiveModalCcr: { + const crossSize = win._modalHorizontal ? _modalBodyBlurAnchor.width : _modalBodyBlurAnchor.height; + const extent = win._modalHorizontal ? _modalBodyBlurAnchor.height : _modalBodyBlurAnchor.width; + return Theme.snap(Math.max(0, Math.min(win._ccr, win._surfaceRadius, extent, crossSize / 2)), win._dpr); + } + readonly property real _effectiveModalFarCcr: { + const crossSize = win._modalHorizontal ? _modalBodyBlurAnchor.width : _modalBodyBlurAnchor.height; + return Theme.snap(Math.max(0, Math.min(win._ccr, win._surfaceRadius, crossSize / 2)), win._dpr); + } + readonly property real _effectiveModalStartCcr: win._modalState.omitStartConnector ? 0 : win._effectiveModalCcr + readonly property real _effectiveModalEndCcr: win._modalState.omitEndConnector ? 0 : win._effectiveModalCcr + readonly property real _effectiveModalFarStartCcr: win._modalState.omitStartConnector ? win._effectiveModalFarCcr : 0 + readonly property real _effectiveModalFarEndCcr: win._modalState.omitEndConnector ? win._effectiveModalFarCcr : 0 + readonly property real _effectiveModalFarExtent: Math.max(win._effectiveModalFarStartCcr, win._effectiveModalFarEndCcr) + readonly property color _surfaceColor: Theme.connectedSurfaceColor + readonly property real _surfaceOpacity: _surfaceColor.a + readonly property color _opaqueSurfaceColor: Qt.rgba(_surfaceColor.r, _surfaceColor.g, _surfaceColor.b, 1) + readonly property real _surfaceRadius: Theme.connectedSurfaceRadius + readonly property real _seamOverlap: Theme.hairline(win._dpr) + readonly property bool _disableLayer: Quickshell.env("DMS_DISABLE_LAYER") === "true" || Quickshell.env("DMS_DISABLE_LAYER") === "1" + + function _regionInt(value) { + return Math.max(0, Math.round(Theme.px(value, win._dpr))); + } + + readonly property int cutoutTopInset: win._regionInt(barEdges.includes("top") ? SettingsData.frameBarSize : SettingsData.frameThickness) + readonly property int cutoutBottomInset: win._regionInt(barEdges.includes("bottom") ? SettingsData.frameBarSize : SettingsData.frameThickness) + readonly property int cutoutLeftInset: win._regionInt(barEdges.includes("left") ? SettingsData.frameBarSize : SettingsData.frameThickness) + readonly property int cutoutRightInset: win._regionInt(barEdges.includes("right") ? SettingsData.frameBarSize : SettingsData.frameThickness) + readonly property int cutoutWidth: Math.max(0, win._windowRegionWidth - win.cutoutLeftInset - win.cutoutRightInset) + readonly property int cutoutHeight: Math.max(0, win._windowRegionHeight - win.cutoutTopInset - win.cutoutBottomInset) + readonly property int cutoutRadius: { + const requested = win._regionInt(SettingsData.frameRounding); + const maxRadius = Math.floor(Math.min(win.cutoutWidth, win.cutoutHeight) / 2); + return Math.max(0, Math.min(requested, maxRadius)); + } + + readonly property int _blurCutoutCompensation: SettingsData.frameOpacity <= 0.2 ? 1 : 0 + readonly property int _blurCutoutLeft: Math.max(0, win.cutoutLeftInset - win._blurCutoutCompensation) + readonly property int _blurCutoutTop: Math.max(0, win.cutoutTopInset - win._blurCutoutCompensation) + readonly property int _blurCutoutRight: Math.min(win._windowRegionWidth, win._windowRegionWidth - win.cutoutRightInset + win._blurCutoutCompensation) + readonly property int _blurCutoutBottom: Math.min(win._windowRegionHeight, win._windowRegionHeight - win.cutoutBottomInset + win._blurCutoutCompensation) + readonly property int _blurCutoutRadius: { + const requested = win.cutoutRadius + win._blurCutoutCompensation; + const maxRadius = Math.floor(Math.min(_blurCutout.width, _blurCutout.height) / 2); + return Math.max(0, Math.min(requested, maxRadius)); + } + + // Invisible items providing scene coordinates for blur Region anchors + Item { + id: _blurCutout + x: win._blurCutoutLeft + y: win._blurCutoutTop + width: Math.max(0, win._blurCutoutRight - win._blurCutoutLeft) + height: Math.max(0, win._blurCutoutBottom - win._blurCutoutTop) + } + + Item { + id: _popoutBodyBlurAnchor + visible: false + + readonly property bool _active: ConnectedModeState.popoutVisible && ConnectedModeState.popoutScreen === win._screenName + + readonly property real _dyClamp: (ConnectedModeState.popoutBarSide === "top" || ConnectedModeState.popoutBarSide === "bottom") ? Math.max(-ConnectedModeState.popoutBodyH, Math.min(ConnectedModeState.popoutAnimY, ConnectedModeState.popoutBodyH)) : 0 + readonly property real _dxClamp: (ConnectedModeState.popoutBarSide === "left" || ConnectedModeState.popoutBarSide === "right") ? Math.max(-ConnectedModeState.popoutBodyW, Math.min(ConnectedModeState.popoutAnimX, ConnectedModeState.popoutBodyW)) : 0 + + x: _active ? ConnectedModeState.popoutBodyX + (ConnectedModeState.popoutBarSide === "right" ? _dxClamp : 0) : 0 + y: _active ? ConnectedModeState.popoutBodyY + (ConnectedModeState.popoutBarSide === "bottom" ? _dyClamp : 0) : 0 + width: _active ? Math.max(0, ConnectedModeState.popoutBodyW - Math.abs(_dxClamp)) : 0 + height: _active ? Math.max(0, ConnectedModeState.popoutBodyH - Math.abs(_dyClamp)) : 0 + } + + Item { + id: _dockBodyBlurAnchor + visible: false + + readonly property bool _active: win._dockState.reveal && win._dockState.bodyW > 0 && win._dockState.bodyH > 0 + + x: _active ? win._dockState.bodyX + (win._dockSlide.x || 0) : 0 + y: _active ? win._dockState.bodyY + (win._dockSlide.y || 0) : 0 + width: _active ? win._dockState.bodyW : 0 + height: _active ? win._dockState.bodyH : 0 + } + + Item { + id: _popoutBodyBlurCap + opacity: 0 + + readonly property string _side: ConnectedModeState.popoutBarSide + readonly property real _capThickness: win._popoutBlurCapThickness() + readonly property bool _active: _popoutBodyBlurAnchor._active && _capThickness > 0 && _popoutBodyBlurAnchor.width > 0 && _popoutBodyBlurAnchor.height > 0 + readonly property real _capWidth: (_side === "left" || _side === "right") ? Math.min(_capThickness, _popoutBodyBlurAnchor.width) : _popoutBodyBlurAnchor.width + readonly property real _capHeight: (_side === "top" || _side === "bottom") ? Math.min(_capThickness, _popoutBodyBlurAnchor.height) : _popoutBodyBlurAnchor.height + + x: !_active ? 0 : (_side === "right" ? _popoutBodyBlurAnchor.x + _popoutBodyBlurAnchor.width - _capWidth : _popoutBodyBlurAnchor.x) + y: !_active ? 0 : (_side === "bottom" ? _popoutBodyBlurAnchor.y + _popoutBodyBlurAnchor.height - _capHeight : _popoutBodyBlurAnchor.y) + width: _active ? _capWidth : 0 + height: _active ? _capHeight : 0 + } + + Item { + id: _dockBodyBlurCap + opacity: 0 + + readonly property string _side: win._dockState.barSide + readonly property bool _active: _dockBodyBlurAnchor._active && _dockBodyBlurAnchor.width > 0 && _dockBodyBlurAnchor.height > 0 + readonly property real _capWidth: (_side === "left" || _side === "right") ? Math.min(win._dockConnectorRadiusValue, _dockBodyBlurAnchor.width) : _dockBodyBlurAnchor.width + readonly property real _capHeight: (_side === "top" || _side === "bottom") ? Math.min(win._dockConnectorRadiusValue, _dockBodyBlurAnchor.height) : _dockBodyBlurAnchor.height + + x: !_active ? 0 : (_side === "right" ? _dockBodyBlurAnchor.x + _dockBodyBlurAnchor.width - _capWidth : _dockBodyBlurAnchor.x) + y: !_active ? 0 : (_side === "bottom" ? _dockBodyBlurAnchor.y + _dockBodyBlurAnchor.height - _capHeight : _dockBodyBlurAnchor.y) + width: _active ? _capWidth : 0 + height: _active ? _capHeight : 0 + } + + Item { + id: _popoutLeftConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._popoutConnectorRadiusLeft + readonly property bool _active: _popoutBodyBlurAnchor._active && _radius > 0 + readonly property real _w: ConnectorGeometry.connectorWidth(ConnectedModeState.popoutBarSide, 0, win._popoutConnectorRadiusLeft) + readonly property real _h: ConnectorGeometry.connectorHeight(ConnectedModeState.popoutBarSide, 0, win._popoutConnectorRadiusLeft) + + x: _active ? Theme.snap(ConnectorGeometry.connectorX(ConnectedModeState.popoutBarSide, _popoutBodyBlurAnchor.x, _popoutBodyBlurAnchor.width, "left", 0, win._popoutConnectorRadiusLeft), win._dpr) : 0 + y: _active ? Theme.snap(ConnectorGeometry.connectorY(ConnectedModeState.popoutBarSide, _popoutBodyBlurAnchor.y, _popoutBodyBlurAnchor.height, "left", 0, win._popoutConnectorRadiusLeft), win._dpr) : 0 + width: _active ? _w : 0 + height: _active ? _h : 0 + } + + Item { + id: _popoutRightConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._popoutConnectorRadiusRight + readonly property bool _active: _popoutBodyBlurAnchor._active && _radius > 0 + readonly property real _w: ConnectorGeometry.connectorWidth(ConnectedModeState.popoutBarSide, 0, win._popoutConnectorRadiusRight) + readonly property real _h: ConnectorGeometry.connectorHeight(ConnectedModeState.popoutBarSide, 0, win._popoutConnectorRadiusRight) + + x: _active ? Theme.snap(ConnectorGeometry.connectorX(ConnectedModeState.popoutBarSide, _popoutBodyBlurAnchor.x, _popoutBodyBlurAnchor.width, "right", 0, win._popoutConnectorRadiusRight), win._dpr) : 0 + y: _active ? Theme.snap(ConnectorGeometry.connectorY(ConnectedModeState.popoutBarSide, _popoutBodyBlurAnchor.y, _popoutBodyBlurAnchor.height, "right", 0, win._popoutConnectorRadiusRight), win._dpr) : 0 + width: _active ? _w : 0 + height: _active ? _h : 0 + } + + Item { + id: _popoutLeftConnectorCutout + opacity: 0 + + readonly property bool _active: _popoutLeftConnectorBlurAnchor.width > 0 && _popoutLeftConnectorBlurAnchor.height > 0 + readonly property string _arcCorner: ConnectorGeometry.arcCorner(ConnectedModeState.popoutBarSide, "left") + readonly property real _radius: win._popoutConnectorRadiusLeft + + x: _active ? win._connectorCutoutX(_popoutLeftConnectorBlurAnchor.x, _popoutLeftConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_popoutLeftConnectorBlurAnchor.y, _popoutLeftConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _popoutRightConnectorCutout + opacity: 0 + + readonly property bool _active: _popoutRightConnectorBlurAnchor.width > 0 && _popoutRightConnectorBlurAnchor.height > 0 + readonly property string _arcCorner: ConnectorGeometry.arcCorner(ConnectedModeState.popoutBarSide, "right") + readonly property real _radius: win._popoutConnectorRadiusRight + + x: _active ? win._connectorCutoutX(_popoutRightConnectorBlurAnchor.x, _popoutRightConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_popoutRightConnectorBlurAnchor.y, _popoutRightConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _popoutFarStartConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._effectivePopoutFarStartCcr + readonly property bool _active: _popoutBodyBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farConnectorX(_popoutBodyBlurAnchor.x, _popoutBodyBlurAnchor.y, _popoutBodyBlurAnchor.width, _popoutBodyBlurAnchor.height, ConnectedModeState.popoutBarSide, "left", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farConnectorY(_popoutBodyBlurAnchor.x, _popoutBodyBlurAnchor.y, _popoutBodyBlurAnchor.width, _popoutBodyBlurAnchor.height, ConnectedModeState.popoutBarSide, "left", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _popoutFarStartBodyBlurCap + opacity: 0 + + readonly property real _radius: win._effectivePopoutFarStartCcr + readonly property bool _active: _popoutBodyBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farBodyCapX(_popoutBodyBlurAnchor.x, _popoutBodyBlurAnchor.width, ConnectedModeState.popoutBarSide, "left", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farBodyCapY(_popoutBodyBlurAnchor.y, _popoutBodyBlurAnchor.height, ConnectedModeState.popoutBarSide, "left", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _popoutFarEndBodyBlurCap + opacity: 0 + + readonly property real _radius: win._effectivePopoutFarEndCcr + readonly property bool _active: _popoutBodyBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farBodyCapX(_popoutBodyBlurAnchor.x, _popoutBodyBlurAnchor.width, ConnectedModeState.popoutBarSide, "right", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farBodyCapY(_popoutBodyBlurAnchor.y, _popoutBodyBlurAnchor.height, ConnectedModeState.popoutBarSide, "right", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _popoutFarEndConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._effectivePopoutFarEndCcr + readonly property bool _active: _popoutBodyBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farConnectorX(_popoutBodyBlurAnchor.x, _popoutBodyBlurAnchor.y, _popoutBodyBlurAnchor.width, _popoutBodyBlurAnchor.height, ConnectedModeState.popoutBarSide, "right", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farConnectorY(_popoutBodyBlurAnchor.x, _popoutBodyBlurAnchor.y, _popoutBodyBlurAnchor.width, _popoutBodyBlurAnchor.height, ConnectedModeState.popoutBarSide, "right", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _popoutFarStartConnectorCutout + opacity: 0 + + readonly property bool _active: _popoutFarStartConnectorBlurAnchor.width > 0 && _popoutFarStartConnectorBlurAnchor.height > 0 + readonly property string _barSide: win._farConnectorBarSide(ConnectedModeState.popoutBarSide, "left") + readonly property string _placement: win._farConnectorPlacement(ConnectedModeState.popoutBarSide, "left") + readonly property string _arcCorner: ConnectorGeometry.arcCorner(_barSide, _placement) + readonly property real _radius: win._effectivePopoutFarStartCcr + + x: _active ? win._connectorCutoutX(_popoutFarStartConnectorBlurAnchor.x, _popoutFarStartConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_popoutFarStartConnectorBlurAnchor.y, _popoutFarStartConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _popoutFarEndConnectorCutout + opacity: 0 + + readonly property bool _active: _popoutFarEndConnectorBlurAnchor.width > 0 && _popoutFarEndConnectorBlurAnchor.height > 0 + readonly property string _barSide: win._farConnectorBarSide(ConnectedModeState.popoutBarSide, "right") + readonly property string _placement: win._farConnectorPlacement(ConnectedModeState.popoutBarSide, "right") + readonly property string _arcCorner: ConnectorGeometry.arcCorner(_barSide, _placement) + readonly property real _radius: win._effectivePopoutFarEndCcr + + x: _active ? win._connectorCutoutX(_popoutFarEndConnectorBlurAnchor.x, _popoutFarEndConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_popoutFarEndConnectorBlurAnchor.y, _popoutFarEndConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _dockLeftConnectorBlurAnchor + opacity: 0 + + readonly property bool _active: _dockBodyBlurAnchor._active && win._dockConnectorRadiusValue > 0 + readonly property real _w: ConnectorGeometry.connectorWidth(win._dockState.barSide, 0, win._dockConnectorRadiusValue) + readonly property real _h: ConnectorGeometry.connectorHeight(win._dockState.barSide, 0, win._dockConnectorRadiusValue) + + x: _active ? Theme.snap(ConnectorGeometry.connectorX(win._dockState.barSide, _dockBodyBlurAnchor.x, _dockBodyBlurAnchor.width, "left", 0, win._dockConnectorRadiusValue), win._dpr) : 0 + y: _active ? Theme.snap(ConnectorGeometry.connectorY(win._dockState.barSide, _dockBodyBlurAnchor.y, _dockBodyBlurAnchor.height, "left", 0, win._dockConnectorRadiusValue), win._dpr) : 0 + width: _active ? _w : 0 + height: _active ? _h : 0 + } + + Item { + id: _dockRightConnectorBlurAnchor + opacity: 0 + + readonly property bool _active: _dockBodyBlurAnchor._active && win._dockConnectorRadiusValue > 0 + readonly property real _w: ConnectorGeometry.connectorWidth(win._dockState.barSide, 0, win._dockConnectorRadiusValue) + readonly property real _h: ConnectorGeometry.connectorHeight(win._dockState.barSide, 0, win._dockConnectorRadiusValue) + + x: _active ? Theme.snap(ConnectorGeometry.connectorX(win._dockState.barSide, _dockBodyBlurAnchor.x, _dockBodyBlurAnchor.width, "right", 0, win._dockConnectorRadiusValue), win._dpr) : 0 + y: _active ? Theme.snap(ConnectorGeometry.connectorY(win._dockState.barSide, _dockBodyBlurAnchor.y, _dockBodyBlurAnchor.height, "right", 0, win._dockConnectorRadiusValue), win._dpr) : 0 + width: _active ? _w : 0 + height: _active ? _h : 0 + } + + Item { + id: _dockLeftConnectorCutout + opacity: 0 + + readonly property bool _active: _dockLeftConnectorBlurAnchor.width > 0 && _dockLeftConnectorBlurAnchor.height > 0 + readonly property string _arcCorner: ConnectorGeometry.arcCorner(win._dockState.barSide, "left") + + x: _active ? win._connectorCutoutX(_dockLeftConnectorBlurAnchor.x, _dockLeftConnectorBlurAnchor.width, _arcCorner, win._dockConnectorRadiusValue) : 0 + y: _active ? win._connectorCutoutY(_dockLeftConnectorBlurAnchor.y, _dockLeftConnectorBlurAnchor.height, _arcCorner, win._dockConnectorRadiusValue) : 0 + width: _active ? win._dockConnectorRadiusValue * 2 : 0 + height: _active ? win._dockConnectorRadiusValue * 2 : 0 + } + + Item { + id: _dockRightConnectorCutout + opacity: 0 + + readonly property bool _active: _dockRightConnectorBlurAnchor.width > 0 && _dockRightConnectorBlurAnchor.height > 0 + readonly property string _arcCorner: ConnectorGeometry.arcCorner(win._dockState.barSide, "right") + + x: _active ? win._connectorCutoutX(_dockRightConnectorBlurAnchor.x, _dockRightConnectorBlurAnchor.width, _arcCorner, win._dockConnectorRadiusValue) : 0 + y: _active ? win._connectorCutoutY(_dockRightConnectorBlurAnchor.y, _dockRightConnectorBlurAnchor.height, _arcCorner, win._dockConnectorRadiusValue) : 0 + width: _active ? win._dockConnectorRadiusValue * 2 : 0 + height: _active ? win._dockConnectorRadiusValue * 2 : 0 + } + + Item { + id: _notifBodyBlurAnchor + visible: false + + readonly property bool _active: win._frameActive && win._notifState.visible && win._notifState.bodyW > 0 && win._notifState.bodyH > 0 + + x: _active ? Theme.snap(win._notifState.bodyX, win._dpr) : 0 + y: _active ? Theme.snap(win._notifState.bodyY, win._dpr) : 0 + width: _active ? Theme.snap(win._notifState.bodyW, win._dpr) : 0 + height: _active ? Theme.snap(win._notifState.bodyH, win._dpr) : 0 + } + + Item { + id: _modalBodyBlurAnchor + visible: false + + readonly property bool _active: win._frameActive && win._modalState.visible && win._modalState.bodyW > 0 && win._modalState.bodyH > 0 + + // Clamp animX/Y so the blur body shrinks toward the bar edge (same as _popoutBodyBlurAnchor). + readonly property real _dyClamp: ConnectorGeometry.isHorizontal(win._modalState.barSide) ? Math.max(-win._modalState.bodyH, Math.min(win._modalState.animY, win._modalState.bodyH)) : 0 + readonly property real _dxClamp: (win._modalState.barSide === "left" || win._modalState.barSide === "right") ? Math.max(-win._modalState.bodyW, Math.min(win._modalState.animX, win._modalState.bodyW)) : 0 + + x: _active ? Theme.snap(win._modalState.bodyX + (win._modalState.barSide === "right" ? _dxClamp : 0), win._dpr) : 0 + y: _active ? Theme.snap(win._modalState.bodyY + (win._modalState.barSide === "bottom" ? _dyClamp : 0), win._dpr) : 0 + width: _active ? Theme.snap(Math.max(0, win._modalState.bodyW - Math.abs(_dxClamp)), win._dpr) : 0 + height: _active ? Theme.snap(Math.max(0, win._modalState.bodyH - Math.abs(_dyClamp)), win._dpr) : 0 + } + + Item { + id: _modalBodyBlurCap + opacity: 0 + + readonly property string _side: win._modalState.barSide + readonly property real _capThickness: win._modalBlurCapThickness() + readonly property bool _active: _modalBodyBlurAnchor._active && _capThickness > 0 && _modalBodyBlurAnchor.width > 0 && _modalBodyBlurAnchor.height > 0 + readonly property real _capWidth: (_side === "left" || _side === "right") ? Math.min(_capThickness, _modalBodyBlurAnchor.width) : _modalBodyBlurAnchor.width + readonly property real _capHeight: (_side === "top" || _side === "bottom") ? Math.min(_capThickness, _modalBodyBlurAnchor.height) : _modalBodyBlurAnchor.height + + x: !_active ? 0 : (_side === "right" ? _modalBodyBlurAnchor.x + _modalBodyBlurAnchor.width - _capWidth : _modalBodyBlurAnchor.x) + y: !_active ? 0 : (_side === "bottom" ? _modalBodyBlurAnchor.y + _modalBodyBlurAnchor.height - _capHeight : _modalBodyBlurAnchor.y) + width: _active ? _capWidth : 0 + height: _active ? _capHeight : 0 + } + + Item { + id: _modalLeftConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._modalConnectorRadiusLeft + readonly property bool _active: _modalBodyBlurAnchor._active && _radius > 0 + readonly property real _w: ConnectorGeometry.connectorWidth(win._modalState.barSide, 0, win._modalConnectorRadiusLeft) + readonly property real _h: ConnectorGeometry.connectorHeight(win._modalState.barSide, 0, win._modalConnectorRadiusLeft) + + x: _active ? Theme.snap(ConnectorGeometry.connectorX(win._modalState.barSide, _modalBodyBlurAnchor.x, _modalBodyBlurAnchor.width, "left", 0, win._modalConnectorRadiusLeft), win._dpr) : 0 + y: _active ? Theme.snap(ConnectorGeometry.connectorY(win._modalState.barSide, _modalBodyBlurAnchor.y, _modalBodyBlurAnchor.height, "left", 0, win._modalConnectorRadiusLeft), win._dpr) : 0 + width: _active ? _w : 0 + height: _active ? _h : 0 + } + + Item { + id: _modalRightConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._modalConnectorRadiusRight + readonly property bool _active: _modalBodyBlurAnchor._active && _radius > 0 + readonly property real _w: ConnectorGeometry.connectorWidth(win._modalState.barSide, 0, win._modalConnectorRadiusRight) + readonly property real _h: ConnectorGeometry.connectorHeight(win._modalState.barSide, 0, win._modalConnectorRadiusRight) + + x: _active ? Theme.snap(ConnectorGeometry.connectorX(win._modalState.barSide, _modalBodyBlurAnchor.x, _modalBodyBlurAnchor.width, "right", 0, win._modalConnectorRadiusRight), win._dpr) : 0 + y: _active ? Theme.snap(ConnectorGeometry.connectorY(win._modalState.barSide, _modalBodyBlurAnchor.y, _modalBodyBlurAnchor.height, "right", 0, win._modalConnectorRadiusRight), win._dpr) : 0 + width: _active ? _w : 0 + height: _active ? _h : 0 + } + + Item { + id: _modalLeftConnectorCutout + opacity: 0 + + readonly property bool _active: _modalLeftConnectorBlurAnchor.width > 0 && _modalLeftConnectorBlurAnchor.height > 0 + readonly property string _arcCorner: ConnectorGeometry.arcCorner(win._modalState.barSide, "left") + readonly property real _radius: win._modalConnectorRadiusLeft + + x: _active ? win._connectorCutoutX(_modalLeftConnectorBlurAnchor.x, _modalLeftConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_modalLeftConnectorBlurAnchor.y, _modalLeftConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _modalRightConnectorCutout + opacity: 0 + + readonly property bool _active: _modalRightConnectorBlurAnchor.width > 0 && _modalRightConnectorBlurAnchor.height > 0 + readonly property string _arcCorner: ConnectorGeometry.arcCorner(win._modalState.barSide, "right") + readonly property real _radius: win._modalConnectorRadiusRight + + x: _active ? win._connectorCutoutX(_modalRightConnectorBlurAnchor.x, _modalRightConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_modalRightConnectorBlurAnchor.y, _modalRightConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _modalFarStartConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._effectiveModalFarStartCcr + readonly property bool _active: _modalBodyBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farConnectorX(_modalBodyBlurAnchor.x, _modalBodyBlurAnchor.y, _modalBodyBlurAnchor.width, _modalBodyBlurAnchor.height, win._modalState.barSide, "left", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farConnectorY(_modalBodyBlurAnchor.x, _modalBodyBlurAnchor.y, _modalBodyBlurAnchor.width, _modalBodyBlurAnchor.height, win._modalState.barSide, "left", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _modalFarStartBodyBlurCap + opacity: 0 + + readonly property real _radius: win._effectiveModalFarStartCcr + readonly property bool _active: _modalBodyBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farBodyCapX(_modalBodyBlurAnchor.x, _modalBodyBlurAnchor.width, win._modalState.barSide, "left", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farBodyCapY(_modalBodyBlurAnchor.y, _modalBodyBlurAnchor.height, win._modalState.barSide, "left", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _modalFarEndBodyBlurCap + opacity: 0 + + readonly property real _radius: win._effectiveModalFarEndCcr + readonly property bool _active: _modalBodyBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farBodyCapX(_modalBodyBlurAnchor.x, _modalBodyBlurAnchor.width, win._modalState.barSide, "right", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farBodyCapY(_modalBodyBlurAnchor.y, _modalBodyBlurAnchor.height, win._modalState.barSide, "right", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _modalFarEndConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._effectiveModalFarEndCcr + readonly property bool _active: _modalBodyBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farConnectorX(_modalBodyBlurAnchor.x, _modalBodyBlurAnchor.y, _modalBodyBlurAnchor.width, _modalBodyBlurAnchor.height, win._modalState.barSide, "right", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farConnectorY(_modalBodyBlurAnchor.x, _modalBodyBlurAnchor.y, _modalBodyBlurAnchor.width, _modalBodyBlurAnchor.height, win._modalState.barSide, "right", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _modalFarStartConnectorCutout + opacity: 0 + + readonly property bool _active: _modalFarStartConnectorBlurAnchor.width > 0 && _modalFarStartConnectorBlurAnchor.height > 0 + readonly property string _barSide: win._farConnectorBarSide(win._modalState.barSide, "left") + readonly property string _placement: win._farConnectorPlacement(win._modalState.barSide, "left") + readonly property string _arcCorner: ConnectorGeometry.arcCorner(_barSide, _placement) + readonly property real _radius: win._effectiveModalFarStartCcr + + x: _active ? win._connectorCutoutX(_modalFarStartConnectorBlurAnchor.x, _modalFarStartConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_modalFarStartConnectorBlurAnchor.y, _modalFarStartConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _modalFarEndConnectorCutout + opacity: 0 + + readonly property bool _active: _modalFarEndConnectorBlurAnchor.width > 0 && _modalFarEndConnectorBlurAnchor.height > 0 + readonly property string _barSide: win._farConnectorBarSide(win._modalState.barSide, "right") + readonly property string _placement: win._farConnectorPlacement(win._modalState.barSide, "right") + readonly property string _arcCorner: ConnectorGeometry.arcCorner(_barSide, _placement) + readonly property real _radius: win._effectiveModalFarEndCcr + + x: _active ? win._connectorCutoutX(_modalFarEndConnectorBlurAnchor.x, _modalFarEndConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_modalFarEndConnectorBlurAnchor.y, _modalFarEndConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _notifBodySceneBlurAnchor + visible: false + + readonly property bool _active: _notifBodyBlurAnchor._active + readonly property var _scene: _active ? win._notifBodyScene() : null + + x: _scene ? Theme.snap(_scene.x, win._dpr) : 0 + y: _scene ? Theme.snap(_scene.y, win._dpr) : 0 + width: _scene ? Theme.snap(_scene.width, win._dpr) : 0 + height: _scene ? Theme.snap(_scene.height, win._dpr) : 0 + } + + Item { + id: _notifBodyBlurCap + opacity: 0 + + readonly property string _side: win._notifState.barSide + readonly property real _capRadius: win._effectiveNotifMaxCcr + readonly property bool _active: _notifBodySceneBlurAnchor._active && _notifBodySceneBlurAnchor.width > 0 && _notifBodySceneBlurAnchor.height > 0 && _capRadius > 0 + readonly property real _capWidth: (_side === "left" || _side === "right") ? Math.min(_capRadius, _notifBodySceneBlurAnchor.width) : _notifBodySceneBlurAnchor.width + readonly property real _capHeight: (_side === "top" || _side === "bottom") ? Math.min(_capRadius, _notifBodySceneBlurAnchor.height) : _notifBodySceneBlurAnchor.height + + x: !_active ? 0 : (_side === "right" ? _notifBodySceneBlurAnchor.x + _notifBodySceneBlurAnchor.width - _capWidth : _notifBodySceneBlurAnchor.x) + y: !_active ? 0 : (_side === "bottom" ? _notifBodySceneBlurAnchor.y + _notifBodySceneBlurAnchor.height - _capHeight : _notifBodySceneBlurAnchor.y) + width: _active ? _capWidth : 0 + height: _active ? _capHeight : 0 + } + + Item { + id: _notifLeftConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._notifConnectorRadiusLeft + readonly property bool _active: _notifBodySceneBlurAnchor._active && _radius > 0 + readonly property real _w: ConnectorGeometry.connectorWidth(win._notifState.barSide, 0, win._notifConnectorRadiusLeft) + readonly property real _h: ConnectorGeometry.connectorHeight(win._notifState.barSide, 0, win._notifConnectorRadiusLeft) + + x: _active ? Theme.snap(ConnectorGeometry.connectorX(win._notifState.barSide, _notifBodySceneBlurAnchor.x, _notifBodySceneBlurAnchor.width, "left", 0, win._notifConnectorRadiusLeft), win._dpr) : 0 + y: _active ? Theme.snap(ConnectorGeometry.connectorY(win._notifState.barSide, _notifBodySceneBlurAnchor.y, _notifBodySceneBlurAnchor.height, "left", 0, win._notifConnectorRadiusLeft), win._dpr) : 0 + width: _active ? _w : 0 + height: _active ? _h : 0 + } + + Item { + id: _notifRightConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._notifConnectorRadiusRight + readonly property bool _active: _notifBodySceneBlurAnchor._active && _radius > 0 + readonly property real _w: ConnectorGeometry.connectorWidth(win._notifState.barSide, 0, win._notifConnectorRadiusRight) + readonly property real _h: ConnectorGeometry.connectorHeight(win._notifState.barSide, 0, win._notifConnectorRadiusRight) + + x: _active ? Theme.snap(ConnectorGeometry.connectorX(win._notifState.barSide, _notifBodySceneBlurAnchor.x, _notifBodySceneBlurAnchor.width, "right", 0, win._notifConnectorRadiusRight), win._dpr) : 0 + y: _active ? Theme.snap(ConnectorGeometry.connectorY(win._notifState.barSide, _notifBodySceneBlurAnchor.y, _notifBodySceneBlurAnchor.height, "right", 0, win._notifConnectorRadiusRight), win._dpr) : 0 + width: _active ? _w : 0 + height: _active ? _h : 0 + } + + Item { + id: _notifLeftConnectorCutout + opacity: 0 + + readonly property bool _active: _notifLeftConnectorBlurAnchor.width > 0 && _notifLeftConnectorBlurAnchor.height > 0 + readonly property string _arcCorner: ConnectorGeometry.arcCorner(win._notifState.barSide, "left") + readonly property real _radius: win._notifConnectorRadiusLeft + + x: _active ? win._connectorCutoutX(_notifLeftConnectorBlurAnchor.x, _notifLeftConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_notifLeftConnectorBlurAnchor.y, _notifLeftConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _notifRightConnectorCutout + opacity: 0 + + readonly property bool _active: _notifRightConnectorBlurAnchor.width > 0 && _notifRightConnectorBlurAnchor.height > 0 + readonly property string _arcCorner: ConnectorGeometry.arcCorner(win._notifState.barSide, "right") + readonly property real _radius: win._notifConnectorRadiusRight + + x: _active ? win._connectorCutoutX(_notifRightConnectorBlurAnchor.x, _notifRightConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_notifRightConnectorBlurAnchor.y, _notifRightConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _notifFarStartConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._effectiveNotifFarStartCcr + readonly property bool _active: _notifBodySceneBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farConnectorX(_notifBodySceneBlurAnchor.x, _notifBodySceneBlurAnchor.y, _notifBodySceneBlurAnchor.width, _notifBodySceneBlurAnchor.height, win._notifState.barSide, "left", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farConnectorY(_notifBodySceneBlurAnchor.x, _notifBodySceneBlurAnchor.y, _notifBodySceneBlurAnchor.width, _notifBodySceneBlurAnchor.height, win._notifState.barSide, "left", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _notifFarStartBodyBlurCap + opacity: 0 + + readonly property real _radius: win._effectiveNotifFarStartCcr + readonly property bool _active: _notifBodySceneBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farBodyCapX(_notifBodySceneBlurAnchor.x, _notifBodySceneBlurAnchor.width, win._notifState.barSide, "left", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farBodyCapY(_notifBodySceneBlurAnchor.y, _notifBodySceneBlurAnchor.height, win._notifState.barSide, "left", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _notifFarEndBodyBlurCap + opacity: 0 + + readonly property real _radius: win._effectiveNotifFarEndCcr + readonly property bool _active: _notifBodySceneBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farBodyCapX(_notifBodySceneBlurAnchor.x, _notifBodySceneBlurAnchor.width, win._notifState.barSide, "right", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farBodyCapY(_notifBodySceneBlurAnchor.y, _notifBodySceneBlurAnchor.height, win._notifState.barSide, "right", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _notifFarEndConnectorBlurAnchor + opacity: 0 + + readonly property real _radius: win._effectiveNotifFarEndCcr + readonly property bool _active: _notifBodySceneBlurAnchor._active && _radius > 0 + + x: _active ? Theme.snap(win._farConnectorX(_notifBodySceneBlurAnchor.x, _notifBodySceneBlurAnchor.y, _notifBodySceneBlurAnchor.width, _notifBodySceneBlurAnchor.height, win._notifState.barSide, "right", _radius), win._dpr) : 0 + y: _active ? Theme.snap(win._farConnectorY(_notifBodySceneBlurAnchor.x, _notifBodySceneBlurAnchor.y, _notifBodySceneBlurAnchor.width, _notifBodySceneBlurAnchor.height, win._notifState.barSide, "right", _radius), win._dpr) : 0 + width: _active ? _radius : 0 + height: _active ? _radius : 0 + } + + Item { + id: _notifFarStartConnectorCutout + opacity: 0 + + readonly property bool _active: _notifFarStartConnectorBlurAnchor.width > 0 && _notifFarStartConnectorBlurAnchor.height > 0 + readonly property string _barSide: win._farConnectorBarSide(win._notifState.barSide, "left") + readonly property string _placement: win._farConnectorPlacement(win._notifState.barSide, "left") + readonly property string _arcCorner: ConnectorGeometry.arcCorner(_barSide, _placement) + readonly property real _radius: win._effectiveNotifFarStartCcr + + x: _active ? win._connectorCutoutX(_notifFarStartConnectorBlurAnchor.x, _notifFarStartConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_notifFarStartConnectorBlurAnchor.y, _notifFarStartConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Item { + id: _notifFarEndConnectorCutout + opacity: 0 + + readonly property bool _active: _notifFarEndConnectorBlurAnchor.width > 0 && _notifFarEndConnectorBlurAnchor.height > 0 + readonly property string _barSide: win._farConnectorBarSide(win._notifState.barSide, "right") + readonly property string _placement: win._farConnectorPlacement(win._notifState.barSide, "right") + readonly property string _arcCorner: ConnectorGeometry.arcCorner(_barSide, _placement) + readonly property real _radius: win._effectiveNotifFarEndCcr + + x: _active ? win._connectorCutoutX(_notifFarEndConnectorBlurAnchor.x, _notifFarEndConnectorBlurAnchor.width, _arcCorner, _radius) : 0 + y: _active ? win._connectorCutoutY(_notifFarEndConnectorBlurAnchor.y, _notifFarEndConnectorBlurAnchor.height, _arcCorner, _radius) : 0 + width: _active ? _radius * 2 : 0 + height: _active ? _radius * 2 : 0 + } + + Region { + id: _staticBlurRegion + x: 0 + y: 0 + width: win._windowRegionWidth + height: win._windowRegionHeight + + // Frame cutout (always active when frame is on) + Region { + item: _blurCutout + intersection: Intersection.Subtract + radius: win._blurCutoutRadius + } + + Region { + item: _popoutBodyBlurAnchor + radius: win._surfaceRadius + } + Region { + item: _popoutBodyBlurCap + } + Region { + item: _popoutLeftConnectorBlurAnchor + Region { + item: _popoutLeftConnectorCutout + intersection: Intersection.Subtract + radius: win._popoutConnectorRadiusLeft + } + } + Region { + item: _popoutRightConnectorBlurAnchor + Region { + item: _popoutRightConnectorCutout + intersection: Intersection.Subtract + radius: win._popoutConnectorRadiusRight + } + } + Region { + item: _popoutFarStartBodyBlurCap + } + Region { + item: _popoutFarEndBodyBlurCap + } + Region { + item: _popoutFarStartConnectorBlurAnchor + Region { + item: _popoutFarStartConnectorCutout + intersection: Intersection.Subtract + radius: win._effectivePopoutFarStartCcr + } + } + Region { + item: _popoutFarEndConnectorBlurAnchor + Region { + item: _popoutFarEndConnectorCutout + intersection: Intersection.Subtract + radius: win._effectivePopoutFarEndCcr + } + } + + Region { + item: _dockBodyBlurAnchor + radius: win._dockBodyBlurRadiusValue + } + Region { + item: _dockBodyBlurCap + } + Region { + item: _dockLeftConnectorBlurAnchor + Region { + item: _dockLeftConnectorCutout + intersection: Intersection.Subtract + radius: win._dockConnectorRadiusValue + } + } + Region { + item: _dockRightConnectorBlurAnchor + Region { + item: _dockRightConnectorCutout + intersection: Intersection.Subtract + radius: win._dockConnectorRadiusValue + } + } + + Region { + item: _notifBodySceneBlurAnchor + radius: win._surfaceRadius + } + Region { + item: _notifBodyBlurCap + } + Region { + item: _notifLeftConnectorBlurAnchor + Region { + item: _notifLeftConnectorCutout + intersection: Intersection.Subtract + radius: win._notifConnectorRadiusLeft + } + } + Region { + item: _notifRightConnectorBlurAnchor + Region { + item: _notifRightConnectorCutout + intersection: Intersection.Subtract + radius: win._notifConnectorRadiusRight + } + } + Region { + item: _notifFarStartBodyBlurCap + } + Region { + item: _notifFarEndBodyBlurCap + } + Region { + item: _notifFarStartConnectorBlurAnchor + Region { + item: _notifFarStartConnectorCutout + intersection: Intersection.Subtract + radius: win._effectiveNotifFarStartCcr + } + } + Region { + item: _notifFarEndConnectorBlurAnchor + Region { + item: _notifFarEndConnectorCutout + intersection: Intersection.Subtract + radius: win._effectiveNotifFarEndCcr + } + } + + Region { + item: _modalBodyBlurAnchor + radius: win._surfaceRadius + } + Region { + item: _modalBodyBlurCap + } + Region { + item: _modalLeftConnectorBlurAnchor + Region { + item: _modalLeftConnectorCutout + intersection: Intersection.Subtract + radius: win._modalConnectorRadiusLeft + } + } + Region { + item: _modalRightConnectorBlurAnchor + Region { + item: _modalRightConnectorCutout + intersection: Intersection.Subtract + radius: win._modalConnectorRadiusRight + } + } + Region { + item: _modalFarStartBodyBlurCap + } + Region { + item: _modalFarEndBodyBlurCap + } + Region { + item: _modalFarStartConnectorBlurAnchor + Region { + item: _modalFarStartConnectorCutout + intersection: Intersection.Subtract + radius: win._effectiveModalFarStartCcr + } + } + Region { + item: _modalFarEndConnectorBlurAnchor + Region { + item: _modalFarEndConnectorCutout + intersection: Intersection.Subtract + radius: win._effectiveModalFarEndCcr + } + } + } + + // Notif body scene rect, accounting for start/end/side underlaps per bar orientation. + function _notifBodyScene() { + const isHoriz = ConnectorGeometry.isHorizontal(win._notifState.barSide); + const start = win._notifStartUnderlapValue; + const end = win._notifEndUnderlapValue; + const side = win._notifSideUnderlapValue; + if (isHoriz) { + return { + "x": _notifBodyBlurAnchor.x - start, + "y": _notifBodyBlurAnchor.y, + "width": _notifBodyBlurAnchor.width + start + end, + "height": _notifBodyBlurAnchor.height + }; + } + return { + "x": _notifBodyBlurAnchor.x - (win._notifState.barSide === "left" ? side : 0), + "y": _notifBodyBlurAnchor.y - start, + "width": _notifBodyBlurAnchor.width + side, + "height": _notifBodyBlurAnchor.height + start + end + }; + } + + function _modalBlurCapThickness() { + const extent = win._modalArcExtent; + return Math.max(0, Math.min(win._effectiveModalCcr, extent - win._surfaceRadius)); + } + + function _popoutArcVisible() { + if (!_popoutBodyBlurAnchor._active || _popoutBodyBlurAnchor.width <= 0 || _popoutBodyBlurAnchor.height <= 0) + return false; + return win._popoutArcExtent >= win._ccr * (1 + win._ccr * 0.02); + } + + function _popoutBlurCapThickness() { + const extent = win._popoutArcExtent; + return Math.max(0, Math.min(win._effectivePopoutMaxCcr, extent - win._surfaceRadius)); + } + + function _popoutChromeX() { + const barSide = ConnectedModeState.popoutBarSide; + return ConnectedModeState.popoutBodyX - ((barSide === "top" || barSide === "bottom") ? win._effectivePopoutStartCcr : 0); + } + + function _popoutChromeY() { + const barSide = ConnectedModeState.popoutBarSide; + return ConnectedModeState.popoutBodyY - ((barSide === "left" || barSide === "right") ? win._effectivePopoutStartCcr : 0); + } + + function _popoutChromeWidth() { + const barSide = ConnectedModeState.popoutBarSide; + return ConnectedModeState.popoutBodyW + ((barSide === "top" || barSide === "bottom") ? win._effectivePopoutStartCcr + win._effectivePopoutEndCcr : 0); + } + + function _popoutChromeHeight() { + const barSide = ConnectedModeState.popoutBarSide; + return ConnectedModeState.popoutBodyH + ((barSide === "left" || barSide === "right") ? win._effectivePopoutStartCcr + win._effectivePopoutEndCcr : 0); + } + + function _popoutClipX() { + return _popoutBodyBlurAnchor.x - win._popoutChromeX() - win._popoutFillOverlapXValue; + } + + function _popoutClipY() { + return _popoutBodyBlurAnchor.y - win._popoutChromeY() - win._popoutFillOverlapYValue; + } + + function _popoutClipWidth() { + return _popoutBodyBlurAnchor.width + win._popoutFillOverlapXValue * 2; + } + + function _popoutClipHeight() { + return _popoutBodyBlurAnchor.height + win._popoutFillOverlapYValue * 2; + } + + function _popoutShapeBodyOffsetX() { + const side = ConnectedModeState.popoutBarSide; + if (ConnectorGeometry.isHorizontal(side)) + return win._effectivePopoutStartCcr; + return side === "right" ? win._effectivePopoutFarExtent : 0; + } + + function _popoutShapeBodyOffsetY() { + const side = ConnectedModeState.popoutBarSide; + if (ConnectorGeometry.isHorizontal(side)) + return side === "bottom" ? win._effectivePopoutFarExtent : 0; + return win._effectivePopoutStartCcr; + } + + function _popoutShapeWidth() { + const side = ConnectedModeState.popoutBarSide; + if (ConnectorGeometry.isHorizontal(side)) + return win._popoutClipWidth() + win._effectivePopoutStartCcr + win._effectivePopoutEndCcr; + return win._popoutClipWidth() + win._effectivePopoutFarExtent; + } + + function _popoutShapeHeight() { + const side = ConnectedModeState.popoutBarSide; + if (ConnectorGeometry.isHorizontal(side)) + return win._popoutClipHeight() + win._effectivePopoutFarExtent; + return win._popoutClipHeight() + win._effectivePopoutStartCcr + win._effectivePopoutEndCcr; + } + + function _popoutBodyXInClip() { + return (ConnectedModeState.popoutBarSide === "left" ? _popoutBodyBlurAnchor._dxClamp : 0) - win._popoutFillOverlapXValue; + } + + function _popoutBodyYInClip() { + return (ConnectedModeState.popoutBarSide === "top" ? _popoutBodyBlurAnchor._dyClamp : 0) - win._popoutFillOverlapYValue; + } + + function _popoutBodyFullWidth() { + return ConnectedModeState.popoutBodyW + win._popoutFillOverlapXValue * 2; + } + + function _popoutBodyFullHeight() { + return ConnectedModeState.popoutBodyH + win._popoutFillOverlapYValue * 2; + } + + function _dockChromeX() { + const dockSide = win._dockState.barSide; + return _dockBodyBlurAnchor.x - ((dockSide === "top" || dockSide === "bottom") ? win._dockConnectorRadiusValue : 0); + } + + function _dockChromeY() { + const dockSide = win._dockState.barSide; + return _dockBodyBlurAnchor.y - ((dockSide === "left" || dockSide === "right") ? win._dockConnectorRadiusValue : 0); + } + + function _dockChromeWidth() { + const dockSide = win._dockState.barSide; + return _dockBodyBlurAnchor.width + ((dockSide === "top" || dockSide === "bottom") ? win._dockConnectorRadiusValue * 2 : 0); + } + + function _dockChromeHeight() { + const dockSide = win._dockState.barSide; + return _dockBodyBlurAnchor.height + ((dockSide === "left" || dockSide === "right") ? win._dockConnectorRadiusValue * 2 : 0); + } + + function _dockBodyXInChrome() { + return (ConnectorGeometry.isHorizontal(win._dockState.barSide) ? win._dockConnectorRadiusValue : 0) - win._dockFillOverlapXValue; + } + + function _dockBodyYInChrome() { + return ((win._dockState.barSide === "left" || win._dockState.barSide === "right") ? win._dockConnectorRadiusValue : 0) - win._dockFillOverlapYValue; + } + + function _farConnectorBarSide(sourceSide, placement) { + if (sourceSide === "top" || sourceSide === "bottom") + return placement === "left" ? "left" : "right"; + return placement === "left" ? "top" : "bottom"; + } + + function _farConnectorPlacement(sourceSide, placement) { + if (sourceSide === "top") + return "right"; + if (sourceSide === "bottom") + return "left"; + if (sourceSide === "left") + return "right"; + return "left"; + } + + function _farConnectorX(baseX, baseY, bodyWidth, bodyHeight, sourceSide, placement, radius) { + if (sourceSide === "top" || sourceSide === "bottom") + return placement === "left" ? baseX : baseX + bodyWidth - radius; + if (sourceSide === "left") + return baseX + bodyWidth; + return baseX - radius; + } + + function _farConnectorY(baseX, baseY, bodyWidth, bodyHeight, sourceSide, placement, radius) { + if (sourceSide === "top") + return baseY + bodyHeight; + if (sourceSide === "bottom") + return baseY - radius; + return placement === "left" ? baseY : baseY + bodyHeight - radius; + } + + function _farBodyCapX(baseX, bodyWidth, sourceSide, placement, radius) { + if (sourceSide === "top" || sourceSide === "bottom") + return placement === "left" ? baseX : baseX + bodyWidth - radius; + if (sourceSide === "left") + return baseX + bodyWidth - radius; + return baseX; + } + + function _farBodyCapY(baseY, bodyHeight, sourceSide, placement, radius) { + if (sourceSide === "top") + return baseY + bodyHeight - radius; + if (sourceSide === "bottom") + return baseY; + return placement === "left" ? baseY : baseY + bodyHeight - radius; + } + + function _connectorCutoutX(connectorX, connectorWidth, arcCorner, radius) { + const r = radius === undefined ? win._effectivePopoutCcr : radius; + return (arcCorner === "topLeft" || arcCorner === "bottomLeft") ? connectorX - r : connectorX + connectorWidth - r; + } + + function _connectorCutoutY(connectorY, connectorHeight, arcCorner, radius) { + const r = radius === undefined ? win._effectivePopoutCcr : radius; + return (arcCorner === "topLeft" || arcCorner === "topRight") ? connectorY - r : connectorY + connectorHeight - r; + } + + function _buildBlur() { + try { + if (!BlurService.enabled || !SettingsData.frameBlurEnabled || !win._frameActive || !win.visible) { + win.BackgroundEffect.blurRegion = null; + return; + } + win.BackgroundEffect.blurRegion = _staticBlurRegion; + } catch (e) { + win.log.warn("Failed to set blur region:", e); + } + } + + function _teardownBlur() { + try { + win.BackgroundEffect.blurRegion = null; + } catch (e) {} + } + + // Coalesce bursts of settings-change signals into a single _buildBlur() call + // on the next event loop tick. + property bool _blurRebuildPending: false + function _scheduleBlurRebuild() { + if (_blurRebuildPending) + return; + _blurRebuildPending = true; + Qt.callLater(_runBlurRebuild); + } + function _runBlurRebuild() { + _blurRebuildPending = false; + win._buildBlur(); + } + + Connections { + target: SettingsData + function onFrameBlurEnabledChanged() { + win._scheduleBlurRebuild(); + } + function onFrameEnabledChanged() { + win._scheduleBlurRebuild(); + } + function onFrameThicknessChanged() { + win._scheduleBlurRebuild(); + } + function onFrameBarSizeChanged() { + win._scheduleBlurRebuild(); + } + function onFrameOpacityChanged() { + win._scheduleBlurRebuild(); + } + function onFrameRoundingChanged() { + win._scheduleBlurRebuild(); + } + function onFrameScreenPreferencesChanged() { + win._scheduleBlurRebuild(); + } + function onBarConfigsChanged() { + win._scheduleBlurRebuild(); + } + function onConnectedFrameModeActiveChanged() { + win._scheduleBlurRebuild(); + } + function onFrameCloseGapsChanged() { + win._scheduleBlurRebuild(); + } + } + + Connections { + target: BlurService + function onEnabledChanged() { + win._scheduleBlurRebuild(); + } + } + + onVisibleChanged: { + if (visible) { + win._scheduleBlurRebuild(); + } else { + _teardownBlur(); + } + } + + Component.onCompleted: win._scheduleBlurRebuild() + Component.onDestruction: win._teardownBlur() + + FrameBorder { + anchors.fill: parent + visible: win._frameActive && !win._connectedActive + cutoutTopInset: win.cutoutTopInset + cutoutBottomInset: win.cutoutBottomInset + cutoutLeftInset: win.cutoutLeftInset + cutoutRightInset: win.cutoutRightInset + cutoutRadius: win.cutoutRadius + } + + Item { + id: _connectedSurfaceLayer + anchors.fill: parent + visible: win._connectedActive + opacity: win._surfaceOpacity + // Skip FBO when disabled, invisible, or when neither elevation nor alpha blend is active + layer.enabled: win._connectedActive && !win._disableLayer && (Theme.elevationEnabled || win._surfaceOpacity < 1) + layer.smooth: false + + layer.effect: MultiEffect { + readonly property var level: Theme.elevationLevel2 + readonly property real _shadowBlur: Theme.elevationEnabled ? (level && level.blurPx !== undefined ? level.blurPx : 0) : 0 + readonly property real _shadowSpread: Theme.elevationEnabled ? (level && level.spreadPx !== undefined ? level.spreadPx : 0) : 0 + + autoPaddingEnabled: true + blurEnabled: false + maskEnabled: false + + shadowEnabled: !win._disableLayer && Theme.elevationEnabled + shadowBlur: Math.max(0, Math.min(1, _shadowBlur / Math.max(1, Theme.elevationBlurMax))) + shadowScale: 1 + (2 * _shadowSpread) / Math.max(1, Math.min(_connectedSurfaceLayer.width, _connectedSurfaceLayer.height)) + shadowHorizontalOffset: Theme.elevationOffsetXFor(level, Theme.elevationLightDirection, 4) + shadowVerticalOffset: Theme.elevationOffsetYFor(level, Theme.elevationLightDirection, 4) + shadowColor: Theme.elevationShadowColor(level) + shadowOpacity: 1 + } + + FrameBorder { + anchors.fill: parent + borderColor: win._opaqueSurfaceColor + cutoutTopInset: win.cutoutTopInset + cutoutBottomInset: win.cutoutBottomInset + cutoutLeftInset: win.cutoutLeftInset + cutoutRightInset: win.cutoutRightInset + cutoutRadius: win.cutoutRadius + } + + Item { + id: _connectedChrome + anchors.fill: parent + visible: true + + Item { + id: _popoutChrome + visible: ConnectedModeState.popoutVisible && ConnectedModeState.popoutScreen === win._screenName + x: win._popoutChromeX() + y: win._popoutChromeY() + width: win._popoutChromeWidth() + height: win._popoutChromeHeight() + + Item { + id: _popoutClip + x: win._popoutClipX() - win._popoutShapeBodyOffsetX() + y: win._popoutClipY() - win._popoutShapeBodyOffsetY() + width: win._popoutShapeWidth() + height: win._popoutShapeHeight() + clip: true + + ConnectedShape { + id: _popoutShape + visible: _popoutBodyBlurAnchor._active && _popoutBodyBlurAnchor.width > 0 && _popoutBodyBlurAnchor.height > 0 + barSide: ConnectedModeState.popoutBarSide + bodyWidth: win._popoutClipWidth() + bodyHeight: win._popoutClipHeight() + connectorRadius: win._effectivePopoutCcr + startConnectorRadius: win._effectivePopoutStartCcr + endConnectorRadius: win._effectivePopoutEndCcr + farStartConnectorRadius: win._effectivePopoutFarStartCcr + farEndConnectorRadius: win._effectivePopoutFarEndCcr + surfaceRadius: win._surfaceRadius + fillColor: win._opaqueSurfaceColor + x: 0 + y: 0 + } + } + } + + Item { + id: _dockChrome + visible: _dockBodyBlurAnchor._active + x: win._dockChromeX() + y: win._dockChromeY() + width: win._dockChromeWidth() + height: win._dockChromeHeight() + + Rectangle { + id: _dockFill + x: win._dockBodyXInChrome() + y: win._dockBodyYInChrome() + width: _dockBodyBlurAnchor.width + win._dockFillOverlapXValue * 2 + height: _dockBodyBlurAnchor.height + win._dockFillOverlapYValue * 2 + color: win._opaqueSurfaceColor + z: 1 + + readonly property string _dockSide: win._dockState.barSide + readonly property real _dockRadius: win._dockBodyBlurRadiusValue + topLeftRadius: (_dockSide === "top" || _dockSide === "left") ? 0 : _dockRadius + topRightRadius: (_dockSide === "top" || _dockSide === "right") ? 0 : _dockRadius + bottomLeftRadius: (_dockSide === "bottom" || _dockSide === "left") ? 0 : _dockRadius + bottomRightRadius: (_dockSide === "bottom" || _dockSide === "right") ? 0 : _dockRadius + } + + ConnectedCorner { + id: _connDockLeft + visible: _dockBodyBlurAnchor._active + barSide: win._dockState.barSide + placement: "left" + spacing: 0 + connectorRadius: win._dockConnectorRadiusValue + color: win._opaqueSurfaceColor + dpr: win._dpr + x: Theme.snap(ConnectorGeometry.connectorX(win._dockState.barSide, _dockBodyBlurAnchor.x, _dockBodyBlurAnchor.width, "left", 0, win._dockConnectorRadiusValue) - _dockChrome.x, win._dpr) + y: Theme.snap(ConnectorGeometry.connectorY(win._dockState.barSide, _dockBodyBlurAnchor.y, _dockBodyBlurAnchor.height, "left", 0, win._dockConnectorRadiusValue) - _dockChrome.y, win._dpr) + } + + ConnectedCorner { + id: _connDockRight + visible: _dockBodyBlurAnchor._active + barSide: win._dockState.barSide + placement: "right" + spacing: 0 + connectorRadius: win._dockConnectorRadiusValue + color: win._opaqueSurfaceColor + dpr: win._dpr + x: Theme.snap(ConnectorGeometry.connectorX(win._dockState.barSide, _dockBodyBlurAnchor.x, _dockBodyBlurAnchor.width, "right", 0, win._dockConnectorRadiusValue) - _dockChrome.x, win._dpr) + y: Theme.snap(ConnectorGeometry.connectorY(win._dockState.barSide, _dockBodyBlurAnchor.y, _dockBodyBlurAnchor.height, "right", 0, win._dockConnectorRadiusValue) - _dockChrome.y, win._dpr) + } + } + } + + Item { + id: _notifChrome + visible: _notifBodySceneBlurAnchor._active + + readonly property string _notifSide: win._notifState.barSide + readonly property bool _isHoriz: _notifSide === "top" || _notifSide === "bottom" + readonly property real _startCcr: win._effectiveNotifStartCcr + readonly property real _endCcr: win._effectiveNotifEndCcr + readonly property real _farExtent: win._effectiveNotifFarExtent + readonly property real _bodyOffsetX: _isHoriz ? _startCcr : (_notifSide === "right" ? _farExtent : 0) + readonly property real _bodyOffsetY: _isHoriz ? (_notifSide === "bottom" ? _farExtent : 0) : _startCcr + readonly property real _bodyW: Theme.snap(_notifBodySceneBlurAnchor.width, win._dpr) + readonly property real _bodyH: Theme.snap(_notifBodySceneBlurAnchor.height, win._dpr) + + z: _isHoriz ? 0 : -1 + x: Theme.snap(_notifBodySceneBlurAnchor.x - _bodyOffsetX, win._dpr) + y: Theme.snap(_notifBodySceneBlurAnchor.y - _bodyOffsetY, win._dpr) + width: _isHoriz ? Theme.snap(_bodyW + _startCcr + _endCcr, win._dpr) : Theme.snap(_bodyW + _farExtent, win._dpr) + height: _isHoriz ? Theme.snap(_bodyH + _farExtent, win._dpr) : Theme.snap(_bodyH + _startCcr + _endCcr, win._dpr) + + ConnectedShape { + visible: _notifBodySceneBlurAnchor._active && _notifBodySceneBlurAnchor.width > 0 && _notifBodySceneBlurAnchor.height > 0 + barSide: _notifChrome._notifSide + bodyWidth: _notifChrome._bodyW + bodyHeight: _notifChrome._bodyH + connectorRadius: win._effectiveNotifCcr + startConnectorRadius: _notifChrome._startCcr + endConnectorRadius: _notifChrome._endCcr + farStartConnectorRadius: win._effectiveNotifFarStartCcr + farEndConnectorRadius: win._effectiveNotifFarEndCcr + surfaceRadius: win._surfaceRadius + fillColor: win._opaqueSurfaceColor + x: 0 + y: 0 + } + } + + // Bar-side-bounded clip so modal chrome retracts behind the bar on exit + // instead of sliding over bar widgets (mirrors the popout `_popoutClip`). + Item { + id: _modalClip + visible: _modalBodyBlurAnchor._active + z: 1 + + readonly property string _modalSide: win._modalState.barSide + readonly property real _inset: _modalBodyBlurAnchor._active && win.screen ? SettingsData.frameEdgeInsetForSide(win.screen, _modalSide) : 0 + readonly property real _topBound: _modalSide === "top" ? _inset : 0 + readonly property real _bottomBound: _modalSide === "bottom" ? (win.height - _inset) : win.height + readonly property real _leftBound: _modalSide === "left" ? _inset : 0 + readonly property real _rightBound: _modalSide === "right" ? (win.width - _inset) : win.width + + x: _leftBound + y: _topBound + width: Math.max(0, _rightBound - _leftBound) + height: Math.max(0, _bottomBound - _topBound) + clip: true + + Item { + id: _modalChrome + + readonly property string _modalSide: win._modalState.barSide + readonly property bool _isHoriz: _modalSide === "top" || _modalSide === "bottom" + readonly property real _startCcr: win._effectiveModalStartCcr + readonly property real _endCcr: win._effectiveModalEndCcr + readonly property real _farExtent: win._effectiveModalFarExtent + readonly property real _bodyOffsetX: _isHoriz ? _startCcr : (_modalSide === "right" ? _farExtent : 0) + readonly property real _bodyOffsetY: _isHoriz ? (_modalSide === "bottom" ? _farExtent : 0) : _startCcr + readonly property real _bodyW: Theme.snap(_modalBodyBlurAnchor.width, win._dpr) + readonly property real _bodyH: Theme.snap(_modalBodyBlurAnchor.height, win._dpr) + + x: Theme.snap(_modalBodyBlurAnchor.x - _bodyOffsetX - _modalClip.x, win._dpr) + y: Theme.snap(_modalBodyBlurAnchor.y - _bodyOffsetY - _modalClip.y, win._dpr) + width: _isHoriz ? Theme.snap(_bodyW + _startCcr + _endCcr, win._dpr) : Theme.snap(_bodyW + _farExtent, win._dpr) + height: _isHoriz ? Theme.snap(_bodyH + _farExtent, win._dpr) : Theme.snap(_bodyH + _startCcr + _endCcr, win._dpr) + + ConnectedShape { + visible: _modalBodyBlurAnchor._active && _modalChrome._bodyW > 0 && _modalChrome._bodyH > 0 + barSide: _modalChrome._modalSide + bodyWidth: _modalChrome._bodyW + bodyHeight: _modalChrome._bodyH + connectorRadius: win._effectiveModalCcr + startConnectorRadius: _modalChrome._startCcr + endConnectorRadius: _modalChrome._endCcr + farStartConnectorRadius: win._effectiveModalFarStartCcr + farEndConnectorRadius: win._effectiveModalFarEndCcr + surfaceRadius: win._surfaceRadius + fillColor: win._opaqueSurfaceColor + x: 0 + y: 0 + } + } + } + } +} diff --git a/quickshell/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml index c20c9bb4..e2af9a26 100644 --- a/quickshell/Modules/Greetd/GreeterContent.qml +++ b/quickshell/Modules/Greetd/GreeterContent.qml @@ -1,4 +1,3 @@ -import QtCore import QtQuick import QtQuick.Effects import QtQuick.Layouts @@ -119,15 +118,7 @@ Item { function greeterPamStackHasModule(moduleName) { if (pamModuleEnabled(greetdPamText, moduleName)) return true; - const includedPamStacks = [ - ["system-auth", systemAuthPamText], - ["common-auth", commonAuthPamText], - ["password-auth", passwordAuthPamText], - ["system-login", systemLoginPamText], - ["system-local-login", systemLocalLoginPamText], - ["common-auth-pc", commonAuthPcPamText], - ["login", loginPamText] - ]; + const includedPamStacks = [["system-auth", systemAuthPamText], ["common-auth", commonAuthPamText], ["password-auth", passwordAuthPamText], ["system-login", systemLoginPamText], ["system-local-login", systemLocalLoginPamText], ["common-auth-pc", commonAuthPcPamText], ["login", loginPamText]]; for (let i = 0; i < includedPamStacks.length; i++) { const stack = includedPamStacks[i]; if (pamTextIncludesFile(greetdPamText, stack[0]) && pamModuleEnabled(stack[1], moduleName)) @@ -609,13 +600,7 @@ Item { running: false // sh wrapper: emits PROBE_UNAVAILABLE if gdbus is absent or fprintd unreachable, // keeping the PAM-only fallback active in those cases. - command: ["sh", "-c", - "command -v gdbus >/dev/null 2>&1 || { echo PROBE_UNAVAILABLE; exit 0; }; " + - "gdbus call --system " + - "--dest net.reactivated.Fprint " + - "--object-path /net/reactivated/Fprint/Manager " + - "--method net.reactivated.Fprint.Manager.GetDevices 2>/dev/null " + - "|| echo PROBE_UNAVAILABLE"] + command: ["sh", "-c", "command -v gdbus >/dev/null 2>&1 || { echo PROBE_UNAVAILABLE; exit 0; }; " + "gdbus call --system " + "--dest net.reactivated.Fprint " + "--object-path /net/reactivated/Fprint/Manager " + "--method net.reactivated.Fprint.Manager.GetDevices 2>/dev/null " + "|| echo PROBE_UNAVAILABLE"] stdout: StdioCollector { onStreamFinished: { if (text.includes("PROBE_UNAVAILABLE")) @@ -625,7 +610,7 @@ Item { root.maybeAutoStartExternalAuth(); } } - onExited: function(exitCode, exitStatus) { + onExited: function (exitCode, exitStatus) { if (!root.fprintdProbeComplete) root.maybeAutoStartExternalAuth(); // PAM-only fallback stays active } @@ -1754,7 +1739,7 @@ Item { authTimeout.interval = defaultAuthTimeoutMs; authTimeout.stop(); if (resumePasswordSubmit) { - Qt.callLater(function() { + Qt.callLater(function () { root.startAuthSession(true); }); return; diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 1a5bb5d7..be2d6c21 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -1025,7 +1025,7 @@ Item { color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.9) } - RotationAnimation on rotation { + RotationAnimator on rotation { running: pam.passwd.active && !root.unlocking loops: Animation.Infinite duration: Anims.durLong diff --git a/quickshell/Modules/Notepad/NotepadTextEditor.qml b/quickshell/Modules/Notepad/NotepadTextEditor.qml index 7ea0b27d..3d92d696 100644 --- a/quickshell/Modules/Notepad/NotepadTextEditor.qml +++ b/quickshell/Modules/Notepad/NotepadTextEditor.qml @@ -1,19 +1,18 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Controls import QtQuick.Layouts -import Quickshell.Io import qs.Common import qs.Services import qs.Widgets -pragma ComponentBehavior: Bound - Column { id: root Component.onCompleted: { if (PluginService.isPluginLoaded("dankNotepadModule")) { - pluginHighlightedHtml = SettingsData.getBuiltInPluginSetting("dankNotepadModule", "highlightedHtml", "") + pluginHighlightedHtml = SettingsData.getBuiltInPluginSetting("dankNotepadModule", "highlightedHtml", ""); } } @@ -33,65 +32,57 @@ Column { property string lastPluginContent: "" property int loadRequestId: 0 - signal saveRequested() - signal openRequested() - signal newRequested() - signal previewRequested() - signal escapePressed() - signal contentChanged() - signal settingsRequested() + signal saveRequested + signal openRequested + signal newRequested + signal previewRequested + signal escapePressed + signal contentChanged + signal settingsRequested function hasUnsavedChanges() { if (!currentTab || !contentLoaded) { - return false + return false; } if (currentTab.isTemporary) { - return textArea.text.length > 0 + return textArea.text.length > 0; } - return textArea.text !== lastSavedContent + return textArea.text !== lastSavedContent; } function loadCurrentTabContent() { - if (!currentTab) return - - const requestedTabId = currentTab.id - const requestId = ++loadRequestId - contentLoaded = false - NotepadStorageService.loadTabContent( - NotepadStorageService.currentTabIndex, - (content) => { - const activeTab = NotepadStorageService.tabs.length > NotepadStorageService.currentTabIndex - ? NotepadStorageService.tabs[NotepadStorageService.currentTabIndex] - : null - if (requestId !== loadRequestId || !activeTab || activeTab.id !== requestedTabId) - return - - lastSavedContent = content - textArea.text = content - contentLoaded = true - syncContentToPlugin() - } - ) + if (!currentTab) + return; + const requestedTabId = currentTab.id; + const requestId = ++loadRequestId; + contentLoaded = false; + NotepadStorageService.loadTabContent(NotepadStorageService.currentTabIndex, content => { + const activeTab = NotepadStorageService.tabs.length > NotepadStorageService.currentTabIndex ? NotepadStorageService.tabs[NotepadStorageService.currentTabIndex] : null; + if (requestId !== loadRequestId || !activeTab || activeTab.id !== requestedTabId) + return; + lastSavedContent = content; + textArea.text = content; + contentLoaded = true; + syncContentToPlugin(); + }); } function saveCurrentTabContent() { - if (!currentTab || !contentLoaded) return - - NotepadStorageService.saveTabContent( - NotepadStorageService.currentTabIndex, - textArea.text - ) - lastSavedContent = textArea.text + if (!currentTab || !contentLoaded) + return; + NotepadStorageService.saveTabContent(NotepadStorageService.currentTabIndex, textArea.text); + lastSavedContent = textArea.text; } function autoSaveToSession() { - if (!currentTab || !contentLoaded) return - saveCurrentTabContent() + if (!currentTab || !contentLoaded) + return; + saveCurrentTabContent(); } function setTextDocumentLineHeight() { - return + return; } property string lastTextForLineModel: "" @@ -99,147 +90,146 @@ Column { function updateLineModel() { if (!SettingsData.notepadShowLineNumbers) { - lineModel = [] - lastTextForLineModel = "" - return + lineModel = []; + lastTextForLineModel = ""; + return; } if (textArea.text !== lastTextForLineModel || lineModel.length === 0) { - lastTextForLineModel = textArea.text - lineModel = textArea.text.split('\n') + lastTextForLineModel = textArea.text; + lineModel = textArea.text.split('\n'); } } function performSearch() { - let matches = [] - currentMatchIndex = -1 + let matches = []; + currentMatchIndex = -1; if (!searchQuery || searchQuery.length === 0) { - searchMatches = [] - matchCount = 0 - textArea.select(0, 0) - return + searchMatches = []; + matchCount = 0; + textArea.select(0, 0); + return; } - const text = textArea.text - const query = searchQuery.toLowerCase() - let index = 0 + const text = textArea.text; + const query = searchQuery.toLowerCase(); + let index = 0; while (index < text.length) { - const foundIndex = text.toLowerCase().indexOf(query, index) - if (foundIndex === -1) break - + const foundIndex = text.toLowerCase().indexOf(query, index); + if (foundIndex === -1) + break; matches.push({ start: foundIndex, end: foundIndex + searchQuery.length - }) - index = foundIndex + 1 + }); + index = foundIndex + 1; } - searchMatches = matches - matchCount = matches.length + searchMatches = matches; + matchCount = matches.length; if (matchCount > 0) { - currentMatchIndex = 0 - highlightCurrentMatch() + currentMatchIndex = 0; + highlightCurrentMatch(); } else { - textArea.select(0, 0) + textArea.select(0, 0); } } function highlightCurrentMatch() { if (currentMatchIndex >= 0 && currentMatchIndex < searchMatches.length) { - const match = searchMatches[currentMatchIndex] + const match = searchMatches[currentMatchIndex]; - textArea.cursorPosition = match.start - textArea.moveCursorSelection(match.end, TextEdit.SelectCharacters) + textArea.cursorPosition = match.start; + textArea.moveCursorSelection(match.end, TextEdit.SelectCharacters); - const flickable = textArea.parent + const flickable = textArea.parent; if (flickable && flickable.contentY !== undefined) { - const lineHeight = textArea.font.pixelSize * 1.5 - const approxLine = textArea.text.substring(0, match.start).split('\n').length - const targetY = approxLine * lineHeight - flickable.height / 2 - flickable.contentY = Math.max(0, Math.min(targetY, flickable.contentHeight - flickable.height)) + const lineHeight = textArea.font.pixelSize * 1.5; + const approxLine = textArea.text.substring(0, match.start).split('\n').length; + const targetY = approxLine * lineHeight - flickable.height / 2; + flickable.contentY = Math.max(0, Math.min(targetY, flickable.contentHeight - flickable.height)); } } } function findNext() { - if (matchCount === 0 || searchMatches.length === 0) return - - currentMatchIndex = (currentMatchIndex + 1) % matchCount - highlightCurrentMatch() + if (matchCount === 0 || searchMatches.length === 0) + return; + currentMatchIndex = (currentMatchIndex + 1) % matchCount; + highlightCurrentMatch(); } function findPrevious() { - if (matchCount === 0 || searchMatches.length === 0) return - - currentMatchIndex = currentMatchIndex <= 0 ? matchCount - 1 : currentMatchIndex - 1 - highlightCurrentMatch() + if (matchCount === 0 || searchMatches.length === 0) + return; + currentMatchIndex = currentMatchIndex <= 0 ? matchCount - 1 : currentMatchIndex - 1; + highlightCurrentMatch(); } function showSearch() { - searchVisible = true + searchVisible = true; Qt.callLater(() => { - searchField.forceActiveFocus() - }) + searchField.forceActiveFocus(); + }); } function togglePreview() { if (!inlinePreviewVisible) { - inlinePreviewVisible = true - previewMode = "split" + inlinePreviewVisible = true; + previewMode = "split"; } else if (previewMode === "split") { - previewMode = "full" + previewMode = "full"; } else { - inlinePreviewVisible = false - previewMode = "split" + inlinePreviewVisible = false; + previewMode = "split"; } - syncContentToPlugin() + syncContentToPlugin(); } function renderPreviewHtml() { - if (!inlinePreviewVisible) return "" - return pluginHighlightedHtml.length > 0 ? pluginHighlightedHtml : "

Rendering preview…

" + if (!inlinePreviewVisible) + return ""; + return pluginHighlightedHtml.length > 0 ? pluginHighlightedHtml : "

Rendering preview…

"; } function syncContentToPlugin() { if (!PluginService.isPluginLoaded("dankNotepadModule")) - return - + return; if (!currentTab) - return - - const filePath = currentTab?.filePath || "" - const ext = filePath.split('.').pop().toLowerCase() - const content = textArea.text + return; + const filePath = currentTab?.filePath || ""; + const ext = filePath.split('.').pop().toLowerCase(); + const content = textArea.text; if (content === lastPluginContent && SettingsData.getBuiltInPluginSetting("dankNotepadModule", "previewActive", false) === inlinePreviewVisible) { - return + return; } - lastPluginContent = content - SettingsData.setBuiltInPluginSetting("dankNotepadModule", "previewActive", inlinePreviewVisible) - SettingsData.setBuiltInPluginSetting("dankNotepadModule", "currentFilePath", filePath) - SettingsData.setBuiltInPluginSetting("dankNotepadModule", "currentFileExtension", ext) - SettingsData.setBuiltInPluginSetting("dankNotepadModule", "sourceContent", content) - SettingsData.setBuiltInPluginSetting("dankNotepadModule", "updatedAt", Date.now()) + lastPluginContent = content; + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "previewActive", inlinePreviewVisible); + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "currentFilePath", filePath); + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "currentFileExtension", ext); + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "sourceContent", content); + SettingsData.setBuiltInPluginSetting("dankNotepadModule", "updatedAt", Date.now()); } function hideSearch() { - searchVisible = false - searchQuery = "" - searchMatches = [] - matchCount = 0 - currentMatchIndex = -1 - textArea.select(0, 0) - textArea.forceActiveFocus() + searchVisible = false; + searchQuery = ""; + searchMatches = []; + matchCount = 0; + currentMatchIndex = -1; + textArea.select(0, 0); + textArea.forceActiveFocus(); } function copyPlainTextToClipboard() { - if (!inlinePreviewVisible || !textArea.text) return - - const content = textArea.text + if (!inlinePreviewVisible || !textArea.text) + return; + const content = textArea.text; if (content.length > 0) { const proc = Qt.createQmlObject(` import QtQuick @@ -249,22 +239,19 @@ Column { command: ["sh", "-c", "printf '%s' \\"$CONTENT\\" | dms clipboard copy"] environment: { "CONTENT": content } running: false - }`, - root, - "copyProc" - ) - proc.content = content - proc.running = true + }`, root, "copyProc"); + proc.content = content; + proc.running = true; proc.exited.connect(() => { - ToastService.showInfo(I18n.tr("Copied to clipboard")) - proc.destroy() - }) + ToastService.showInfo(I18n.tr("Copied to clipboard")); + proc.destroy(); + }); } } function copyHtmlToClipboard() { - if (!inlinePreviewVisible || !pluginHighlightedHtml) return - + if (!inlinePreviewVisible || !pluginHighlightedHtml) + return; if (pluginHighlightedHtml.length > 0) { const proc = Qt.createQmlObject(` import QtQuick @@ -274,16 +261,13 @@ Column { command: ["sh", "-c", "printf '%s' \\"$CONTENT\\" | dms clipboard copy"] environment: { "CONTENT": content } running: false - }`, - root, - "copyProcHtml" - ) - proc.content = pluginHighlightedHtml - proc.running = true + }`, root, "copyProcHtml"); + proc.content = pluginHighlightedHtml; + proc.running = true; proc.exited.connect(() => { - ToastService.showInfo(I18n.tr("HTML copied to clipboard")) - proc.destroy() - }) + ToastService.showInfo(I18n.tr("HTML copied to clipboard")); + proc.destroy(); + }); } } @@ -334,43 +318,43 @@ Column { clip: true Component.onCompleted: { - text = root.searchQuery + text = root.searchQuery; } Connections { target: root function onSearchQueryChanged() { if (searchField.text !== root.searchQuery) { - searchField.text = root.searchQuery + searchField.text = root.searchQuery; } } } onTextChanged: { if (root.searchQuery !== text) { - root.searchQuery = text - root.performSearch() + root.searchQuery = text; + root.performSearch(); } } Keys.onEscapePressed: event => { - root.hideSearch() - event.accepted = true + root.hideSearch(); + event.accepted = true; } Keys.onReturnPressed: event => { if (event.modifiers & Qt.ShiftModifier) { - root.findPrevious() + root.findPrevious(); } else { - root.findNext() + root.findNext(); } - event.accepted = true + event.accepted = true; } Keys.onEnterPressed: event => { if (event.modifiers & Qt.ShiftModifier) { - root.findPrevious() + root.findPrevious(); } else { - root.findNext() + root.findNext(); } - event.accepted = true + event.accepted = true; } } @@ -541,31 +525,41 @@ Column { SequentialAnimation on opacity { running: textArea.activeFocus loops: Animation.Infinite - PropertyAnimation { from: 1.0; to: 0.0; duration: 650; easing.type: Easing.InOutQuad } - PropertyAnimation { from: 0.0; to: 1.0; duration: 650; easing.type: Easing.InOutQuad } + OpacityAnimator { + from: 1.0 + to: 0.0 + duration: 650 + easing.type: Easing.InOutQuad + } + OpacityAnimator { + from: 0.0 + to: 1.0 + duration: 650 + easing.type: Easing.InOutQuad + } } } Component.onCompleted: { - loadCurrentTabContent() - setTextDocumentLineHeight() - root.updateLineModel() + loadCurrentTabContent(); + setTextDocumentLineHeight(); + root.updateLineModel(); Qt.callLater(() => { - textArea.forceActiveFocus() - }) + textArea.forceActiveFocus(); + }); } Connections { target: NotepadStorageService function onCurrentTabIndexChanged() { - loadCurrentTabContent() + loadCurrentTabContent(); Qt.callLater(() => { - textArea.forceActiveFocus() - }) + textArea.forceActiveFocus(); + }); } function onTabsChanged() { if (NotepadStorageService.tabs.length > 0 && !contentLoaded) { - loadCurrentTabContent() + loadCurrentTabContent(); } } } @@ -573,53 +567,53 @@ Column { Connections { target: SettingsData function onNotepadShowLineNumbersChanged() { - root.updateLineModel() + root.updateLineModel(); } } onTextChanged: { if (contentLoaded && text !== lastSavedContent) { - autoSaveTimer.restart() + autoSaveTimer.restart(); } - root.contentChanged() - root.updateLineModel() - pluginSyncTimer.restart() + root.contentChanged(); + root.updateLineModel(); + pluginSyncTimer.restart(); } - Keys.onEscapePressed: (event) => { - root.escapePressed() - event.accepted = true + Keys.onEscapePressed: event => { + root.escapePressed(); + event.accepted = true; } - Keys.onPressed: (event) => { + Keys.onPressed: event => { if (event.modifiers & Qt.ControlModifier) { switch (event.key) { case Qt.Key_S: - event.accepted = true - root.saveRequested() - break + event.accepted = true; + root.saveRequested(); + break; case Qt.Key_O: - event.accepted = true - root.openRequested() - break + event.accepted = true; + root.openRequested(); + break; case Qt.Key_N: - event.accepted = true - root.newRequested() - break + event.accepted = true; + root.newRequested(); + break; case Qt.Key_A: - event.accepted = true - textArea.selectAll() - break + event.accepted = true; + textArea.selectAll(); + break; case Qt.Key_F: - event.accepted = true - root.showSearch() - break + event.accepted = true; + root.showSearch(); + break; case Qt.Key_P: if (PluginService.isPluginLoaded("dankNotepadModule")) { - event.accepted = true - root.previewRequested() + event.accepted = true; + root.previewRequested(); } - break + break; } } } @@ -845,19 +839,16 @@ Column { StyledText { text: { const len = textArea.text.length; - if (len === 0) return I18n.tr("Empty"); - return len === 1 - ? I18n.tr("%1 character").arg(len) - : I18n.tr("%1 characters").arg(len); + if (len === 0) + return I18n.tr("Empty"); + return len === 1 ? I18n.tr("%1 character").arg(len) : I18n.tr("%1 characters").arg(len); } font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceTextMedium } StyledText { - text: textArea.lineCount === 1 - ? I18n.tr("Line: %1").arg(textArea.lineCount) - : I18n.tr("Lines: %1").arg(textArea.lineCount) + text: textArea.lineCount === 1 ? I18n.tr("Line: %1").arg(textArea.lineCount) : I18n.tr("Lines: %1").arg(textArea.lineCount) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceTextMedium visible: textArea.text.length > 0 @@ -867,29 +858,29 @@ Column { StyledText { text: { if (autoSaveTimer.running) { - return I18n.tr("Auto-saving...") + return I18n.tr("Auto-saving..."); } if (hasUnsavedChanges()) { if (currentTab && currentTab.isTemporary) { - return I18n.tr("Unsaved note...") + return I18n.tr("Unsaved note..."); } else { - return I18n.tr("Unsaved changes") + return I18n.tr("Unsaved changes"); } } else { - return I18n.tr("Saved") + return I18n.tr("Saved"); } } font.pixelSize: Theme.fontSizeSmall color: { if (autoSaveTimer.running) { - return Theme.primary + return Theme.primary; } if (hasUnsavedChanges()) { - return Theme.warning + return Theme.warning; } else { - return Theme.success + return Theme.success; } } opacity: textArea.text.length > 0 ? 1.0 : 0.0 @@ -902,7 +893,7 @@ Column { interval: 2000 repeat: false onTriggered: { - autoSaveToSession() + autoSaveToSession(); } } @@ -917,7 +908,7 @@ Column { target: SettingsData function onBuiltInPluginSettingsChanged() { if (PluginService.isPluginLoaded("dankNotepadModule")) { - pluginHighlightedHtml = SettingsData.getBuiltInPluginSetting("dankNotepadModule", "highlightedHtml", "") + pluginHighlightedHtml = SettingsData.getBuiltInPluginSetting("dankNotepadModule", "highlightedHtml", ""); } } } diff --git a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml index 855163dd..9587e7a6 100644 --- a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml +++ b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml @@ -16,8 +16,7 @@ DankListView { property bool listInitialized: false property int swipingCardIndex: -1 property real swipingCardOffset: 0 - property real __pendingStableHeight: 0 - property real __heightUpdateThreshold: 20 + property bool _stableHeightUpdatePending: false readonly property real shadowBlurPx: Theme.elevationEnabled ? ((Theme.elevationLevel1 && Theme.elevationLevel1.blurPx !== undefined) ? Theme.elevationLevel1.blurPx : 4) : 0 readonly property real shadowHorizontalGutter: Theme.snap(Math.max(Theme.spacingS, Math.min(32, shadowBlurPx * 1.5 + 6)), 1) readonly property real shadowVerticalGutter: Theme.snap(Math.max(Theme.spacingXS, 6), 1) @@ -27,51 +26,52 @@ DankListView { Qt.callLater(() => { if (listView) { listView.listInitialized = true; - listView.stableContentHeight = listView.contentHeight; + listView.syncStableContentHeight(false); } }); } - Timer { - id: heightUpdateDebounce - interval: Theme.mediumDuration + 20 - repeat: false - onTriggered: { - if (!listView.isAnimatingExpansion && Math.abs(listView.__pendingStableHeight - listView.stableContentHeight) > listView.__heightUpdateThreshold) { - listView.stableContentHeight = listView.__pendingStableHeight; - } + function targetContentHeight() { + if (count <= 0) + return contentHeight; + + let total = topMargin + bottomMargin + Math.max(0, count - 1) * spacing; + for (let i = 0; i < count; i++) { + const item = itemAtIndex(i); + if (!item || item.nonAnimHeight === undefined) + return contentHeight; + total += item.nonAnimHeight; } + return Math.max(0, total); + } + + function syncStableContentHeight(useTarget) { + const nextHeight = useTarget ? targetContentHeight() : contentHeight; + if (Math.abs(nextHeight - stableContentHeight) <= 0.5) + return; + stableContentHeight = nextHeight; + } + + function queueStableContentHeightUpdate(useTarget) { + if (_stableHeightUpdatePending) + return; + _stableHeightUpdatePending = true; + Qt.callLater(() => { + _stableHeightUpdatePending = false; + syncStableContentHeight(useTarget || isAnimatingExpansion); + }); } onContentHeightChanged: { - if (!isAnimatingExpansion) { - __pendingStableHeight = contentHeight; - if (Math.abs(contentHeight - stableContentHeight) > __heightUpdateThreshold) { - heightUpdateDebounce.restart(); - } else { - stableContentHeight = contentHeight; - } - } + if (!isAnimatingExpansion) + queueStableContentHeightUpdate(false); } onIsAnimatingExpansionChanged: { if (isAnimatingExpansion) { - heightUpdateDebounce.stop(); - let delta = 0; - for (let i = 0; i < count; i++) { - const item = itemAtIndex(i); - if (item && item.children[0] && item.children[0].isAnimating) { - const targetDelegateHeight = item.children[0].targetHeight + listView.delegateShadowGutter; - delta += targetDelegateHeight - item.height; - } - } - const targetHeight = contentHeight + delta; - // During expansion, always update immediately without threshold check - stableContentHeight = targetHeight; + syncStableContentHeight(true); } else { - __pendingStableHeight = contentHeight; - heightUpdateDebounce.stop(); - stableContentHeight = __pendingStableHeight; + queueStableContentHeightUpdate(false); } } @@ -148,11 +148,14 @@ DankListView { readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(listView.swipingCardOffset) / width * 0.02 : 1.0 readonly property real swipeFadeStartOffset: width * 0.75 readonly property real swipeFadeDistance: Math.max(1, width - swipeFadeStartOffset) + readonly property real nonAnimHeight: notificationCard.targetHeight + listView.delegateShadowGutter Component.onCompleted: { Qt.callLater(() => { - if (delegateRoot) + if (delegateRoot) { delegateRoot.__delegateInitialized = true; + listView.queueStableContentHeightUpdate(listView.isAnimatingExpansion); + } }); } @@ -180,6 +183,7 @@ DankListView { onIsAnimatingChanged: { if (isAnimating) { listView.isAnimatingExpansion = true; + listView.syncStableContentHeight(true); } else { Qt.callLater(() => { if (!notificationCard || !listView) @@ -197,6 +201,13 @@ DankListView { } } + onTargetHeightChanged: { + if (isAnimating || listView.isAnimatingExpansion) + listView.syncStableContentHeight(true); + else + listView.queueStableContentHeightUpdate(false); + } + isGroupSelected: { if (!keyboardController || !keyboardController.keyboardNavigationActive || !listView.keyboardActive) return false; diff --git a/quickshell/Modules/Notifications/Center/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml index c20db57a..8b9c03c5 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCard.qml @@ -15,6 +15,13 @@ Rectangle { property bool userInitiatedExpansion: false property bool isAnimating: false property bool animateExpansion: true + property bool isDescriptionToggleAnimation: false + property bool _retainedExpandedContent: false + property bool _clipAnimatedContent: false + property real expandedContentOpacity: expanded ? 1 : 0 + property real collapsedContentOpacity: expanded ? 0 : 1 + readonly property bool renderExpandedContent: expanded || _retainedExpandedContent + readonly property bool renderCollapsedContent: !expanded property bool isGroupSelected: false property int selectedNotificationIndex: -1 @@ -33,11 +40,12 @@ Rectangle { readonly property real actionButtonHeight: compactMode ? 20 : 24 readonly property real collapsedContentHeight: Math.max(iconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) readonly property real baseCardHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing + readonly property bool connectedFrameMode: SettingsData.connectedFrameModeActive width: parent ? parent.width : 400 height: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight) readonly property real targetHeight: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight) - radius: Theme.cornerRadius + radius: connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius scale: (cardHoverHandler.hovered ? 1.004 : 1.0) * listLevelAdjacentScaleInfluence readonly property bool shadowsAllowed: Theme.elevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !BlurService.enabled readonly property var shadowElevation: Theme.elevationLevel1 @@ -55,6 +63,16 @@ Rectangle { }); } + function expansionMotionDuration() { + if (isDescriptionToggleAnimation) + return descriptionExpanded ? Theme.notificationInlineExpandDuration : Theme.notificationInlineCollapseDuration; + return Theme.variantDuration(Theme.popoutAnimationDuration, root.expanded); + } + + function expansionMotionCurve() { + return root.expanded ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve; + } + Behavior on scale { enabled: listLevelScaleAnimationsEnabled NumberAnimation { @@ -64,6 +82,7 @@ Rectangle { } Behavior on shadowBlurPx { + enabled: !root.connectedFrameMode NumberAnimation { duration: Theme.shortDuration easing.type: Theme.standardEasing @@ -71,6 +90,7 @@ Rectangle { } Behavior on shadowOffsetXPx { + enabled: !root.connectedFrameMode NumberAnimation { duration: Theme.shortDuration easing.type: Theme.standardEasing @@ -78,6 +98,7 @@ Rectangle { } Behavior on shadowOffsetYPx { + enabled: !root.connectedFrameMode NumberAnimation { duration: Theme.shortDuration easing.type: Theme.standardEasing @@ -92,6 +113,24 @@ Rectangle { } } + Behavior on expandedContentOpacity { + enabled: root.__initialized && root.userInitiatedExpansion && root.animateExpansion + NumberAnimation { + duration: root.expansionMotionDuration() + easing.type: Easing.BezierSpline + easing.bezierCurve: root.expansionMotionCurve() + } + } + + Behavior on collapsedContentOpacity { + enabled: root.__initialized && root.userInitiatedExpansion && root.animateExpansion + NumberAnimation { + duration: root.expansionMotionDuration() + easing.type: Easing.BezierSpline + easing.bezierCurve: root.expansionMotionCurve() + } + } + color: { if (isGroupSelected && keyboardNavigationActive) { return Theme.primaryPressed; @@ -125,7 +164,31 @@ Rectangle { } return Theme.layerOutlineWidth; } - clip: false + clip: _clipAnimatedContent + + onExpandedChanged: { + if (__initialized && userInitiatedExpansion && animateExpansion) + _clipAnimatedContent = true; + if (expanded) { + _retainedExpandedContent = false; + return; + } + if (__initialized && userInitiatedExpansion && animateExpansion) + _retainedExpandedContent = true; + } + + onHeightChanged: { + if (Math.abs(height - targetHeight) > 0.5) + return; + _clipAnimatedContent = false; + if (!expanded && _retainedExpandedContent) + _retainedExpandedContent = false; + } + + onExpandedContentOpacityChanged: { + if (!expanded && _retainedExpandedContent && expandedContentOpacity <= 0.01) + _retainedExpandedContent = false; + } HoverHandler { id: cardHoverHandler @@ -145,7 +208,7 @@ Rectangle { shadowOffsetX: root.shadowOffsetXPx shadowOffsetY: root.shadowOffsetYPx shadowColor: root.shadowElevation ? Theme.elevationShadowColor(root.shadowElevation) : "transparent" - shadowEnabled: root.shadowsAllowed + shadowEnabled: root.shadowsAllowed && !root.connectedFrameMode } Rectangle { @@ -185,7 +248,8 @@ Rectangle { anchors.leftMargin: Theme.spacingL anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin height: collapsedContentHeight + extraHeight - visible: !expanded + visible: renderCollapsedContent + opacity: root.collapsedContentOpacity DankCircularImage { id: iconContainer @@ -348,6 +412,7 @@ Rectangle { onClicked: mouse => { if (!parent.hoveredLink && (parent.hasMoreText || descriptionExpanded)) { root.userInitiatedExpansion = true; + root.isDescriptionToggleAnimation = true; const messageId = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.notification && notificationGroup.latestNotification.notification.id) ? (notificationGroup.latestNotification.notification.id + "_desc") : ""; NotificationService.toggleMessageExpansion(messageId); Qt.callLater(() => { @@ -357,7 +422,7 @@ Rectangle { } } - propagateComposedEvents: true + propagateComposedEvents: false onPressed: mouse => { if (parent.hoveredLink) mouse.accepted = false; @@ -382,7 +447,8 @@ Rectangle { anchors.leftMargin: Theme.spacingL anchors.rightMargin: Theme.spacingL spacing: compactMode ? Theme.spacingXS : Theme.spacingS - visible: expanded + visible: renderExpandedContent + opacity: root.expandedContentOpacity Item { width: parent.width @@ -513,7 +579,12 @@ Rectangle { } Behavior on height { - enabled: false + enabled: expandedDelegateWrapper.__delegateInitialized && root.animateExpansion && root.userInitiatedExpansion + NumberAnimation { + duration: root.expansionMotionDuration() + easing.type: Easing.BezierSpline + easing.bezierCurve: root.expansionMotionCurve() + } } Item { @@ -650,6 +721,7 @@ Rectangle { onClicked: mouse => { if (!parent.hoveredLink && (bodyText.hasMoreText || messageExpanded)) { root.userInitiatedExpansion = true; + root.isDescriptionToggleAnimation = true; NotificationService.toggleMessageExpansion(modelData?.notification?.id || ""); Qt.callLater(() => { if (root && !root.isAnimating) @@ -658,7 +730,7 @@ Rectangle { } } - propagateComposedEvents: true + propagateComposedEvents: false onPressed: mouse => { if (parent.hoveredLink) { mouse.accepted = false; @@ -825,7 +897,8 @@ Rectangle { } Row { - visible: !expanded + visible: renderCollapsedContent + opacity: root.collapsedContentOpacity anchors.right: clearButton.visible ? clearButton.left : parent.right anchors.rightMargin: clearButton.visible ? contentSpacing : Theme.spacingL anchors.top: collapsedContent.bottom @@ -882,7 +955,8 @@ Rectangle { property bool isHovered: false readonly property int actionCount: (notificationGroup?.latestNotification?.actions || []).length - visible: !expanded && actionCount < 3 + visible: renderCollapsedContent && actionCount < 3 + opacity: root.collapsedContentOpacity anchors.right: parent.right anchors.rightMargin: Theme.spacingL anchors.top: collapsedContent.bottom @@ -913,10 +987,11 @@ Rectangle { MouseArea { anchors.fill: parent - visible: !expanded && (notificationGroup?.count || 0) > 1 && !descriptionExpanded + visible: renderCollapsedContent && (notificationGroup?.count || 0) > 1 && !descriptionExpanded cursorShape: Qt.PointingHandCursor onClicked: { root.userInitiatedExpansion = true; + root.isDescriptionToggleAnimation = false; NotificationService.toggleGroupExpansion(notificationGroup?.key || ""); } z: -1 @@ -940,6 +1015,7 @@ Rectangle { buttonSize: compactMode ? 24 : 28 onClicked: { root.userInitiatedExpansion = true; + root.isDescriptionToggleAnimation = false; NotificationService.toggleGroupExpansion(notificationGroup?.key || ""); } } @@ -957,15 +1033,18 @@ Rectangle { Behavior on height { enabled: root.__initialized && root.userInitiatedExpansion && root.animateExpansion NumberAnimation { - duration: root.expanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration + duration: root.expansionMotionDuration() easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasized + easing.bezierCurve: root.expansionMotionCurve() onRunningChanged: { if (running) { root.isAnimating = true; } else { root.isAnimating = false; root.userInitiatedExpansion = false; + root.isDescriptionToggleAnimation = false; + root._retainedExpandedContent = false; + root._clipAnimatedContent = false; } } } diff --git a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml index 809d0446..9c1c7672 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml @@ -14,6 +14,7 @@ DankPopout { property real stablePopupHeight: 400 property real _lastAlignedContentHeight: -1 property bool _pendingSizedOpen: false + property bool _heightUpdatePending: false function updateStablePopupHeight() { const item = contentLoader.item; @@ -30,6 +31,16 @@ DankPopout { stablePopupHeight = target; } + function queueStablePopupHeightUpdate() { + if (_heightUpdatePending) + return; + _heightUpdatePending = true; + Qt.callLater(() => { + _heightUpdatePending = false; + updateStablePopupHeight(); + }); + } + NotificationKeyboardController { id: keyboardController listView: null @@ -39,11 +50,9 @@ DankPopout { } } - popupWidth: triggerScreen ? Math.min(500, Math.max(380, triggerScreen.width - 48)) : 400 + popupWidth: 400 popupHeight: stablePopupHeight positioning: "" - animationScaleCollapsed: 0.94 - animationOffset: 0 suspendShadowWhileResizing: false screen: triggerScreen @@ -130,7 +139,7 @@ DankPopout { Connections { target: contentLoader.item function onImplicitHeightChanged() { - root.updateStablePopupHeight(); + root.queueStablePopupHeightUpdate(); } } diff --git a/quickshell/Modules/Notifications/Center/NotificationSettings.qml b/quickshell/Modules/Notifications/Center/NotificationSettings.qml index ea377527..829471b4 100644 --- a/quickshell/Modules/Notifications/Center/NotificationSettings.qml +++ b/quickshell/Modules/Notifications/Center/NotificationSettings.qml @@ -123,327 +123,327 @@ Rectangle { anchors.margins: Theme.spacingL spacing: Theme.spacingM - StyledText { - text: I18n.tr("Notification Settings") - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Bold - color: Theme.surfaceText - } - - Item { - width: parent.width - height: Math.max(dndRow.implicitHeight, dndToggle.implicitHeight) + Theme.spacingS - - Row { - id: dndRow - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingM - - DankIcon { - name: SessionData.doNotDisturb ? "notifications_off" : "notifications" - size: Theme.iconSizeSmall - color: SessionData.doNotDisturb ? Theme.error : Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - - StyledText { - text: I18n.tr("Do Not Disturb") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } + StyledText { + text: I18n.tr("Notification Settings") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Bold + color: Theme.surfaceText } - DankToggle { - id: dndToggle - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: SessionData.doNotDisturb - onToggled: SessionData.setDoNotDisturb(!SessionData.doNotDisturb) - } - } + Item { + width: parent.width + height: Math.max(dndRow.implicitHeight, dndToggle.implicitHeight) + Theme.spacingS - Rectangle { - width: parent.width - height: 1 - color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1) - } + Row { + id: dndRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM - StyledText { - text: I18n.tr("Notification Timeouts") - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - color: Theme.surfaceVariantText - } - - DankDropdown { - text: I18n.tr("Low Priority") - description: I18n.tr("Timeout for low priority notifications") - currentValue: getTimeoutText(SettingsData.notificationTimeoutLow) - options: timeoutOptions.map(opt => opt.text) - onValueChanged: value => { - for (let i = 0; i < timeoutOptions.length; i++) { - if (timeoutOptions[i].text === value) { - SettingsData.set("notificationTimeoutLow", timeoutOptions[i].value); - break; + DankIcon { + name: SessionData.doNotDisturb ? "notifications_off" : "notifications" + size: Theme.iconSizeSmall + color: SessionData.doNotDisturb ? Theme.error : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter } - } - } - } - - DankDropdown { - text: I18n.tr("Normal Priority") - description: I18n.tr("Timeout for normal priority notifications") - currentValue: getTimeoutText(SettingsData.notificationTimeoutNormal) - options: timeoutOptions.map(opt => opt.text) - onValueChanged: value => { - for (let i = 0; i < timeoutOptions.length; i++) { - if (timeoutOptions[i].text === value) { - SettingsData.set("notificationTimeoutNormal", timeoutOptions[i].value); - break; - } - } - } - } - - DankDropdown { - text: I18n.tr("Critical Priority") - description: I18n.tr("Timeout for critical priority notifications") - currentValue: getTimeoutText(SettingsData.notificationTimeoutCritical) - options: timeoutOptions.map(opt => opt.text) - onValueChanged: value => { - for (let i = 0; i < timeoutOptions.length; i++) { - if (timeoutOptions[i].text === value) { - SettingsData.set("notificationTimeoutCritical", timeoutOptions[i].value); - break; - } - } - } - } - - Rectangle { - width: parent.width - height: 1 - color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1) - } - - Item { - width: parent.width - height: Math.max(overlayRow.implicitHeight, overlayToggle.implicitHeight) + Theme.spacingS - - Row { - id: overlayRow - anchors.left: parent.left - anchors.right: overlayToggle.left - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingM - - DankIcon { - name: "notifications_active" - size: Theme.iconSizeSmall - color: SettingsData.notificationOverlayEnabled ? Theme.primary : Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - - Column { - spacing: 2 - anchors.verticalCenter: parent.verticalCenter - width: overlayRow.width - Theme.iconSizeSmall - Theme.spacingM StyledText { - width: parent.width - text: I18n.tr("Notification Overlay") + text: I18n.tr("Do Not Disturb") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText - wrapMode: Text.Wrap + anchors.verticalCenter: parent.verticalCenter } + } - StyledText { - width: parent.width - text: I18n.tr("Display all priorities over fullscreen apps") - font.pixelSize: Theme.fontSizeSmall - 1 - color: Theme.surfaceVariantText - wrapMode: Text.Wrap + DankToggle { + id: dndToggle + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: SessionData.doNotDisturb + onToggled: SessionData.setDoNotDisturb(!SessionData.doNotDisturb) + } + } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1) + } + + StyledText { + text: I18n.tr("Notification Timeouts") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + } + + DankDropdown { + text: I18n.tr("Low Priority") + description: I18n.tr("Timeout for low priority notifications") + currentValue: getTimeoutText(SettingsData.notificationTimeoutLow) + options: timeoutOptions.map(opt => opt.text) + onValueChanged: value => { + for (let i = 0; i < timeoutOptions.length; i++) { + if (timeoutOptions[i].text === value) { + SettingsData.set("notificationTimeoutLow", timeoutOptions[i].value); + break; + } } } } - DankToggle { - id: overlayToggle - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: SettingsData.notificationOverlayEnabled - onToggled: toggled => SettingsData.set("notificationOverlayEnabled", toggled) + DankDropdown { + text: I18n.tr("Normal Priority") + description: I18n.tr("Timeout for normal priority notifications") + currentValue: getTimeoutText(SettingsData.notificationTimeoutNormal) + options: timeoutOptions.map(opt => opt.text) + onValueChanged: value => { + for (let i = 0; i < timeoutOptions.length; i++) { + if (timeoutOptions[i].text === value) { + SettingsData.set("notificationTimeoutNormal", timeoutOptions[i].value); + break; + } + } + } } - } - Item { - width: parent.width - height: Math.max(privacyRow.implicitHeight, privacyToggle.implicitHeight) + Theme.spacingS + DankDropdown { + text: I18n.tr("Critical Priority") + description: I18n.tr("Timeout for critical priority notifications") + currentValue: getTimeoutText(SettingsData.notificationTimeoutCritical) + options: timeoutOptions.map(opt => opt.text) + onValueChanged: value => { + for (let i = 0; i < timeoutOptions.length; i++) { + if (timeoutOptions[i].text === value) { + SettingsData.set("notificationTimeoutCritical", timeoutOptions[i].value); + break; + } + } + } + } - Row { - id: privacyRow - anchors.left: parent.left - anchors.right: privacyToggle.left - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingM + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1) + } - DankIcon { - name: "privacy_tip" - size: Theme.iconSizeSmall - color: SettingsData.notificationPopupPrivacyMode ? Theme.primary : Theme.surfaceText + Item { + width: parent.width + height: Math.max(overlayRow.implicitHeight, overlayToggle.implicitHeight) + Theme.spacingS + + Row { + id: overlayRow + anchors.left: parent.left + anchors.right: overlayToggle.left + anchors.rightMargin: Theme.spacingM anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "notifications_active" + size: Theme.iconSizeSmall + color: SettingsData.notificationOverlayEnabled ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + spacing: 2 + anchors.verticalCenter: parent.verticalCenter + width: overlayRow.width - Theme.iconSizeSmall - Theme.spacingM + + StyledText { + width: parent.width + text: I18n.tr("Notification Overlay") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + wrapMode: Text.Wrap + } + + StyledText { + width: parent.width + text: I18n.tr("Display all priorities over fullscreen apps") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + wrapMode: Text.Wrap + } + } } - Column { - spacing: 2 + DankToggle { + id: overlayToggle + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - width: privacyRow.width - Theme.iconSizeSmall - Theme.spacingM + checked: SettingsData.notificationOverlayEnabled + onToggled: toggled => SettingsData.set("notificationOverlayEnabled", toggled) + } + } + + Item { + width: parent.width + height: Math.max(privacyRow.implicitHeight, privacyToggle.implicitHeight) + Theme.spacingS + + Row { + id: privacyRow + anchors.left: parent.left + anchors.right: privacyToggle.left + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "privacy_tip" + size: Theme.iconSizeSmall + color: SettingsData.notificationPopupPrivacyMode ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + spacing: 2 + anchors.verticalCenter: parent.verticalCenter + width: privacyRow.width - Theme.iconSizeSmall - Theme.spacingM + + StyledText { + width: parent.width + text: I18n.tr("Privacy Mode") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + wrapMode: Text.Wrap + } + + StyledText { + width: parent.width + text: I18n.tr("Hide notification content until expanded") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + wrapMode: Text.Wrap + } + } + } + + DankToggle { + id: privacyToggle + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: SettingsData.notificationPopupPrivacyMode + onToggled: toggled => SettingsData.set("notificationPopupPrivacyMode", toggled) + } + } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1) + } + + StyledText { + text: I18n.tr("History Settings") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + } + + Item { + width: parent.width + height: Math.max(lowRow.implicitHeight, lowToggle.implicitHeight) + Theme.spacingS + + Row { + id: lowRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "low_priority" + size: Theme.iconSizeSmall + color: SettingsData.notificationHistorySaveLow ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } StyledText { - width: parent.width - text: I18n.tr("Privacy Mode") + text: I18n.tr("Low Priority") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText - wrapMode: Text.Wrap + anchors.verticalCenter: parent.verticalCenter + } + } + + DankToggle { + id: lowToggle + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: SettingsData.notificationHistorySaveLow + onToggled: toggled => SettingsData.set("notificationHistorySaveLow", toggled) + } + } + + Item { + width: parent.width + height: Math.max(normalRow.implicitHeight, normalToggle.implicitHeight) + Theme.spacingS + + Row { + id: normalRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "notifications" + size: Theme.iconSizeSmall + color: SettingsData.notificationHistorySaveNormal ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter } StyledText { - width: parent.width - text: I18n.tr("Hide notification content until expanded") - font.pixelSize: Theme.fontSizeSmall - 1 - color: Theme.surfaceVariantText - wrapMode: Text.Wrap + text: I18n.tr("Normal Priority") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter } } - } - DankToggle { - id: privacyToggle - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: SettingsData.notificationPopupPrivacyMode - onToggled: toggled => SettingsData.set("notificationPopupPrivacyMode", toggled) - } - } - - Rectangle { - width: parent.width - height: 1 - color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1) - } - - StyledText { - text: I18n.tr("History Settings") - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - color: Theme.surfaceVariantText - } - - Item { - width: parent.width - height: Math.max(lowRow.implicitHeight, lowToggle.implicitHeight) + Theme.spacingS - - Row { - id: lowRow - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingM - - DankIcon { - name: "low_priority" - size: Theme.iconSizeSmall - color: SettingsData.notificationHistorySaveLow ? Theme.primary : Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - - StyledText { - text: I18n.tr("Low Priority") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText + DankToggle { + id: normalToggle + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter + checked: SettingsData.notificationHistorySaveNormal + onToggled: toggled => SettingsData.set("notificationHistorySaveNormal", toggled) } } - DankToggle { - id: lowToggle - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: SettingsData.notificationHistorySaveLow - onToggled: toggled => SettingsData.set("notificationHistorySaveLow", toggled) + Item { + width: parent.width + height: Math.max(criticalRow.implicitHeight, criticalToggle.implicitHeight) + Theme.spacingS + + Row { + id: criticalRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "priority_high" + size: Theme.iconSizeSmall + color: SettingsData.notificationHistorySaveCritical ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Critical Priority") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + DankToggle { + id: criticalToggle + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: SettingsData.notificationHistorySaveCritical + onToggled: toggled => SettingsData.set("notificationHistorySaveCritical", toggled) + } } } - - Item { - width: parent.width - height: Math.max(normalRow.implicitHeight, normalToggle.implicitHeight) + Theme.spacingS - - Row { - id: normalRow - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingM - - DankIcon { - name: "notifications" - size: Theme.iconSizeSmall - color: SettingsData.notificationHistorySaveNormal ? Theme.primary : Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - - StyledText { - text: I18n.tr("Normal Priority") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - } - - DankToggle { - id: normalToggle - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: SettingsData.notificationHistorySaveNormal - onToggled: toggled => SettingsData.set("notificationHistorySaveNormal", toggled) - } - } - - Item { - width: parent.width - height: Math.max(criticalRow.implicitHeight, criticalToggle.implicitHeight) + Theme.spacingS - - Row { - id: criticalRow - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingM - - DankIcon { - name: "priority_high" - size: Theme.iconSizeSmall - color: SettingsData.notificationHistorySaveCritical ? Theme.primary : Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - - StyledText { - text: I18n.tr("Critical Priority") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - } - - DankToggle { - id: criticalToggle - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: SettingsData.notificationHistorySaveCritical - onToggled: toggled => SettingsData.set("notificationHistorySaveCritical", toggled) - } - } - } } } diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index d7a4baa8..a29ecfa6 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -10,13 +10,40 @@ import qs.Widgets PanelWindow { id: win + readonly property bool connectedFrameMode: SettingsData.frameEnabled && Theme.isConnectedEffect && SettingsData.isScreenInPreferences(win.screen, SettingsData.frameScreenPreferences) + readonly property string notifBarSide: { + const pos = SettingsData.notificationPopupPosition; + if (pos === -1) + return "top"; + switch (pos) { + case SettingsData.Position.Top: + return "right"; + case SettingsData.Position.Left: + return "left"; + case SettingsData.Position.BottomCenter: + return "bottom"; + case SettingsData.Position.Right: + return "right"; + case SettingsData.Position.Bottom: + return "left"; + default: + return "top"; + } + } + readonly property int inlineExpandDuration: Theme.notificationInlineExpandDuration + readonly property int inlineCollapseDuration: Theme.notificationInlineCollapseDuration + property bool inlineHeightAnimating: false + WindowBlur { targetWindow: win - blurX: content.x + content.cardInset + swipeTx.x + tx.x - blurY: content.y + content.cardInset + swipeTx.y + tx.y - blurWidth: !win._finalized ? Math.max(0, content.width - content.cardInset * 2) : 0 - blurHeight: !win._finalized ? Math.max(0, content.height - content.cardInset * 2) : 0 - blurRadius: Theme.cornerRadius + readonly property real s: Math.min(1, content.scale) * Math.max(0, content.opacity) + readonly property real innerW: Math.max(0, content.width - content.cardInset * 2) + readonly property real innerH: Math.max(0, content.height - content.cardInset * 2) + blurX: content.x + content.cardInset + swipeTx.x + tx.x + innerW * (1 - s) * 0.5 + blurY: content.y + content.cardInset + swipeTx.y + tx.y + innerH * (1 - s) * 0.5 + blurWidth: !win._finalized && !win.connectedFrameMode ? innerW * s : 0 + blurHeight: !win._finalized && !win.connectedFrameMode ? innerH * s : 0 + blurRadius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius } WlrLayershell.namespace: "dms:notification-popup" @@ -25,6 +52,15 @@ PanelWindow { required property string notificationId readonly property bool hasValidData: notificationData && notificationData.notification readonly property alias hovered: cardHoverHandler.hovered + readonly property alias swipeActive: content.swipeActive + readonly property alias swipeDismissing: content.swipeDismissing + readonly property bool swipeDismissTowardEdge: { + if (content.swipeDismissing) + return _swipeDismissesTowardFrameEdge(); + if (content.swipeActive) + return content.swipeOffset * _frameEdgeSwipeDirection() > 0; + return false; + } property int screenY: 0 property bool exiting: false property bool _isDestroying: false @@ -32,18 +68,36 @@ PanelWindow { property real _lastReportedAlignedHeight: -1 property real _storedTopMargin: 0 property real _storedBottomMargin: 0 + property bool _inlineGeometryReady: false + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real entryTravel: { + const base = Math.abs(Theme.effectAnimOffset); + if (directionalEffect) { + if (isCenterPosition) + return Math.max(base, Math.round(content.height * 1.1)); + return Math.max(base, Math.round(content.width * 0.95)); + } + if (depthEffect) + return Math.max(base, 44); + return base; + } + readonly property real exitTravel: { + if (directionalEffect) { + if (isCenterPosition) + return Math.max(1, content.height); + return Math.max(1, content.width); + } + if (depthEffect) + return Math.round(entryTravel * 1.35); + return Anims.slidePx; + } readonly property string clearText: I18n.tr("Dismiss") property bool descriptionExpanded: false readonly property bool hasExpandableBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0 onDescriptionExpandedChanged: { - popupHeightChanged(); - } - onImplicitHeightChanged: { - const aligned = Theme.px(implicitHeight, dpr); - if (Math.abs(aligned - _lastReportedAlignedHeight) < 0.5) - return; - _lastReportedAlignedHeight = aligned; - popupHeightChanged(); + if (connectedFrameMode) + popupChromeGeometryChanged(); } readonly property bool compactMode: SettingsData.notificationCompactMode @@ -61,6 +115,7 @@ PanelWindow { signal exitStarted signal exitFinished signal popupHeightChanged + signal popupChromeGeometryChanged function startExit() { if (exiting || _isDestroying) { @@ -68,6 +123,7 @@ PanelWindow { } exiting = true; exitStarted(); + popupChromeGeometryChanged(); exitAnim.restart(); exitWatchdog.restart(); if (NotificationService.removeFromVisibleNotifications) @@ -132,22 +188,84 @@ PanelWindow { return basePopupHeightPrivacy; if (!descriptionExpanded) return basePopupHeight; - const bodyTextHeight = bodyText.contentHeight || 0; + const bodyTextHeight = expandedBodyMeasure.contentHeight || bodyText.contentHeight || 0; const collapsedBodyHeight = Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2); if (bodyTextHeight > collapsedBodyHeight + 2) return basePopupHeight + bodyTextHeight - collapsedBodyHeight; return basePopupHeight; } + readonly property real targetAlignedHeight: Theme.px(Math.max(0, contentImplicitHeight), dpr) + property real renderedAlignedHeight: targetAlignedHeight + property real allocatedAlignedHeight: targetAlignedHeight + readonly property bool inlineGeometryGrowing: targetAlignedHeight >= renderedAlignedHeight + readonly property bool contentAnchorsTop: isTopCenter || SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left + readonly property real renderedContentOffsetY: contentAnchorsTop ? 0 : Math.max(0, allocatedAlignedHeight - renderedAlignedHeight) implicitWidth: contentImplicitWidth + (windowShadowPad * 2) - implicitHeight: contentImplicitHeight + (windowShadowPad * 2) + implicitHeight: allocatedAlignedHeight + (windowShadowPad * 2) - Behavior on implicitHeight { - enabled: !exiting && !_isDestroying + function inlineMotionDuration(growing) { + return growing ? inlineExpandDuration : inlineCollapseDuration; + } + + function syncInlineTargetHeight() { + const target = Math.max(0, Number(targetAlignedHeight)); + if (isNaN(target)) + return; + + if (!_inlineGeometryReady) { + renderedHeightAnim.stop(); + renderedAlignedHeight = target; + allocatedAlignedHeight = target; + _lastReportedAlignedHeight = target; + return; + } + + const currentRendered = Math.max(0, Number(renderedAlignedHeight)); + const nextAllocation = Math.max(target, currentRendered, allocatedAlignedHeight); + if (Math.abs(nextAllocation - allocatedAlignedHeight) >= 0.5) + allocatedAlignedHeight = nextAllocation; + + if (Math.abs(target - renderedAlignedHeight) < 0.5) { + finishInlineHeightAnimation(); + return; + } + + renderedAlignedHeight = target; + if (connectedFrameMode) + popupChromeGeometryChanged(); + if (inlineMotionDuration(target >= currentRendered) <= 0) + Qt.callLater(() => finishInlineHeightAnimation()); + } + + function finishInlineHeightAnimation() { + const target = Math.max(0, Number(targetAlignedHeight)); + if (isNaN(target)) + return; + if (Math.abs(renderedAlignedHeight - target) >= 0.5) + renderedAlignedHeight = target; + if (Math.abs(allocatedAlignedHeight - target) >= 0.5) + allocatedAlignedHeight = target; + _lastReportedAlignedHeight = renderedAlignedHeight; + popupHeightChanged(); + if (connectedFrameMode) + popupChromeGeometryChanged(); + } + + onTargetAlignedHeightChanged: syncInlineTargetHeight() + onAllocatedAlignedHeightChanged: { + if (connectedFrameMode) + popupChromeGeometryChanged(); + } + + Behavior on renderedAlignedHeight { + enabled: !win.exiting && !win._isDestroying NumberAnimation { - id: implicitHeightAnim - duration: descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration + id: renderedHeightAnim + duration: win.inlineMotionDuration(win.inlineGeometryGrowing) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasized + easing.bezierCurve: win.inlineGeometryGrowing ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + onRunningChanged: win.inlineHeightAnimating = running + onFinished: win.finishInlineHeightAnimation() } } @@ -157,7 +275,11 @@ PanelWindow { } } Component.onCompleted: { - _lastReportedAlignedHeight = Theme.px(implicitHeight, dpr); + renderedHeightAnim.stop(); + renderedAlignedHeight = targetAlignedHeight; + allocatedAlignedHeight = targetAlignedHeight; + _inlineGeometryReady = true; + _lastReportedAlignedHeight = renderedAlignedHeight; _storedTopMargin = getTopMargin(); _storedBottomMargin = getBottomMargin(); if (SettingsData.notificationPopupPrivacyMode) @@ -195,7 +317,8 @@ PanelWindow { readonly property real maxPopupShadowBlurPx: Math.max((Theme.elevationLevel3 && Theme.elevationLevel3.blurPx !== undefined) ? Theme.elevationLevel3.blurPx : 12, (Theme.elevationLevel4 && Theme.elevationLevel4.blurPx !== undefined) ? Theme.elevationLevel4.blurPx : 16) readonly property real maxPopupShadowOffsetXPx: Math.max(Math.abs(Theme.elevationOffsetX(Theme.elevationLevel3)), Math.abs(Theme.elevationOffsetX(Theme.elevationLevel4))) readonly property real maxPopupShadowOffsetYPx: Math.max(Math.abs(Theme.elevationOffsetY(Theme.elevationLevel3, 6)), Math.abs(Theme.elevationOffsetY(Theme.elevationLevel4, 8))) - readonly property real windowShadowPad: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled ? Theme.snap(Math.max(16, maxPopupShadowBlurPx + Math.max(maxPopupShadowOffsetXPx, maxPopupShadowOffsetYPx) + 8), dpr) : 0 + readonly property bool popupWindowShadowActive: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled && !connectedFrameMode + readonly property real windowShadowPad: popupWindowShadowActive ? Theme.snap(Math.max(16, maxPopupShadowBlurPx + Math.max(maxPopupShadowOffsetXPx, maxPopupShadowOffsetYPx) + 8), dpr) : 0 anchors.top: true anchors.left: true @@ -240,12 +363,32 @@ PanelWindow { }); } + function _frameEdgeInset(side) { + if (!screen) + return 0; + const raw = SettingsData.frameEdgeInsetForSide(screen, side); + return Math.max(0, Math.round(Theme.px(raw, dpr))); + } + + readonly property bool frameOnlyNoConnected: SettingsData.frameEnabled && !connectedFrameMode && !!screen && SettingsData.isScreenInPreferences(screen, SettingsData.frameScreenPreferences) + + // Frame ON + Connected OFF. frameEdgeInset is the full bar/frame inset + function _frameGapMargin(side) { + return _frameEdgeInset(side) + Theme.popupDistance; + } + function getTopMargin() { const popupPos = SettingsData.notificationPopupPosition; const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left; if (!isTop) return 0; + if (connectedFrameMode) { + const cornerClear = (isCenterPosition || SettingsData.frameCloseGaps) ? 0 : (Theme.px(SettingsData.frameRounding, dpr) + Theme.px(Theme.connectedCornerRadius, dpr)); + return _frameEdgeInset("top") + cornerClear + screenY; + } + if (frameOnlyNoConnected) + return _frameGapMargin("top") + screenY; const barInfo = getBarInfo(); const base = barInfo.topBar > 0 ? barInfo.topBar : Theme.popupDistance; return base + screenY; @@ -257,6 +400,12 @@ PanelWindow { if (!isBottom) return 0; + if (connectedFrameMode) { + const cornerClear = (isCenterPosition || SettingsData.frameCloseGaps) ? 0 : (Theme.px(SettingsData.frameRounding, dpr) + Theme.px(Theme.connectedCornerRadius, dpr)); + return _frameEdgeInset("bottom") + cornerClear + screenY; + } + if (frameOnlyNoConnected) + return _frameGapMargin("bottom") + screenY; const barInfo = getBarInfo(); const base = barInfo.bottomBar > 0 ? barInfo.bottomBar : Theme.popupDistance; return base + screenY; @@ -271,6 +420,10 @@ PanelWindow { if (!isLeft) return 0; + if (connectedFrameMode) + return _frameEdgeInset("left"); + if (frameOnlyNoConnected) + return _frameGapMargin("left"); const barInfo = getBarInfo(); return barInfo.leftBar > 0 ? barInfo.leftBar : Theme.popupDistance; } @@ -284,6 +437,10 @@ PanelWindow { if (!isRight) return 0; + if (connectedFrameMode) + return _frameEdgeInset("right"); + if (frameOnlyNoConnected) + return _frameGapMargin("right"); const barInfo = getBarInfo(); return barInfo.rightBar > 0 ? barInfo.rightBar : Theme.popupDistance; } @@ -303,7 +460,7 @@ PanelWindow { return Theme.snap(screen.width - alignedWidth - barRight, dpr); } - function getContentY() { + function getAllocatedContentY() { if (!screen) return 0; @@ -313,7 +470,11 @@ PanelWindow { const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left; if (isTop) return Theme.snap(barTop, dpr); - return Theme.snap(screen.height - alignedHeight - barBottom, dpr); + return Theme.snap(screen.height - allocatedAlignedHeight - barBottom, dpr); + } + + function getContentY() { + return Theme.snap(getAllocatedContentY() + renderedContentOffsetY, dpr); } function getWindowLeftMargin() { @@ -325,23 +486,107 @@ PanelWindow { function getWindowTopMargin() { if (!screen) return 0; - return Theme.snap(getContentY() - windowShadowPad, dpr); + return Theme.snap(getAllocatedContentY() - windowShadowPad, dpr); + } + + function _swipeDismissTarget() { + return (content.swipeDismissDirection < 0 ? -1 : 1) * content.width; + } + + function _frameEdgeSwipeDirection() { + const popupPos = SettingsData.notificationPopupPosition; + return (popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom) ? -1 : 1; + } + + function _swipeDismissesTowardFrameEdge() { + return content.swipeDismissDirection === _frameEdgeSwipeDirection(); + } + + function popupChromeMotionActive() { + return popupChromeOpenProgress() < 1 || exiting || content.swipeActive || content.swipeDismissing || Math.abs(content.swipeOffset) > 0.5; + } + + function popupLayoutReservesSlot() { + return !content.swipeDismissing; + } + + function popupChromeReservesSlot() { + return !content.swipeDismissing; + } + + function _chromeMotionOffset() { + return isCenterPosition ? tx.y : tx.x; + } + + function _chromeCardTravel() { + return Math.max(1, isCenterPosition ? alignedHeight : alignedWidth); + } + + function popupChromeOpenProgress() { + if (exiting || content.swipeDismissing) + return 1; + return Math.max(0, Math.min(1, 1 - Math.abs(_chromeMotionOffset()) / _chromeCardTravel())); + } + + function popupChromeReleaseProgress() { + if (exiting) { + const exitRel = Math.max(0, Math.min(1, Math.abs(_chromeMotionOffset()) / _chromeCardTravel())); + if (content.swipeDismissing) { + const swipeRel = Math.max(0, Math.min(1, Math.abs(content.swipeOffset) / Math.max(1, content.swipeTravelDistance))); + return Math.max(exitRel, swipeRel); + } + return exitRel; + } + if (content.swipeDismissing) + return Math.max(0, Math.min(1, Math.abs(content.swipeOffset) / Math.max(1, content.swipeTravelDistance))); + if (content.swipeActive && content.swipeOffset * _frameEdgeSwipeDirection() > 0) + return Math.max(0, Math.min(1, Math.abs(content.swipeOffset) / Math.max(1, content.swipeTravelDistance))); + return 0; + } + + function popupChromeFollowsCardMotion() { + return false; + } + + function popupChromeMotionX() { + if (!popupChromeMotionActive() || isCenterPosition) + return 0; + const motion = content.swipeOffset + tx.x; + if (content.swipeDismissing && !_swipeDismissesTowardFrameEdge()) + return exiting ? Theme.snap(tx.x, dpr) : 0; + if (content.swipeActive && motion * _frameEdgeSwipeDirection() < 0) + return 0; + return Theme.snap(motion, dpr); + } + + function popupChromeMotionY() { + return popupChromeMotionActive() ? Theme.snap(tx.y, dpr) : 0; } readonly property bool screenValid: win.screen && !_isDestroying readonly property real dpr: screenValid ? CompositorService.getScreenScale(win.screen) : 1 readonly property real alignedWidth: Theme.px(Math.max(0, implicitWidth - (windowShadowPad * 2)), dpr) - readonly property real alignedHeight: Theme.px(Math.max(0, implicitHeight - (windowShadowPad * 2)), dpr) + readonly property real alignedHeight: renderedAlignedHeight + onScreenYChanged: if (connectedFrameMode) + popupChromeGeometryChanged() + onScreenChanged: if (connectedFrameMode) + popupChromeGeometryChanged() + // Intentionally unconditional: Manager needs the signal when frame mode toggles off + onConnectedFrameModeChanged: popupChromeGeometryChanged() + onAlignedWidthChanged: if (connectedFrameMode) + popupChromeGeometryChanged() + onAlignedHeightChanged: if (connectedFrameMode) + popupChromeGeometryChanged() Item { id: content x: Theme.snap(windowShadowPad, dpr) - y: Theme.snap(windowShadowPad, dpr) + y: Theme.snap(windowShadowPad + renderedContentOffsetY, dpr) width: alignedWidth height: alignedHeight - visible: !win._finalized - scale: cardHoverHandler.hovered ? 1.01 : 1.0 + visible: !win._finalized && !chromeOnlyExit + scale: (!win.inlineHeightAnimating && cardHoverHandler.hovered) ? 1.01 : 1.0 transformOrigin: Item.Center Behavior on scale { @@ -352,15 +597,27 @@ PanelWindow { } property real swipeOffset: 0 - readonly property real dismissThreshold: isCenterPosition ? height * 0.4 : width * 0.35 + property real swipeDismissDirection: 1 + property bool chromeOnlyExit: false + readonly property real dismissThreshold: width * 0.35 readonly property real swipeFadeStartRatio: 0.75 - readonly property real swipeTravelDistance: isCenterPosition ? height : width + readonly property real swipeTravelDistance: width readonly property real swipeFadeStartOffset: swipeTravelDistance * swipeFadeStartRatio readonly property real swipeFadeDistance: Math.max(1, swipeTravelDistance - swipeFadeStartOffset) readonly property bool swipeActive: swipeDragHandler.active property bool swipeDismissing: false + onSwipeDismissingChanged: { + if (!win.connectedFrameMode) + return; + win.popupHeightChanged(); + win.popupChromeGeometryChanged(); + } + onSwipeOffsetChanged: { + if (win.connectedFrameMode) + win.popupChromeGeometryChanged(); + } - readonly property bool shadowsAllowed: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled && !BlurService.enabled + readonly property bool shadowsAllowed: win.popupWindowShadowActive readonly property var elevLevel: cardHoverHandler.hovered ? Theme.elevationLevel4 : Theme.elevationLevel3 readonly property real cardInset: Theme.snap(4, win.dpr) readonly property real shadowRenderPadding: shadowsAllowed ? Theme.snap(Math.max(16, shadowBlurPx + Math.max(Math.abs(shadowOffsetX), Math.abs(shadowOffsetY)) + 8), win.dpr) : 0 @@ -370,21 +627,21 @@ PanelWindow { Behavior on shadowBlurPx { NumberAnimation { - duration: Theme.shortDuration + duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration easing.type: Theme.standardEasing } } Behavior on shadowOffsetX { NumberAnimation { - duration: Theme.shortDuration + duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration easing.type: Theme.standardEasing } } Behavior on shadowOffsetY { NumberAnimation { - duration: Theme.shortDuration + duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration easing.type: Theme.standardEasing } } @@ -399,7 +656,7 @@ PanelWindow { shadowOffsetX: content.shadowOffsetX shadowOffsetY: content.shadowOffsetY shadowColor: content.shadowsAllowed && content.elevLevel ? Theme.elevationShadowColor(content.elevLevel) : "transparent" - shadowEnabled: !win._isDestroying && win.screenValid && content.shadowsAllowed + shadowEnabled: !win._isDestroying && win.screenValid && content.shadowsAllowed && !win.connectedFrameMode layer.textureSize: Qt.size(Math.round(width * win.dpr), Math.round(height * win.dpr)) layer.textureMirroring: ShaderEffectSource.MirrorVertically @@ -408,38 +665,42 @@ PanelWindow { sourceRect.y: content.shadowRenderPadding + content.cardInset sourceRect.width: Math.max(0, content.width - (content.cardInset * 2)) sourceRect.height: Math.max(0, content.height - (content.cardInset * 2)) - sourceRect.radius: Theme.cornerRadius - sourceRect.color: Theme.readableSurface - sourceRect.border.color: notificationData && notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.outlineMedium - sourceRect.border.width: notificationData && notificationData.urgency === NotificationUrgency.Critical ? 2 : 1 + sourceRect.radius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius + sourceRect.color: win.connectedFrameMode ? Theme.floatingSurface : Theme.readableSurface + sourceRect.antialiasing: true + sourceRect.layer.enabled: false + sourceRect.layer.textureSize: Qt.size(0, 0) + sourceRect.border.color: notificationData && notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08) + sourceRect.border.width: notificationData && notificationData.urgency === NotificationUrgency.Critical ? 2 : 0 + } - Rectangle { - x: bgShadowLayer.sourceRect.x - y: bgShadowLayer.sourceRect.y - width: bgShadowLayer.sourceRect.width - height: bgShadowLayer.sourceRect.height - radius: bgShadowLayer.sourceRect.radius - visible: notificationData && notificationData.urgency === NotificationUrgency.Critical - opacity: 1 - clip: true + // Keep critical accent outside shadow rendering so connected mode still shows it. + Rectangle { + x: content.cardInset + y: content.cardInset + width: Math.max(0, content.width - content.cardInset * 2) + height: Math.max(0, content.height - content.cardInset * 2) + radius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius + visible: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical + opacity: 1 + clip: true - gradient: Gradient { - orientation: Gradient.Horizontal + gradient: Gradient { + orientation: Gradient.Horizontal - GradientStop { - position: 0 - color: Theme.primary - } + GradientStop { + position: 0 + color: Theme.primary + } - GradientStop { - position: 0.02 - color: Theme.primary - } + GradientStop { + position: 0.02 + color: Theme.primary + } - GradientStop { - position: 0.021 - color: "transparent" - } + GradientStop { + position: 0.021 + color: "transparent" } } } @@ -447,11 +708,10 @@ PanelWindow { Rectangle { anchors.fill: parent anchors.margins: content.cardInset - radius: Theme.cornerRadius - antialiasing: true + radius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius color: "transparent" - border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium - border.width: BlurService.enabled ? BlurService.borderWidth : 1 + border.color: win.connectedFrameMode ? "transparent" : BlurService.borderColor + border.width: win.connectedFrameMode ? 0 : BlurService.borderWidth z: 100 } @@ -482,10 +742,23 @@ PanelWindow { LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true + StyledText { + id: expandedBodyMeasure + + visible: false + width: Math.max(0, backgroundContainer.width - Theme.spacingL - (Theme.spacingL + Theme.notificationHoverRevealMargin) - popupIconSize - Theme.spacingM) + text: notificationData ? (notificationData.htmlBody || "") : "" + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideNone + horizontalAlignment: Text.AlignLeft + maximumLineCount: -1 + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + Item { id: notificationContent - readonly property real expandedTextHeight: bodyText.contentHeight || 0 + readonly property real expandedTextHeight: expandedBodyMeasure.contentHeight || bodyText.contentHeight || 0 readonly property real collapsedBodyHeight: Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2) readonly property real effectiveCollapsedHeight: (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) ? win.privacyCollapsedContentHeight : win.collapsedContentHeight readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > collapsedBodyHeight + 2) ? (expandedTextHeight - collapsedBodyHeight) : 0 @@ -653,7 +926,7 @@ PanelWindow { win.descriptionExpanded = !win.descriptionExpanded; } - propagateComposedEvents: true + propagateComposedEvents: false onPressed: mouse => { if (parent.hoveredLink) mouse.accepted = false; @@ -849,14 +1122,15 @@ PanelWindow { DragHandler { id: swipeDragHandler target: null - xAxis.enabled: !isCenterPosition - yAxis.enabled: isCenterPosition + xAxis.enabled: true + yAxis.enabled: false onActiveChanged: { if (active || win.exiting || content.swipeDismissing) return; if (Math.abs(content.swipeOffset) > content.dismissThreshold) { + content.swipeDismissDirection = content.swipeOffset < 0 ? -1 : 1; content.swipeDismissing = true; swipeDismissAnim.start(); } else { @@ -865,18 +1139,10 @@ PanelWindow { } onTranslationChanged: { - if (win.exiting) + if (win.exiting || content.swipeDismissing) return; - const raw = isCenterPosition ? translation.y : translation.x; - if (isTopCenter) { - content.swipeOffset = Math.min(0, raw); - } else if (isBottomCenter) { - content.swipeOffset = Math.max(0, raw); - } else { - const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; - content.swipeOffset = isLeft ? Math.min(0, raw) : Math.max(0, raw); - } + content.swipeOffset = translation.x; } } @@ -889,7 +1155,7 @@ PanelWindow { } Behavior on opacity { - enabled: !content.swipeActive + enabled: !content.swipeActive && !content.swipeDismissing NumberAnimation { duration: Theme.shortDuration } @@ -907,20 +1173,28 @@ PanelWindow { id: swipeDismissAnim target: content property: "swipeOffset" - to: isTopCenter ? -content.height : isBottomCenter ? content.height : (SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom ? -content.width : content.width) + to: win._swipeDismissTarget() duration: Theme.notificationExitDuration easing.type: Easing.OutCubic onStopped: { - NotificationService.dismissNotification(notificationData); - win.forceExit(); + const inwardConnectedExit = win.connectedFrameMode && !win.isCenterPosition && !win._swipeDismissesTowardFrameEdge(); + if (inwardConnectedExit) + content.chromeOnlyExit = true; + if (win.connectedFrameMode) { + win.startExit(); + NotificationService.dismissNotification(notificationData); + } else { + NotificationService.dismissNotification(notificationData); + win.forceExit(); + } } } transform: [ Translate { id: swipeTx - x: isCenterPosition ? 0 : content.swipeOffset - y: isCenterPosition ? content.swipeOffset : 0 + x: content.swipeOffset + y: 0 }, Translate { id: tx @@ -928,9 +1202,17 @@ PanelWindow { if (isCenterPosition) return 0; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; - return isLeft ? -Anims.slidePx : Anims.slidePx; + return isLeft ? -entryTravel : entryTravel; + } + y: isTopCenter ? -entryTravel : isBottomCenter ? entryTravel : 0 + onXChanged: { + if (win.connectedFrameMode) + win.popupChromeGeometryChanged(); + } + onYChanged: { + if (win.connectedFrameMode) + win.popupChromeGeometryChanged(); } - y: isTopCenter ? -Anims.slidePx : isBottomCenter ? Anims.slidePx : 0 } ] } @@ -942,16 +1224,16 @@ PanelWindow { property: isCenterPosition ? "y" : "x" from: { if (isTopCenter) - return -Anims.slidePx; + return -entryTravel; if (isBottomCenter) - return Anims.slidePx; + return entryTravel; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; - return isLeft ? -Anims.slidePx : Anims.slidePx; + return isLeft ? -entryTravel : entryTravel; } to: 0 duration: Theme.notificationEnterDuration easing.type: Easing.BezierSpline - easing.bezierCurve: isCenterPosition ? Theme.expressiveCurves.standardDecel : Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantPopoutEnterCurve onStopped: { if (!win.exiting && !win._isDestroying) { if (isCenterPosition) { @@ -976,35 +1258,33 @@ PanelWindow { from: 0 to: { if (isTopCenter) - return -Anims.slidePx; + return -exitTravel; if (isBottomCenter) - return Anims.slidePx; + return exitTravel; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; - return isLeft ? -Anims.slidePx : Anims.slidePx; + return isLeft ? -exitTravel : exitTravel; } duration: Theme.notificationExitDuration easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel + easing.bezierCurve: Theme.variantPopoutExitCurve } NumberAnimation { target: content property: "opacity" - from: 1 - to: 0 + to: Theme.isDirectionalEffect ? 1 : 0 duration: Theme.notificationExitDuration easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.standardAccel + easing.bezierCurve: Theme.variantPopoutExitCurve } NumberAnimation { target: content property: "scale" - from: 1 - to: 0.98 + to: Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed duration: Theme.notificationExitDuration easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel + easing.bezierCurve: Theme.variantPopoutExitCurve } } diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml index 02bfc0fe..5c4d967c 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import qs.Common import qs.Services @@ -8,23 +10,51 @@ QtObject { property var modelData property int topMargin: 0 readonly property bool compactMode: SettingsData.notificationCompactMode + readonly property bool notificationConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && SettingsData.isScreenInPreferences(manager.modelData, SettingsData.frameScreenPreferences) + readonly property bool closeGapNotifications: notificationConnectedMode && SettingsData.frameCloseGaps + readonly property string notifBarSide: { + const pos = SettingsData.notificationPopupPosition; + if (pos === -1) + return "top"; + switch (pos) { + case SettingsData.Position.Top: + return "right"; + case SettingsData.Position.Left: + return "left"; + case SettingsData.Position.BottomCenter: + return "bottom"; + case SettingsData.Position.Right: + return "right"; + case SettingsData.Position.Bottom: + return "left"; + default: + return "top"; + } + } readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding readonly property real popupIconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal readonly property real actionButtonHeight: compactMode ? 20 : 24 readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS - readonly property real popupSpacing: compactMode ? 0 : Theme.spacingXS + readonly property real popupSpacing: notificationConnectedMode ? 0 : (compactMode ? 0 : Theme.spacingXS) readonly property real collapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) readonly property int baseNotificationHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing + popupSpacing property var popupWindows: [] property var destroyingWindows: new Set() property var pendingDestroys: [] property int destroyDelayMs: 100 + property bool _chromeSyncPending: false + property bool _syncingVisibleNotifications: false + readonly property real chromeOpenProgressThreshold: 0.10 + readonly property real chromeReleaseTailStart: 0.90 + readonly property real chromeReleaseDropProgress: 0.995 property Component popupComponent popupComponent: Component { NotificationPopup { onExitFinished: manager._onPopupExitFinished(this) + onExitStarted: manager._onPopupExitStarted(this) onPopupHeightChanged: manager._onPopupHeightChanged(this) + onPopupChromeGeometryChanged: manager._onPopupChromeGeometryChanged(this) } } @@ -108,6 +138,29 @@ QtObject { return p && p.status !== Component.Null && !p._isDestroying && p.hasValidData; } + function _layoutWindows() { + return popupWindows.filter(p => _isValidWindow(p) && p.notificationData?.popup && !p.exiting && (!p.popupLayoutReservesSlot || p.popupLayoutReservesSlot())); + } + + function _chromeWindows() { + return popupWindows.filter(p => { + if (!p || p.status === Component.Null || !p.visible || p._finalized || !p.hasValidData) + return false; + if (!p.notificationData?.popup && !p.exiting) + return false; + if (p.exiting && p.notificationData?.removedByLimit && _layoutWindows().length > 0) + return true; + if (!p.exiting && p.popupChromeOpenProgress && p.popupChromeOpenProgress() < chromeOpenProgressThreshold) + return false; + // Keep the connected shell until the card is almost fully closed. + if (p.exiting && !p.swipeActive && p.popupChromeReleaseProgress) { + if (p.popupChromeReleaseProgress() > chromeReleaseDropProgress) + return false; + } + return true; + }); + } + function _isFocusedScreen() { if (!SettingsData.notificationFocusedMonitor) return true; @@ -116,27 +169,34 @@ QtObject { } function _sync(newWrappers) { + let needsReposition = false; + _syncingVisibleNotifications = true; for (const p of popupWindows.slice()) { if (!_isValidWindow(p) || p.exiting) continue; if (p.notificationData && newWrappers.indexOf(p.notificationData) === -1) { p.notificationData.removedByLimit = true; p.notificationData.popup = false; + needsReposition = true; } } for (const w of newWrappers) { - if (w && !_hasWindowFor(w) && _isFocusedScreen()) - _insertAtTop(w); + if (w && !_hasWindowFor(w) && _isFocusedScreen()) { + needsReposition = _insertAtTop(w, true) || needsReposition; + } } + _syncingVisibleNotifications = false; + if (needsReposition) + _repositionAll(); } function _popupHeight(p) { return (p.alignedHeight || p.implicitHeight || (baseNotificationHeight - popupSpacing)) + popupSpacing; } - function _insertAtTop(wrapper) { + function _insertAtTop(wrapper, deferReposition) { if (!wrapper) - return; + return false; const notificationId = wrapper?.notification ? wrapper.notification.id : ""; const win = popupComponent.createObject(null, { "notificationData": wrapper, @@ -145,19 +205,21 @@ QtObject { "screen": manager.modelData }); if (!win) - return; + return false; if (!win.hasValidData) { win.destroy(); - return; + return false; } popupWindows.unshift(win); - _repositionAll(); + if (!deferReposition) + _repositionAll(); if (!sweeper.running) sweeper.start(); + return true; } function _repositionAll() { - const active = popupWindows.filter(p => _isValidWindow(p) && p.notificationData?.popup && !p.exiting); + const active = _layoutWindows(); const pinnedSlots = []; for (const p of active) { @@ -181,6 +243,315 @@ QtObject { win.screenY = currentY; currentY += _popupHeight(win); } + _scheduleNotificationChromeSync(); + } + + function _scheduleNotificationChromeSync() { + if (_chromeSyncPending) + return; + _chromeSyncPending = true; + Qt.callLater(() => { + _chromeSyncPending = false; + _syncNotificationChromeState(); + }); + } + + function _clamp01(value) { + return Math.max(0, Math.min(1, value)); + } + + function _clipRectFromBarSide(rect, visibleFraction) { + const fraction = _clamp01(visibleFraction); + const w = Math.max(0, rect.right - rect.x); + const h = Math.max(0, rect.bottom - rect.y); + + if (notifBarSide === "right") { + rect.x = rect.right - w * fraction; + } else if (notifBarSide === "left") { + rect.right = rect.x + w * fraction; + } else if (notifBarSide === "bottom") { + rect.y = rect.bottom - h * fraction; + } else { + rect.bottom = rect.y + h * fraction; + } + return rect; + } + + function _popupChromeVisibleFraction(p) { + if (p.popupChromeReleaseProgress) { + const rel = p.popupChromeReleaseProgress(); + if (p.exiting) + return Math.max(0, 1 - rel); + if (rel > 0) + return p.swipeDismissTowardEdge ? Math.max(0, 1 - rel) : 1 - _chromeReleaseTailProgress(rel); + } + if (p.popupChromeOpenProgress) + return _clamp01(p.popupChromeOpenProgress()); + return 1; + } + + function _popupChromeRect(p, useMotionOffset) { + if (!p || !p.screen) + return null; + const x = p.getContentX ? p.getContentX() : 0; + const y = p.getContentY ? p.getContentY() : 0; + const w = p.alignedWidth || 0; + const h = Math.max(p.alignedHeight || 0, baseNotificationHeight); + if (w <= 0 || h <= 0) + return null; + const rect = { + x: x, + y: y, + right: x + w, + bottom: y + h + }; + + if (!useMotionOffset) + return rect; + + if (p.popupChromeFollowsCardMotion && p.popupChromeFollowsCardMotion()) { + const motionX = p.popupChromeMotionX ? p.popupChromeMotionX() : 0; + const motionY = p.popupChromeMotionY ? p.popupChromeMotionY() : 0; + rect.x += motionX; + rect.y += motionY; + rect.right += motionX; + rect.bottom += motionY; + return rect; + } + + return _clipRectFromBarSide(rect, _popupChromeVisibleFraction(p)); + } + + function _chromeReleaseTailProgress(rawProgress) { + const progress = Math.max(0, Math.min(1, rawProgress)); + if (progress <= chromeReleaseTailStart) + return 0; + return Math.max(0, Math.min(1, (progress - chromeReleaseTailStart) / Math.max(0.001, 1 - chromeReleaseTailStart))); + } + + function _popupChromeBoundsRect(p, trailing, useMotionOffset) { + const rect = _popupChromeRect(p, useMotionOffset); + if (!rect || p !== trailing || !p.popupChromeReleaseProgress) + return rect; + + // Keep maxed-stack chrome anchored while a replacement tail exits. + if (p.exiting && p.notificationData?.removedByLimit && _layoutWindows().length > 0) + return rect; + + const progress = _chromeReleaseTailProgress(p.popupChromeReleaseProgress()); + if (progress <= 0) + return rect; + + const anchorsTop = _stackAnchorsTop(); + const h = Math.max(0, rect.bottom - rect.y); + const shrink = h * progress; + if (anchorsTop) + rect.bottom = Math.max(rect.y, rect.bottom - shrink); + else + rect.y = Math.min(rect.bottom, rect.y + shrink); + return rect; + } + + function _stackAnchorsTop() { + const pos = SettingsData.notificationPopupPosition; + return pos === -1 || pos === SettingsData.Position.Top || pos === SettingsData.Position.Left; + } + + function _frameEdgeInset(side) { + if (!manager.modelData) + return 0; + const edges = SettingsData.getActiveBarEdgesForScreen(manager.modelData); + const raw = edges.includes(side) ? SettingsData.frameBarSize : SettingsData.frameThickness; + const dpr = CompositorService.getScreenScale(manager.modelData); + return Math.max(0, Math.round(Theme.px(raw, dpr))); + } + + function _closeGapChromeAnchorEdge(anchorsTop) { + if (!closeGapNotifications || !manager.modelData) + return null; + if (anchorsTop) + return _frameEdgeInset("top") + topMargin; + return manager.modelData.height - _frameEdgeInset("bottom") - topMargin; + } + + function _trailingChromeWindow(candidates) { + const anchorsTop = _stackAnchorsTop(); + let trailing = null; + let edge = anchorsTop ? -Infinity : Infinity; + for (const p of candidates) { + const rect = _popupChromeRect(p, false); + if (!rect) + continue; + const candidateEdge = anchorsTop ? rect.bottom : rect.y; + if ((anchorsTop && candidateEdge > edge) || (!anchorsTop && candidateEdge < edge)) { + edge = candidateEdge; + trailing = p; + } + } + return trailing; + } + + function _chromeWindowReservesSlot(p, trailing) { + if (p === trailing) + return true; + return !p.popupChromeReservesSlot || p.popupChromeReservesSlot(); + } + + function _stackAnchoredChromeEdge(candidates) { + const anchorsTop = _stackAnchorsTop(); + let edge = anchorsTop ? Infinity : -Infinity; + for (const p of candidates) { + const rect = _popupChromeRect(p, false); + if (!rect) + continue; + if (anchorsTop && rect.y < edge) + edge = rect.y; + if (!anchorsTop && rect.bottom > edge) + edge = rect.bottom; + } + if (edge === Infinity || edge === -Infinity) + return null; + return { + anchorsTop: anchorsTop, + edge: edge + }; + } + + function _filledMaxStackChromeEdge(candidates, stackEdge) { + const layoutWindows = _layoutWindows(); + if (layoutWindows.length < NotificationService.maxVisibleNotifications) + return null; + const anchorsTop = _stackAnchorsTop(); + const layoutAnchorEdge = _stackAnchoredChromeEdge(layoutWindows); + const anchorEdge = layoutAnchorEdge !== null ? layoutAnchorEdge : (stackEdge !== null ? stackEdge : _stackAnchoredChromeEdge(candidates)); + if (anchorEdge === null) + return null; + let span = 0; + for (const p of layoutWindows) { + const rect = _popupChromeRect(p, false); + if (!rect) + continue; + span += Math.max(0, rect.bottom - rect.y); + } + if (span <= 0) + return null; + if (layoutWindows.length > 1) + span += popupSpacing * (layoutWindows.length - 1); + return { + anchorsTop: anchorsTop, + startEdge: anchorEdge.edge, + edge: anchorsTop ? anchorEdge.edge + span : anchorEdge.edge - span + }; + } + + function _syncNotificationChromeState() { + const screenName = manager.modelData?.name || ""; + if (!screenName) + return; + if (!notificationConnectedMode) { + ConnectedModeState.clearNotificationState(screenName); + return; + } + const chromeCandidates = _chromeWindows(); + if (chromeCandidates.length === 0) { + ConnectedModeState.clearNotificationState(screenName); + return; + } + + const trailing = chromeCandidates.length > 1 ? _trailingChromeWindow(chromeCandidates) : null; + let active = chromeCandidates; + if (chromeCandidates.length > 1) { + const reserving = chromeCandidates.filter(p => _chromeWindowReservesSlot(p, trailing)); + if (reserving.length > 0) + active = reserving; + } + + let minX = Infinity; + let minY = Infinity; + let maxXEnd = -Infinity; + let maxYEnd = -Infinity; + const useMotionOffset = active.length === 1 && active[0].popupChromeMotionActive && active[0].popupChromeMotionActive(); + for (const p of active) { + const rect = _popupChromeBoundsRect(p, trailing, useMotionOffset); + if (!rect) + continue; + if (rect.x < minX) + minX = rect.x; + if (rect.y < minY) + minY = rect.y; + if (rect.right > maxXEnd) + maxXEnd = rect.right; + if (rect.bottom > maxYEnd) + maxYEnd = rect.bottom; + } + const stackEdge = _stackAnchoredChromeEdge(chromeCandidates); + if (stackEdge !== null) { + if (stackEdge.anchorsTop && stackEdge.edge < minY) + minY = stackEdge.edge; + if (!stackEdge.anchorsTop && stackEdge.edge > maxYEnd) + maxYEnd = stackEdge.edge; + } + const filledMaxStackEdge = _filledMaxStackChromeEdge(chromeCandidates, stackEdge); + if (filledMaxStackEdge !== null) { + if (filledMaxStackEdge.anchorsTop) { + minY = filledMaxStackEdge.startEdge; + maxYEnd = filledMaxStackEdge.edge; + } else { + minY = filledMaxStackEdge.edge; + maxYEnd = filledMaxStackEdge.startEdge; + } + } + const anchorsTop = stackEdge !== null ? stackEdge.anchorsTop : _stackAnchorsTop(); + const closeGapAnchorEdge = _closeGapChromeAnchorEdge(anchorsTop); + if (closeGapAnchorEdge !== null) { + if (anchorsTop) + minY = closeGapAnchorEdge; + else + maxYEnd = closeGapAnchorEdge; + } + if (minX === Infinity || minY === Infinity || maxXEnd <= minX || maxYEnd <= minY) { + ConnectedModeState.clearNotificationState(screenName); + return; + } + ConnectedModeState.setNotificationState(screenName, { + visible: true, + barSide: notifBarSide, + bodyX: minX, + bodyY: minY, + bodyW: maxXEnd - minX, + bodyH: maxYEnd - minY, + omitStartConnector: _notificationOmitStartConnector(), + omitEndConnector: _notificationOmitEndConnector() + }); + } + + function _notificationOmitStartConnector() { + return closeGapNotifications && (SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left); + } + + function _notificationOmitEndConnector() { + return closeGapNotifications && (SettingsData.notificationPopupPosition === SettingsData.Position.Right || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom); + } + + function _onPopupChromeGeometryChanged(p) { + if (!p || popupWindows.indexOf(p) === -1) + return; + _scheduleNotificationChromeSync(); + } + + // Coalesce resize repositioning; exit-path moves remain immediate. + property bool _repositionPending: false + + function _queueReposition() { + if (_repositionPending) + return; + _repositionPending = true; + Qt.callLater(_flushReposition); + } + + function _flushReposition() { + _repositionPending = false; + _repositionAll(); } function _onPopupHeightChanged(p) { @@ -188,6 +559,14 @@ QtObject { return; if (popupWindows.indexOf(p) === -1) return; + _queueReposition(); + } + + function _onPopupExitStarted(p) { + if (!p || popupWindows.indexOf(p) === -1) + return; + if (_syncingVisibleNotifications) + return; _repositionAll(); } @@ -227,8 +606,16 @@ QtObject { } popupWindows = []; destroyingWindows.clear(); + _chromeSyncPending = false; + _syncNotificationChromeState(); } + onNotificationConnectedModeChanged: _scheduleNotificationChromeSync() + onCloseGapNotificationsChanged: _scheduleNotificationChromeSync() + onNotifBarSideChanged: _scheduleNotificationChromeSync() + onModelDataChanged: _scheduleNotificationChromeSync() + onTopMarginChanged: _repositionAll() + onPopupWindowsChanged: { if (popupWindows.length > 0 && !sweeper.running) { sweeper.start(); diff --git a/quickshell/Modules/Settings/DankBarTab.qml b/quickshell/Modules/Settings/DankBarTab.qml index 1c8cc229..ab3d1a57 100644 --- a/quickshell/Modules/Settings/DankBarTab.qml +++ b/quickshell/Modules/Settings/DankBarTab.qml @@ -27,6 +27,7 @@ Item { const pos = selectedBarConfig?.position ?? SettingsData.Position.Top; return pos === SettingsData.Position.Left || pos === SettingsData.Position.Right; } + readonly property bool connectedFrameModeActive: SettingsData.connectedFrameModeActive Timer { id: horizontalBarChangeDebounce @@ -695,6 +696,8 @@ Item { SettingsToggleRow { visible: CompositorService.isNiri + enabled: !SettingsData.frameEnabled + opacity: SettingsData.frameEnabled ? 0.5 : 1.0 text: I18n.tr("Show on Overview") checked: selectedBarConfig?.openOnOverview ?? false onToggled: toggled => { @@ -705,11 +708,18 @@ Item { } } + SettingsControlledByFrame { + visible: SettingsData.frameEnabled + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar spacing and size") + reason: I18n.tr("Managed by Frame") + } + SettingsCard { iconName: "space_bar" title: I18n.tr("Spacing") settingKey: "barSpacing" - visible: selectedBarConfig?.enabled + visible: (selectedBarConfig?.enabled ?? false) && !SettingsData.frameEnabled SettingsSliderRow { id: edgeSpacingSlider @@ -860,6 +870,7 @@ Item { SettingsSliderRow { id: barTransparencySlider + visible: !SettingsData.frameEnabled text: I18n.tr("Bar Transparency") value: (selectedBarConfig?.transparency ?? 1.0) * 100 minimum: 0 @@ -901,6 +912,13 @@ Item { restoreMode: Binding.RestoreBinding } } + + SettingsControlledByFrame { + visible: SettingsData.frameEnabled + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar transparency") + reason: I18n.tr("Managed by Frame") + } } SettingsSliderCard { @@ -961,8 +979,16 @@ Item { expanded: false visible: selectedBarConfig?.enabled + SettingsControlledByFrame { + visible: SettingsData.frameEnabled + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar corners and background") + reason: I18n.tr("Managed by Frame") + } + SettingsToggleRow { text: I18n.tr("Square Corners") + visible: !SettingsData.frameEnabled checked: selectedBarConfig?.squareCorners ?? false onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { squareCorners: checked @@ -971,6 +997,7 @@ Item { SettingsToggleRow { text: I18n.tr("No Background") + visible: !SettingsData.frameEnabled checked: selectedBarConfig?.noBackground ?? false onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { noBackground: checked @@ -1010,6 +1037,7 @@ Item { SettingsToggleRow { text: I18n.tr("Goth Corners") + visible: !SettingsData.frameEnabled checked: selectedBarConfig?.gothCornersEnabled ?? false onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { gothCornersEnabled: checked @@ -1345,6 +1373,13 @@ Item { } } + SettingsControlledByFrame { + visible: dankBarTab.connectedFrameModeActive + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar shadow, border, and corners") + reason: I18n.tr("Managed by Frame in Connected Mode") + } + SettingsCard { id: shadowCard iconName: "layers" @@ -1352,7 +1387,7 @@ Item { settingKey: "barShadow" collapsible: true expanded: false - visible: selectedBarConfig?.enabled + visible: (selectedBarConfig?.enabled ?? false) && !dankBarTab.connectedFrameModeActive readonly property bool shadowActive: (selectedBarConfig?.shadowIntensity ?? 0) > 0 readonly property bool isCustomColor: (selectedBarConfig?.shadowColorMode ?? "default") === "custom" diff --git a/quickshell/Modules/Settings/DockTab.qml b/quickshell/Modules/Settings/DockTab.qml index ef029801..8ade7e07 100644 --- a/quickshell/Modules/Settings/DockTab.qml +++ b/quickshell/Modules/Settings/DockTab.qml @@ -8,6 +8,9 @@ import qs.Modules.Settings.Widgets Item { id: root + property var parentModal: null + readonly property bool connectedFrameModeActive: SettingsData.connectedFrameModeActive + FileBrowserModal { id: dockLogoFileBrowser browserTitle: I18n.tr("Select Dock Launcher Logo") @@ -604,6 +607,7 @@ Item { SettingsSliderRow { text: I18n.tr("Exclusive Zone Offset") + visible: !root.connectedFrameModeActive value: SettingsData.dockBottomGap minimum: -100 maximum: 100 @@ -613,6 +617,7 @@ Item { SettingsSliderRow { text: I18n.tr("Margin") + visible: !root.connectedFrameModeActive value: SettingsData.dockMargin minimum: 0 maximum: 100 @@ -621,11 +626,19 @@ Item { } } + SettingsControlledByFrame { + visible: root.connectedFrameModeActive + parentModal: root.parentModal + settingLabel: I18n.tr("Dock spacing, transparency, and border") + reason: I18n.tr("Managed by Frame in Connected Mode") + } + SettingsCard { width: parent.width iconName: "opacity" title: I18n.tr("Transparency") settingKey: "dockTransparency" + visible: !root.connectedFrameModeActive SettingsSliderRow { text: I18n.tr("Dock Transparency") @@ -645,6 +658,7 @@ Item { settingKey: "dockBorder" collapsible: true expanded: false + visible: !root.connectedFrameModeActive SettingsToggleRow { text: I18n.tr("Border") diff --git a/quickshell/Modules/Settings/FrameTab.qml b/quickshell/Modules/Settings/FrameTab.qml new file mode 100644 index 00000000..448499f0 --- /dev/null +++ b/quickshell/Modules/Settings/FrameTab.qml @@ -0,0 +1,370 @@ +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 + + 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) + } + } + + SettingsCard { + width: parent.width + iconName: "tune" + title: I18n.tr("Mode") + settingKey: "frameMode" + visible: SettingsData.frameEnabled + + SettingsButtonGroupRow { + settingKey: "frameModeSelector" + tags: ["frame", "mode", "connected", "separate", "popout"] + text: I18n.tr("Surface Behavior") + description: SettingsData.frameMode === "connected" ? I18n.tr("Surfaces emerge flush from the bar") : I18n.tr("Surfaces float independently of the frame") + model: [I18n.tr("Separate"), I18n.tr("Connected")] + currentIndex: SettingsData.frameMode === "connected" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + switch (index) { + case 1: + SettingsData.set("frameMode", "connected"); + break; + default: + SettingsData.set("frameMode", "separate"); + break; + } + } + } + } + + 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 Radius") + unit: "px" + minimum: 0 + maximum: 100 + step: 1 + defaultValue: 23 + 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 Width") + unit: "px" + minimum: 2 + maximum: 100 + step: 1 + defaultValue: 16 + value: SettingsData.frameThickness + onSliderDragFinished: v => SettingsData.set("frameThickness", v) + + Binding { + target: thicknessSlider + property: "value" + value: SettingsData.frameThickness + } + } + + SettingsSliderRow { + id: barThicknessSlider + settingKey: "frameBarSize" + tags: ["frame", "bar", "thickness", "size", "height", "width"] + text: I18n.tr("Size") + description: I18n.tr("Horizontal and vertical bar thickness") + unit: "px" + minimum: 24 + maximum: 100 + step: 1 + defaultValue: 40 + value: SettingsData.frameBarSize + onSliderDragFinished: v => SettingsData.set("frameBarSize", v) + + Binding { + target: barThicknessSlider + property: "value" + value: SettingsData.frameBarSize + } + } + + SettingsSliderRow { + id: opacitySlider + settingKey: "frameOpacity" + tags: ["frame", "border", "surface", "popup", "opacity", "transparency"] + text: I18n.tr("Surface 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 + } + } + + SettingsToggleRow { + id: frameBlurToggle + settingKey: "frameBlurEnabled" + tags: ["frame", "blur", "background", "glass", "transparency", "frosted"] + text: I18n.tr("Frame Blur") + description: !BlurService.available ? I18n.tr("Requires a newer version of Quickshell") : I18n.tr("Apply compositor blur behind the frame border") + checked: SettingsData.frameBlurEnabled + onToggled: checked => SettingsData.set("frameBlurEnabled", checked) + enabled: BlurService.available && SettingsData.blurEnabled + opacity: enabled ? 1.0 : 0.5 + visible: BlurService.available + } + + Item { + visible: BlurService.available && !SettingsData.blurEnabled + width: parent.width + height: blurToggleNote.height + Theme.spacingM * 2 + + Row { + id: blurToggleNote + x: Theme.spacingM + width: parent.width - Theme.spacingM * 2 + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: "blur_on" + size: Theme.fontSizeMedium + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Frame Blur follows Background Blur in Theme & Colors") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width - Theme.fontSizeMedium - Theme.spacingS + } + } + } + + SettingsButtonGroupRow { + settingKey: "frameColor" + tags: ["frame", "border", "color", "theme", "primary", "surface", "default"] + text: I18n.tr("Border Color") + model: [I18n.tr("Default"), I18n.tr("Primary"), I18n.tr("Surface"), I18n.tr("Custom")] + currentIndex: { + const fc = SettingsData.frameColor; + if (!fc || fc === "default") + return 0; + switch (fc) { + case "primary": + return 1; + case "surface": + return 2; + default: + return 3; + } + } + onSelectionChanged: (index, selected) => { + if (!selected) + return; + switch (index) { + case 0: + SettingsData.set("frameColor", ""); + break; + case 1: + SettingsData.set("frameColor", "primary"); + break; + case 2: + SettingsData.set("frameColor", "surface"); + break; + case 3: + const cur = SettingsData.frameColor; + const isPreset = !cur || cur === "primary" || cur === "surface"; + if (isPreset) + SettingsData.set("frameColor", "#2a2a2a"); + break; + } + } + } + + Item { + visible: { + const fc = SettingsData.frameColor; + return !!(fc && fc !== "primary" && fc !== "surface"); + } + width: parent.width + height: customColorRow.height + Theme.spacingM * 2 + + Row { + id: customColorRow + width: parent.width - Theme.spacingM * 2 + x: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Custom Color") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 32 + height: 32 + radius: 16 + color: SettingsData.effectiveFrameColor + border.color: Theme.outline + border.width: 1 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + PopoutService.colorPickerModal.selectedColor = SettingsData.effectiveFrameColor; + PopoutService.colorPickerModal.pickerTitle = I18n.tr("Frame Border Color"); + PopoutService.colorPickerModal.onColorSelectedCallback = function (color) { + SettingsData.set("frameColor", color.toString()); + }; + PopoutService.colorPickerModal.show(); + } + } + } + } + } + } + + SettingsCard { + width: parent.width + iconName: "blur_linear" + title: I18n.tr("Connected Options") + settingKey: "frameConnectedOptions" + collapsible: true + expanded: true + visible: SettingsData.frameEnabled && SettingsData.frameMode === "connected" + + SettingsToggleRow { + settingKey: "frameCloseGaps" + tags: ["frame", "connected", "gap", "edge", "curves", "arcs", "expose", "popout", "notification"] + text: I18n.tr("Expose the Arcs") + description: I18n.tr("Reveal the arcs where surfaces meet the frame") + checked: !SettingsData.frameCloseGaps + onToggled: checked => SettingsData.set("frameCloseGaps", !checked) + } + + SettingsButtonGroupRow { + settingKey: "frameLauncherEmergeSide" + tags: ["frame", "connected", "launcher", "modal", "emerge", "direction", "bottom", "top"] + text: I18n.tr("Launcher Emerge Side") + description: I18n.tr("Edge the launcher slides from") + model: [I18n.tr("Bottom"), I18n.tr("Top")] + currentIndex: SettingsData.frameLauncherEmergeSide === "top" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.set("frameLauncherEmergeSide", index === 1 ? "top" : "bottom"); + } + } + + SettingsToggleRow { + settingKey: "frameLauncherArcExtender" + tags: ["frame", "connected", "launcher", "arc", "extender", "center"] + text: I18n.tr("Arc Extender") + description: I18n.tr("Use the extended surface for launcher content") + checked: SettingsData.frameLauncherArcExtender + onToggled: checked => SettingsData.set("frameLauncherArcExtender", checked) + } + } + + SettingsCard { + width: parent.width + iconName: "toolbar" + title: I18n.tr("Integrations") + settingKey: "frameBarIntegration" + collapsible: true + expanded: true + visible: SettingsData.frameEnabled && CompositorService.isNiri + + SettingsToggleRow { + settingKey: "frameShowOnOverview" + tags: ["frame", "overview", "show", "hide", "niri"] + text: I18n.tr("Show on Overview") + description: I18n.tr("Show during Niri overview") + checked: SettingsData.frameShowOnOverview + onToggled: checked => SettingsData.set("frameShowOnOverview", checked) + } + } + + SettingsCard { + width: parent.width + iconName: "monitor" + title: I18n.tr("Display Assignment") + settingKey: "frameDisplays" + collapsible: true + expanded: false + visible: SettingsData.frameEnabled + + SettingsDisplayPicker { + displayPreferences: SettingsData.frameScreenPreferences + onPreferencesChanged: prefs => SettingsData.set("frameScreenPreferences", prefs) + } + } + } + } +} diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index 6bf5d547..ba46d9bd 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -344,11 +344,7 @@ Item { return I18n.tr("%1 exists but is not included in config. Custom keybinds will not work until this is fixed.").arg(bindsFile); if (warningBox.showWarning) { const count = warningBox.status.overriddenBy; - return I18n.ntr( - "%1 DMS bind may be overridden by config binds that come after the include.", - "%1 DMS binds may be overridden by config binds that come after the include.", - count - ).arg(count); + return I18n.ntr("%1 DMS bind may be overridden by config binds that come after the include.", "%1 DMS binds may be overridden by config binds that come after the include.", count).arg(count); } return ""; } @@ -543,13 +539,11 @@ Item { StyledText { text: { - if (KeybindsService.loading) - return I18n.tr("Shortcuts"); - const count = keybindsTab._filteredBinds.length; - return count === 1 - ? I18n.tr("Shortcut (%1)").arg(count) - : I18n.tr("Shortcuts (%1)").arg(count); - } + if (KeybindsService.loading) + return I18n.tr("Shortcuts"); + const count = keybindsTab._filteredBinds.length; + return count === 1 ? I18n.tr("Shortcut (%1)").arg(count) : I18n.tr("Shortcuts (%1)").arg(count); + } font.pixelSize: Theme.fontSizeMedium font.weight: Font.Medium color: Theme.surfaceText @@ -569,7 +563,7 @@ Item { color: Theme.primary anchors.verticalCenter: parent.verticalCenter - RotationAnimation on rotation { + RotationAnimator on rotation { from: 0 to: 360 duration: 1000 diff --git a/quickshell/Modules/Settings/NetworkTab.qml b/quickshell/Modules/Settings/NetworkTab.qml index ace8adc2..a229314e 100644 --- a/quickshell/Modules/Settings/NetworkTab.qml +++ b/quickshell/Modules/Settings/NetworkTab.qml @@ -36,36 +36,36 @@ Item { function normalizePinList(value) { if (Array.isArray(value)) - return value.filter(v => v) + return value.filter(v => v); if (typeof value === "string" && value.length > 0) - return [value] - return [] + return [value]; + return []; } function getPinnedWifiNetworks() { - const pins = SettingsData.wifiNetworkPins || {} - return normalizePinList(pins["preferredWifi"]) + const pins = SettingsData.wifiNetworkPins || {}; + return normalizePinList(pins["preferredWifi"]); } function toggleWifiPin(ssid) { - const pins = JSON.parse(JSON.stringify(SettingsData.wifiNetworkPins || {})) - let pinnedList = normalizePinList(pins["preferredWifi"]) - const pinIndex = pinnedList.indexOf(ssid) + const pins = JSON.parse(JSON.stringify(SettingsData.wifiNetworkPins || {})); + let pinnedList = normalizePinList(pins["preferredWifi"]); + const pinIndex = pinnedList.indexOf(ssid); if (pinIndex !== -1) { - pinnedList.splice(pinIndex, 1) + pinnedList.splice(pinIndex, 1); } else { - pinnedList.unshift(ssid) + pinnedList.unshift(ssid); if (pinnedList.length > maxPinnedWifiNetworks) - pinnedList = pinnedList.slice(0, maxPinnedWifiNetworks) + pinnedList = pinnedList.slice(0, maxPinnedWifiNetworks); } if (pinnedList.length > 0) - pins["preferredWifi"] = pinnedList + pins["preferredWifi"] = pinnedList; else - delete pins["preferredWifi"] + delete pins["preferredWifi"]; - SettingsData.set("wifiNetworkPins", pins) + SettingsData.set("wifiNetworkPins", pins); } LazyLoader { @@ -340,9 +340,7 @@ Item { if (devices.length === 0) return I18n.tr("No adapters"); if (connected === 0) - return devices.length === 1 - ? I18n.tr("%1 adapter, none connected").arg(devices.length) - : I18n.tr("%1 adapters, none connected").arg(devices.length); + return devices.length === 1 ? I18n.tr("%1 adapter, none connected").arg(devices.length) : I18n.tr("%1 adapters, none connected").arg(devices.length); return I18n.tr("%1 connected").arg(connected); } font.pixelSize: Theme.fontSizeSmall @@ -658,16 +656,14 @@ Item { SequentialAnimation { running: NetworkService.networkWiredInfoLoading loops: Animation.Infinite - NumberAnimation { + OpacityAnimator { target: wiredLoadIcon - property: "opacity" to: 0.3 duration: 400 easing.type: Easing.InOutQuad } - NumberAnimation { + OpacityAnimator { target: wiredLoadIcon - property: "opacity" to: 1.0 duration: 400 easing.type: Easing.InOutQuad @@ -1046,16 +1042,14 @@ Item { SequentialAnimation { running: NetworkService.isScanning loops: Animation.Infinite - NumberAnimation { + OpacityAnimator { target: scanningIcon - property: "opacity" to: 0.3 duration: 400 easing.type: Easing.InOutQuad } - NumberAnimation { + OpacityAnimator { target: scanningIcon - property: "opacity" to: 1.0 duration: 400 easing.type: Easing.InOutQuad @@ -1087,14 +1081,14 @@ Item { let sorted = [...networks]; sorted.sort((a, b) => { - const aPinnedIndex = pinnedList.indexOf(a.ssid) - const bPinnedIndex = pinnedList.indexOf(b.ssid) + const aPinnedIndex = pinnedList.indexOf(a.ssid); + const bPinnedIndex = pinnedList.indexOf(b.ssid); if (aPinnedIndex !== -1 || bPinnedIndex !== -1) { if (aPinnedIndex === -1) - return 1 + return 1; if (bPinnedIndex === -1) - return -1 - return aPinnedIndex - bPinnedIndex + return -1; + return aPinnedIndex - bPinnedIndex; } if (a.ssid === ssid) return -1; @@ -1297,7 +1291,7 @@ Item { buttonSize: 28 iconColor: isPinned ? Theme.primary : Theme.surfaceVariantText onClicked: { - networkTab.toggleWifiPin(modelData.ssid) + networkTab.toggleWifiPin(modelData.ssid); } } @@ -1375,16 +1369,14 @@ Item { SequentialAnimation { running: NetworkService.networkInfoLoading loops: Animation.Infinite - NumberAnimation { + OpacityAnimator { target: wifiInfoLoadIcon - property: "opacity" to: 0.3 duration: 400 easing.type: Easing.InOutQuad } - NumberAnimation { + OpacityAnimator { target: wifiInfoLoadIcon - property: "opacity" to: 1.0 duration: 400 easing.type: Easing.InOutQuad @@ -1866,16 +1858,14 @@ Item { SequentialAnimation { running: VPNService.configLoading loops: Animation.Infinite - NumberAnimation { + OpacityAnimator { target: vpnLoadIcon - property: "opacity" to: 0.3 duration: 400 easing.type: Easing.InOutQuad } - NumberAnimation { + OpacityAnimator { target: vpnLoadIcon - property: "opacity" to: 1.0 duration: 400 easing.type: Easing.InOutQuad @@ -1984,7 +1974,9 @@ Item { checked: configData ? (configData.autoconnect || false) : false visible: !VPNService.configLoading && configData !== null onToggled: checked => { - VPNService.updateConfig(modelData.uuid, {autoconnect: checked}); + VPNService.updateConfig(modelData.uuid, { + autoconnect: checked + }); } } diff --git a/quickshell/Modules/Settings/PrinterTab.qml b/quickshell/Modules/Settings/PrinterTab.qml index 437dc472..b2c63a5b 100644 --- a/quickshell/Modules/Settings/PrinterTab.qml +++ b/quickshell/Modules/Settings/PrinterTab.qml @@ -458,7 +458,7 @@ Item { enabled: !CupsService.loadingDevices onClicked: CupsService.getDevices() - RotationAnimation on rotation { + RotationAnimator on rotation { running: CupsService.loadingDevices loops: Animation.Infinite from: 0 @@ -736,7 +736,7 @@ Item { enabled: !CupsService.loadingPPDs onClicked: CupsService.getPPDs() - RotationAnimation on rotation { + RotationAnimator on rotation { running: CupsService.loadingPPDs loops: Animation.Infinite from: 0 diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index b0df7192..be78b134 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -11,6 +11,8 @@ import qs.Modules.Settings.Widgets Item { id: themeColorsTab + property var parentModal: null + readonly property bool connectedFrameModeActive: SettingsData.connectedFrameModeActive property var cachedIconThemes: SettingsData.availableIconThemes property var cachedCursorThemes: SettingsData.availableCursorThemes property var cachedMatugenSchemes: Theme.availableMatugenSchemes.map(option => option.label) @@ -1613,12 +1615,20 @@ Item { } } + SettingsControlledByFrame { + visible: themeColorsTab.connectedFrameModeActive + parentModal: themeColorsTab.parentModal + settingLabel: I18n.tr("Surface Opacity") + reason: I18n.tr("Managed by Frame in Connected Mode") + } + SettingsSliderRow { tab: "theme" - tags: ["popup", "transparency", "opacity", "modal"] + tags: ["surface", "popup", "transparency", "opacity", "modal"] settingKey: "popupTransparency" - text: I18n.tr("Popup Transparency") + text: I18n.tr("Surface Opacity") description: I18n.tr("Controls opacity of all popouts, modals, and their content layers") + visible: !themeColorsTab.connectedFrameModeActive value: Math.round(SettingsData.popupTransparency * 100) minimum: 0 maximum: 100 @@ -1837,7 +1847,7 @@ Item { tags: ["blur", "background", "transparency", "glass", "frosted"] settingKey: "blurEnabled" text: I18n.tr("Background Blur") - description: BlurService.available ? I18n.tr("Blur the background behind bars, popouts, modals, and notifications. Requires compositor support and configuration.") : I18n.tr("Requires a newer version of Quickshell") + description: !BlurService.available ? I18n.tr("Requires a newer version of Quickshell") : I18n.tr("Blur the background behind bars, popouts, modals, and notifications. Requires compositor support and configuration.") checked: SettingsData.blurEnabled ?? false enabled: BlurService.available onToggled: checked => SettingsData.set("blurEnabled", checked) @@ -2240,12 +2250,20 @@ Item { settingKey: "modalBackground" iconName: "layers" + SettingsControlledByFrame { + visible: themeColorsTab.connectedFrameModeActive + parentModal: themeColorsTab.parentModal + settingLabel: I18n.tr("Darken Modal Background") + reason: I18n.tr("Managed by Frame in Connected Mode") + } + SettingsToggleRow { tab: "theme" tags: ["modal", "darken", "background", "overlay"] settingKey: "modalDarkenBackground" text: I18n.tr("Darken Modal Background") description: I18n.tr("Show darkened overlay behind modal dialogs") + visible: !themeColorsTab.connectedFrameModeActive checked: SettingsData.modalDarkenBackground onToggled: checked => SettingsData.set("modalDarkenBackground", checked) } diff --git a/quickshell/Modules/Settings/TimeWeatherTab.qml b/quickshell/Modules/Settings/TimeWeatherTab.qml index 61f4d43c..b6c29b7e 100644 --- a/quickshell/Modules/Settings/TimeWeatherTab.qml +++ b/quickshell/Modules/Settings/TimeWeatherTab.qml @@ -693,7 +693,7 @@ Item { onTriggered: refreshButton.isRefreshing = false } - NumberAnimation on rotation { + RotationAnimator on rotation { running: refreshButton.isRefreshing from: 0 to: 360 diff --git a/quickshell/Modules/Settings/TypographyMotionTab.qml b/quickshell/Modules/Settings/TypographyMotionTab.qml index 8648acb4..4ad421cd 100644 --- a/quickshell/Modules/Settings/TypographyMotionTab.qml +++ b/quickshell/Modules/Settings/TypographyMotionTab.qml @@ -55,6 +55,144 @@ Item { anchors.horizontalCenter: parent.horizontalCenter spacing: Theme.spacingXL + SettingsCard { + tab: "typography" + tags: ["animation", "variant", "style", "slide", "fluent", "dynamic", "motion"] + title: I18n.tr("Animation Style") + settingKey: "animationVariant" + iconName: "auto_awesome_motion" + + Item { + width: parent.width + height: animVariantGroup.implicitHeight + clip: true + + DankButtonGroup { + id: animVariantGroup + anchors.horizontalCenter: parent.horizontalCenter + buttonPadding: parent.width < 480 ? Theme.spacingS : Theme.spacingL + minButtonWidth: parent.width < 480 ? 64 : 96 + textSize: parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium + model: [I18n.tr("Material"), I18n.tr("Fluent"), I18n.tr("Dynamic")] + selectionMode: "single" + currentIndex: SettingsData.animationVariant + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.set("animationVariant", index); + } + + Connections { + target: SettingsData + function onAnimationVariantChanged() { + animVariantGroup.currentIndex = SettingsData.animationVariant; + } + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.15 + } + + Item { + width: parent.width + height: variantDescription.implicitHeight + Theme.spacingS * 2 + + StyledText { + id: variantDescription + x: Theme.spacingM + y: Theme.spacingS + width: parent.width - Theme.spacingM * 2 + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + text: { + switch (SettingsData.animationVariant) { + case 1: + return I18n.tr("Fluent: Smooth cubic deceleration in, quick snap out — clean, elegant curves."); + case 2: + return I18n.tr("Dynamic: Spring bezier with overshoot — entry briefly exceeds its target then settles. Expressive and alive."); + default: + return I18n.tr("Material: Material Design 3 Expressive bezier curves. The DMS default feel."); + } + } + } + } + } + + SettingsCard { + tab: "typography" + tags: ["animation", "motion", "effect", "slide", "directional", "depth", "spring", "physics"] + title: I18n.tr("Motion Effects") + settingKey: "motionEffect" + iconName: "motion_photos_on" + + Item { + width: parent.width + height: motionEffectGroup.implicitHeight + clip: true + + DankButtonGroup { + id: motionEffectGroup + anchors.horizontalCenter: parent.horizontalCenter + buttonPadding: parent.width < 480 ? Theme.spacingS : Theme.spacingL + minButtonWidth: parent.width < 480 ? 64 : 96 + textSize: parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium + model: [I18n.tr("Standard"), I18n.tr("Directional"), I18n.tr("Depth")] + selectionMode: "single" + currentIndex: SettingsData.motionEffect + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.set("motionEffect", index); + } + + Connections { + target: SettingsData + function onMotionEffectChanged() { + motionEffectGroup.currentIndex = SettingsData.motionEffect; + } + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.15 + } + + Item { + width: parent.width + height: motionEffectDescription.implicitHeight + Theme.spacingS * 2 + + StyledText { + id: motionEffectDescription + x: Theme.spacingM + y: Theme.spacingS + width: parent.width - Theme.spacingM * 2 + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + text: { + switch (SettingsData.motionEffect) { + case 1: + return I18n.tr("Directional: Panels glide in from a larger distance at full size — no scale change, pure clean motion."); + case 2: + return I18n.tr("Depth: Panels scale up from small as they slide in — a dramatic pop-forward depth effect."); + default: + return I18n.tr("Standard: Classic Material Design 3 — panels rise from below with a subtle scale. The DMS default."); + } + } + } + } + } + SettingsCard { tab: "typography" tags: ["font", "family", "text", "typography"] @@ -285,12 +423,6 @@ Item { description: I18n.tr("Popouts and Modals follow global Animation Speed (disable to customize independently)") checked: SettingsData.syncComponentAnimationSpeeds onToggled: checked => SettingsData.set("syncComponentAnimationSpeeds", checked) - - Connections { - target: SettingsData - function onSyncComponentAnimationSpeedsChanged() { - } - } } } diff --git a/quickshell/Modules/Settings/Widgets/SettingsControlledByFrame.qml b/quickshell/Modules/Settings/Widgets/SettingsControlledByFrame.qml new file mode 100644 index 00000000..fe09f90c --- /dev/null +++ b/quickshell/Modules/Settings/Widgets/SettingsControlledByFrame.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +StyledRect { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + property string settingLabel: "" + property string reason: "" + property var parentModal: null + + width: parent?.width ?? 0 + height: contentRow.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.primary, 0.08) + border.color: Theme.withAlpha(Theme.primary, 0.18) + border.width: 1 + + Row { + id: contentRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingM + + DankIcon { + name: "frame_source" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSize - openButton.width - Theme.spacingM * 2 + spacing: 2 + + StyledText { + text: root.settingLabel + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + wrapMode: Text.WordWrap + } + + StyledText { + text: root.reason + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.WordWrap + visible: root.reason !== "" + } + } + + DankButton { + id: openButton + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Open Frame") + backgroundColor: Theme.primary + textColor: Theme.primaryText + buttonHeight: 32 + horizontalPadding: Theme.spacingM + onClicked: { + if (!root.parentModal) + return; + root.parentModal.showWithTabName("frame"); + } + } + } +} diff --git a/quickshell/Modules/Settings/Widgets/SettingsDisplayPicker.qml b/quickshell/Modules/Settings/Widgets/SettingsDisplayPicker.qml index ba453fdc..ab4074f8 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsDisplayPicker.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsDisplayPicker.qml @@ -83,7 +83,6 @@ Item { description: modelData.width + "×" + modelData.height checked: localChecked onToggled: isChecked => { - localChecked = isChecked; var prefs = JSON.parse(JSON.stringify(root.displayPreferences)); if (!Array.isArray(prefs) || prefs.includes("all")) prefs = []; @@ -94,6 +93,11 @@ Item { model: modelData.model || "" }); } + if (prefs.length === 0) { + localChecked = true; + return; + } + localChecked = isChecked; root.preferencesChanged(prefs); } } diff --git a/quickshell/Modules/WorkspaceOverlays/HyprlandOverview.qml b/quickshell/Modules/WorkspaceOverlays/HyprlandOverview.qml index 19330352..2c605d11 100644 --- a/quickshell/Modules/WorkspaceOverlays/HyprlandOverview.qml +++ b/quickshell/Modules/WorkspaceOverlays/HyprlandOverview.qml @@ -48,244 +48,256 @@ Scope { bottom: true } - HyprlandFocusGrab { - id: grab - windows: [root] - active: false - property bool hasBeenActivated: false - onActiveChanged: { - if (active) { - hasBeenActivated = true - } - } - onCleared: () => { - if (hasBeenActivated && overviewScope.overviewOpen) { - overviewScope.overviewOpen = false - } - } - } - - Connections { - target: overviewScope - function onOverviewOpenChanged() { - if (overviewScope.overviewOpen) { - grab.hasBeenActivated = false - if (CompositorService.useHyprlandFocusGrab) - delayedGrabTimer.start() - } else { - delayedGrabTimer.stop() - grab.active = false - grab.hasBeenActivated = false - } - } - } - - Connections { - target: root - function onMonitorIsFocusedChanged() { - if (!CompositorService.useHyprlandFocusGrab) - return; - if (overviewScope.overviewOpen && root.monitorIsFocused && !grab.active) { - grab.hasBeenActivated = false - grab.active = true - } else if (overviewScope.overviewOpen && !root.monitorIsFocused && grab.active) { - grab.active = false - } - } - } - - Timer { - id: delayedGrabTimer - interval: 150 - repeat: false - onTriggered: { - if (CompositorService.useHyprlandFocusGrab && overviewScope.overviewOpen && root.monitorIsFocused) { - grab.active = true - } - } - } - - Timer { - id: closeTimer - interval: Theme.expressiveDurations.expressiveDefaultSpatial + 120 - onTriggered: { - root.visible = false - } - } - - Rectangle { - id: background - anchors.fill: parent - color: "black" - opacity: overviewScope.overviewOpen ? 0.5 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized - } - } - - MouseArea { - anchors.fill: parent - onClicked: mouse => { - const localPos = mapToItem(contentContainer, mouse.x, mouse.y) - if (localPos.x < 0 || localPos.x > contentContainer.width || localPos.y < 0 || localPos.y > contentContainer.height) { - overviewScope.overviewOpen = false - closeTimer.restart() + HyprlandFocusGrab { + id: grab + windows: [root] + active: false + property bool hasBeenActivated: false + onActiveChanged: { + if (active) { + hasBeenActivated = true; } } - } - } - - Item { - id: contentContainer - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: 100 - width: childrenRect.width - height: childrenRect.height - - opacity: overviewScope.overviewOpen ? 1 : 0 - transform: [scaleTransform, motionTransform] - - Scale { - id: scaleTransform - origin.x: contentContainer.width / 2 - origin.y: contentContainer.height / 2 - xScale: overviewScope.overviewOpen ? 1 : 0.96 - yScale: overviewScope.overviewOpen ? 1 : 0.96 - - Behavior on xScale { - NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized - } - } - - Behavior on yScale { - NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + onCleared: () => { + if (hasBeenActivated && overviewScope.overviewOpen) { + overviewScope.overviewOpen = false; } } } - Translate { - id: motionTransform - x: 0 - y: overviewScope.overviewOpen ? 0 : Theme.spacingL - - Behavior on y { - NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized - } - } - } - - Behavior on opacity { - NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized - } - } - - Loader { - id: overviewLoader - active: overviewScope.overviewOpen - asynchronous: false - - sourceComponent: OverviewWidget { - panelWindow: root - overviewOpen: overviewScope.overviewOpen - } - } - } - - FocusScope { - id: focusScope - anchors.fill: parent - visible: overviewScope.overviewOpen - focus: overviewScope.overviewOpen && root.monitorIsFocused - - Keys.onEscapePressed: event => { - if (!root.monitorIsFocused) return - overviewScope.overviewOpen = false - closeTimer.restart() - event.accepted = true - } - - Keys.onPressed: event => { - if (!root.monitorIsFocused) return - - if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) { - if (!overviewLoader.item) return - - const thisMonitorWorkspaceIds = overviewLoader.item.thisMonitorWorkspaceIds - if (thisMonitorWorkspaceIds.length === 0) return - - const currentId = root.monitor.activeWorkspace?.id ?? thisMonitorWorkspaceIds[0] - const currentIndex = thisMonitorWorkspaceIds.indexOf(currentId) - - let targetIndex - if (event.key === Qt.Key_Left) { - targetIndex = currentIndex - 1 - if (targetIndex < 0) targetIndex = thisMonitorWorkspaceIds.length - 1 + Connections { + target: overviewScope + function onOverviewOpenChanged() { + if (overviewScope.overviewOpen) { + grab.hasBeenActivated = false; + if (CompositorService.useHyprlandFocusGrab) + delayedGrabTimer.start(); } else { - targetIndex = currentIndex + 1 - if (targetIndex >= thisMonitorWorkspaceIds.length) targetIndex = 0 + delayedGrabTimer.stop(); + grab.active = false; + grab.hasBeenActivated = false; } - - const targetId = thisMonitorWorkspaceIds[targetIndex] - Hyprland.dispatch("workspace " + targetId) - event.accepted = true - } - } - - onVisibleChanged: { - if (visible && overviewScope.overviewOpen && root.monitorIsFocused) { - Qt.callLater(() => focusScope.forceActiveFocus()) } } Connections { target: root function onMonitorIsFocusedChanged() { - if (root.monitorIsFocused && overviewScope.overviewOpen) { - Qt.callLater(() => focusScope.forceActiveFocus()) + if (!CompositorService.useHyprlandFocusGrab) + return; + if (overviewScope.overviewOpen && root.monitorIsFocused && !grab.active) { + grab.hasBeenActivated = false; + grab.active = true; + } else if (overviewScope.overviewOpen && !root.monitorIsFocused && grab.active) { + grab.active = false; + } + } + } + + Timer { + id: delayedGrabTimer + interval: 150 + repeat: false + onTriggered: { + if (CompositorService.useHyprlandFocusGrab && overviewScope.overviewOpen && root.monitorIsFocused) { + grab.active = true; + } + } + } + + Timer { + id: closeTimer + interval: Theme.expressiveDurations.expressiveDefaultSpatial + 120 + onTriggered: { + root.visible = false; + } + } + + Rectangle { + id: background + anchors.fill: parent + color: "black" + opacity: overviewScope.overviewOpen ? 0.5 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + MouseArea { + anchors.fill: parent + onClicked: mouse => { + const localPos = mapToItem(contentAnchor, mouse.x, mouse.y); + if (localPos.x < 0 || localPos.x > contentAnchor.width || localPos.y < 0 || localPos.y > contentAnchor.height) { + overviewScope.overviewOpen = false; + closeTimer.restart(); + } + } + } + } + + Item { + id: contentAnchor + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 100 + width: contentContainer.width + height: contentContainer.height + + Item { + id: contentContainer + width: childrenRect.width + height: childrenRect.height + transformOrigin: Item.Center + + opacity: overviewScope.overviewOpen ? 1 : 0 + scale: overviewScope.overviewOpen ? 1 : Theme.effectScaleCollapsed + x: { + if (overviewScope.overviewOpen) + return 0; + if (Theme.isDepthEffect) + return Theme.effectAnimOffset * 0.25; + return 0; + } + y: { + if (overviewScope.overviewOpen) + return 0; + if (Theme.isDirectionalEffect) + return -Math.max(contentContainer.height * 0.8, Theme.effectAnimOffset * 1.1); + if (Theme.isDepthEffect) + return Math.max(Theme.effectAnimOffset * 0.85, 28); + return Theme.effectAnimOffset; + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + Behavior on scale { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + Behavior on x { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + Behavior on y { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + Loader { + id: overviewLoader + active: overviewScope.overviewOpen + asynchronous: false + + sourceComponent: OverviewWidget { + panelWindow: root + overviewOpen: overviewScope.overviewOpen + } + } + } + } + + FocusScope { + id: focusScope + anchors.fill: parent + visible: overviewScope.overviewOpen + focus: overviewScope.overviewOpen && root.monitorIsFocused + + Keys.onEscapePressed: event => { + if (!root.monitorIsFocused) + return; + overviewScope.overviewOpen = false; + closeTimer.restart(); + event.accepted = true; + } + + Keys.onPressed: event => { + if (!root.monitorIsFocused) + return; + if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) { + if (!overviewLoader.item) + return; + const thisMonitorWorkspaceIds = overviewLoader.item.thisMonitorWorkspaceIds; + if (thisMonitorWorkspaceIds.length === 0) + return; + const currentId = root.monitor.activeWorkspace?.id ?? thisMonitorWorkspaceIds[0]; + const currentIndex = thisMonitorWorkspaceIds.indexOf(currentId); + + let targetIndex; + if (event.key === Qt.Key_Left) { + targetIndex = currentIndex - 1; + if (targetIndex < 0) + targetIndex = thisMonitorWorkspaceIds.length - 1; + } else { + targetIndex = currentIndex + 1; + if (targetIndex >= thisMonitorWorkspaceIds.length) + targetIndex = 0; + } + + const targetId = thisMonitorWorkspaceIds[targetIndex]; + Hyprland.dispatch("workspace " + targetId); + event.accepted = true; + } + } + + onVisibleChanged: { + if (visible && overviewScope.overviewOpen && root.monitorIsFocused) { + Qt.callLater(() => focusScope.forceActiveFocus()); + } + } + + Connections { + target: root + function onMonitorIsFocusedChanged() { + if (root.monitorIsFocused && overviewScope.overviewOpen) { + Qt.callLater(() => focusScope.forceActiveFocus()); + } + } + } + } + + onVisibleChanged: { + if (visible && overviewScope.overviewOpen) { + Qt.callLater(() => focusScope.forceActiveFocus()); + } else if (!visible) { + grab.active = false; + } + } + + Connections { + target: overviewScope + function onOverviewOpenChanged() { + if (overviewScope.overviewOpen) { + closeTimer.stop(); + root.visible = true; + Qt.callLater(() => focusScope.forceActiveFocus()); + } else { + closeTimer.restart(); + grab.active = false; } } } } - - onVisibleChanged: { - if (visible && overviewScope.overviewOpen) { - Qt.callLater(() => focusScope.forceActiveFocus()) - } else if (!visible) { - grab.active = false - } - } - - Connections { - target: overviewScope - function onOverviewOpenChanged() { - if (overviewScope.overviewOpen) { - closeTimer.stop() - root.visible = true - Qt.callLater(() => focusScope.forceActiveFocus()) - } else { - closeTimer.restart() - grab.active = false - } - } - } - } } } } diff --git a/quickshell/Modules/WorkspaceOverlays/NiriOverviewOverlay.qml b/quickshell/Modules/WorkspaceOverlays/NiriOverviewOverlay.qml index 8699b489..89155b40 100644 --- a/quickshell/Modules/WorkspaceOverlays/NiriOverviewOverlay.qml +++ b/quickshell/Modules/WorkspaceOverlays/NiriOverviewOverlay.qml @@ -141,6 +141,8 @@ Scope { WindowBlur { targetWindow: overlayWindow + // Track the container's scale so blur shrinks with the content + // during exit — otherwise blur pops away one frame after content. readonly property real s: Math.min(1, spotlightContainer.scale) readonly property bool active: overlayWindow.shouldShowSpotlight && spotlightContainer.opacity > 0 blurX: spotlightContainer.x + spotlightContainer.width * (1 - s) * 0.5 @@ -228,8 +230,26 @@ Scope { Item { id: spotlightContainer + + // Connected-frame mode: dock flush against the emerge-side frame + // edge and slide in from beyond that edge. In any other mode the + // spotlight stays centered — identical to master. + readonly property string connectedEmergeSide: SettingsData.frameLauncherEmergeSide || "bottom" + readonly property real _centerY: (parent.height - height) / 2 + readonly property real _connectedRestY: { + if (!Theme.isConnectedEffect || !overlayWindow.screen) + return _centerY; + const inset = SettingsData.frameEdgeInsetForSide(overlayWindow.screen, connectedEmergeSide); + return connectedEmergeSide === "top" ? inset : parent.height - height - inset; + } + readonly property real _connectedCollapsedY: connectedEmergeSide === "top" ? -height : parent.height + x: Theme.snap((parent.width - width) / 2, overlayWindow.dpr) - y: Theme.snap((parent.height - height) / 2, overlayWindow.dpr) + y: { + if (!Theme.isConnectedEffect) + return Theme.snap(_centerY, overlayWindow.dpr); + return Theme.snap(overlayWindow.shouldShowSpotlight ? _connectedRestY : _connectedCollapsedY, overlayWindow.dpr); + } readonly property int baseWidth: { switch (SettingsData.dankLauncherV2Size) { @@ -260,8 +280,8 @@ Scope { readonly property bool animatingOut: niriOverviewScope.isClosing && overlayWindow.isSpotlightScreen - scale: overlayWindow.shouldShowSpotlight ? 1.0 : 0.96 - opacity: overlayWindow.shouldShowSpotlight ? 1 : 0 + scale: Theme.isConnectedEffect ? 1.0 : (overlayWindow.shouldShowSpotlight ? 1.0 : 0.96) + opacity: Theme.isConnectedEffect ? 1 : (overlayWindow.shouldShowSpotlight ? 1 : 0) visible: overlayWindow.shouldShowSpotlight || animatingOut enabled: overlayWindow.shouldShowSpotlight @@ -270,6 +290,22 @@ Scope { layer.textureSize: layer.enabled ? Qt.size(Math.round(width * overlayWindow.dpr), Math.round(height * overlayWindow.dpr)) : Qt.size(0, 0) Behavior on scale { + id: scaleAnimation + enabled: !Theme.isConnectedEffect + NumberAnimation { + duration: Theme.expressiveDurations.fast + easing.type: Easing.BezierSpline + easing.bezierCurve: spotlightContainer.visible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel + onRunningChanged: { + if (running || !spotlightContainer.animatingOut) + return; + niriOverviewScope.resetState(); + } + } + } + + Behavior on opacity { + enabled: !Theme.isConnectedEffect NumberAnimation { duration: Theme.expressiveDurations.fast easing.type: Easing.BezierSpline @@ -277,11 +313,20 @@ Scope { } } - Behavior on opacity { + // Connected-mode slide — only animates in full connected-frame mode. + // Drives resetState when the slide-out finishes (scale/opacity are + // static in connected mode so their onRunningChanged never fires). + Behavior on y { + enabled: Theme.isConnectedEffect NumberAnimation { - duration: Theme.expressiveDurations.fast + duration: Theme.variantDuration(Theme.popoutAnimationDuration, overlayWindow.shouldShowSpotlight) easing.type: Easing.BezierSpline - easing.bezierCurve: spotlightContainer.visible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel + easing.bezierCurve: overlayWindow.shouldShowSpotlight ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + onRunningChanged: { + if (running || !spotlightContainer.animatingOut) + return; + niriOverviewScope.resetState(); + } } } diff --git a/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml b/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml index 43899714..2a4d7f52 100644 --- a/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml +++ b/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml @@ -62,30 +62,30 @@ Item { Behavior on x { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } Behavior on y { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } Behavior on width { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } Behavior on height { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } @@ -124,16 +124,16 @@ Item { Behavior on width { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } Behavior on height { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } } diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml index 691d0f88..1f152e70 100644 --- a/quickshell/Services/PopoutService.qml +++ b/quickshell/Services/PopoutService.qml @@ -202,10 +202,9 @@ Singleton { } function unloadDankDash() { - if (!dankDashPopoutLoader) - return; - dankDashPopout = null; - dankDashPopoutLoader.active = false; + // DankDash is intentionally kept alive after first use. Destroying this + // lazy popout during its close signal can invalidate connected overlay + // bindings while Qt is still unwinding the signal stack. } function toggleDankDash(tabIndex, x, y, width, section, screen) { diff --git a/quickshell/Widgets/ConnectedCorner.qml b/quickshell/Widgets/ConnectedCorner.qml new file mode 100644 index 00000000..44fb9c15 --- /dev/null +++ b/quickshell/Widgets/ConnectedCorner.qml @@ -0,0 +1,151 @@ +import QtQuick +import QtQuick.Shapes +import "../Common/ConnectorGeometry.js" as ConnectorGeometry + +// Concave arc connector filling the gap between a bar corner and an adjacent surface. +// +// NOTE: FrameWindow now uses ConnectedShape.qml for frame-owned connected chrome +// (unified single-path rendering). This component is still used by DankPopout's +// own shadow source for non-frame-owned chrome (popouts on non-frame screens). + +Item { + id: root + + property string barSide: "top" + property string placement: "left" + property real spacing: 4 + property real connectorRadius: 12 + property color color: "transparent" + property real edgeStrokeWidth: 0 + property color edgeStrokeColor: color + property real dpr: 1 + + readonly property bool isHorizontalBar: barSide === "top" || barSide === "bottom" + readonly property bool isPlacementLeft: placement === "left" + readonly property real _edgeStrokeWidth: Math.max(0, edgeStrokeWidth) + readonly property string arcCorner: ConnectorGeometry.arcCorner(barSide, placement) + readonly property real pathStartX: { + switch (arcCorner) { + case "topLeft": + return width; + case "topRight": + case "bottomLeft": + return 0; + default: + return 0; + } + } + readonly property real pathStartY: { + switch (arcCorner) { + case "bottomRight": + return height; + default: + return 0; + } + } + readonly property real firstLineX: { + switch (arcCorner) { + case "topLeft": + case "bottomLeft": + return width; + default: + return 0; + } + } + readonly property real firstLineY: { + switch (arcCorner) { + case "topLeft": + case "topRight": + return height; + default: + return 0; + } + } + readonly property real secondLineX: { + switch (arcCorner) { + case "topRight": + case "bottomLeft": + case "bottomRight": + return width; + default: + return 0; + } + } + readonly property real secondLineY: { + switch (arcCorner) { + case "topLeft": + case "topRight": + case "bottomLeft": + return height; + default: + return 0; + } + } + readonly property real arcCenterX: arcCorner === "topRight" || arcCorner === "bottomRight" ? width : 0 + readonly property real arcCenterY: arcCorner === "bottomLeft" || arcCorner === "bottomRight" ? height : 0 + readonly property real arcStartAngle: { + switch (arcCorner) { + case "topLeft": + case "topRight": + return 90; + case "bottomLeft": + return 0; + default: + return -90; + } + } + readonly property real arcSweepAngle: { + switch (arcCorner) { + case "topRight": + return 90; + default: + return -90; + } + } + + width: isHorizontalBar ? connectorRadius : (spacing + connectorRadius) + height: isHorizontalBar ? (spacing + connectorRadius) : connectorRadius + + Shape { + x: -root._edgeStrokeWidth + y: -root._edgeStrokeWidth + width: root.width + root._edgeStrokeWidth * 2 + height: root.height + root._edgeStrokeWidth * 2 + asynchronous: false + antialiasing: true + preferredRendererType: Shape.CurveRenderer + layer.enabled: true + layer.smooth: true + layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0) + + ShapePath { + fillColor: root.color + strokeColor: root._edgeStrokeWidth > 0 ? root.edgeStrokeColor : "transparent" + strokeWidth: root._edgeStrokeWidth * 2 + joinStyle: ShapePath.RoundJoin + capStyle: ShapePath.RoundCap + fillRule: ShapePath.WindingFill + startX: root.pathStartX + root._edgeStrokeWidth + startY: root.pathStartY + root._edgeStrokeWidth + + PathLine { + x: root.firstLineX + root._edgeStrokeWidth + y: root.firstLineY + root._edgeStrokeWidth + } + + PathLine { + x: root.secondLineX + root._edgeStrokeWidth + y: root.secondLineY + root._edgeStrokeWidth + } + + PathAngleArc { + centerX: root.arcCenterX + root._edgeStrokeWidth + centerY: root.arcCenterY + root._edgeStrokeWidth + radiusX: root.connectorRadius + radiusY: root.connectorRadius + startAngle: root.arcStartAngle + sweepAngle: root.arcSweepAngle + } + } + } +} diff --git a/quickshell/Widgets/ConnectedShape.qml b/quickshell/Widgets/ConnectedShape.qml new file mode 100644 index 00000000..4622a8b3 --- /dev/null +++ b/quickshell/Widgets/ConnectedShape.qml @@ -0,0 +1,414 @@ +import QtQuick +import QtQuick.Shapes +import qs.Common + +// Unified connected silhouette: body + near/far concave arcs as one ShapePath. +// Keeping the connected chrome in one path avoids sibling alignment seams. + +Item { + id: root + + property string barSide: "top" + + property real bodyWidth: 0 + property real bodyHeight: 0 + + property real connectorRadius: 12 + property real startConnectorRadius: connectorRadius + property real endConnectorRadius: connectorRadius + property real farStartConnectorRadius: 0 + property real farEndConnectorRadius: 0 + + property real surfaceRadius: 12 + + property color fillColor: "transparent" + + readonly property bool _horiz: barSide === "top" || barSide === "bottom" + readonly property real _sc: Math.max(0, startConnectorRadius) + readonly property real _ec: Math.max(0, endConnectorRadius) + readonly property real _fsc: Math.max(0, farStartConnectorRadius) + readonly property real _fec: Math.max(0, farEndConnectorRadius) + readonly property real _firstCr: barSide === "left" ? _sc : _ec + readonly property real _secondCr: barSide === "left" ? _ec : _sc + readonly property real _firstFarCr: barSide === "left" ? _fsc : _fec + readonly property real _secondFarCr: barSide === "left" ? _fec : _fsc + readonly property real _farExtent: Math.max(_fsc, _fec) + readonly property real _sr: Math.max(0, Math.min(surfaceRadius, (_horiz ? bodyWidth : bodyHeight) / 2, (_horiz ? bodyHeight : bodyWidth) / 2)) + readonly property real _firstSr: _firstFarCr > 0 ? 0 : _sr + readonly property real _secondSr: _secondFarCr > 0 ? 0 : _sr + readonly property real _firstFarInset: _firstFarCr > 0 ? _firstFarCr : _firstSr + readonly property real _secondFarInset: _secondFarCr > 0 ? _secondFarCr : _secondSr + + // Root-level aliases — PathArc/PathLine elements can't use `parent`. + readonly property real _bw: bodyWidth + readonly property real _bh: bodyHeight + readonly property real _bodyLeft: _horiz ? _sc : (barSide === "right" ? _farExtent : 0) + readonly property real _bodyRight: _bodyLeft + _bw + readonly property real _bodyTop: _horiz ? (barSide === "bottom" ? _farExtent : 0) : _sc + readonly property real _bodyBottom: _bodyTop + _bh + readonly property real _totalW: _horiz ? _bw + _sc + _ec : _bw + _farExtent + readonly property real _totalH: _horiz ? _bh + _farExtent : _bh + _sc + _ec + + width: _totalW + height: _totalH + + readonly property real bodyX: root._bodyLeft + readonly property real bodyY: root._bodyTop + + Shape { + anchors.fill: parent + asynchronous: false + preferredRendererType: Shape.CurveRenderer + antialiasing: true + + ShapePath { + fillColor: root.fillColor + strokeWidth: -1 + fillRule: ShapePath.WindingFill + + // CW path: bar edge → concave arc → body → convex arc → far edge → convex arc → body → concave arc + + startX: root.barSide === "right" ? root._totalW : 0 + startY: { + switch (root.barSide) { + case "bottom": + return root._totalH; + case "left": + return root._totalH; + case "right": + return 0; + default: + return 0; + } + } + + // Bar edge + PathLine { + x: { + switch (root.barSide) { + case "left": + return 0; + case "right": + return root._totalW; + default: + return root._totalW; + } + } + y: { + switch (root.barSide) { + case "bottom": + return root._totalH; + case "left": + return 0; + case "right": + return root._totalH; + default: + return 0; + } + } + } + + // Concave arc 1 + PathArc { + relativeX: { + switch (root.barSide) { + case "left": + return root._firstCr; + case "right": + return -root._firstCr; + default: + return -root._firstCr; + } + } + relativeY: { + switch (root.barSide) { + case "bottom": + return -root._firstCr; + case "left": + return root._firstCr; + case "right": + return -root._firstCr; + default: + return root._firstCr; + } + } + radiusX: root._firstCr + radiusY: root._firstCr + direction: root.barSide === "bottom" ? PathArc.Clockwise : PathArc.Counterclockwise + } + + // Body edge to first convex corner + PathLine { + x: { + switch (root.barSide) { + case "left": + return root._bodyRight - root._firstSr; + case "right": + return root._bodyLeft + root._firstSr; + default: + return root._bodyRight; + } + } + y: { + switch (root.barSide) { + case "bottom": + return root._bodyTop + root._firstSr; + case "left": + return root._bodyTop; + case "right": + return root._bodyBottom; + default: + return root._bodyBottom - root._firstSr; + } + } + } + + // Convex arc 1 + PathArc { + relativeX: { + switch (root.barSide) { + case "left": + return root._firstSr; + case "right": + return -root._firstSr; + default: + return -root._firstSr; + } + } + relativeY: { + switch (root.barSide) { + case "bottom": + return -root._firstSr; + case "left": + return root._firstSr; + case "right": + return -root._firstSr; + default: + return root._firstSr; + } + } + radiusX: root._firstSr + radiusY: root._firstSr + direction: root.barSide === "bottom" ? PathArc.Counterclockwise : PathArc.Clockwise + } + + // Opposite-side connector 1 + PathLine { + x: { + switch (root.barSide) { + case "left": + return root._firstFarCr > 0 ? root._bodyRight + root._firstFarCr : root._bodyRight; + case "right": + return root._firstFarCr > 0 ? root._bodyLeft - root._firstFarCr : root._bodyLeft; + default: + return root._firstFarCr > 0 ? root._bodyRight : root._bodyRight - root._firstSr; + } + } + y: { + switch (root.barSide) { + case "bottom": + return root._firstFarCr > 0 ? root._bodyTop - root._firstFarCr : root._bodyTop; + case "left": + return root._firstFarCr > 0 ? root._bodyTop : root._bodyTop + root._firstSr; + case "right": + return root._firstFarCr > 0 ? root._bodyBottom : root._bodyBottom - root._firstSr; + default: + return root._firstFarCr > 0 ? root._bodyBottom + root._firstFarCr : root._bodyBottom; + } + } + } + + PathArc { + relativeX: { + switch (root.barSide) { + case "left": + return -root._firstFarCr; + case "right": + return root._firstFarCr; + default: + return -root._firstFarCr; + } + } + relativeY: { + switch (root.barSide) { + case "bottom": + return root._firstFarCr; + case "left": + return root._firstFarCr; + case "right": + return -root._firstFarCr; + default: + return -root._firstFarCr; + } + } + radiusX: root._firstFarCr + radiusY: root._firstFarCr + direction: root.barSide === "bottom" ? PathArc.Clockwise : PathArc.Counterclockwise + } + + // Far edge + PathLine { + x: { + switch (root.barSide) { + case "left": + return root._bodyRight; + case "right": + return root._bodyLeft; + default: + return root._bodyLeft + root._secondFarInset; + } + } + y: { + switch (root.barSide) { + case "bottom": + return root._bodyTop; + case "left": + return root._bodyBottom - root._secondFarInset; + case "right": + return root._bodyTop + root._secondFarInset; + default: + return root._bodyBottom; + } + } + } + + // Opposite-side connector 2 + PathArc { + relativeX: { + switch (root.barSide) { + case "left": + return root._secondFarCr; + case "right": + return -root._secondFarCr; + default: + return -root._secondFarCr; + } + } + relativeY: { + switch (root.barSide) { + case "bottom": + return -root._secondFarCr; + case "left": + return root._secondFarCr; + case "right": + return -root._secondFarCr; + default: + return root._secondFarCr; + } + } + radiusX: root._secondFarCr + radiusY: root._secondFarCr + direction: root.barSide === "bottom" ? PathArc.Clockwise : PathArc.Counterclockwise + } + + PathLine { + x: { + switch (root.barSide) { + case "left": + return root._secondFarCr > 0 ? root._bodyRight : root._bodyRight; + case "right": + return root._secondFarCr > 0 ? root._bodyLeft : root._bodyLeft; + default: + return root._secondFarCr > 0 ? root._bodyLeft : root._bodyLeft + root._secondSr; + } + } + y: { + switch (root.barSide) { + case "bottom": + return root._secondFarCr > 0 ? root._bodyTop : root._bodyTop; + case "left": + return root._secondFarCr > 0 ? root._bodyBottom : root._bodyBottom - root._secondSr; + case "right": + return root._secondFarCr > 0 ? root._bodyTop : root._bodyTop + root._secondSr; + default: + return root._secondFarCr > 0 ? root._bodyBottom : root._bodyBottom; + } + } + } + + // Convex arc 2 + PathArc { + relativeX: { + switch (root.barSide) { + case "left": + return -root._secondSr; + case "right": + return root._secondSr; + default: + return -root._secondSr; + } + } + relativeY: { + switch (root.barSide) { + case "bottom": + return root._secondSr; + case "left": + return root._secondSr; + case "right": + return -root._secondSr; + default: + return -root._secondSr; + } + } + radiusX: root._secondSr + radiusY: root._secondSr + direction: root.barSide === "bottom" ? PathArc.Counterclockwise : PathArc.Clockwise + } + + // Body edge to second concave arc + PathLine { + x: { + switch (root.barSide) { + case "left": + return root._bodyLeft + root._ec; + case "right": + return root._bodyRight - root._sc; + default: + return root._bodyLeft; + } + } + y: { + switch (root.barSide) { + case "bottom": + return root._bodyBottom - root._sc; + case "left": + return root._bodyBottom; + case "right": + return root._bodyTop; + default: + return root._bodyTop + root._sc; + } + } + } + + // Concave arc 2 + PathArc { + relativeX: { + switch (root.barSide) { + case "left": + return -root._secondCr; + case "right": + return root._secondCr; + default: + return -root._secondCr; + } + } + relativeY: { + switch (root.barSide) { + case "bottom": + return root._secondCr; + case "left": + return root._secondCr; + case "right": + return -root._secondCr; + default: + return -root._secondCr; + } + } + radiusX: root._secondCr + radiusY: root._secondCr + direction: root.barSide === "bottom" ? PathArc.Clockwise : PathArc.Counterclockwise + } + } + } +} diff --git a/quickshell/Widgets/DankButton.qml b/quickshell/Widgets/DankButton.qml index 6de3a8d4..4f978d87 100644 --- a/quickshell/Widgets/DankButton.qml +++ b/quickshell/Widgets/DankButton.qml @@ -28,7 +28,8 @@ Rectangle { Behavior on scale { enabled: enableScaleAnimation && Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None - DankAnim { + NumberAnimation { + easing.type: Easing.BezierSpline duration: 100 easing.bezierCurve: Theme.expressiveCurves.standard } diff --git a/quickshell/Widgets/DankCollapsibleSection.qml b/quickshell/Widgets/DankCollapsibleSection.qml index 0ed0e59c..bb527e8a 100644 --- a/quickshell/Widgets/DankCollapsibleSection.qml +++ b/quickshell/Widgets/DankCollapsibleSection.qml @@ -45,7 +45,8 @@ ColumnLayout { Behavior on rotation { enabled: Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None - DankAnim { + NumberAnimation { + easing.type: Easing.BezierSpline duration: Theme.shortDuration easing.bezierCurve: Theme.expressiveCurves.standard } @@ -88,7 +89,8 @@ ColumnLayout { Behavior on opacity { enabled: Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None - DankAnim { + NumberAnimation { + easing.type: Easing.BezierSpline duration: Theme.shortDuration easing.bezierCurve: Theme.expressiveCurves.standard } @@ -108,7 +110,8 @@ ColumnLayout { Behavior on opacity { enabled: Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None - DankAnim { + NumberAnimation { + easing.type: Easing.BezierSpline duration: Theme.shortDuration easing.bezierCurve: Theme.expressiveCurves.standard } diff --git a/quickshell/Widgets/DankPopout.qml b/quickshell/Widgets/DankPopout.qml index a82a27b3..fb8a64d5 100644 --- a/quickshell/Widgets/DankPopout.qml +++ b/quickshell/Widgets/DankPopout.qml @@ -1,6 +1,4 @@ import QtQuick -import Quickshell -import Quickshell.Wayland import qs.Common import qs.Services @@ -9,11 +7,8 @@ Item { readonly property var log: Log.scoped("DankPopout") property string layerNamespace: "dms:popout" - property alias content: contentLoader.sourceComponent - property alias contentLoader: contentLoader + property Component content: null property Component overlayContent: null - property alias overlayLoader: overlayLoader - readonly property alias backgroundWindow: backgroundWindow property real popupWidth: 400 property real popupHeight: 300 property real triggerX: 0 @@ -22,10 +17,10 @@ Item { property string triggerSection: "" property string positioning: "center" property int animationDuration: Theme.popoutAnimationDuration - property real animationScaleCollapsed: 0.96 - property real animationOffset: Theme.spacingL - property list animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial - property list animationExitCurve: Theme.expressiveCurves.emphasized + property real animationScaleCollapsed: Theme.effectScaleCollapsed + property real animationOffset: Theme.effectAnimOffset + property list animationEnterCurve: Theme.variantPopoutEnterCurve + property list animationExitCurve: Theme.variantPopoutExitCurve property bool suspendShadowWhileResizing: false property bool shouldBeVisible: false property var customKeyboardFocus: null @@ -33,9 +28,6 @@ Item { property bool contentHandlesKeys: false property bool fullHeightSurface: false property bool _primeContent: false - property bool _resizeActive: false - property real _surfaceMarginLeft: 0 - property real _surfaceW: 0 property real storedBarThickness: Theme.barHeight - 4 property real storedBarSpacing: 4 @@ -47,90 +39,118 @@ Item { "rightBar": 0 }) property var screen: null - - readonly property real effectiveBarThickness: { - const padding = storedBarConfig ? (storedBarConfig.innerPadding !== undefined ? storedBarConfig.innerPadding : 4) : 4; - return Math.max(26 + padding * 0.6, Theme.barHeight - 4 - (8 - padding)) + storedBarSpacing; - } - - readonly property var barBounds: { - if (!screen) - return { - "x": 0, - "y": 0, - "width": 0, - "height": 0, - "wingSize": 0 - }; - return SettingsData.getBarBounds(screen, effectiveBarThickness, effectiveBarPosition, storedBarConfig); - } - - readonly property real barX: barBounds.x - readonly property real barY: barBounds.y - readonly property real barWidth: barBounds.width - readonly property real barHeight: barBounds.height - readonly property real barWingSize: barBounds.wingSize + property int effectiveBarPosition: 0 + property real effectiveBarBottomGap: 0 signal opened signal popoutClosed signal backgroundClicked - property var _lastOpenedScreen: null + readonly property var contentLoader: impl.item ? impl.item.contentLoader : _fallbackContentLoader + readonly property var overlayLoader: impl.item ? impl.item.overlayLoader : _fallbackOverlayLoader + readonly property var backgroundWindow: impl.item ? impl.item.backgroundWindow : null - property int effectiveBarPosition: 0 - property real effectiveBarBottomGap: 0 - readonly property string autoBarShadowDirection: { - const section = triggerSection || "center"; - switch (effectiveBarPosition) { - case SettingsData.Position.Top: - if (section === "left") - return "topLeft"; - if (section === "right") - return "topRight"; - return "top"; - case SettingsData.Position.Bottom: - if (section === "left") - return "bottomLeft"; - if (section === "right") - return "bottomRight"; - return "bottom"; - case SettingsData.Position.Left: - if (section === "left") - return "topLeft"; - if (section === "right") - return "bottomLeft"; - return "left"; - case SettingsData.Position.Right: - if (section === "left") - return "topRight"; - if (section === "right") - return "bottomRight"; - return "right"; - default: - return "top"; + Loader { + id: _fallbackContentLoader + active: false + } + Loader { + id: _fallbackOverlayLoader + active: false + } + readonly property bool isClosing: impl.item ? (impl.item.isClosing ?? false) : false + readonly property real dpr: impl.item ? impl.item.dpr : 1 + readonly property real screenWidth: impl.item ? impl.item.screenWidth : 0 + readonly property real screenHeight: impl.item ? impl.item.screenHeight : 0 + readonly property real alignedX: impl.item ? impl.item.alignedX : 0 + readonly property real alignedY: impl.item ? impl.item.alignedY : 0 + readonly property real alignedWidth: impl.item ? impl.item.alignedWidth : 0 + readonly property real alignedHeight: impl.item ? impl.item.alignedHeight : 0 + readonly property real maskX: impl.item ? impl.item.maskX : 0 + readonly property real maskY: impl.item ? impl.item.maskY : 0 + readonly property real maskWidth: impl.item ? impl.item.maskWidth : 0 + readonly property real maskHeight: impl.item ? impl.item.maskHeight : 0 + readonly property real barX: impl.item ? impl.item.barX : 0 + readonly property real barY: impl.item ? impl.item.barY : 0 + readonly property real barWidth: impl.item ? impl.item.barWidth : 0 + readonly property real barHeight: impl.item ? impl.item.barHeight : 0 + readonly property bool useConnectedBackend: _usesConnectedBackendForScreen(screen) + property var _resolvedBackend: null + property bool _pendingOpen: false + + Timer { + id: _pendingOpenTimer + interval: 0 + onTriggered: { + if (!root._pendingOpen || !impl.item) + return; + root._pendingOpen = false; + impl.item.open(); } } - readonly property string effectiveShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection - // Snapshot mask geometry to prevent background damage on bar updates - property real _frozenMaskX: 0 - property real _frozenMaskY: 0 - property real _frozenMaskWidth: 0 - property real _frozenMaskHeight: 0 + onUseConnectedBackendChanged: _maybeResolveBackend() + Component.onCompleted: _resolvedBackend = _backendForScreen(screen) + + Connections { + target: SettingsData + function onConnectedFrameModeActiveChanged() { + root._maybeResolveBackend(); + } + function onFrameScreenPreferencesChanged() { + root._maybeResolveBackend(); + } + } + + function _usesConnectedBackendForScreen(targetScreen) { + return SettingsData.connectedFrameModeActive && !!targetScreen && SettingsData.isScreenInPreferences(targetScreen, SettingsData.frameScreenPreferences); + } + + function _backendForScreen(targetScreen) { + return _usesConnectedBackendForScreen(targetScreen) ? connectedComp : standaloneComp; + } + + // Defer Loader source-component swap until impl is fully closed; avoids + // tearing down a popout mid-animation when frame mode is toggled. + function _maybeResolveBackend() { + _resolveBackendForScreen(screen); + } + + function _resolveBackendForScreen(targetScreen) { + const backend = _backendForScreen(targetScreen); + if (_resolvedBackend === backend) + return; + if (impl.item && (impl.item.shouldBeVisible || impl.item.isClosing)) + return; + _resolvedBackend = backend; + } + + function open() { + _maybeResolveBackend(); + if (impl.item) { + _pendingOpen = false; + impl.item.open(); + return; + } + _pendingOpen = true; + } + + function close() { + _pendingOpen = false; + _pendingOpenTimer.stop(); + if (impl.item) + impl.item.close(); + } + + function toggle() { + (shouldBeVisible || _pendingOpen) ? close() : open(); + } function setBarContext(position, bottomGap) { effectiveBarPosition = position !== undefined ? position : 0; effectiveBarBottomGap = bottomGap !== undefined ? bottomGap : 0; } - function primeContent() { - _primeContent = true; - } - - function clearPrimedContent() { - _primeContent = false; - } - function setTriggerPosition(x, y, width, section, targetScreen, barPosition, barThickness, barSpacing, barConfig) { triggerX = x; triggerY = y; @@ -147,477 +167,112 @@ Item { adjacentBarInfo = SettingsData.getAdjacentBarInfo(targetScreen, pos, barConfig); setBarContext(pos, bottomGap); + _resolveBackendForScreen(targetScreen); } - readonly property bool useBackgroundWindow: !CompositorService.isHyprland || CompositorService.useHyprlandFocusGrab - function updateSurfacePosition() { - if (useBackgroundWindow && shouldBeVisible) { - _surfaceMarginLeft = alignedX - shadowBuffer; - _surfaceW = alignedWidth + shadowBuffer * 2; - } + if (impl.item && typeof impl.item.updateSurfacePosition === "function") + impl.item.updateSurfacePosition(); } - function open() { - if (!screen) + Loader { + id: impl + active: root.screen !== null + sourceComponent: root._resolvedBackend + onItemChanged: if (item) + root._wireBackend(item) + } + + Component { + id: standaloneComp + DankPopoutStandalone {} + } + + Component { + id: connectedComp + DankPopoutConnected {} + } + + function _wireBackend(it) { + if (!it) return; - closeTimer.stop(); - // Snapshot mask geometry - _frozenMaskX = maskX; - _frozenMaskY = maskY; - _frozenMaskWidth = maskWidth; - _frozenMaskHeight = maskHeight; + it.popoutHandle = root; + it.layerNamespace = Qt.binding(() => root.layerNamespace); + it.content = Qt.binding(() => root.content); + it.overlayContent = Qt.binding(() => root.overlayContent); + it.popupWidth = Qt.binding(() => root.popupWidth); + it.popupHeight = Qt.binding(() => root.popupHeight); + it.triggerX = Qt.binding(() => root.triggerX); + it.triggerY = Qt.binding(() => root.triggerY); + it.triggerWidth = Qt.binding(() => root.triggerWidth); + it.triggerSection = Qt.binding(() => root.triggerSection); + it.positioning = Qt.binding(() => root.positioning); + it.animationDuration = Qt.binding(() => root.animationDuration); + it.animationScaleCollapsed = Qt.binding(() => root.animationScaleCollapsed); + it.animationOffset = Qt.binding(() => root.animationOffset); + it.animationEnterCurve = Qt.binding(() => root.animationEnterCurve); + it.animationExitCurve = Qt.binding(() => root.animationExitCurve); + it.suspendShadowWhileResizing = Qt.binding(() => root.suspendShadowWhileResizing); + it.customKeyboardFocus = Qt.binding(() => root.customKeyboardFocus); + it.backgroundInteractive = Qt.binding(() => root.backgroundInteractive); + it.contentHandlesKeys = Qt.binding(() => root.contentHandlesKeys); + it.fullHeightSurface = Qt.binding(() => root.fullHeightSurface); + it.storedBarThickness = Qt.binding(() => root.storedBarThickness); + it.storedBarSpacing = Qt.binding(() => root.storedBarSpacing); + it.storedBarConfig = Qt.binding(() => root.storedBarConfig); + it.adjacentBarInfo = Qt.binding(() => root.adjacentBarInfo); + it.screen = Qt.binding(() => root.screen); + it.effectiveBarPosition = Qt.binding(() => root.effectiveBarPosition); + it.effectiveBarBottomGap = Qt.binding(() => root.effectiveBarBottomGap); - if (_lastOpenedScreen !== null && _lastOpenedScreen !== screen) { - contentWindow.visible = false; - if (useBackgroundWindow) - backgroundWindow.visible = false; - } - _lastOpenedScreen = screen; - - shouldBeVisible = true; - if (useBackgroundWindow) { - _surfaceMarginLeft = alignedX - shadowBuffer; - _surfaceW = alignedWidth + shadowBuffer * 2; - } - Qt.callLater(() => { - if (shouldBeVisible && screen) { - if (useBackgroundWindow) - backgroundWindow.visible = true; - contentWindow.visible = true; - PopoutManager.showPopout(root); - opened(); - } - }); + it.shouldBeVisible = root.shouldBeVisible; + if (root._primeContent && typeof it.primeContent === "function") + it.primeContent(); + if (_pendingOpen) + _pendingOpenTimer.restart(); } - function close() { - shouldBeVisible = false; + function primeContent() { + _primeContent = true; + if (impl.item) + impl.item.primeContent(); + } + + function clearPrimedContent() { _primeContent = false; - PopoutManager.popoutChanged(); - closeTimer.restart(); - } - - function toggle() { - shouldBeVisible ? close() : open(); + if (impl.item) + impl.item.clearPrimedContent(); } Connections { - target: Quickshell - function onScreensChanged() { - if (!shouldBeVisible || !screen) - return; - const currentScreenName = screen.name; - let screenStillExists = false; - for (let i = 0; i < Quickshell.screens.length; i++) { - if (Quickshell.screens[i].name === currentScreenName) { - screenStillExists = true; - break; - } - } - if (!screenStillExists) { - close(); - } + target: root + function onShouldBeVisibleChanged() { + if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible) + impl.item.shouldBeVisible = root.shouldBeVisible; } } - Timer { - id: closeTimer - interval: animationDuration - onTriggered: { - if (!shouldBeVisible) { - contentWindow.visible = false; - if (useBackgroundWindow) - backgroundWindow.visible = false; - PopoutManager.hidePopout(root); - popoutClosed(); - } - } - } + Connections { + target: impl.item + ignoreUnknownSignals: true - readonly property real screenWidth: screen ? screen.width : 0 - readonly property real screenHeight: screen ? screen.height : 0 - readonly property real dpr: screen ? screen.devicePixelRatio : 1 - - readonly property var shadowLevel: Theme.elevationLevel3 - readonly property real shadowFallbackOffset: 6 - readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.popoutElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, effectiveShadowDirection, shadowFallbackOffset, 8, 16) : 0 - readonly property real shadowMotionPadding: Math.max(0, animationOffset) - readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) - readonly property real alignedWidth: Theme.px(popupWidth, dpr) - readonly property real alignedHeight: Theme.px(popupHeight, dpr) - - onAlignedHeightChanged: { - if (!suspendShadowWhileResizing || !shouldBeVisible) - return; - _resizeActive = true; - resizeSettleTimer.restart(); - } - onShouldBeVisibleChanged: { - if (!shouldBeVisible) { - _resizeActive = false; - resizeSettleTimer.stop(); - } - } - - Timer { - id: resizeSettleTimer - interval: 80 - repeat: false - onTriggered: root._resizeActive = false - } - - readonly property real alignedX: Theme.snap((() => { - const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; - const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; - const popupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; - - switch (effectiveBarPosition) { - case SettingsData.Position.Left: - return Math.max(popupGap, Math.min(screenWidth - popupWidth - popupGap, triggerX)); - case SettingsData.Position.Right: - return Math.max(popupGap, Math.min(screenWidth - popupWidth - popupGap, triggerX - popupWidth)); - default: - const rawX = triggerX + (triggerWidth / 2) - (popupWidth / 2); - const minX = adjacentBarInfo.leftBar > 0 ? adjacentBarInfo.leftBar : popupGap; - const maxX = screenWidth - popupWidth - (adjacentBarInfo.rightBar > 0 ? adjacentBarInfo.rightBar : popupGap); - return Math.max(minX, Math.min(maxX, rawX)); - } - })(), dpr) - - readonly property real alignedY: Theme.snap((() => { - const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; - const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; - const popupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; - - switch (effectiveBarPosition) { - case SettingsData.Position.Bottom: - return Math.max(popupGap, Math.min(screenHeight - popupHeight - popupGap, triggerY - popupHeight)); - case SettingsData.Position.Top: - return Math.max(popupGap, Math.min(screenHeight - popupHeight - popupGap, triggerY)); - default: - const rawY = triggerY - (popupHeight / 2); - const minY = adjacentBarInfo.topBar > 0 ? adjacentBarInfo.topBar : popupGap; - const maxY = screenHeight - popupHeight - (adjacentBarInfo.bottomBar > 0 ? adjacentBarInfo.bottomBar : popupGap); - return Math.max(minY, Math.min(maxY, rawY)); - } - })(), dpr) - - readonly property real triggeringBarLeftExclusion: (effectiveBarPosition === SettingsData.Position.Left && barWidth > 0) ? Math.max(0, barX + barWidth) : 0 - readonly property real triggeringBarTopExclusion: (effectiveBarPosition === SettingsData.Position.Top && barHeight > 0) ? Math.max(0, barY + barHeight) : 0 - readonly property real triggeringBarRightExclusion: (effectiveBarPosition === SettingsData.Position.Right && barWidth > 0) ? Math.max(0, screenWidth - barX) : 0 - readonly property real triggeringBarBottomExclusion: (effectiveBarPosition === SettingsData.Position.Bottom && barHeight > 0) ? Math.max(0, screenHeight - barY) : 0 - - readonly property real maskX: { - const adjacentLeftBar = adjacentBarInfo?.leftBar ?? 0; - return Math.max(triggeringBarLeftExclusion, adjacentLeftBar); - } - - readonly property real maskY: { - const adjacentTopBar = adjacentBarInfo?.topBar ?? 0; - return Math.max(triggeringBarTopExclusion, adjacentTopBar); - } - - readonly property real maskWidth: { - const adjacentRightBar = adjacentBarInfo?.rightBar ?? 0; - const rightExclusion = Math.max(triggeringBarRightExclusion, adjacentRightBar); - return Math.max(100, screenWidth - maskX - rightExclusion); - } - - readonly property real maskHeight: { - const adjacentBottomBar = adjacentBarInfo?.bottomBar ?? 0; - const bottomExclusion = Math.max(triggeringBarBottomExclusion, adjacentBottomBar); - return Math.max(100, screenHeight - maskY - bottomExclusion); - } - - PanelWindow { - id: backgroundWindow - screen: root.screen - visible: false - color: "transparent" - Component.onCompleted: { - if (typeof updatesEnabled !== "undefined" && !root.overlayContent) - updatesEnabled = false; + function onShouldBeVisibleChanged() { + if (impl.item && root.shouldBeVisible !== impl.item.shouldBeVisible) + root.shouldBeVisible = impl.item.shouldBeVisible; } - WlrLayershell.namespace: root.layerNamespace + ":background" - WlrLayershell.layer: WlrLayershell.Top - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - - anchors { - top: true - left: true - right: true - bottom: true + function onOpened() { + root.opened(); } - mask: Region { - item: maskRect + function onPopoutClosed() { + root.popoutClosed(); + root._maybeResolveBackend(); } - Rectangle { - id: maskRect - visible: false - color: "transparent" - x: root._frozenMaskX - y: root._frozenMaskY - width: (shouldBeVisible && backgroundInteractive) ? root._frozenMaskWidth : 0 - height: (shouldBeVisible && backgroundInteractive) ? root._frozenMaskHeight : 0 - } - - MouseArea { - x: root._frozenMaskX - y: root._frozenMaskY - width: root._frozenMaskWidth - height: root._frozenMaskHeight - hoverEnabled: false - enabled: shouldBeVisible && backgroundInteractive - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - onClicked: mouse => { - const clickX = mouse.x + root._frozenMaskX; - const clickY = mouse.y + root._frozenMaskY; - const outsideContent = clickX < root.alignedX || clickX > root.alignedX + root.alignedWidth || clickY < root.alignedY || clickY > root.alignedY + root.alignedHeight; - - if (!outsideContent) - return; - backgroundClicked(); - } - } - - Loader { - id: overlayLoader - anchors.fill: parent - active: root.overlayContent !== null && backgroundWindow.visible - sourceComponent: root.overlayContent - } - } - - PanelWindow { - id: contentWindow - screen: root.screen - visible: false - color: "transparent" - - WindowBlur { - id: popoutBlur - targetWindow: contentWindow - readonly property real s: Math.min(1, contentContainer.scaleValue) - blurX: contentContainer.x + contentContainer.width * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) - blurY: contentContainer.y + contentContainer.height * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) - blurWidth: (shouldBeVisible && contentWrapper.opacity > 0) ? contentContainer.width * s : 0 - blurHeight: (shouldBeVisible && contentWrapper.opacity > 0) ? contentContainer.height * s : 0 - blurRadius: Theme.cornerRadius - } - - WlrLayershell.namespace: root.layerNamespace - WlrLayershell.layer: { - switch (Quickshell.env("DMS_POPOUT_LAYER")) { - case "bottom": - log.warn("'bottom' layer is not valid for popouts. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "background": - log.warn("'background' layer is not valid for popouts. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "overlay": - return WlrLayershell.Overlay; - default: - return WlrLayershell.Top; - } - } - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: { - if (customKeyboardFocus !== null) - return customKeyboardFocus; - if (!shouldBeVisible) - return WlrKeyboardFocus.None; - if (CompositorService.useHyprlandFocusGrab) - return WlrKeyboardFocus.OnDemand; - return WlrKeyboardFocus.Exclusive; - } - - readonly property bool _fullHeight: useBackgroundWindow && root.fullHeightSurface - - anchors { - left: true - top: true - right: !useBackgroundWindow - bottom: _fullHeight || !useBackgroundWindow - } - - WlrLayershell.margins { - left: useBackgroundWindow ? root._surfaceMarginLeft : 0 - top: (useBackgroundWindow && !_fullHeight) ? (root.alignedY - shadowBuffer) : 0 - } - - implicitWidth: useBackgroundWindow ? root._surfaceW : 0 - implicitHeight: (useBackgroundWindow && !_fullHeight) ? (root.alignedHeight + shadowBuffer * 2) : 0 - - mask: useBackgroundWindow ? contentInputMask : null - - Region { - id: contentInputMask - item: contentMaskRect - } - - Item { - id: contentMaskRect - visible: false - x: contentContainer.x - y: contentContainer.y - width: shouldBeVisible ? root.alignedWidth : 0 - height: shouldBeVisible ? root.alignedHeight : 0 - } - - MouseArea { - anchors.fill: parent - enabled: !useBackgroundWindow && shouldBeVisible && backgroundInteractive - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - z: -1 - onClicked: mouse => { - const clickX = mouse.x; - const clickY = mouse.y; - const outsideContent = clickX < root.alignedX || clickX > root.alignedX + root.alignedWidth || clickY < root.alignedY || clickY > root.alignedY + root.alignedHeight; - if (!outsideContent) - return; - backgroundClicked(); - } - } - - Item { - id: contentContainer - x: useBackgroundWindow ? shadowBuffer : root.alignedX - y: (useBackgroundWindow && !contentWindow._fullHeight) ? shadowBuffer : root.alignedY - width: root.alignedWidth - height: root.alignedHeight - - readonly property bool barTop: effectiveBarPosition === SettingsData.Position.Top - readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom - readonly property bool barLeft: effectiveBarPosition === SettingsData.Position.Left - readonly property bool barRight: effectiveBarPosition === SettingsData.Position.Right - readonly property real offsetX: barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0) - readonly property real offsetY: barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0) - - property real animX: 0 - property real animY: 0 - property real scaleValue: root.animationScaleCollapsed - - onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr) - onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr) - - Connections { - target: root - function onShouldBeVisibleChanged() { - contentContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetX, root.dpr); - contentContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetY, root.dpr); - contentContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed; - } - } - - Behavior on animX { - NumberAnimation { - duration: root.animationDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Behavior on animY { - NumberAnimation { - duration: root.animationDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Behavior on scaleValue { - NumberAnimation { - duration: root.animationDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - ElevationShadow { - id: shadowSource - width: parent.width - height: parent.height - opacity: contentWrapper.opacity - scale: contentWrapper.scale - x: contentWrapper.x - y: contentWrapper.y - level: root.shadowLevel - direction: root.effectiveShadowDirection - fallbackOffset: root.shadowFallbackOffset - targetRadius: Theme.cornerRadius - targetColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) - shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive) && !BlurService.enabled - } - - Item { - id: contentWrapper - anchors.centerIn: parent - width: parent.width - height: parent.height - opacity: shouldBeVisible ? 1 : 0 - visible: opacity > 0 - scale: contentContainer.scaleValue - x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) - y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) - - layer.enabled: contentWrapper.opacity < 1 - layer.smooth: false - layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0) - - Behavior on opacity { - NumberAnimation { - duration: animationDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Loader { - id: contentLoader - anchors.fill: parent - active: root._primeContent || shouldBeVisible || contentWindow.visible - asynchronous: false - } - } - - Rectangle { - width: parent.width - height: parent.height - x: contentWrapper.x - y: contentWrapper.y - opacity: contentWrapper.opacity - scale: contentWrapper.scale - visible: contentWrapper.visible - radius: Theme.cornerRadius - antialiasing: true - color: "transparent" - border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium - border.width: BlurService.enabled ? BlurService.borderWidth : 1 - z: 100 - } - } - - Item { - id: focusHelper - parent: contentContainer - anchors.fill: parent - visible: !root.contentHandlesKeys - enabled: !root.contentHandlesKeys - focus: !root.contentHandlesKeys - Keys.onPressed: event => { - if (root.contentHandlesKeys) - return; - if (event.key === Qt.Key_Escape) { - close(); - event.accepted = true; - } - } + function onBackgroundClicked() { + root.backgroundClicked(); } } } diff --git a/quickshell/Widgets/DankPopoutConnected.qml b/quickshell/Widgets/DankPopoutConnected.qml new file mode 100644 index 00000000..57f0c322 --- /dev/null +++ b/quickshell/Widgets/DankPopoutConnected.qml @@ -0,0 +1,1142 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import "../Common/ConnectorGeometry.js" as ConnectorGeometry + +Item { + id: root + readonly property var log: Log.scoped("DankPopoutConnected") + + property var popoutHandle: root + property string layerNamespace: "dms:popout" + property alias content: contentLoader.sourceComponent + property alias contentLoader: contentLoader + property Component overlayContent: null + property alias overlayLoader: overlayLoader + readonly property alias backgroundWindow: contentWindow + property real popupWidth: 400 + property real popupHeight: 300 + property real triggerX: 0 + property real triggerY: 0 + property real triggerWidth: 40 + property string triggerSection: "" + property string positioning: "center" + property int animationDuration: Theme.popoutAnimationDuration + property real animationScaleCollapsed: Theme.effectScaleCollapsed + property real animationOffset: Theme.effectAnimOffset + property list animationEnterCurve: Theme.variantPopoutEnterCurve + property list animationExitCurve: Theme.variantPopoutExitCurve + property bool suspendShadowWhileResizing: false + property bool shouldBeVisible: false + property var customKeyboardFocus: null + property bool backgroundInteractive: true + property bool contentHandlesKeys: false + property bool fullHeightSurface: false + property bool _primeContent: false + property bool _resizeActive: false + property string _chromeClaimId: "" + property int _connectedChromeSerial: 0 + property real _chromeAnimTravelX: 1 + property real _chromeAnimTravelY: 1 + property bool _fullSyncQueued: false + + property real storedBarThickness: Theme.barHeight - 4 + property real storedBarSpacing: 4 + property var storedBarConfig: null + property var adjacentBarInfo: ({ + "topBar": 0, + "bottomBar": 0, + "leftBar": 0, + "rightBar": 0 + }) + property var screen: null + // Connected resize uses one full-screen surface; body-sized regions are masks. + readonly property bool useBackgroundWindow: false + + readonly property real effectiveBarThickness: { + if (Theme.isConnectedEffect) + return Math.max(0, storedBarThickness); + const padding = storedBarConfig ? (storedBarConfig.innerPadding !== undefined ? storedBarConfig.innerPadding : 4) : 4; + return Math.max(26 + padding * 0.6, Theme.barHeight - 4 - (8 - padding)) + storedBarSpacing; + } + + readonly property var barBounds: { + if (!screen) + return { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "wingSize": 0 + }; + return SettingsData.getBarBounds(screen, effectiveBarThickness, effectiveBarPosition, storedBarConfig); + } + + readonly property real barX: barBounds.x + readonly property real barY: barBounds.y + readonly property real barWidth: barBounds.width + readonly property real barHeight: barBounds.height + readonly property real barWingSize: barBounds.wingSize + readonly property bool effectiveSurfaceBlurEnabled: Theme.connectedSurfaceBlurEnabled + + signal opened + signal popoutClosed + signal backgroundClicked + + // Coalesce per-channel dirty bits; one ConnectedModeState write per tick. + Timer { + id: _syncTimer + interval: 0 + onTriggered: root._flushSync() + } + + property var _lastOpenedScreen: null + property bool isClosing: false + + property int effectiveBarPosition: 0 + property real effectiveBarBottomGap: 0 + readonly property string autoBarShadowDirection: { + const section = triggerSection || "center"; + switch (effectiveBarPosition) { + case SettingsData.Position.Top: + if (section === "left") + return "topLeft"; + if (section === "right") + return "topRight"; + return "top"; + case SettingsData.Position.Bottom: + if (section === "left") + return "bottomLeft"; + if (section === "right") + return "bottomRight"; + return "bottom"; + case SettingsData.Position.Left: + if (section === "left") + return "topLeft"; + if (section === "right") + return "bottomLeft"; + return "left"; + case SettingsData.Position.Right: + if (section === "left") + return "topRight"; + if (section === "right") + return "bottomRight"; + return "right"; + default: + return "top"; + } + } + readonly property string effectiveShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection + + function setBarContext(position, bottomGap) { + effectiveBarPosition = position !== undefined ? position : 0; + effectiveBarBottomGap = bottomGap !== undefined ? bottomGap : 0; + } + + function primeContent() { + _primeContent = true; + } + + function clearPrimedContent() { + _primeContent = false; + } + + function setTriggerPosition(x, y, width, section, targetScreen, barPosition, barThickness, barSpacing, barConfig) { + triggerX = x; + triggerY = y; + triggerWidth = width; + triggerSection = section; + screen = targetScreen; + + storedBarThickness = barThickness !== undefined ? barThickness : (Theme.barHeight - 4); + storedBarSpacing = barSpacing !== undefined ? barSpacing : 4; + storedBarConfig = barConfig; + + const pos = barPosition !== undefined ? barPosition : 0; + const bottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : 0) : 0; + + adjacentBarInfo = SettingsData.getAdjacentBarInfo(targetScreen, pos, barConfig); + setBarContext(pos, bottomGap); + } + + function _nextChromeClaimId() { + _connectedChromeSerial += 1; + return layerNamespace + ":" + _connectedChromeSerial + ":" + (new Date()).getTime(); + } + + function _captureChromeAnimTravel() { + _chromeAnimTravelX = Math.max(1, Math.abs(contentContainer.offsetX)); + _chromeAnimTravelY = Math.max(1, Math.abs(contentContainer.offsetY)); + } + + function _connectedChromeAnimX() { + const barSide = contentContainer.connectedBarSide; + if (barSide !== "left" && barSide !== "right") + return contentContainer.animX; + + const extent = Math.max(0, root.alignedWidth); + const progress = Math.min(1, Math.abs(contentContainer.animX) / Math.max(1, _chromeAnimTravelX)); + const offset = Theme.snap(extent * progress, root.dpr); + return contentContainer.animX < 0 ? -offset : offset; + } + + function _connectedChromeAnimY() { + const barSide = contentContainer.connectedBarSide; + if (barSide !== "top" && barSide !== "bottom") + return contentContainer.animY; + + const extent = Math.max(0, root.renderedAlignedHeight); + const progress = Math.min(1, Math.abs(contentContainer.animY) / Math.max(1, _chromeAnimTravelY)); + const offset = Theme.snap(extent * progress, root.dpr); + return contentContainer.animY < 0 ? -offset : offset; + } + + function _connectedChromeState(visibleOverride) { + const visible = visibleOverride !== undefined ? !!visibleOverride : contentWindow.visible; + return { + "visible": visible, + "barSide": contentContainer.connectedBarSide, + "bodyX": root.alignedX, + "bodyY": root.renderedAlignedY, + "bodyW": root.alignedWidth, + "bodyH": root.renderedAlignedHeight, + "animX": _connectedChromeAnimX(), + "animY": _connectedChromeAnimY(), + "screen": root.screen ? root.screen.name : "", + "omitStartConnector": root._closeGapOmitStartConnector(), + "omitEndConnector": root._closeGapOmitEndConnector() + }; + } + + function _publishConnectedChromeState(forceClaim, visibleOverride) { + if (!root.frameOwnsConnectedChrome || !root.screen || !_chromeClaimId) + return; + + const state = _connectedChromeState(visibleOverride); + if (forceClaim || !ConnectedModeState.hasPopoutOwner(_chromeClaimId)) { + ConnectedModeState.claimPopout(_chromeClaimId, state); + } else { + ConnectedModeState.updatePopout(_chromeClaimId, state); + } + } + + function _releaseConnectedChromeState() { + if (_chromeClaimId) + ConnectedModeState.releasePopout(_chromeClaimId); + _chromeClaimId = ""; + } + + // ─── Exposed animation state for ConnectedModeState ──────────────────── + readonly property real contentAnimX: contentContainer.animX + readonly property real contentAnimY: contentContainer.animY + + // ─── ConnectedModeState sync ──────────────────────────────────────────── + function _syncPopoutChromeState() { + if (!root.frameOwnsConnectedChrome) { + _releaseConnectedChromeState(); + return; + } + if (!root.screen) { + _releaseConnectedChromeState(); + return; + } + if (!contentWindow.visible && !shouldBeVisible) + return; + if (!_chromeClaimId) + _chromeClaimId = _nextChromeClaimId(); + _publishConnectedChromeState(contentWindow.visible && !ConnectedModeState.hasPopoutOwner(_chromeClaimId)); + } + + function _syncPopoutAnim(axis) { + if (!root.frameOwnsConnectedChrome || !_chromeClaimId) + return; + if (!contentWindow.visible && !shouldBeVisible) + return; + const barSide = contentContainer.connectedBarSide; + const syncX = axis === "x" && (barSide === "left" || barSide === "right"); + const syncY = axis === "y" && (barSide === "top" || barSide === "bottom"); + if (!syncX && !syncY) + return; + ConnectedModeState.setPopoutAnim(_chromeClaimId, syncX ? _connectedChromeAnimX() : undefined, syncY ? _connectedChromeAnimY() : undefined); + } + + function _syncPopoutBody() { + if (!root.frameOwnsConnectedChrome || !_chromeClaimId) + return; + if (!contentWindow.visible && !shouldBeVisible) + return; + ConnectedModeState.setPopoutBody(_chromeClaimId, root.alignedX, root.renderedAlignedY, root.alignedWidth, root.renderedAlignedHeight); + } + + property bool _animSyncQueued: false + property bool _bodySyncQueued: false + + function _queueFullSync() { + _fullSyncQueued = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _queueAnimSync() { + _animSyncQueued = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _queueBodySync() { + _bodySyncQueued = true; + if (!_syncTimer.running) + _syncTimer.restart(); + } + function _flushSync() { + const fullDirty = _fullSyncQueued; + const animDirty = _animSyncQueued; + const bodyDirty = _bodySyncQueued; + _fullSyncQueued = false; + _animSyncQueued = false; + _bodySyncQueued = false; + if (fullDirty) + _syncPopoutChromeState(); + if (animDirty) { + _syncPopoutAnim("x"); + _syncPopoutAnim("y"); + } + if (bodyDirty) + _syncPopoutBody(); + } + + onAlignedXChanged: _queueFullSync() + onAlignedYChanged: _queueFullSync() + onAlignedWidthChanged: _queueFullSync() + onContentAnimXChanged: _queueAnimSync() + onContentAnimYChanged: _queueAnimSync() + onRenderedAlignedYChanged: _queueBodySync() + onRenderedAlignedHeightChanged: _queueBodySync() + onScreenChanged: _queueFullSync() + onEffectiveBarPositionChanged: _queueFullSync() + + Connections { + target: contentWindow + function onVisibleChanged() { + if (contentWindow.visible) + root._publishConnectedChromeState(true); + else + root._releaseConnectedChromeState(); + } + } + + Connections { + target: SettingsData + function onConnectedFrameModeActiveChanged() { + if (root.frameOwnsConnectedChrome) { + if (contentWindow.visible || root.shouldBeVisible) { + if (!root._chromeClaimId) + root._chromeClaimId = root._nextChromeClaimId(); + root._publishConnectedChromeState(true); + } + } else { + root._releaseConnectedChromeState(); + } + } + function onFrameCloseGapsChanged() { + root._syncPopoutChromeState(); + } + } + + readonly property bool frameOwnsConnectedChrome: SettingsData.connectedFrameModeActive && !!root.screen && SettingsData.isScreenInPreferences(root.screen, SettingsData.frameScreenPreferences) + + property bool animationsEnabled: true + + function open() { + if (!screen) + return; + closeTimer.stop(); + isClosing = false; + animationsEnabled = false; + _primeContent = true; + + if (_lastOpenedScreen !== null && _lastOpenedScreen !== screen) { + contentWindow.visible = false; + } + _lastOpenedScreen = screen; + + if (contentContainer) { + // animationsEnabled is false here, so this snaps to closed without animating. + morph.openProgress = 0; + _captureChromeAnimTravel(); + } + + if (root.frameOwnsConnectedChrome) { + _chromeClaimId = _nextChromeClaimId(); + _publishConnectedChromeState(true, true); + } else { + _chromeClaimId = ""; + } + + contentWindow.visible = true; + + animationsEnabled = true; + shouldBeVisible = true; + if (shouldBeVisible && screen) { + contentWindow.visible = true; + PopoutManager.showPopout(popoutHandle); + opened(); + } + } + + function close() { + isClosing = true; + shouldBeVisible = false; + _primeContent = false; + PopoutManager.popoutChanged(); + closeTimer.restart(); + } + + function toggle() { + shouldBeVisible ? close() : open(); + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (!shouldBeVisible || !screen) + return; + const currentScreenName = screen.name; + let screenStillExists = false; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === currentScreenName) { + screenStillExists = true; + break; + } + } + if (!screenStillExists) { + close(); + } + } + } + + Timer { + id: closeTimer + interval: Theme.variantCloseInterval(animationDuration) + onTriggered: { + if (!shouldBeVisible) { + isClosing = false; + contentWindow.visible = false; + PopoutManager.hidePopout(popoutHandle); + popoutClosed(); + } + } + } + + Component.onDestruction: _releaseConnectedChromeState() + + readonly property real screenWidth: screen ? screen.width : 0 + readonly property real screenHeight: screen ? screen.height : 0 + readonly property real dpr: screen ? screen.devicePixelRatio : 1 + readonly property bool closeFrameGapsActive: SettingsData.frameCloseGaps && frameOwnsConnectedChrome + readonly property real frameInset: { + if (!SettingsData.frameEnabled) + return 0; + const ft = SettingsData.frameThickness; + const fr = SettingsData.frameRounding; + const ccr = Theme.connectedCornerRadius; + if (Theme.isConnectedEffect) + return Math.max(ft * 4, ft + ccr * 2); + const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; + const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 6; + const gap = useAutoGaps ? Math.max(6, storedBarSpacing) : manualGapValue; + return Math.max(ft + gap, fr); + } + + function _popupGapValue() { + const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; + const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; + const rawPopupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; + return Theme.isConnectedEffect ? 0 : rawPopupGap; + } + + function _frameEdgeInset(side) { + if (!SettingsData.frameEnabled || !root.screen) + return 0; + const edges = SettingsData.getActiveBarEdgesForScreen(root.screen); + const raw = edges.includes(side) ? SettingsData.frameBarSize : SettingsData.frameThickness; + return Math.max(0, raw); + } + + function _edgeGapFor(side, popupGap) { + if (root.closeFrameGapsActive) + return Math.max(popupGap, _frameEdgeInset(side)); + return Math.max(popupGap, frameInset); + } + + function _sideAdjacentClearance(side) { + switch (side) { + case "left": + return adjacentBarClearance(adjacentBarInfo.leftBar); + case "right": + return adjacentBarClearance(adjacentBarInfo.rightBar); + case "top": + return adjacentBarClearance(adjacentBarInfo.topBar); + case "bottom": + return adjacentBarClearance(adjacentBarInfo.bottomBar); + default: + return 0; + } + } + + function _nearFrameBound(value, bound) { + return Math.abs(value - bound) <= Math.max(1, Theme.hairline(root.dpr) * 2); + } + + function _closeGapClampedToFrameSide(side) { + if (!root.closeFrameGapsActive) + return false; + const popupGap = _popupGapValue(); + const edgeGap = _edgeGapFor(side, popupGap); + const adjacentGap = _sideAdjacentClearance(side); + if (edgeGap < adjacentGap - Math.max(1, Theme.hairline(root.dpr) * 2)) + return false; + + switch (side) { + case "left": + return _nearFrameBound(root.alignedX, edgeGap); + case "right": + return _nearFrameBound(root.alignedX, screenWidth - popupWidth - edgeGap); + case "top": + return _nearFrameBound(root.alignedY, edgeGap); + case "bottom": + return _nearFrameBound(root.alignedY, screenHeight - popupHeight - edgeGap); + default: + return false; + } + } + + function _closeGapOmitStartConnector() { + const side = contentContainer.connectedBarSide; + if (side === "top" || side === "bottom") + return _closeGapClampedToFrameSide("left"); + return _closeGapClampedToFrameSide("top"); + } + + function _closeGapOmitEndConnector() { + const side = contentContainer.connectedBarSide; + if (side === "top" || side === "bottom") + return _closeGapClampedToFrameSide("right"); + return _closeGapClampedToFrameSide("bottom"); + } + + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.popoutElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, effectiveShadowDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowMotionPadding: { + if (Theme.isConnectedEffect) + return Math.max(storedBarSpacing + Theme.connectedCornerRadius + 4, 40); + if (Theme.isDirectionalEffect) + return 16; + if (Theme.isDepthEffect) + return Math.max(0, animationOffset) + 8; + return Math.max(0, animationOffset); + } + readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) + readonly property real alignedWidth: Theme.px(popupWidth, dpr) + readonly property real alignedHeight: Theme.px(popupHeight, dpr) + property real renderedAlignedY: alignedY + property real renderedAlignedHeight: alignedHeight + readonly property bool renderedGeometryGrowing: alignedHeight >= renderedAlignedHeight + + Behavior on renderedAlignedY { + enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve + } + } + + Behavior on renderedAlignedHeight { + enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve + } + } + readonly property real connectedAnchorX: { + if (!Theme.isConnectedEffect) + return triggerX; + switch (effectiveBarPosition) { + case SettingsData.Position.Left: + return barX + barWidth; + case SettingsData.Position.Right: + return barX; + default: + return triggerX; + } + } + readonly property real connectedAnchorY: { + if (!Theme.isConnectedEffect) + return triggerY; + switch (effectiveBarPosition) { + case SettingsData.Position.Top: + return barY + barHeight; + case SettingsData.Position.Bottom: + return barY; + default: + return triggerY; + } + } + + function adjacentBarClearance(exclusion) { + if (exclusion <= 0) + return 0; + if (!Theme.isConnectedEffect) + return exclusion; + // In a shared frame corner, the adjacent connected bar already occupies + // one rounded-corner radius before the popout's own connector begins. + return exclusion + Theme.connectedCornerRadius * 2; + } + + onAlignedHeightChanged: { + _queueFullSync(); + if (!suspendShadowWhileResizing || !shouldBeVisible) + return; + _resizeActive = true; + resizeSettleTimer.restart(); + } + onShouldBeVisibleChanged: { + if (!shouldBeVisible) { + _resizeActive = false; + resizeSettleTimer.stop(); + } + } + + Timer { + id: resizeSettleTimer + interval: 80 + repeat: false + onTriggered: root._resizeActive = false + } + + readonly property real alignedX: Theme.snap((() => { + const popupGap = _popupGapValue(); + const edgeGapLeft = _edgeGapFor("left", popupGap); + const edgeGapRight = _edgeGapFor("right", popupGap); + const anchorX = Theme.isConnectedEffect ? connectedAnchorX : triggerX; + + switch (effectiveBarPosition) { + case SettingsData.Position.Left: + // bar on left: left side is bar-adjacent (popupGap), right side is frame-perpendicular (edgeGap) + return Math.max(popupGap, Math.min(screenWidth - popupWidth - edgeGapRight, anchorX)); + case SettingsData.Position.Right: + // bar on right: right side is bar-adjacent (popupGap), left side is frame-perpendicular (edgeGap) + return Math.max(edgeGapLeft, Math.min(screenWidth - popupWidth - popupGap, anchorX - popupWidth)); + default: + const rawX = triggerX + (triggerWidth / 2) - (popupWidth / 2); + const minX = Math.max(edgeGapLeft, adjacentBarClearance(adjacentBarInfo.leftBar)); + const maxX = screenWidth - popupWidth - Math.max(edgeGapRight, adjacentBarClearance(adjacentBarInfo.rightBar)); + return Math.max(minX, Math.min(maxX, rawX)); + } + })(), dpr) + + readonly property real alignedY: Theme.snap((() => { + const popupGap = _popupGapValue(); + const edgeGapTop = _edgeGapFor("top", popupGap); + const edgeGapBottom = _edgeGapFor("bottom", popupGap); + const anchorY = Theme.isConnectedEffect ? connectedAnchorY : triggerY; + + switch (effectiveBarPosition) { + case SettingsData.Position.Bottom: + // bar on bottom: bottom side is bar-adjacent (popupGap), top side is frame-perpendicular (edgeGap) + return Math.max(edgeGapTop, Math.min(screenHeight - popupHeight - popupGap, anchorY - popupHeight)); + case SettingsData.Position.Top: + // bar on top: top side is bar-adjacent (popupGap), bottom side is frame-perpendicular (edgeGap) + return Math.max(popupGap, Math.min(screenHeight - popupHeight - edgeGapBottom, anchorY)); + default: + const rawY = triggerY - (popupHeight / 2); + const minY = Math.max(edgeGapTop, adjacentBarClearance(adjacentBarInfo.topBar)); + const maxY = screenHeight - popupHeight - Math.max(edgeGapBottom, adjacentBarClearance(adjacentBarInfo.bottomBar)); + return Math.max(minY, Math.min(maxY, rawY)); + } + })(), dpr) + + readonly property real triggeringBarLeftExclusion: (effectiveBarPosition === SettingsData.Position.Left && barWidth > 0) ? Math.max(0, barX + barWidth) : 0 + readonly property real triggeringBarTopExclusion: (effectiveBarPosition === SettingsData.Position.Top && barHeight > 0) ? Math.max(0, barY + barHeight) : 0 + readonly property real triggeringBarRightExclusion: (effectiveBarPosition === SettingsData.Position.Right && barWidth > 0) ? Math.max(0, screenWidth - barX) : 0 + readonly property real triggeringBarBottomExclusion: (effectiveBarPosition === SettingsData.Position.Bottom && barHeight > 0) ? Math.max(0, screenHeight - barY) : 0 + + readonly property real maskX: { + const adjacentLeftBar = adjacentBarInfo?.leftBar ?? 0; + return Math.max(triggeringBarLeftExclusion, adjacentLeftBar); + } + + readonly property real maskY: { + const adjacentTopBar = adjacentBarInfo?.topBar ?? 0; + return Math.max(triggeringBarTopExclusion, adjacentTopBar); + } + + readonly property real maskWidth: { + const adjacentRightBar = adjacentBarInfo?.rightBar ?? 0; + const rightExclusion = Math.max(triggeringBarRightExclusion, adjacentRightBar); + return Math.max(100, screenWidth - maskX - rightExclusion); + } + + readonly property real maskHeight: { + const adjacentBottomBar = adjacentBarInfo?.bottomBar ?? 0; + const bottomExclusion = Math.max(triggeringBarBottomExclusion, adjacentBottomBar); + return Math.max(100, screenHeight - maskY - bottomExclusion); + } + + PanelWindow { + id: contentWindow + screen: root.screen + visible: false + color: "transparent" + + WindowBlur { + id: popoutBlur + targetWindow: contentWindow + blurEnabled: root.effectiveSurfaceBlurEnabled && !root.frameOwnsConnectedChrome + + readonly property real s: Math.min(1, contentContainer.scaleValue) + readonly property bool trackBlurFromBarEdge: Theme.isConnectedEffect || Theme.isDirectionalEffect + + // Directional popouts clip to the bar edge, so the blur needs to grow from + // that same edge instead of translating through the bar before settling. + readonly property real _dyClamp: (contentContainer.barTop || contentContainer.barBottom) ? Math.max(-contentContainer.height, Math.min(contentContainer.animY, contentContainer.height)) : 0 + readonly property real _dxClamp: (contentContainer.barLeft || contentContainer.barRight) ? Math.max(-contentContainer.width, Math.min(contentContainer.animX, contentContainer.width)) : 0 + + blurX: trackBlurFromBarEdge ? contentContainer.x + (contentContainer.barRight ? _dxClamp : 0) : contentContainer.x + contentContainer.width * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) - contentContainer.horizontalConnectorExtent * s + blurY: trackBlurFromBarEdge ? contentContainer.y + (contentContainer.barBottom ? _dyClamp : 0) : contentContainer.y + contentContainer.height * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) - contentContainer.verticalConnectorExtent * s + blurWidth: shouldBeVisible ? (trackBlurFromBarEdge ? Math.max(0, contentContainer.width - Math.abs(_dxClamp)) : (contentContainer.width + contentContainer.horizontalConnectorExtent * 2) * s) : 0 + blurHeight: shouldBeVisible ? (trackBlurFromBarEdge ? Math.max(0, contentContainer.height - Math.abs(_dyClamp)) : (contentContainer.height + contentContainer.verticalConnectorExtent * 2) * s) : 0 + blurRadius: Theme.isConnectedEffect ? Theme.connectedCornerRadius : Theme.connectedSurfaceRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: { + switch (Quickshell.env("DMS_POPOUT_LAYER")) { + case "bottom": + log.warn("'bottom' layer is not valid for popouts. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + log.warn("'background' layer is not valid for popouts. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: { + if (customKeyboardFocus !== null) + return customKeyboardFocus; + if (!shouldBeVisible) + return WlrKeyboardFocus.None; + if (CompositorService.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + + readonly property bool _fullHeight: root.fullHeightSurface + anchors { + left: true + top: true + right: true + bottom: true + } + + WlrLayershell.margins { + left: 0 + top: 0 + } + + implicitWidth: 0 + implicitHeight: 0 + + mask: contentInputMask + + Region { + id: contentInputMask + // Use bar-aware mask so bar widget clicks pass through when a popout is open. + item: (shouldBeVisible && backgroundInteractive) ? backgroundDismissalMask : contentMaskRect + } + + Item { + id: backgroundDismissalMask + visible: false + x: root.maskX + y: root.maskY + width: root.maskWidth + height: root.maskHeight + } + + Item { + id: contentMaskRect + visible: false + x: contentContainer.x - contentContainer.horizontalConnectorExtent + y: contentContainer.y - contentContainer.verticalConnectorExtent + width: root.alignedWidth + contentContainer.horizontalConnectorExtent * 2 + height: root.renderedAlignedHeight + contentContainer.verticalConnectorExtent * 2 + } + + MouseArea { + anchors.fill: parent + enabled: shouldBeVisible && backgroundInteractive + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + z: -1 + onClicked: mouse => { + const clickX = mouse.x; + const clickY = mouse.y; + const outsideContent = clickX < root.alignedX || clickX > root.alignedX + root.alignedWidth || clickY < root.renderedAlignedY || clickY > root.renderedAlignedY + root.renderedAlignedHeight; + if (!outsideContent) + return; + backgroundClicked(); + } + } + + Item { + id: contentContainer + x: root.alignedX + y: root.renderedAlignedY + width: root.alignedWidth + height: root.renderedAlignedHeight + + readonly property bool barTop: effectiveBarPosition === SettingsData.Position.Top + readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom + readonly property bool barLeft: effectiveBarPosition === SettingsData.Position.Left + readonly property bool barRight: effectiveBarPosition === SettingsData.Position.Right + readonly property string connectedBarSide: barTop ? "top" : (barBottom ? "bottom" : (barLeft ? "left" : "right")) + readonly property real surfaceRadius: Theme.connectedSurfaceRadius + readonly property color surfaceColor: Theme.popupLayerColor(Theme.surfaceContainer) + readonly property color surfaceBorderColor: Theme.isConnectedEffect ? "transparent" : (BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium) + readonly property real surfaceBorderWidth: Theme.isConnectedEffect ? 0 : BlurService.borderWidth + readonly property real surfaceTopLeftRadius: Theme.isConnectedEffect && (barTop || barLeft) ? 0 : surfaceRadius + readonly property real surfaceTopRightRadius: Theme.isConnectedEffect && (barTop || barRight) ? 0 : surfaceRadius + readonly property real surfaceBottomLeftRadius: Theme.isConnectedEffect && (barBottom || barLeft) ? 0 : surfaceRadius + readonly property real surfaceBottomRightRadius: Theme.isConnectedEffect && (barBottom || barRight) ? 0 : surfaceRadius + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real directionalTravelX: Math.max(root.animationOffset, root.alignedWidth + Theme.spacingL) + readonly property real directionalTravelY: Math.max(root.animationOffset, root.alignedHeight + Theme.spacingL) + readonly property real depthTravel: Math.max(root.animationOffset * 0.7, 28) + readonly property real sectionTilt: (triggerSection === "left" ? -1 : (triggerSection === "right" ? 1 : 0)) + readonly property real horizontalConnectorExtent: Theme.isConnectedEffect && (barTop || barBottom) ? Theme.connectedCornerRadius : 0 + readonly property real verticalConnectorExtent: Theme.isConnectedEffect && (barLeft || barRight) ? Theme.connectedCornerRadius : 0 + + readonly property real offsetX: { + if (directionalEffect) { + if (barLeft) + return -directionalTravelX; + if (barRight) + return directionalTravelX; + if (barTop || barBottom) + return 0; + return sectionTilt * directionalTravelX * 0.2; + } + if (depthEffect) { + if (barLeft) + return -depthTravel; + if (barRight) + return depthTravel; + if (barTop || barBottom) + return 0; + return sectionTilt * depthTravel * 0.2; + } + return barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0); + } + readonly property real offsetY: { + if (directionalEffect) { + if (barBottom) + return directionalTravelY; + if (barTop) + return -directionalTravelY; + if (barLeft || barRight) + return 0; + return directionalTravelY; + } + if (depthEffect) { + if (barBottom) + return depthTravel; + if (barTop) + return -depthTravel; + if (barLeft || barRight) + return 0; + return depthTravel; + } + return barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0); + } + + readonly property real computedScaleCollapsed: root.animationScaleCollapsed + + // openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1). + QtObject { + id: morph + property real openProgress: 0 + Behavior on openProgress { + enabled: root.animationsEnabled + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + + readonly property real animX: contentContainer.offsetX * (1 - morph.openProgress) + readonly property real animY: contentContainer.offsetY * (1 - morph.openProgress) + readonly property real scaleValue: contentContainer.computedScaleCollapsed + (1.0 - contentContainer.computedScaleCollapsed) * morph.openProgress + + Component.onCompleted: { + morph.openProgress = root.shouldBeVisible ? 1 : 0; + root._captureChromeAnimTravel(); + } + + Connections { + target: root + function onShouldBeVisibleChanged() { + root._captureChromeAnimTravel(); + morph.openProgress = root.shouldBeVisible ? 1 : 0; + } + } + + Item { + id: directionalClipMask + + readonly property bool shouldClip: Theme.isDirectionalEffect || Theme.isConnectedEffect + readonly property real clipOversize: 1000 + readonly property real connectedClipAllowance: { + if (!Theme.isConnectedEffect) + return 0; + if (root.frameOwnsConnectedChrome) + return 0; + return -Theme.connectedCornerRadius; + } + + clip: shouldClip + + // Bound the clipping strictly to the bar side, allowing massive overflow on the other 3 sides for shadows + x: shouldClip ? (contentContainer.barLeft ? -connectedClipAllowance : -clipOversize) : 0 + y: shouldClip ? (contentContainer.barTop ? -connectedClipAllowance : -clipOversize) : 0 + + width: { + if (!shouldClip) + return parent.width; + if (contentContainer.barLeft) + return parent.width + connectedClipAllowance + clipOversize; + if (contentContainer.barRight) + return parent.width + clipOversize + connectedClipAllowance; + return parent.width + clipOversize * 2; + } + height: { + if (!shouldClip) + return parent.height; + if (contentContainer.barTop) + return parent.height + connectedClipAllowance + clipOversize; + if (contentContainer.barBottom) + return parent.height + clipOversize + connectedClipAllowance; + return parent.height + clipOversize * 2; + } + + Item { + id: rollOutAdjuster + readonly property real baseWidth: contentContainer.width + readonly property real baseHeight: contentContainer.height + + x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0 + y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0 + width: baseWidth + height: baseHeight + + clip: false + + ElevationShadow { + id: shadowSource + readonly property real connectorExtent: Theme.isConnectedEffect ? Theme.connectedCornerRadius : 0 + readonly property real extraLeft: Theme.isConnectedEffect && (contentContainer.barTop || contentContainer.barBottom) ? connectorExtent : 0 + readonly property real extraRight: Theme.isConnectedEffect && (contentContainer.barTop || contentContainer.barBottom) ? connectorExtent : 0 + readonly property real extraTop: Theme.isConnectedEffect && (contentContainer.barLeft || contentContainer.barRight) ? connectorExtent : 0 + readonly property real extraBottom: Theme.isConnectedEffect && (contentContainer.barLeft || contentContainer.barRight) ? connectorExtent : 0 + readonly property real bodyX: extraLeft + readonly property real bodyY: extraTop + readonly property real bodyWidth: rollOutAdjuster.baseWidth + readonly property real bodyHeight: rollOutAdjuster.baseHeight + + width: rollOutAdjuster.baseWidth + extraLeft + extraRight + height: rollOutAdjuster.baseHeight + extraTop + extraBottom + opacity: contentWrapper.publishedOpacity + scale: contentWrapper.scale + x: contentWrapper.x - extraLeft + y: contentWrapper.y - extraTop + level: root.shadowLevel + direction: root.effectiveShadowDirection + fallbackOffset: root.shadowFallbackOffset + targetRadius: contentContainer.surfaceRadius + topLeftRadius: contentContainer.surfaceTopLeftRadius + topRightRadius: contentContainer.surfaceTopRightRadius + bottomLeftRadius: contentContainer.surfaceBottomLeftRadius + bottomRightRadius: contentContainer.surfaceBottomRightRadius + targetColor: contentContainer.surfaceColor + borderColor: contentContainer.surfaceBorderColor + borderWidth: contentContainer.surfaceBorderWidth + useCustomSource: Theme.isConnectedEffect + shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive) && !root.frameOwnsConnectedChrome + + Item { + anchors.fill: parent + visible: Theme.isConnectedEffect && !root.frameOwnsConnectedChrome + clip: false + + Rectangle { + x: shadowSource.bodyX + y: shadowSource.bodyY + width: shadowSource.bodyWidth + height: shadowSource.bodyHeight + topLeftRadius: contentContainer.surfaceTopLeftRadius + topRightRadius: contentContainer.surfaceTopRightRadius + bottomLeftRadius: contentContainer.surfaceBottomLeftRadius + bottomRightRadius: contentContainer.surfaceBottomRightRadius + color: contentContainer.surfaceColor + } + + ConnectedCorner { + visible: Theme.isConnectedEffect + barSide: contentContainer.connectedBarSide + placement: "left" + spacing: 0 + connectorRadius: Theme.connectedCornerRadius + color: contentContainer.surfaceColor + dpr: root.dpr + x: Theme.snap(ConnectorGeometry.connectorX(contentContainer.connectedBarSide, shadowSource.bodyX, shadowSource.bodyWidth, placement, spacing, Theme.connectedCornerRadius), root.dpr) + y: Theme.snap(ConnectorGeometry.connectorY(contentContainer.connectedBarSide, shadowSource.bodyY, shadowSource.bodyHeight, placement, spacing, Theme.connectedCornerRadius), root.dpr) + } + + ConnectedCorner { + visible: Theme.isConnectedEffect + barSide: contentContainer.connectedBarSide + placement: "right" + spacing: 0 + connectorRadius: Theme.connectedCornerRadius + color: contentContainer.surfaceColor + dpr: root.dpr + x: Theme.snap(ConnectorGeometry.connectorX(contentContainer.connectedBarSide, shadowSource.bodyX, shadowSource.bodyWidth, placement, spacing, Theme.connectedCornerRadius), root.dpr) + y: Theme.snap(ConnectorGeometry.connectorY(contentContainer.connectedBarSide, shadowSource.bodyY, shadowSource.bodyHeight, placement, spacing, Theme.connectedCornerRadius), root.dpr) + } + } + } + + Item { + id: contentWrapper + width: rollOutAdjuster.baseWidth + height: rollOutAdjuster.baseHeight + + property bool _renderActive: Theme.isDirectionalEffect || shouldBeVisible + property bool _animating: false + property real publishedOpacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) + + opacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) + visible: _renderActive + + scale: contentContainer.scaleValue + x: Theme.snap(contentContainer.animX + (rollOutAdjuster.baseWidth - width) * (1 - scale) * 0.5, root.dpr) + y: Theme.snap(contentContainer.animY + (rollOutAdjuster.baseHeight - height) * (1 - scale) * 0.5, root.dpr) + + layer.enabled: _animating || (!Theme.isDirectionalEffect && publishedOpacity < 1) + layer.smooth: false + layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0) + + Behavior on opacity { + enabled: !Theme.isDirectionalEffect + NumberAnimation { + duration: Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + onRunningChanged: { + contentWrapper._animating = running; + if (!running && !root.shouldBeVisible) + contentWrapper._renderActive = false; + } + } + } + + Behavior on publishedOpacity { + enabled: !Theme.isDirectionalEffect + NumberAnimation { + duration: Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Connections { + target: root + function onShouldBeVisibleChanged() { + if (root.shouldBeVisible) + contentWrapper._renderActive = true; + } + } + + Connections { + target: contentWindow + function onVisibleChanged() { + if (!contentWindow.visible) + contentWrapper._renderActive = false; + } + } + + Item { + anchors.fill: parent + clip: false + visible: !Theme.isConnectedEffect + + Rectangle { + anchors.fill: parent + antialiasing: true + topLeftRadius: contentContainer.surfaceTopLeftRadius + topRightRadius: contentContainer.surfaceTopRightRadius + bottomLeftRadius: contentContainer.surfaceBottomLeftRadius + bottomRightRadius: contentContainer.surfaceBottomRightRadius + color: contentContainer.surfaceColor + border.color: contentContainer.surfaceBorderColor + border.width: contentContainer.surfaceBorderWidth + } + } + + Loader { + id: contentLoader + anchors.fill: parent + active: root._primeContent || shouldBeVisible || contentWindow.visible + asynchronous: false + } + } + } + } + } + + Item { + id: focusHelper + parent: contentContainer + anchors.fill: parent + visible: !root.contentHandlesKeys + enabled: !root.contentHandlesKeys + focus: !root.contentHandlesKeys + Keys.onPressed: event => { + if (root.contentHandlesKeys) + return; + if (event.key === Qt.Key_Escape) { + close(); + event.accepted = true; + } + } + } + + Loader { + id: overlayLoader + anchors.fill: parent + active: root.overlayContent !== null && contentWindow.visible + sourceComponent: root.overlayContent + } + } +} diff --git a/quickshell/Widgets/DankPopoutStandalone.qml b/quickshell/Widgets/DankPopoutStandalone.qml new file mode 100644 index 00000000..5ca7a0a5 --- /dev/null +++ b/quickshell/Widgets/DankPopoutStandalone.qml @@ -0,0 +1,893 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services + +Item { + id: root + + readonly property var log: Log.scoped("DankPopoutStandalone") + + property var popoutHandle: root + property string layerNamespace: "dms:popout" + property alias content: contentLoader.sourceComponent + property alias contentLoader: contentLoader + property Component overlayContent: null + property alias overlayLoader: overlayLoader + readonly property alias backgroundWindow: backgroundWindow + property real popupWidth: 400 + property real popupHeight: 300 + property real triggerX: 0 + property real triggerY: 0 + property real triggerWidth: 40 + property string triggerSection: "" + property string positioning: "center" + property int animationDuration: Theme.popoutAnimationDuration + property real animationScaleCollapsed: Theme.effectScaleCollapsed + property real animationOffset: Theme.effectAnimOffset + property list animationEnterCurve: Theme.variantPopoutEnterCurve + property list animationExitCurve: Theme.variantPopoutExitCurve + property bool suspendShadowWhileResizing: false + property bool shouldBeVisible: false + property bool isClosing: false + property bool animationsEnabled: true + property var customKeyboardFocus: null + property bool backgroundInteractive: true + property bool contentHandlesKeys: false + property bool fullHeightSurface: false + property bool _primeContent: false + property bool _resizeActive: false + property real _surfaceMarginLeft: 0 + property real _surfaceMarginTop: 0 + property real _surfaceW: 0 + property real _surfaceH: 0 + property real _surfaceBodyX: 0 + property real _surfaceBodyY: 0 + property real _surfaceBodyW: 0 + property real _surfaceBodyH: 0 + + property real storedBarThickness: Theme.barHeight - 4 + property real storedBarSpacing: 4 + property var storedBarConfig: null + property var adjacentBarInfo: ({ + "topBar": 0, + "bottomBar": 0, + "leftBar": 0, + "rightBar": 0 + }) + property var screen: null + readonly property bool frameOnlyNoConnected: SettingsData.frameEnabled && !!screen && SettingsData.isScreenInPreferences(screen, SettingsData.frameScreenPreferences) + readonly property bool fluidStandaloneActive: Theme.isDirectionalEffect + readonly property bool backgroundDismissWindowRequired: backgroundInteractive + readonly property bool backgroundWindowRequired: backgroundDismissWindowRequired || root.overlayContent !== null + readonly property bool _fullHeight: fullHeightSurface + + function _frameEdgeInset(side) { + if (!screen) + return 0; + return SettingsData.frameEdgeInsetForSide(screen, side); + } + + function _frameGapMargin(side) { + return _frameEdgeInset(side) + Theme.popupDistance; + } + + function _edgeClearance(side, popupGap, adjacentInset) { + if (frameOnlyNoConnected) + return Math.max(adjacentInset, _frameGapMargin(side)); + return adjacentInset > 0 ? adjacentInset : popupGap; + } + + readonly property real effectiveBarThickness: { + const padding = storedBarConfig ? (storedBarConfig.innerPadding !== undefined ? storedBarConfig.innerPadding : 4) : 4; + return Math.max(26 + padding * 0.6, Theme.barHeight - 4 - (8 - padding)) + storedBarSpacing; + } + + readonly property var barBounds: { + if (!screen) + return { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "wingSize": 0 + }; + return SettingsData.getBarBounds(screen, effectiveBarThickness, effectiveBarPosition, storedBarConfig); + } + + readonly property real barX: barBounds.x + readonly property real barY: barBounds.y + readonly property real barWidth: barBounds.width + readonly property real barHeight: barBounds.height + readonly property real barWingSize: barBounds.wingSize + + signal opened + signal popoutClosed + signal backgroundClicked + + property var _lastOpenedScreen: null + + property int effectiveBarPosition: 0 + property real effectiveBarBottomGap: 0 + readonly property string autoBarShadowDirection: { + const section = triggerSection || "center"; + switch (effectiveBarPosition) { + case SettingsData.Position.Top: + if (section === "left") + return "topLeft"; + if (section === "right") + return "topRight"; + return "top"; + case SettingsData.Position.Bottom: + if (section === "left") + return "bottomLeft"; + if (section === "right") + return "bottomRight"; + return "bottom"; + case SettingsData.Position.Left: + if (section === "left") + return "topLeft"; + if (section === "right") + return "bottomLeft"; + return "left"; + case SettingsData.Position.Right: + if (section === "left") + return "topRight"; + if (section === "right") + return "bottomRight"; + return "right"; + default: + return "top"; + } + } + readonly property string effectiveShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection + + // Snapshot mask geometry to prevent background damage on bar updates + property real _frozenMaskX: 0 + property real _frozenMaskY: 0 + property real _frozenMaskWidth: 0 + property real _frozenMaskHeight: 0 + + function setBarContext(position, bottomGap) { + effectiveBarPosition = position !== undefined ? position : 0; + effectiveBarBottomGap = bottomGap !== undefined ? bottomGap : 0; + } + + function primeContent() { + _primeContent = true; + } + + function clearPrimedContent() { + _primeContent = false; + } + + function setTriggerPosition(x, y, width, section, targetScreen, barPosition, barThickness, barSpacing, barConfig) { + triggerX = x; + triggerY = y; + triggerWidth = width; + triggerSection = section; + screen = targetScreen; + + storedBarThickness = barThickness !== undefined ? barThickness : (Theme.barHeight - 4); + storedBarSpacing = barSpacing !== undefined ? barSpacing : 4; + storedBarConfig = barConfig; + + const pos = barPosition !== undefined ? barPosition : 0; + const bottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : 0) : 0; + + adjacentBarInfo = SettingsData.getAdjacentBarInfo(targetScreen, pos, barConfig); + setBarContext(pos, bottomGap); + } + + // Briefly forces backgroundWindow.updatesEnabled true while the surface + // body changes, so the contentHoleRect mask carve-out commits to the + // compositor — otherwise the input region stays stuck at the popup's + // initial size and clicks in any newly-grown area dismiss the popup. + // Cleared by the frameSwapped Connections below as soon as the dirty + // frame ships, so the bg window goes back to skipping buffer updates. + property bool _bgCommitWindow: false + + function _setSurfaceGeometry(bodyX, bodyY, bodyW, bodyH) { + const newX = Theme.snap(bodyX, dpr); + const newY = Theme.snap(bodyY, dpr); + const newW = Theme.snap(bodyW, dpr); + const newH = Theme.snap(bodyH, dpr); + const changed = newX !== _surfaceBodyX || newY !== _surfaceBodyY || newW !== _surfaceBodyW || newH !== _surfaceBodyH; + _surfaceBodyX = newX; + _surfaceBodyY = newY; + _surfaceBodyW = newW; + _surfaceBodyH = newH; + _surfaceMarginLeft = _surfaceBodyX - shadowBuffer; + _surfaceMarginTop = _surfaceBodyY - shadowBuffer; + _surfaceW = _surfaceBodyW + shadowBuffer * 2; + _surfaceH = _surfaceBodyH + shadowBuffer * 2; + if (changed && backgroundWindow.visible) { + _bgCommitWindow = true; + } + } + + Connections { + target: backgroundWindow + ignoreUnknownSignals: true + function onFrameSwapped() { + if (root._bgCommitWindow) + root._bgCommitWindow = false; + } + } + + // Forces contentWindow to render a frame so Quickshell ships the updated + // WindowBlur region to the compositor. WindowBlur's property updates + // don't dirty the QML scene graph by themselves, so when the popup grows, + // shrinks, or closes without an animation running, the blur state can + // get stuck at its previous size. Called from the existing + // onAligned*Changed / onShouldBeVisibleChanged handlers. + function _kickBlurCommit() { + if (typeof contentWindow.update === "function") + contentWindow.update(); + } + + function _setSettledSurfaceGeometry() { + if (shouldBeVisible) { + _setSurfaceGeometry(alignedX, alignedY, alignedWidth, alignedHeight); + } + } + + function _setAnimatedSurfaceEnvelope() { + if (!shouldBeVisible) + return; + if (_fullHeight) { + _setSettledSurfaceGeometry(); + return; + } + + const currentY = renderedAlignedY; + const currentBottom = renderedAlignedY + renderedAlignedHeight; + const targetY = alignedY; + const targetBottom = alignedY + alignedHeight; + const existingY = _surfaceBodyH > 0 ? _surfaceBodyY : currentY; + const existingBottom = _surfaceBodyH > 0 ? _surfaceBodyY + _surfaceBodyH : currentBottom; + const envelopeY = Math.min(currentY, targetY, existingY); + const envelopeBottom = Math.max(currentBottom, targetBottom, existingBottom); + _setSurfaceGeometry(alignedX, envelopeY, alignedWidth, Math.max(0, envelopeBottom - envelopeY)); + surfaceSettleTimer.restart(); + } + + function updateSurfacePosition() { + _setSettledSurfaceGeometry(); + } + + onAlignedXChanged: { + if (shouldBeVisible) + _setAnimatedSurfaceEnvelope(); + _kickBlurCommit(); + } + + onAlignedYChanged: { + if (shouldBeVisible) + _setAnimatedSurfaceEnvelope(); + _kickBlurCommit(); + } + + onAlignedWidthChanged: { + if (shouldBeVisible) + _setAnimatedSurfaceEnvelope(); + _kickBlurCommit(); + } + + function open() { + if (!screen) + return; + closeTimer.stop(); + isClosing = false; + animationsEnabled = false; + _primeContent = true; + + _frozenMaskX = maskX; + _frozenMaskY = maskY; + _frozenMaskWidth = maskWidth; + _frozenMaskHeight = maskHeight; + + if (_lastOpenedScreen !== null && _lastOpenedScreen !== screen) { + contentWindow.visible = false; + backgroundWindow.visible = false; + } + _lastOpenedScreen = screen; + + if (contentContainer) { + // animationsEnabled is false here, so this snaps to closed without animating. + morph.openProgress = 0; + } + + _setSurfaceGeometry(alignedX, alignedY, alignedWidth, alignedHeight); + if (backgroundWindowRequired) + backgroundWindow.visible = true; + contentWindow.visible = true; + + animationsEnabled = true; + shouldBeVisible = true; + if (screen) { + PopoutManager.showPopout(popoutHandle); + opened(); + } + } + + function close() { + isClosing = true; + shouldBeVisible = false; + _primeContent = false; + PopoutManager.popoutChanged(); + closeTimer.restart(); + } + + function toggle() { + shouldBeVisible ? close() : open(); + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (!shouldBeVisible || !screen) + return; + const currentScreenName = screen.name; + let screenStillExists = false; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === currentScreenName) { + screenStillExists = true; + break; + } + } + if (!screenStillExists) { + close(); + } + } + } + + Timer { + id: closeTimer + interval: Theme.variantCloseInterval(animationDuration) + onTriggered: { + if (!shouldBeVisible) { + isClosing = false; + contentWindow.visible = false; + backgroundWindow.visible = false; + PopoutManager.hidePopout(popoutHandle); + popoutClosed(); + } + } + } + + readonly property real screenWidth: screen ? screen.width : 0 + readonly property real screenHeight: screen ? screen.height : 0 + readonly property real dpr: screen ? screen.devicePixelRatio : 1 + + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.popoutElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, effectiveShadowDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowMotionPadding: fluidStandaloneActive ? 0 : Math.max(0, animationOffset) + readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) + readonly property real alignedWidth: Theme.px(popupWidth, dpr) + readonly property real alignedHeight: Theme.px(popupHeight, dpr) + property real renderedAlignedY: alignedY + property real renderedAlignedHeight: alignedHeight + readonly property bool renderedGeometryGrowing: alignedHeight >= renderedAlignedHeight + + Behavior on renderedAlignedY { + enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve + } + } + + Behavior on renderedAlignedHeight { + enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve + } + } + + onAlignedHeightChanged: { + if (shouldBeVisible) + _setAnimatedSurfaceEnvelope(); + _kickBlurCommit(); + if (!suspendShadowWhileResizing || !shouldBeVisible) + return; + _resizeActive = true; + resizeSettleTimer.restart(); + } + onShouldBeVisibleChanged: { + _kickBlurCommit(); + if (!shouldBeVisible) { + _resizeActive = false; + resizeSettleTimer.stop(); + } + } + onBackgroundWindowRequiredChanged: { + if (shouldBeVisible) + backgroundWindow.visible = backgroundWindowRequired; + } + + Timer { + id: resizeSettleTimer + interval: 80 + repeat: false + onTriggered: root._resizeActive = false + } + + Timer { + id: surfaceSettleTimer + interval: Math.max(0, Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) + 32) + repeat: false + onTriggered: root._setSettledSurfaceGeometry() + } + + readonly property real alignedX: Theme.snap((() => { + const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; + const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; + const popupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; + const leftGap = _edgeClearance("left", popupGap, adjacentBarInfo.leftBar > 0 ? adjacentBarInfo.leftBar : 0); + const rightGap = _edgeClearance("right", popupGap, adjacentBarInfo.rightBar > 0 ? adjacentBarInfo.rightBar : 0); + + switch (effectiveBarPosition) { + case SettingsData.Position.Left: + return Math.max(leftGap, Math.min(screenWidth - popupWidth - rightGap, triggerX)); + case SettingsData.Position.Right: + return Math.max(leftGap, Math.min(screenWidth - popupWidth - rightGap, triggerX - popupWidth)); + default: + const rawX = triggerX + (triggerWidth / 2) - (popupWidth / 2); + const minX = leftGap; + const maxX = screenWidth - popupWidth - rightGap; + return Math.max(minX, Math.min(maxX, rawX)); + } + })(), dpr) + + readonly property real alignedY: Theme.snap((() => { + const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; + const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; + const popupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; + const topGap = _edgeClearance("top", popupGap, adjacentBarInfo.topBar > 0 ? adjacentBarInfo.topBar : 0); + const bottomGap = _edgeClearance("bottom", popupGap, adjacentBarInfo.bottomBar > 0 ? adjacentBarInfo.bottomBar : 0); + + switch (effectiveBarPosition) { + case SettingsData.Position.Bottom: + return Math.max(topGap, Math.min(screenHeight - popupHeight - bottomGap, triggerY - popupHeight)); + case SettingsData.Position.Top: + return Math.max(topGap, Math.min(screenHeight - popupHeight - bottomGap, triggerY)); + default: + const rawY = triggerY - (popupHeight / 2); + const minY = topGap; + const maxY = screenHeight - popupHeight - bottomGap; + return Math.max(minY, Math.min(maxY, rawY)); + } + })(), dpr) + + readonly property real triggeringBarLeftExclusion: (effectiveBarPosition === SettingsData.Position.Left && barWidth > 0) ? Math.max(0, barX + barWidth) : 0 + readonly property real triggeringBarTopExclusion: (effectiveBarPosition === SettingsData.Position.Top && barHeight > 0) ? Math.max(0, barY + barHeight) : 0 + readonly property real triggeringBarRightExclusion: (effectiveBarPosition === SettingsData.Position.Right && barWidth > 0) ? Math.max(0, screenWidth - barX) : 0 + readonly property real triggeringBarBottomExclusion: (effectiveBarPosition === SettingsData.Position.Bottom && barHeight > 0) ? Math.max(0, screenHeight - barY) : 0 + + readonly property real maskX: { + const adjacentLeftBar = adjacentBarInfo?.leftBar ?? 0; + return Math.max(triggeringBarLeftExclusion, adjacentLeftBar); + } + + readonly property real maskY: { + const adjacentTopBar = adjacentBarInfo?.topBar ?? 0; + return Math.max(triggeringBarTopExclusion, adjacentTopBar); + } + + readonly property real maskWidth: { + const adjacentRightBar = adjacentBarInfo?.rightBar ?? 0; + const rightExclusion = Math.max(triggeringBarRightExclusion, adjacentRightBar); + return Math.max(100, screenWidth - maskX - rightExclusion); + } + + readonly property real maskHeight: { + const adjacentBottomBar = adjacentBarInfo?.bottomBar ?? 0; + const bottomExclusion = Math.max(triggeringBarBottomExclusion, adjacentBottomBar); + return Math.max(100, screenHeight - maskY - bottomExclusion); + } + + PanelWindow { + id: backgroundWindow + screen: root.screen + visible: false + color: "transparent" + // Skip buffer updates when there's nothing to render. Briefly flipped + // true via _bgCommitWindow when _surfaceBodyW/H changes so the + // contentHoleRect mask carve-out actually commits to the compositor. + updatesEnabled: root.overlayContent !== null || root._bgCommitWindow + + WlrLayershell.namespace: root.layerNamespace + ":background" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + left: true + right: true + bottom: true + } + + mask: Region { + item: maskRect + Region { + item: contentHoleRect + intersection: Intersection.Subtract + } + } + + Rectangle { + id: maskRect + visible: false + color: "transparent" + x: root.backgroundDismissWindowRequired ? root._frozenMaskX : 0 + y: root.backgroundDismissWindowRequired ? root._frozenMaskY : 0 + width: (root.backgroundDismissWindowRequired && shouldBeVisible && backgroundInteractive) ? root._frozenMaskWidth : 0 + height: (root.backgroundDismissWindowRequired && shouldBeVisible && backgroundInteractive) ? root._frozenMaskHeight : 0 + } + + Rectangle { + id: contentHoleRect + visible: false + color: "transparent" + x: root.backgroundDismissWindowRequired ? root._surfaceBodyX : 0 + y: root.backgroundDismissWindowRequired ? root._surfaceBodyY : 0 + width: (root.backgroundDismissWindowRequired && shouldBeVisible) ? root._surfaceBodyW : 0 + height: (root.backgroundDismissWindowRequired && shouldBeVisible) ? root._surfaceBodyH : 0 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: false + enabled: root.backgroundDismissWindowRequired && shouldBeVisible && backgroundInteractive + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: backgroundClicked() + } + + Loader { + id: overlayLoader + anchors.fill: parent + active: root.overlayContent !== null && backgroundWindow.visible + sourceComponent: root.overlayContent + } + } + + PanelWindow { + id: contentWindow + screen: root.screen + visible: false + color: "transparent" + readonly property bool closeVisualActive: root.shouldBeVisible || root.isClosing + + WindowBlur { + id: popoutBlur + targetWindow: contentWindow + readonly property real s: Math.min(1, contentContainer.scaleValue) + readonly property bool trackBlurFromBarEdge: root.fluidStandaloneActive + readonly property bool blurAlive: trackBlurFromBarEdge ? (contentContainer.revealWidth > 0 && contentContainer.revealHeight > 0) : root.shouldBeVisible + + blurX: trackBlurFromBarEdge ? contentContainer.x + contentContainer.revealX : contentContainer.x + contentContainer.width * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) + blurY: trackBlurFromBarEdge ? contentContainer.y + contentContainer.revealY : contentContainer.y + contentContainer.height * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) + blurWidth: blurAlive ? (trackBlurFromBarEdge ? contentContainer.revealWidth : contentContainer.width * s) : 0 + blurHeight: blurAlive ? (trackBlurFromBarEdge ? contentContainer.revealHeight : contentContainer.height * s) : 0 + blurRadius: Theme.cornerRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: { + switch (Quickshell.env("DMS_POPOUT_LAYER")) { + case "bottom": + root.log.warn("'bottom' layer is not valid for popouts. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + root.log.warn("'background' layer is not valid for popouts. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: { + if (customKeyboardFocus !== null) + return customKeyboardFocus; + if (!shouldBeVisible) + return WlrKeyboardFocus.None; + if (CompositorService.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + + anchors { + left: true + top: true + bottom: root._fullHeight + } + + WlrLayershell.margins { + left: root._surfaceMarginLeft + top: root._fullHeight ? 0 : root._surfaceMarginTop + } + + implicitWidth: root._surfaceW + implicitHeight: root._fullHeight ? 0 : root._surfaceH + + mask: contentInputMask + + Region { + id: contentInputMask + item: contentMaskRect + } + + Item { + id: contentMaskRect + visible: false + x: contentContainer.x + y: contentContainer.y + width: contentWindow.closeVisualActive ? root.alignedWidth : 0 + height: contentWindow.closeVisualActive ? root.renderedAlignedHeight : 0 + } + + Item { + id: contentContainer + x: shadowBuffer + root.alignedX - root._surfaceBodyX + y: root._fullHeight ? root.renderedAlignedY : shadowBuffer + root.renderedAlignedY - root._surfaceBodyY + width: root.alignedWidth + height: root.renderedAlignedHeight + + readonly property bool barTop: effectiveBarPosition === SettingsData.Position.Top + readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom + readonly property bool barLeft: effectiveBarPosition === SettingsData.Position.Left + readonly property bool barRight: effectiveBarPosition === SettingsData.Position.Right + readonly property string connectedBarSide: barTop ? "top" : (barBottom ? "bottom" : (barLeft ? "left" : "right")) + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real directionalTravelX: Math.max(root.animationOffset, root.alignedWidth + Theme.spacingL) + readonly property real directionalTravelY: Math.max(root.animationOffset, root.alignedHeight + Theme.spacingL) + readonly property real depthTravel: Math.max(root.animationOffset * 0.7, 28) + readonly property real sectionTilt: (triggerSection === "left" ? -1 : (triggerSection === "right" ? 1 : 0)) + readonly property real offsetX: { + if (directionalEffect) { + if (barLeft) + return -directionalTravelX; + if (barRight) + return directionalTravelX; + if (barTop || barBottom) + return 0; + return sectionTilt * directionalTravelX * 0.2; + } + if (depthEffect) { + if (barLeft) + return -depthTravel; + if (barRight) + return depthTravel; + if (barTop || barBottom) + return 0; + return sectionTilt * depthTravel * 0.2; + } + return barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0); + } + readonly property real offsetY: { + if (directionalEffect) { + if (barBottom) + return directionalTravelY; + if (barTop) + return -directionalTravelY; + if (barLeft || barRight) + return 0; + return directionalTravelY; + } + if (depthEffect) { + if (barBottom) + return depthTravel; + if (barTop) + return -depthTravel; + if (barLeft || barRight) + return 0; + return depthTravel; + } + return barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0); + } + + readonly property real computedScaleCollapsed: root.animationScaleCollapsed + + // openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1). + QtObject { + id: morph + property real openProgress: 0 + Behavior on openProgress { + enabled: root.animationsEnabled + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + + readonly property real animX: contentContainer.offsetX * (1 - morph.openProgress) + readonly property real animY: contentContainer.offsetY * (1 - morph.openProgress) + readonly property real scaleValue: contentContainer.computedScaleCollapsed + (1.0 - contentContainer.computedScaleCollapsed) * morph.openProgress + readonly property real clampedAnimX: Math.max(-width, Math.min(animX, width)) + readonly property real clampedAnimY: Math.max(-height, Math.min(animY, height)) + readonly property real revealWidth: { + if (!root.fluidStandaloneActive) + return width; + if (barLeft) + return Theme.snap(Math.max(0, width + clampedAnimX), root.dpr); + if (barRight) + return Theme.snap(Math.max(0, width - clampedAnimX), root.dpr); + return width; + } + readonly property real revealHeight: { + if (!root.fluidStandaloneActive) + return height; + if (barTop) + return Theme.snap(Math.max(0, height + clampedAnimY), root.dpr); + if (barBottom) + return Theme.snap(Math.max(0, height - clampedAnimY), root.dpr); + return height; + } + readonly property real revealX: root.fluidStandaloneActive && barRight ? Theme.snap(width - revealWidth, root.dpr) : 0 + readonly property real revealY: root.fluidStandaloneActive && barBottom ? Theme.snap(height - revealHeight, root.dpr) : 0 + + Component.onCompleted: morph.openProgress = root.shouldBeVisible ? 1 : 0 + + Connections { + target: root + function onShouldBeVisibleChanged() { + morph.openProgress = root.shouldBeVisible ? 1 : 0; + } + } + + Item { + id: directionalClipMask + + readonly property bool shouldClip: root.fluidStandaloneActive + + clip: shouldClip + x: shouldClip ? contentContainer.revealX : 0 + y: shouldClip ? contentContainer.revealY : 0 + width: shouldClip ? contentContainer.revealWidth : parent.width + height: shouldClip ? contentContainer.revealHeight : parent.height + + Item { + id: rollOutAdjuster + readonly property real baseWidth: contentContainer.width + readonly property real baseHeight: contentContainer.height + + x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0 + y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0 + width: baseWidth + height: baseHeight + clip: false + + ElevationShadow { + id: shadowSource + width: rollOutAdjuster.baseWidth + height: rollOutAdjuster.baseHeight + opacity: contentWrapper.publishedOpacity + scale: root.fluidStandaloneActive ? 1 : contentWrapper.scale + x: root.fluidStandaloneActive ? 0 : contentWrapper.x + y: root.fluidStandaloneActive ? 0 : contentWrapper.y + level: root.shadowLevel + direction: root.effectiveShadowDirection + fallbackOffset: root.shadowFallbackOffset + targetRadius: Theme.cornerRadius + targetColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive) + } + + Item { + id: contentWrapper + width: rollOutAdjuster.baseWidth + height: rollOutAdjuster.baseHeight + + // publishedOpacity tracks Item.opacity on the GUI thread so consumers (WindowBlur, + // ElevationShadow, sibling rect) see interpolated values while the visual runs on + // the render thread via OpacityAnimator. + property bool _renderActive: Theme.isDirectionalEffect || shouldBeVisible + property real publishedOpacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) + + opacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) + visible: _renderActive + scale: contentContainer.scaleValue + transformOrigin: Item.Center + x: Theme.snap(contentContainer.animX + (rollOutAdjuster.baseWidth - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) + y: Theme.snap(contentContainer.animY + (rollOutAdjuster.baseHeight - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) + + layer.enabled: !Theme.isDirectionalEffect && publishedOpacity < 1 + layer.smooth: false + layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0) + + Behavior on opacity { + enabled: !Theme.isDirectionalEffect + NumberAnimation { + duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + onRunningChanged: { + if (!running && !root.shouldBeVisible) + contentWrapper._renderActive = false; + } + } + } + + Behavior on publishedOpacity { + enabled: !Theme.isDirectionalEffect + NumberAnimation { + duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Connections { + target: root + function onShouldBeVisibleChanged() { + if (root.shouldBeVisible) + contentWrapper._renderActive = true; + } + } + + Connections { + target: contentWindow + function onVisibleChanged() { + if (!contentWindow.visible) + contentWrapper._renderActive = false; + } + } + + Loader { + id: contentLoader + anchors.fill: parent + active: root._primeContent || shouldBeVisible || contentWindow.visible + asynchronous: false + } + } + + Rectangle { + width: rollOutAdjuster.baseWidth + height: rollOutAdjuster.baseHeight + x: root.fluidStandaloneActive ? 0 : contentWrapper.x + y: root.fluidStandaloneActive ? 0 : contentWrapper.y + opacity: contentWrapper.publishedOpacity + scale: root.fluidStandaloneActive ? 1 : contentWrapper.scale + visible: contentWrapper.visible + radius: Theme.cornerRadius + color: "transparent" + border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium + border.width: BlurService.borderWidth + z: 100 + } + } + } + } + + Item { + id: focusHelper + parent: contentContainer + anchors.fill: parent + visible: !root.contentHandlesKeys + enabled: !root.contentHandlesKeys + focus: !root.contentHandlesKeys + Keys.onPressed: event => { + if (root.contentHandlesKeys) + return; + if (event.key === Qt.Key_Escape) { + close(); + event.accepted = true; + } + } + } + } +} diff --git a/quickshell/Widgets/DankSlideout.qml b/quickshell/Widgets/DankSlideout.qml index 77fd31d1..50480e73 100644 --- a/quickshell/Widgets/DankSlideout.qml +++ b/quickshell/Widgets/DankSlideout.qml @@ -1,3 +1,4 @@ +pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls import Quickshell @@ -6,8 +7,6 @@ import qs.Common import qs.Services import qs.Widgets -pragma ComponentBehavior: Bound - PanelWindow { id: root @@ -25,27 +24,28 @@ PanelWindow { property string title: "" property alias container: contentContainer property real customTransparency: -1 + property bool mappedVisible: false signal aboutToHide function show() { - visible = true - isVisible = true + mappedVisible = true; + Qt.callLater(() => { isVisible = true; }); } function hide() { - aboutToHide() - isVisible = false + aboutToHide(); + isVisible = false; } function toggle() { if (isVisible) { - hide() + hide(); } else { - show() + show(); } } - visible: isVisible + visible: root.mappedVisible screen: modelData anchors.top: true @@ -83,15 +83,15 @@ PanelWindow { anchors.top: parent.top anchors.bottom: parent.bottom anchors.right: parent.right - width: alignedWidth - height: alignedHeight + width: root.alignedWidth + height: root.alignedHeight - property real slideOffset: alignedWidth + property real slideOffset: root.alignedWidth Connections { target: root function onIsVisibleChanged() { - slideContainer.slideOffset = root.isVisible ? 0 : slideContainer.width + slideContainer.slideOffset = root.isVisible ? 0 : slideContainer.width; } } @@ -102,8 +102,8 @@ PanelWindow { easing.type: Easing.OutCubic onRunningChanged: { - if (!running && !isVisible) { - root.visible = false + if (!running && !root.isVisible) { + root.mappedVisible = false; } } } @@ -125,7 +125,7 @@ PanelWindow { layer.textureSize: Qt.size(width * root.dpr, height * root.dpr) opacity: 1 - readonly property real effectiveTransparency: customTransparency >= 0 ? customTransparency : SettingsData.popupTransparency + readonly property real effectiveTransparency: root.customTransparency >= 0 ? root.customTransparency : SettingsData.popupTransparency anchors.top: parent.top anchors.bottom: parent.bottom diff --git a/quickshell/Widgets/WindowBlur.qml b/quickshell/Widgets/WindowBlur.qml index 3b45212b..fdf8cdda 100644 --- a/quickshell/Widgets/WindowBlur.qml +++ b/quickshell/Widgets/WindowBlur.qml @@ -1,4 +1,5 @@ import QtQuick +import qs.Common import qs.Services Item { @@ -8,6 +9,7 @@ Item { required property var targetWindow property var blurItem: null + property bool blurEnabled: Theme.connectedSurfaceBlurEnabled property real blurX: 0 property real blurY: 0 property real blurWidth: 0 @@ -17,7 +19,7 @@ Item { property var _region: null function _apply() { - if (!BlurService.enabled || !targetWindow) { + if (!blurEnabled || !BlurService.enabled || !targetWindow) { _cleanup(); return; } @@ -43,6 +45,8 @@ Item { _region = null; } + onBlurEnabledChanged: _apply() + Connections { target: BlurService function onEnabledChanged() { @@ -51,7 +55,7 @@ Item { } Connections { - target: root.targetWindow + target: root.targetWindow ?? null function onVisibleChanged() { if (root.targetWindow && root.targetWindow.visible) { root._region = null; diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 14343b1b..9adab5b2 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -3701,7 +3701,7 @@ }, { "section": "popupTransparency", - "label": "Popup Transparency", + "label": "Surface Opacity", "tabIndex": 10, "category": "Theme & Colors", "keywords": [ @@ -3719,6 +3719,7 @@ "popup", "scheme", "style", + "surface", "their", "theme", "translucent", diff --git a/scripts/format-staged.py b/scripts/format-staged.py new file mode 100755 index 00000000..523b6161 --- /dev/null +++ b/scripts/format-staged.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Format staged .qml files using qmlls (the Qt QML language server). + +Per file: + 1. Speak LSP over stdio to qmlls: initialize -> didOpen -> formatting, + apply returned edits, save, `git add`. + 2. Run qmllint on the formatted file and warn about unused imports + (informational only — never modifies files). + +Refuses to run if any staged file also has unstaged changes, since `git add` +would silently absorb those into the commit. +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +TAB_SIZE = 4 +QMLLS_CANDIDATES = ["qmlls6", "qmlls"] +QMLLINT_CANDIDATES = ["/usr/lib/qt6/bin/qmllint", "qmllint6", "qmllint"] + + +def git(*args, cwd=None): + return subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ).stdout + + +def repo_root(): + return Path(git("rev-parse", "--show-toplevel").strip()) + + +def staged_qml_files(root): + out = git("diff", "--cached", "--name-only", "--diff-filter=ACMR", cwd=root) + return [root / line for line in out.splitlines() if line.endswith(".qml")] + + +def has_unstaged_changes(root, file): + rel = str(file.relative_to(root)) + return git("diff", "--name-only", "--", rel, cwd=root).strip() != "" + + +def find_qmlls(): + for name in QMLLS_CANDIDATES: + path = shutil.which(name) + if path: + return path + return None + + +def find_qmllint(): + for candidate in QMLLINT_CANDIDATES: + path = candidate if "/" in candidate and Path(candidate).is_file() else shutil.which(candidate) + if not path: + continue + try: + result = subprocess.run([path, "--help"], capture_output=True, text=True, timeout=5) + except (subprocess.TimeoutExpired, OSError): + continue + if "--json" in result.stdout: + return path + return None + + +def lint_unused_imports(qmllint, file): + """Return a list of (line, message, suspect) for unused-import warnings. + + `suspect` is True when the same line also has an import-resolution failure, + which often means the warning is a false positive (qmllint couldn't find + the module, so its 'unused' verdict is unreliable). + """ + result = subprocess.run( + [qmllint, "--unused-imports", "warning", "--json", "-", str(file)], + capture_output=True, text=True, + ) + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return [] + + files = data.get("files", []) + if not files: + return [] + warnings = files[0].get("warnings", []) + + failed_lines = {w["line"] for w in warnings if w.get("id") == "import" and "line" in w} + findings = [] + for w in warnings: + if w.get("id") != "unused-imports" or "line" not in w: + continue + line = w["line"] + findings.append((line, w.get("message", "Unused import"), line in failed_lines)) + findings.sort(key=lambda x: x[0]) + return findings + + +class LspClient: + def __init__(self, command): + self.proc = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + self._next_id = 1 + + def _send(self, msg): + body = json.dumps(msg).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + self.proc.stdin.write(header + body) + self.proc.stdin.flush() + + def _read(self): + headers = {} + while True: + line = self.proc.stdout.readline() + if not line: + raise RuntimeError("qmlls closed unexpectedly") + line = line.decode("ascii").rstrip("\r\n") + if line == "": + break + key, _, value = line.partition(":") + headers[key.strip().lower()] = value.strip() + length = int(headers["content-length"]) + body = b"" + while len(body) < length: + chunk = self.proc.stdout.read(length - len(body)) + if not chunk: + raise RuntimeError("qmlls closed mid-message") + body += chunk + return json.loads(body) + + def request(self, method, params): + req_id = self._next_id + self._next_id += 1 + self._send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + while True: + msg = self._read() + if msg.get("id") == req_id and ("result" in msg or "error" in msg): + if "error" in msg: + raise RuntimeError(f"LSP {method} error: {msg['error']}") + return msg.get("result") + if "id" in msg and "method" in msg: + # Server-to-client request — reply with null so it doesn't stall. + self._send({"jsonrpc": "2.0", "id": msg["id"], "result": None}) + + def notify(self, method, params): + self._send({"jsonrpc": "2.0", "method": method, "params": params}) + + def shutdown(self): + try: + self.request("shutdown", None) + self.notify("exit", None) + except Exception: + pass + try: + self.proc.wait(timeout=2) + except subprocess.TimeoutExpired: + self.proc.kill() + + +def apply_edits(text, edits): + """Apply LSP TextEdits (non-overlapping) to text, end-first.""" + if not edits: + return text + + lines = text.splitlines(keepends=True) + line_starts = [0] + for line in lines: + line_starts.append(line_starts[-1] + len(line)) + + def offset(pos): + line = pos["line"] + if line >= len(line_starts): + return len(text) + return min(line_starts[line] + pos["character"], len(text)) + + sorted_edits = sorted( + edits, + key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]), + reverse=True, + ) + for edit in sorted_edits: + start = offset(edit["range"]["start"]) + end = offset(edit["range"]["end"]) + text = text[:start] + edit["newText"] + text[end:] + return text + + +def main(): + root = repo_root() + files = staged_qml_files(root) + if not files: + print("No staged .qml files.") + return 0 + + dirty = [f for f in files if has_unstaged_changes(root, f)] + if dirty: + print("Refusing to format: staged files have unstaged changes:", file=sys.stderr) + for f in dirty: + print(f" {f.relative_to(root)}", file=sys.stderr) + print("\nStash or stage those changes first.", file=sys.stderr) + return 1 + + qmlls = find_qmlls() + if not qmlls: + print(f"qmlls not found (tried: {', '.join(QMLLS_CANDIDATES)})", file=sys.stderr) + return 1 + + qmllint = find_qmllint() + if not qmllint: + print("warning: qmllint with --json not found; skipping unused-import checks", file=sys.stderr) + + client = LspClient([qmlls]) + changed = 0 + unused_by_file = {} + try: + client.request("initialize", { + "processId": os.getpid(), + "rootUri": root.as_uri(), + "workspaceFolders": [{"uri": root.as_uri(), "name": root.name}], + "capabilities": { + "textDocument": { + "formatting": {"dynamicRegistration": False}, + "synchronization": {"dynamicRegistration": False}, + }, + }, + }) + client.notify("initialized", {}) + + for file in files: + rel = file.relative_to(root) + print(f" {rel} ... ", end="", flush=True) + + original = file.read_text() + uri = file.as_uri() + + client.notify("textDocument/didOpen", { + "textDocument": { + "uri": uri, + "languageId": "qml", + "version": 1, + "text": original, + }, + }) + + edits = client.request("textDocument/formatting", { + "textDocument": {"uri": uri}, + "options": {"tabSize": TAB_SIZE, "insertSpaces": True}, + }) + + client.notify("textDocument/didClose", {"textDocument": {"uri": uri}}) + + new_text = apply_edits(original, edits or []) + if new_text == original: + print("unchanged") + continue + + file.write_text(new_text) + git("add", "--", str(rel), cwd=root) + changed += 1 + print("formatted & staged") + + if qmllint: + for file in files: + findings = lint_unused_imports(qmllint, file) + if findings: + unused_by_file[file] = findings + + print(f"\n{changed} of {len(files)} file(s) changed.") + + if unused_by_file: + print("\nUnused import warnings (informational, not auto-removed):") + for file, findings in unused_by_file.items(): + rel = file.relative_to(root) + for line, message, suspect in findings: + suffix = " [suspect: import didn't resolve]" if suspect else "" + print(f" {rel}:{line} {message}{suffix}") + + return 0 + finally: + client.shutdown() + + +if __name__ == "__main__": + sys.exit(main())