diff --git a/.envrc b/.envrc index c90b500c..5a259560 100644 --- a/.envrc +++ b/.envrc @@ -3,10 +3,11 @@ if has nix; then fi shopt -s globstar -watch_file assets/cpp/**/*.cpp -watch_file assets/cpp/**/*.hpp watch_file plugin/**/*.cpp watch_file plugin/**/*.hpp +watch_file plugin/**/*.qml +watch_file plugin/**/*.vert +watch_file plugin/**/*.frag watch_file **/CMakeLists.txt cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_COMPILER=clazy -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DDISTRIBUTOR=direnv diff --git a/.github/workflows/check-format.yml b/.github/workflows/check-format.yml index b26cb949..ea5fe9ef 100644 --- a/.github/workflows/check-format.yml +++ b/.github/workflows/check-format.yml @@ -34,6 +34,5 @@ jobs: - name: Check C++ format shell: fish {0} run: | - for file in (string match -v 'build/*' **.cpp **.hpp) - clang-format $file | diff -u $file - || exit 1 - end + find plugin extras -name '*.cpp' -o -name '*.hpp' \ + | xargs clang-format --dry-run --Werror diff --git a/.github/workflows/update-image.yml b/.github/workflows/update-image.yml index 23006dd0..74fe5435 100644 --- a/.github/workflows/update-image.yml +++ b/.github/workflows/update-image.yml @@ -23,7 +23,7 @@ jobs: run: | cat > /tmp/Dockerfile <> /etc/sudoers && \ sudo -u builder git clone https://aur.archlinux.org/yay-bin.git /home/builder/yay-bin && \ diff --git a/config/AppearanceConfig.qml b/config/AppearanceConfig.qml index 3d590dca..cee140f6 100644 --- a/config/AppearanceConfig.qml +++ b/config/AppearanceConfig.qml @@ -65,7 +65,7 @@ JsonObject { property list standardDecel: [0, 0, 0, 1, 1, 1] property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] - property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] + property list expressiveSlowSpatial: [0.39, 1.29, 0.35, 0.98, 1, 1] } component AnimDurations: JsonObject { @@ -76,7 +76,7 @@ JsonObject { property int extraLarge: 1000 * scale property int expressiveFastSpatial: 350 * scale property int expressiveDefaultSpatial: 500 * scale - property int expressiveEffects: 200 * scale + property int expressiveSlowSpatial: 650 * scale } component Anim: JsonObject { diff --git a/modules/background/Background.qml b/modules/background/Background.qml index 95109e12..0f44b1a0 100644 --- a/modules/background/Background.qml +++ b/modules/background/Background.qml @@ -7,159 +7,154 @@ import qs.components.containers import qs.services import qs.config -Loader { - asynchronous: true - active: Config.background.enabled +Variants { + model: Config.background.enabled ? Screens.screens : [] - sourceComponent: Variants { - model: Screens.screens + StyledWindow { + id: win - StyledWindow { - id: win + required property ShellScreen modelData - required property ShellScreen modelData + screen: modelData + name: "background" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: Config.background.wallpaperEnabled ? WlrLayer.Background : WlrLayer.Bottom + color: Config.background.wallpaperEnabled ? "black" : "transparent" + surfaceFormat.opaque: false - screen: modelData - name: "background" - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: Config.background.wallpaperEnabled ? WlrLayer.Background : WlrLayer.Bottom - color: Config.background.wallpaperEnabled ? "black" : "transparent" - surfaceFormat.opaque: false + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true - anchors.top: true - anchors.bottom: true - anchors.left: true - anchors.right: true + Item { + id: behindClock - Item { - id: behindClock + anchors.fill: parent + + Loader { + id: wallpaper + + asynchronous: true anchors.fill: parent + active: Config.background.wallpaperEnabled - Loader { - id: wallpaper + sourceComponent: Wallpaper {} + } - asynchronous: true + Visualiser { + anchors.fill: parent + screen: win.modelData + wallpaper: wallpaper + } + } - anchors.fill: parent - active: Config.background.wallpaperEnabled + Loader { + id: clockLoader - sourceComponent: Wallpaper {} + asynchronous: true + active: Config.background.desktopClock.enabled + + anchors.margins: Appearance.padding.large * 2 + anchors.leftMargin: Appearance.padding.large * 2 + Config.bar.sizes.innerWidth + Math.max(Appearance.padding.smaller, Config.border.thickness) + + state: Config.background.desktopClock.position + states: [ + State { + name: "top-left" + + AnchorChanges { + target: clockLoader + anchors.top: parent.top + anchors.left: parent.left + } + }, + State { + name: "top-center" + + AnchorChanges { + target: clockLoader + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + } + }, + State { + name: "top-right" + + AnchorChanges { + target: clockLoader + anchors.top: parent.top + anchors.right: parent.right + } + }, + State { + name: "middle-left" + + AnchorChanges { + target: clockLoader + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + } + }, + State { + name: "middle-center" + + AnchorChanges { + target: clockLoader + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + } + }, + State { + name: "middle-right" + + AnchorChanges { + target: clockLoader + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + } + }, + State { + name: "bottom-left" + + AnchorChanges { + target: clockLoader + anchors.bottom: parent.bottom + anchors.left: parent.left + } + }, + State { + name: "bottom-center" + + AnchorChanges { + target: clockLoader + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + } + }, + State { + name: "bottom-right" + + AnchorChanges { + target: clockLoader + anchors.bottom: parent.bottom + anchors.right: parent.right + } } + ] - Visualiser { - anchors.fill: parent - screen: win.modelData - wallpaper: wallpaper + transitions: Transition { + AnchorAnimation { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } - Loader { - id: clockLoader - - asynchronous: true - active: Config.background.desktopClock.enabled - - anchors.margins: Appearance.padding.large * 2 - anchors.leftMargin: Appearance.padding.large * 2 + Config.bar.sizes.innerWidth + Math.max(Appearance.padding.smaller, Config.border.thickness) - - state: Config.background.desktopClock.position - states: [ - State { - name: "top-left" - - AnchorChanges { - target: clockLoader - anchors.top: parent.top - anchors.left: parent.left - } - }, - State { - name: "top-center" - - AnchorChanges { - target: clockLoader - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - } - }, - State { - name: "top-right" - - AnchorChanges { - target: clockLoader - anchors.top: parent.top - anchors.right: parent.right - } - }, - State { - name: "middle-left" - - AnchorChanges { - target: clockLoader - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - } - }, - State { - name: "middle-center" - - AnchorChanges { - target: clockLoader - anchors.verticalCenter: parent.verticalCenter - anchors.horizontalCenter: parent.horizontalCenter - } - }, - State { - name: "middle-right" - - AnchorChanges { - target: clockLoader - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - } - }, - State { - name: "bottom-left" - - AnchorChanges { - target: clockLoader - anchors.bottom: parent.bottom - anchors.left: parent.left - } - }, - State { - name: "bottom-center" - - AnchorChanges { - target: clockLoader - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - } - }, - State { - name: "bottom-right" - - AnchorChanges { - target: clockLoader - anchors.bottom: parent.bottom - anchors.right: parent.right - } - } - ] - - transitions: Transition { - AnchorAnimation { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - - sourceComponent: DesktopClock { - wallpaper: behindClock - absX: clockLoader.x - absY: clockLoader.y - } + sourceComponent: DesktopClock { + wallpaper: behindClock + absX: clockLoader.x + absY: clockLoader.y } } } diff --git a/modules/bar/BarWrapper.qml b/modules/bar/BarWrapper.qml index f29e23f9..ec9df2d1 100644 --- a/modules/bar/BarWrapper.qml +++ b/modules/bar/BarWrapper.qml @@ -4,6 +4,7 @@ import QtQuick import Quickshell import qs.components import qs.config +import qs.utils import qs.modules.bar.popouts as BarPopouts Item { @@ -12,9 +13,10 @@ Item { required property ShellScreen screen required property DrawerVisibilities visibilities required property BarPopouts.Wrapper popouts - required property bool disabled required property bool fullscreen + readonly property bool disabled: Strings.testRegexList(Config.bar.excludedScreens, screen.name) + readonly property int clampedWidth: Math.max(Config.border.minThickness, implicitWidth) readonly property int padding: Math.max(Appearance.padding.smaller, Config.border.thickness) readonly property int contentWidth: Config.bar.sizes.innerWidth + padding * 2 diff --git a/modules/bar/popouts/Background.qml b/modules/bar/popouts/Background.qml deleted file mode 100644 index cfba86d3..00000000 --- a/modules/bar/popouts/Background.qml +++ /dev/null @@ -1,73 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import qs.components -import qs.services -import qs.config - -ShapePath { - id: root - - required property Wrapper wrapper - required property bool invertBottomRounding - readonly property real rounding: wrapper.isDetached ? Appearance.rounding.normal : Config.border.rounding - readonly property bool flatten: wrapper.width < rounding * 2 - readonly property real roundingX: flatten ? wrapper.width / 2 : rounding - property real ibr: invertBottomRounding ? -1 : 1 - - property real sideRounding: startX > 0 ? -1 : 1 - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: root.roundingX - relativeY: root.rounding * root.sideRounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: root.sideRounding < 0 ? PathArc.Clockwise : PathArc.Counterclockwise - } - PathLine { - relativeX: root.wrapper.width - root.roundingX * 2 - relativeY: 0 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.rounding * 2 - } - PathArc { - relativeX: -root.roundingX * root.ibr - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: root.ibr < 0 ? PathArc.Counterclockwise : PathArc.Clockwise - } - PathLine { - relativeX: -(root.wrapper.width - root.roundingX - root.roundingX * root.ibr) - relativeY: 0 - } - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding * root.sideRounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: root.sideRounding < 0 ? PathArc.Clockwise : PathArc.Counterclockwise - } - - Behavior on fillColor { - CAnim {} - } - - Behavior on ibr { - Anim {} - } - - Behavior on sideRounding { - Anim {} - } -} diff --git a/modules/bar/popouts/ClipWrapper.qml b/modules/bar/popouts/ClipWrapper.qml new file mode 100644 index 00000000..bf2a0049 --- /dev/null +++ b/modules/bar/popouts/ClipWrapper.qml @@ -0,0 +1,69 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.components +import qs.config +import qs.modules.bar.popouts // Need to import this module so the Wrapper type is the same as others + +Item { + id: root + + required property ShellScreen screen + required property real borderThickness + + readonly property alias content: content + property real offsetScale: x > 0 || content.hasCurrent ? 0 : 1 + + visible: width > 0 && height > 0 + clip: true + + implicitWidth: content.implicitWidth * (1 - offsetScale) + implicitHeight: content.implicitHeight + + x: content.isDetached ? (parent.width - content.nonAnimWidth) / 2 : 0 + y: { + if (content.isDetached) + return (parent.height - content.nonAnimHeight) / 2; + + const off = content.currentCenter - borderThickness - content.nonAnimHeight / 2; + const diff = parent.height - Math.floor(off + content.nonAnimHeight); + if (diff < 0) + return off + diff; + return Math.max(off, 0); + } + + Behavior on offsetScale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on x { + Anim { + duration: content.animLength + easing.bezierCurve: content.animCurve + } + } + + Behavior on y { + enabled: root.offsetScale < 1 + + Anim { + duration: content.animLength + easing.bezierCurve: content.animCurve + } + } + + Wrapper { + id: content + + screen: root.screen + offsetScale: root.offsetScale + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: (-implicitWidth - 5) * root.offsetScale + } +} diff --git a/modules/bar/popouts/Content.qml b/modules/bar/popouts/Content.qml index 1c4792e1..71a8c5eb 100644 --- a/modules/bar/popouts/Content.qml +++ b/modules/bar/popouts/Content.qml @@ -14,8 +14,6 @@ Item { readonly property Popout currentPopout: content.children.find(c => c.shouldBeActive) ?? null readonly property Item current: currentPopout?.item ?? null - anchors.centerIn: parent - implicitWidth: (currentPopout?.implicitWidth ?? 0) + Appearance.padding.large * 2 implicitHeight: (currentPopout?.implicitHeight ?? 0) + Appearance.padding.large * 2 @@ -171,8 +169,7 @@ Item { required property string name readonly property bool shouldBeActive: root.popouts.currentName === name - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right + anchors.centerIn: parent opacity: 0 scale: 0.8 diff --git a/modules/bar/popouts/Wrapper.qml b/modules/bar/popouts/Wrapper.qml index 3a907433..04212e28 100644 --- a/modules/bar/popouts/Wrapper.qml +++ b/modules/bar/popouts/Wrapper.qml @@ -14,45 +14,50 @@ Item { id: root required property ShellScreen screen + required property real offsetScale - readonly property real nonAnimWidth: x > 0 || hasCurrent ? children.find(c => c.shouldBeActive)?.implicitWidth ?? content.implicitWidth : 0 + readonly property alias content: content + readonly property alias winfo: winfo + readonly property alias controlCenter: controlCenter + + readonly property real nonAnimWidth: children.find(c => c.shouldBeActive)?.implicitWidth ?? content.implicitWidth readonly property real nonAnimHeight: children.find(c => c.shouldBeActive)?.implicitHeight ?? content.implicitHeight readonly property Item current: (content.item as Content)?.current ?? null + readonly property bool isDetached: detachedMode.length > 0 property alias currentName: popoutState.currentName - property real currentCenter property alias hasCurrent: popoutState.hasCurrent - readonly property PopoutState popState: popoutState + property real currentCenter property string detachedMode property string queuedMode - readonly property bool isDetached: detachedMode.length > 0 - property int animLength: Appearance.anim.durations.normal - property list animCurve: Appearance.anim.curves.emphasized + property int animLength: Appearance.anim.durations.expressiveDefaultSpatial + property list animCurve: Appearance.anim.curves.expressiveDefaultSpatial + + function setAnims(detach: bool): void { + const type = `expressive${detach ? "Slow" : "Default"}Spatial`; + animLength = Appearance.anim.durations[type]; + animCurve = Appearance.anim.curves[type]; + } function detach(mode: string): void { - animLength = Appearance.anim.durations.large; + setAnims(true); if (mode === "winfo") { detachedMode = mode; } else { queuedMode = mode; detachedMode = "any"; } + setAnims(false); focus = true; } function close(): void { hasCurrent = false; - animCurve = Appearance.anim.curves.emphasizedAccel; - animLength = Appearance.anim.durations.normal; detachedMode = ""; - animCurve = Appearance.anim.curves.emphasized; } - visible: width > 0 && height > 0 - clip: true - implicitWidth: nonAnimWidth implicitHeight: nonAnimHeight @@ -89,15 +94,7 @@ Item { } Binding { - when: root.isDetached - - target: QsWindow.window - property: "WlrLayershell.keyboardFocus" - value: WlrKeyboardFocus.OnDemand - } - - Binding { - when: root.hasCurrent && root.currentName === "wirelesspassword" + when: root.isDetached || (root.hasCurrent && root.currentName === "wirelesspassword") target: QsWindow.window property: "WlrLayershell.keyboardFocus" @@ -108,8 +105,7 @@ Item { id: content shouldBeActive: root.hasCurrent && !root.detachedMode - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + anchors.fill: parent sourceComponent: Content { popouts: popoutState @@ -117,6 +113,8 @@ Item { } Comp { + id: winfo + shouldBeActive: root.detachedMode === "winfo" anchors.centerIn: parent @@ -127,32 +125,15 @@ Item { } Comp { + id: controlCenter + shouldBeActive: root.detachedMode === "any" anchors.centerIn: parent sourceComponent: ControlCenter { - function close(): void { - root.close(); - } - screen: root.screen active: root.queuedMode - } - } - - Behavior on x { - Anim { - duration: root.animLength - easing.bezierCurve: root.animCurve - } - } - - Behavior on y { - enabled: root.implicitWidth > 0 - - Anim { - duration: root.animLength - easing.bezierCurve: root.animCurve + onClose: root.close() } } @@ -164,7 +145,7 @@ Item { } Behavior on implicitHeight { - enabled: root.implicitWidth > 0 + enabled: root.offsetScale < 1 Anim { duration: root.animLength @@ -180,6 +161,7 @@ Item { active: false opacity: 0 + // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set states: State { name: "active" when: comp.shouldBeActive diff --git a/modules/controlcenter/ControlCenter.qml b/modules/controlcenter/ControlCenter.qml index f542b597..5b067fc0 100644 --- a/modules/controlcenter/ControlCenter.qml +++ b/modules/controlcenter/ControlCenter.qml @@ -25,8 +25,7 @@ Item { root: root } - function close(): void { - } + signal close implicitWidth: implicitHeight * Config.controlCenter.sizes.ratio implicitHeight: screen.height * Config.controlCenter.sizes.heightMult diff --git a/modules/controlcenter/WindowFactory.qml b/modules/controlcenter/WindowFactory.qml index 266af909..dc0dc4a0 100644 --- a/modules/controlcenter/WindowFactory.qml +++ b/modules/controlcenter/WindowFactory.qml @@ -45,12 +45,9 @@ Singleton { ControlCenter { id: cc - function close(): void { - win.destroy(); - } - anchors.fill: parent screen: win.screen + onClose: win.destroy() floating: true } diff --git a/modules/dashboard/Background.qml b/modules/dashboard/Background.qml deleted file mode 100644 index c6223eb6..00000000 --- a/modules/dashboard/Background.qml +++ /dev/null @@ -1,66 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import qs.components -import qs.services -import qs.config - -ShapePath { - id: root - - required property Wrapper wrapper - readonly property real rounding: Config.border.rounding - readonly property bool flatten: wrapper.height < rounding * 2 - readonly property real roundingY: flatten ? wrapper.height / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.roundingY * 2 - } - - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - - PathLine { - relativeX: root.wrapper.width - root.rounding * 2 - relativeY: 0 - } - - PathArc { - relativeX: root.rounding - relativeY: -root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - - PathLine { - relativeX: 0 - relativeY: -(root.wrapper.height - root.roundingY * 2) - } - - PathArc { - relativeX: root.rounding - relativeY: -root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml index a7449689..566f616a 100644 --- a/modules/dashboard/Wrapper.qml +++ b/modules/dashboard/Wrapper.qml @@ -29,59 +29,19 @@ Item { } readonly property real nonAnimHeight: state === "visible" ? ((content.item as Content)?.nonAnimHeight ?? 0) : 0 + readonly property bool shouldBeActive: visibilities.dashboard && Config.dashboard.enabled + property real offsetScale: shouldBeActive ? 0 : 1 - visible: height > 0 - implicitHeight: 0 - implicitWidth: content.implicitWidth + visible: offsetScale < 1 + anchors.topMargin: (-implicitHeight - 5) * offsetScale + implicitHeight: content.implicitHeight + implicitWidth: content.implicitWidth || 854 // Hard coded fallback for first open + opacity: 1 - offsetScale - onStateChanged: { - if (state === "visible" && timer.running) { - timer.triggered(); - timer.stop(); - } - } - - states: State { - name: "visible" - when: root.visibilities.dashboard && Config.dashboard.enabled - - PropertyChanges { - root.implicitHeight: content.implicitHeight - } - } - - transitions: [ - Transition { - from: "" - to: "visible" - - Anim { - target: root - property: "implicitHeight" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - }, - Transition { - from: "visible" - to: "" - - Anim { - target: root - property: "implicitHeight" - easing.bezierCurve: Appearance.anim.curves.emphasized - } - } - ] - - Timer { - id: timer - - running: true - interval: Appearance.anim.durations.extraLarge - onTriggered: { - content.active = Qt.binding(() => (root.visibilities.dashboard && Config.dashboard.enabled) || root.visible); - content.visible = true; + Behavior on offsetScale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } @@ -91,8 +51,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - visible: false - active: true + active: root.shouldBeActive || root.visible sourceComponent: Content { visibilities: root.visibilities diff --git a/modules/drawers/Backgrounds.qml b/modules/drawers/Backgrounds.qml deleted file mode 100644 index 7592411c..00000000 --- a/modules/drawers/Backgrounds.qml +++ /dev/null @@ -1,91 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import qs.config -import qs.modules.dashboard as Dashboard -import qs.modules.launcher as Launcher -import qs.modules.notifications as Notifications -import qs.modules.osd as Osd -import qs.modules.session as Session -import qs.modules.sidebar as Sidebar -import qs.modules.utilities as Utilities -import qs.modules.bar.popouts as BarPopouts - -Shape { - id: root - - required property Panels panels - required property Item bar - required property real borderThickness - required property real borderRounding - - anchors.fill: parent - anchors.margins: root.borderThickness - anchors.leftMargin: bar.implicitWidth - preferredRendererType: Shape.CurveRenderer - - Osd.Background { - wrapper: root.panels.osd // qmllint disable incompatible-type - rounding: Config.border.rounding - - startX: root.width - root.panels.session.width - root.panels.sidebar.width - startY: (root.height - wrapper.height) / 2 - rounding - } - - Notifications.Background { - wrapper: root.panels.notifications // qmllint disable incompatible-type - sidebar: sidebar - rounding: Config.border.rounding - - startX: root.width - startY: 0 - } - - Session.Background { - wrapper: root.panels.session // qmllint disable incompatible-type - - startX: root.width - root.panels.sidebar.width - startY: (root.height - wrapper.height) / 2 - rounding - } - - Launcher.Background { - wrapper: root.panels.launcher // qmllint disable incompatible-type - - startX: (root.width - wrapper.width) / 2 - rounding - startY: root.height - } - - Dashboard.Background { - wrapper: root.panels.dashboard // qmllint disable incompatible-type - - startX: (root.width - wrapper.width) / 2 - rounding - startY: 0 - } - - BarPopouts.Background { - wrapper: root.panels.popouts // qmllint disable incompatible-type - invertBottomRounding: wrapper.y + wrapper.height + 1 >= root.height - - startX: wrapper.x - startY: wrapper.y - rounding * sideRounding - } - - Utilities.Background { - wrapper: root.panels.utilities // qmllint disable incompatible-type - sidebar: sidebar - rounding: root.borderRounding - - startX: root.width - startY: root.height - } - - Sidebar.Background { - id: sidebar - - wrapper: root.panels.sidebar // qmllint disable incompatible-type - panels: root.panels - rounding: root.borderRounding - - startX: root.width - startY: root.panels.notifications.height - } -} diff --git a/modules/drawers/Border.qml b/modules/drawers/Border.qml deleted file mode 100644 index a638479d..00000000 --- a/modules/drawers/Border.qml +++ /dev/null @@ -1,45 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Effects -import qs.components -import qs.services - -Item { - id: root - - required property Item bar - required property real borderThickness - required property real borderRounding - - anchors.fill: parent - - StyledRect { - anchors.fill: parent - color: Colours.palette.m3surface - - layer.enabled: true - layer.effect: MultiEffect { - maskSource: mask - maskEnabled: true - maskInverted: true - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 - } - } - - Item { - id: mask - - anchors.fill: parent - layer.enabled: true - visible: false - - Rectangle { - anchors.fill: parent - anchors.margins: root.borderThickness - anchors.leftMargin: root.bar.implicitWidth - radius: root.borderRounding - } - } -} diff --git a/modules/drawers/ContentWindow.qml b/modules/drawers/ContentWindow.qml new file mode 100644 index 00000000..ff0e847a --- /dev/null +++ b/modules/drawers/ContentWindow.qml @@ -0,0 +1,310 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import Caelestia.Blobs +import qs.components +import qs.components.containers +import qs.services +import qs.config +import qs.modules.bar + +StyledWindow { + id: root + + readonly property alias bar: bar + + readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) + readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject.specialWorkspace?.name.length ?? 0) > 0 + readonly property bool hasFullscreen: { + if (hasSpecialWorkspace) { + const specialName = monitor?.lastIpcObject.specialWorkspace?.name; + if (!specialName) + return false; + const specialWs = Hypr.workspaces.values.find(ws => ws.name === specialName); + return specialWs?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + return monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; + } + property real borderThickness: hasFullscreen ? 0 : Config.border.thickness + readonly property real borderLayoutThickness: hasFullscreen ? 0 : Config.border.thickness + property real borderRounding: hasFullscreen ? 0 : Config.border.rounding + property real shadowOpacity: hasFullscreen ? 0 : 0.7 + + readonly property int dragMaskPadding: { + if (focusGrab.active || panels.popouts.isDetached) + return 0; + + if (monitor?.lastIpcObject.specialWorkspace?.name || monitor?.activeWorkspace.lastIpcObject.windows > 0) + return 0; + + const thresholds = []; + for (const panel of ["dashboard", "launcher", "session", "sidebar"]) + if (Config[panel].enabled) + thresholds.push(Config[panel].dragThreshold); + return Math.max(...thresholds); + } + + onHasFullscreenChanged: { + visibilities.launcher = false; + visibilities.session = false; + visibilities.dashboard = false; + } + + name: "drawers" + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session || panels.dashboard.needsKeyboard ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + + mask: Regions { + bar: bar + panels: panels + win: root + } + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + Behavior on borderThickness { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on borderRounding { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + Behavior on shadowOpacity { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + HyprlandFocusGrab { + id: focusGrab + + active: (visibilities.launcher && Config.launcher.enabled) || (visibilities.session && Config.session.enabled) || (visibilities.sidebar && Config.sidebar.enabled) || (!Config.dashboard.showOnHover && visibilities.dashboard && Config.dashboard.enabled) || (panels.popouts.currentName.startsWith("traymenu") && (panels.popouts.current as StackView)?.depth > 1) + windows: [root] + onCleared: { + visibilities.launcher = false; + visibilities.session = false; + visibilities.sidebar = false; + visibilities.dashboard = false; + panels.popouts.hasCurrent = false; + bar.closeTray(); + } + } + + StyledRect { + anchors.fill: parent + opacity: visibilities.session && Config.session.enabled ? 0.5 : 0 + color: Colours.palette.m3scrim + + Behavior on opacity { + Anim {} + } + } + + Item { + anchors.fill: parent + opacity: Colours.transparency.enabled ? Colours.transparency.base : 1 + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + blurMax: 15 + shadowColor: Qt.alpha(Colours.palette.m3shadow, Math.max(0, root.shadowOpacity)) + } + + BlobGroup { + id: blobGroup + + color: Colours.palette.m3surface + + Behavior on color { + CAnim {} + } + } + + BlobInvertedRect { + anchors.fill: parent + anchors.margins: -50 // Make border thicker to smooth out bulge from closed drawers + group: blobGroup + radius: root.borderRounding + borderLeft: bar.implicitWidth - anchors.margins + borderRight: root.borderThickness - anchors.margins + borderTop: root.borderThickness - anchors.margins + borderBottom: root.borderThickness - anchors.margins + } + + PanelBg { + id: dashBg + + panel: panels.dashboard + deformAmount: 0.1 + } + + PanelBg { + id: launcherBg + + panel: panels.launcher + deformAmount: 0.1 + } + + PanelBg { + id: sessionBg + + panel: panels.sessionWrapper + deformAmount: 0.2 + x: panels.sessionWrapper.x + panels.session.x + bar.implicitWidth + implicitWidth: panels.session.width + } + + PanelBg { + id: sidebarBg + + panel: panels.sidebar + deformAmount: 0.03 + implicitHeight: panel.height * (1 / rawDeformMatrix.m22) + 2 + exclude: panels.sidebar.offsetScale > 0.08 ? [] : [utilsBg] + bottomLeftRadius: Math.max(0, Math.min(1, panels.sidebar.offsetScale / 0.3)) * radius + } + + PanelBg { + id: osdBg + + panel: panels.osdWrapper + deformAmount: 0.25 + x: panels.osdWrapper.x + panels.osd.x + bar.implicitWidth + implicitWidth: panels.osd.width + } + + PanelBg { + id: notifsBg + + panel: panels.notifications + } + + PanelBg { + id: utilsBg + + panel: panels.utilities + deformAmount: panels.sidebar.visible ? 0.1 : 0.15 + exclude: panels.sidebar.offsetScale > 0.08 ? [] : [sidebarBg] + topLeftRadius: Math.max(0, Math.min(1, panels.sidebar.offsetScale / 0.3)) * radius + } + + PanelBg { + id: popoutBg + + // Extra width to prevent vertical movement deformation partially detaching panel from bar + property real extraWidth: panels.popouts.isDetached ? 0 : 0.2 + + panel: panels.popoutsWrapper + deformAmount: panels.popouts.isDetached ? 0.05 : panels.popouts.hasCurrent ? 0.15 : 0.1 + x: panels.popoutsWrapper.x + panels.popouts.x + bar.implicitWidth - panels.popouts.width * extraWidth + implicitWidth: panels.popouts.width * (1 + extraWidth) + + Behavior on extraWidth { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } + + DrawerVisibilities { + id: visibilities + + Component.onCompleted: Visibilities.load(root.screen, this) + } + + Interactions { + screen: root.screen + popouts: panels.popouts + visibilities: visibilities + panels: panels + bar: bar + borderThickness: root.borderLayoutThickness + fullscreen: root.hasFullscreen + + Panels { + id: panels + + screen: root.screen + visibilities: visibilities + bar: bar + borderThickness: root.borderThickness + + utilities.horizontalStretch: (sidebarBg.rawDeformMatrix.m11 - 1) / 2 + 1 + + dashboard.transform: Matrix4x4 { + matrix: dashBg.deformMatrix + } + launcher.transform: Matrix4x4 { + matrix: launcherBg.deformMatrix + } + session.transform: Matrix4x4 { + matrix: sessionBg.deformMatrix + } + sidebar.transform: Matrix4x4 { + matrix: sidebarBg.deformMatrix + } + osd.transform: Matrix4x4 { + matrix: osdBg.deformMatrix + } + notifications.transform: Matrix4x4 { + matrix: notifsBg.deformMatrix + } + utilities.transform: Matrix4x4 { + matrix: utilsBg.deformMatrix + } + popouts.transform: Matrix4x4 { + matrix: popoutBg.deformMatrix + } + } + + BarWrapper { + id: bar + + anchors.top: parent.top + anchors.bottom: parent.bottom + + screen: root.screen + visibilities: visibilities + popouts: panels.popouts + + fullscreen: root.hasFullscreen + + Component.onCompleted: Visibilities.bars.set(root.screen, this) + } + } + + component PanelBg: BlobRect { + required property Item panel + property real deformAmount: 0.15 + + group: blobGroup + x: panel.x + bar.implicitWidth + y: panel.y + root.borderThickness + implicitWidth: panel.width + implicitHeight: panel.height + radius: Config.border.rounding + deformScale: deformAmount / 10000 + } +} diff --git a/modules/drawers/Drawers.qml b/modules/drawers/Drawers.qml index 9d3de776..ed886b31 100644 --- a/modules/drawers/Drawers.qml +++ b/modules/drawers/Drawers.qml @@ -1,17 +1,9 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Controls -import QtQuick.Effects import Quickshell -import Quickshell.Hyprland -import Quickshell.Wayland -import qs.components -import qs.components.containers import qs.services import qs.config -import qs.utils -import qs.modules.bar Variants { model: Screens.screens @@ -20,204 +12,17 @@ Variants { id: scope required property ShellScreen modelData - readonly property bool barDisabled: Strings.testRegexList(Config.bar.excludedScreens, modelData.name) Exclusions { screen: scope.modelData - bar: bar + bar: content.bar borderThickness: Config.border.thickness } - StyledWindow { - id: win - - readonly property var monitor: Hypr.monitorFor(screen) - readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject?.specialWorkspace?.name.length ?? 0) > 0 - readonly property bool hasFullscreen: { - if (hasSpecialWorkspace) { - const specialName = monitor?.lastIpcObject?.specialWorkspace?.name; - if (!specialName) - return false; - const specialWs = Hypr.workspaces.values.find(ws => ws.name === specialName); - return specialWs?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; - } - return monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false; - } - property real borderThickness: hasFullscreen ? 0 : Config.border.thickness - readonly property real borderLayoutThickness: hasFullscreen ? 0 : Config.border.thickness - property real borderRounding: hasFullscreen ? 0 : Config.border.rounding - property real shadowOpacity: hasFullscreen ? 0 : 0.7 - readonly property int dragMaskPadding: { - if (focusGrab.active || panels.popouts.isDetached) - return 0; - - const mon = Hypr.monitorFor(screen); - if (mon?.lastIpcObject.specialWorkspace?.name || mon?.activeWorkspace.lastIpcObject.windows > 0) - return 0; - - const thresholds = []; - for (const panel of ["dashboard", "launcher", "session", "sidebar"]) - if (Config[panel].enabled) - thresholds.push(Config[panel].dragThreshold); - return Math.max(...thresholds); - } - - onHasFullscreenChanged: { - visibilities.launcher = false; - visibilities.session = false; - visibilities.dashboard = false; - } + ContentWindow { + id: content screen: scope.modelData - name: "drawers" - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session || panels.dashboard.needsKeyboard ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - - mask: Region { - x: bar.clampedWidth + win.dragMaskPadding - y: Config.border.clampedThickness + win.dragMaskPadding - width: win.width - bar.clampedWidth - Config.border.clampedThickness - win.dragMaskPadding * 2 - height: win.height - Config.border.clampedThickness * 2 - win.dragMaskPadding * 2 - intersection: Intersection.Xor - - regions: regions.instances // qmllint disable stale-property-read - } - - anchors.top: true - anchors.bottom: true - anchors.left: true - anchors.right: true - - Behavior on borderThickness { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - - Behavior on borderRounding { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - - Behavior on shadowOpacity { - Anim { - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - - Variants { - id: regions - - model: panels.children - - Region { - required property Item modelData - - x: modelData.x + bar.implicitWidth - y: modelData.y + win.borderLayoutThickness - width: modelData.width - height: modelData.height - intersection: Intersection.Subtract - } - } - - HyprlandFocusGrab { - id: focusGrab - - active: (visibilities.launcher && Config.launcher.enabled) || (visibilities.session && Config.session.enabled) || (visibilities.sidebar && Config.sidebar.enabled) || (!Config.dashboard.showOnHover && visibilities.dashboard && Config.dashboard.enabled) || (panels.popouts.currentName.startsWith("traymenu") && (panels.popouts.current as StackView)?.depth > 1) - windows: [win] - onCleared: { - visibilities.launcher = false; - visibilities.session = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - panels.popouts.hasCurrent = false; - bar.closeTray(); - } - } - - StyledRect { - anchors.fill: parent - opacity: visibilities.session && Config.session.enabled ? 0.5 : 0 - color: Colours.palette.m3scrim - - Behavior on opacity { - Anim {} - } - } - - Item { - anchors.fill: parent - opacity: Colours.transparency.enabled ? Colours.transparency.base : 1 - layer.enabled: true - layer.effect: MultiEffect { - shadowEnabled: true - blurMax: 15 - shadowColor: Qt.alpha(Colours.palette.m3shadow, Math.max(0, win.shadowOpacity)) - } - - Border { - bar: bar - borderThickness: win.borderThickness - borderRounding: win.borderRounding - } - - Backgrounds { - panels: panels - bar: bar - borderThickness: win.borderThickness - borderRounding: win.borderRounding - } - } - - DrawerVisibilities { - id: visibilities - - Component.onCompleted: Visibilities.load(scope.modelData, this) - } - - Interactions { - screen: scope.modelData - popouts: panels.popouts - visibilities: visibilities - panels: panels - bar: bar - borderThickness: win.borderLayoutThickness - fullscreen: win.hasFullscreen - - Panels { - id: panels - - screen: scope.modelData - visibilities: visibilities - bar: bar - borderThickness: win.borderLayoutThickness - } - - BarWrapper { - id: bar - - anchors.top: parent.top - anchors.bottom: parent.bottom - - screen: scope.modelData - visibilities: visibilities - popouts: panels.popouts - - disabled: scope.barDisabled - fullscreen: win.hasFullscreen - - Component.onCompleted: Visibilities.bars.set(scope.modelData, this) - } - } } } } diff --git a/modules/drawers/Interactions.qml b/modules/drawers/Interactions.qml index 4b670fca..ad17a0f2 100644 --- a/modules/drawers/Interactions.qml +++ b/modules/drawers/Interactions.qml @@ -42,11 +42,13 @@ CustomMouseArea { } function inTopPanel(panel: Item, x: real, y: real): bool { - return y < Math.max(Config.border.minThickness, Config.border.thickness + panel.height) + panel.y && withinPanelWidth(panel, x, y); + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property + return y < Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) && withinPanelWidth(panel, x, y); } function inBottomPanel(panel: Item, x: real, y: real, isCorner = false): bool { - return y > height - Math.max(Config.border.minThickness, Config.border.thickness + panel.height) - (isCorner ? Config.border.rounding : 0) && withinPanelWidth(panel, x, y); + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property + return y > height - Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) - (isCorner ? Config.border.rounding : 0) && withinPanelWidth(panel, x, y); } function onWheel(event: WheelEvent): void { @@ -107,9 +109,9 @@ CustomMouseArea { visibilities.bar = false; } - if (panels.sidebar.width === 0) { + if (panels.sidebar.offsetScale === 1) { // Show osd on hover - const showOsd = inRightPanel(panels.osd, x, y); + const showOsd = inRightPanel(panels.osdWrapper, x, y); // Always update visibility based on hover if not in shortcut mode if (!osdShortcutActive) { @@ -124,23 +126,23 @@ CustomMouseArea { const showSidebar = pressed && dragStart.x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x); // Show/hide session on drag - if (pressed && inRightPanel(panels.session, dragStart.x, dragStart.y) && withinPanelHeight(panels.session, x, y)) { + if (pressed && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { if (dragX < -Config.session.dragThreshold) visibilities.session = true; else if (dragX > Config.session.dragThreshold) visibilities.session = false; // Show sidebar on drag if in session area and session is nearly fully visible - if (showSidebar && panels.session.width >= panels.session.nonAnimWidth && dragX < -Config.sidebar.dragThreshold) + if (showSidebar && panels.session.offsetScale <= 0 && dragX < -Config.sidebar.dragThreshold) visibilities.sidebar = true; } else if (showSidebar && dragX < -Config.sidebar.dragThreshold) { // Show sidebar on drag if not in session area visibilities.sidebar = true; } } else { - const outOfSidebar = x < width - panels.sidebar.width; + const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); // Show osd on hover - const showOsd = outOfSidebar && inRightPanel(panels.osd, x, y); + const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); // Always update visibility based on hover if not in shortcut mode if (!osdShortcutActive) { @@ -153,7 +155,7 @@ CustomMouseArea { } // Show/hide session on drag - if (pressed && outOfSidebar && inRightPanel(panels.session, dragStart.x, dragStart.y) && withinPanelHeight(panels.session, x, y)) { + if (pressed && outOfSidebar && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { if (dragX < -Config.session.dragThreshold) visibilities.session = true; else if (dragX > Config.session.dragThreshold) @@ -209,7 +211,7 @@ CustomMouseArea { // Show popouts on hover if (x < bar.implicitWidth) { bar.checkPopout(y); - } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popouts, x, y)) { + } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) { popouts.hasCurrent = false; bar.closeTray(); } @@ -226,7 +228,7 @@ CustomMouseArea { // Also hide dashboard and OSD if they're not being hovered const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); - const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); if (!inDashboardArea) { root.visibilities.dashboard = false; @@ -254,7 +256,7 @@ CustomMouseArea { function onOsdChanged() { if (root.visibilities.osd) { // OSD became visible, immediately check if this should be shortcut mode - const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); if (!inOsdArea) { root.osdShortcutActive = true; } diff --git a/modules/drawers/Panels.qml b/modules/drawers/Panels.qml index 04b83c8d..0d567414 100644 --- a/modules/drawers/Panels.qml +++ b/modules/drawers/Panels.qml @@ -22,37 +22,43 @@ Item { required property real borderThickness readonly property alias osd: osd + readonly property alias osdWrapper: osdWrapper readonly property alias notifications: notifications readonly property alias session: session + readonly property alias sessionWrapper: sessionWrapper readonly property alias launcher: launcher readonly property alias dashboard: dashboard - readonly property alias popouts: popouts + readonly property alias popouts: popoutsWrapper.content + readonly property alias popoutsWrapper: popoutsWrapper readonly property alias utilities: utilities readonly property alias toasts: toasts readonly property alias sidebar: sidebar anchors.fill: parent - anchors.margins: root.borderThickness + anchors.margins: borderThickness anchors.leftMargin: bar.implicitWidth - Behavior on anchors.margins { - Anim {} - } - - Behavior on anchors.leftMargin { - Anim {} - } - - Osd.Wrapper { - id: osd - - clip: session.width > 0 || sidebar.width > 0 - screen: root.screen - visibilities: root.visibilities + Item { + id: osdWrapper anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right - anchors.rightMargin: session.width + sidebar.width + anchors.rightMargin: sessionWrapper.anchors.rightMargin + session.width * (1 - session.offsetScale) + clip: sidebar.visible || session.visible + + implicitWidth: osd.implicitWidth * (1 - osd.offsetScale) + implicitHeight: osd.implicitHeight + + Osd.Wrapper { + id: osd + + screen: root.screen + visibilities: root.visibilities + sidebarOrSessionVisible: sidebar.visible || session.visible + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + } } Notifications.Wrapper { @@ -60,23 +66,33 @@ Item { visibilities: root.visibilities sidebarPanel: sidebar - osdPanel: osd - sessionPanel: session + osdPanel: osdWrapper + sessionPanel: sessionWrapper anchors.top: parent.top anchors.right: parent.right } - Session.Wrapper { - id: session - - clip: sidebar.width > 0 - visibilities: root.visibilities - panels: root + Item { + id: sessionWrapper anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right - anchors.rightMargin: sidebar.width + anchors.rightMargin: sidebar.width * (1 - sidebar.offsetScale) + clip: sidebar.visible + + implicitWidth: session.implicitWidth * (1 - session.offsetScale) + implicitHeight: session.implicitHeight + + Session.Wrapper { + id: session + + visibilities: root.visibilities + sidebarVisible: sidebar.visible + + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + } } Launcher.Wrapper { @@ -99,22 +115,11 @@ Item { anchors.top: parent.top } - BarPopouts.Wrapper { - id: popouts + BarPopouts.ClipWrapper { + id: popoutsWrapper screen: root.screen - - x: isDetached ? (root.width - nonAnimWidth) / 2 : 0 - y: { - if (isDetached) - return (root.height - nonAnimHeight) / 2; - - const off = currentCenter - root.borderThickness - nonAnimHeight / 2; - const diff = root.height - Math.floor(off + nonAnimHeight); - if (diff < 0) - return off + diff; - return Math.max(off, 0); - } + borderThickness: root.borderThickness } Utilities.Wrapper { @@ -122,7 +127,7 @@ Item { visibilities: root.visibilities sidebar: sidebar - popouts: popouts + popouts: popoutsWrapper.content anchors.bottom: parent.bottom anchors.right: parent.right @@ -140,7 +145,6 @@ Item { id: sidebar visibilities: root.visibilities - panels: root anchors.top: notifications.bottom anchors.bottom: utilities.top diff --git a/modules/drawers/Regions.qml b/modules/drawers/Regions.qml new file mode 100644 index 00000000..d4281762 --- /dev/null +++ b/modules/drawers/Regions.qml @@ -0,0 +1,81 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.config +import qs.modules.bar as Bar + +Region { + id: root + + required property Bar.BarWrapper bar + required property Panels panels + required property var win + + x: bar.clampedWidth + win.dragMaskPadding + y: Config.border.clampedThickness + win.dragMaskPadding + width: win.width - bar.clampedWidth - Config.border.clampedThickness - win.dragMaskPadding * 2 + height: win.height - Config.border.clampedThickness * 2 - win.dragMaskPadding * 2 + intersection: Intersection.Xor + + R { + panel: root.panels.dashboard + y: 0 + height: panel.height * (1 - root.panels.dashboard.offsetScale) + Config.border.thickness + } + + R { + panel: root.panels.launcher + y: root.win.height - height + height: panel.height * (1 - root.panels.launcher.offsetScale) + Config.border.thickness + } + + R { + id: sessionRegion + + panel: root.panels.sessionWrapper + x: root.win.width - width + width: panel.width * (1 - root.panels.session.offsetScale) + Config.border.thickness + sidebarRegion.width + } + + R { + id: sidebarRegion + + panel: root.panels.sidebar + x: root.win.width - width + width: panel.width * (1 - root.panels.sidebar.offsetScale) + Config.border.thickness + } + + R { + panel: root.panels.osdWrapper + x: root.win.width - width + width: panel.width * (1 - root.panels.osd.offsetScale) + Config.border.thickness + sessionRegion.width + } + + R { + panel: root.panels.notifications + y: 0 + height: panel.height + Config.border.thickness + } + + R { + panel: root.panels.utilities + y: root.win.height - height + height: panel.height * (1 - root.panels.utilities.offsetScale) + Config.border.thickness + } + + R { + panel: root.panels.popoutsWrapper + width: panel.width * (1 - root.panels.popoutsWrapper.offsetScale) + } + + component R: Region { + required property Item panel + + x: panel.x + root.bar.implicitWidth + y: panel.y + Config.border.thickness + width: panel.width + height: panel.height + intersection: Intersection.Subtract + } +} diff --git a/modules/launcher/Background.qml b/modules/launcher/Background.qml deleted file mode 100644 index 508c75d4..00000000 --- a/modules/launcher/Background.qml +++ /dev/null @@ -1,60 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import qs.components -import qs.services -import qs.config - -ShapePath { - id: root - - required property Wrapper wrapper - readonly property real rounding: Config.border.rounding - readonly property bool flatten: wrapper.height < rounding * 2 - readonly property real roundingY: flatten ? wrapper.height / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: root.rounding - relativeY: -root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: 0 - relativeY: -(root.wrapper.height - root.roundingY * 2) - } - PathArc { - relativeX: root.rounding - relativeY: -root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - PathLine { - relativeX: root.wrapper.width - root.rounding * 2 - relativeY: 0 - } - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.roundingY * 2 - } - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/launcher/Wrapper.qml b/modules/launcher/Wrapper.qml index cc5e86c6..5dfffeae 100644 --- a/modules/launcher/Wrapper.qml +++ b/modules/launcher/Wrapper.qml @@ -4,6 +4,7 @@ import QtQuick import Quickshell import qs.components import qs.config +import qs.modules.launcher.services Item { id: root @@ -13,7 +14,6 @@ Item { required property var panels readonly property bool shouldBeActive: visibilities.launcher && Config.launcher.enabled - property int contentHeight readonly property real maxHeight: { let max = screen.height - Config.border.thickness * 2 - Appearance.spacing.large; @@ -22,91 +22,28 @@ Item { return max; } - onMaxHeightChanged: timer.start() - - visible: height > 0 - implicitHeight: 0 - implicitWidth: content.implicitWidth + property real offsetScale: shouldBeActive ? 0 : 1 onShouldBeActiveChanged: { - if (shouldBeActive) { - timer.stop(); - hideAnim.stop(); - showAnim.start(); - } else { - showAnim.stop(); - hideAnim.start(); - } + if (shouldBeActive) + implicitHeight = Qt.binding(() => content.implicitHeight); + else + implicitHeight = implicitHeight; // Break binding during close anim } - SequentialAnimation { - id: showAnim + visible: offsetScale < 1 + anchors.bottomMargin: (-implicitHeight - 5) * offsetScale + implicitHeight: content.implicitHeight + implicitWidth: content.implicitWidth || 630 // Hard coded fallback for first open + opacity: 1 - offsetScale + Component.onCompleted: Qt.callLater(() => Apps) // Load apps on init + + Behavior on offsetScale { Anim { - target: root - property: "implicitHeight" - to: root.contentHeight duration: Appearance.anim.durations.expressiveDefaultSpatial easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } - ScriptAction { - script: root.implicitHeight = Qt.binding(() => content.implicitHeight) - } - } - - SequentialAnimation { - id: hideAnim - - ScriptAction { - script: root.implicitHeight = root.implicitHeight - } - Anim { - target: root - property: "implicitHeight" - to: 0 - easing.bezierCurve: Appearance.anim.curves.emphasized - } - } - - Connections { - function onEnabledChanged(): void { - timer.start(); - } - - function onMaxShownChanged(): void { - timer.start(); - } - - target: Config.launcher - } - - Connections { - function onValuesChanged(): void { - if (DesktopEntries.applications.values.length < Config.launcher.maxShown) - timer.start(); - } - - target: DesktopEntries.applications - } - - Timer { - id: timer - - interval: Appearance.anim.durations.extraLarge - onRunningChanged: { - if (running && !root.shouldBeActive) { - content.visible = false; - content.active = true; - } else { - root.contentHeight = Math.min(root.maxHeight, content.implicitHeight); - content.active = Qt.binding(() => root.shouldBeActive || root.visible); - content.visible = true; - if (showAnim.running) { - showAnim.stop(); - showAnim.start(); - } - } - } } Loader { @@ -115,16 +52,12 @@ Item { anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter - visible: false - active: false - Component.onCompleted: timer.start() + active: root.shouldBeActive || root.visible sourceComponent: Content { visibilities: root.visibilities panels: root.panels maxHeight: root.maxHeight - - Component.onCompleted: root.contentHeight = implicitHeight } } } diff --git a/modules/notifications/Background.qml b/modules/notifications/Background.qml deleted file mode 100644 index 740cda10..00000000 --- a/modules/notifications/Background.qml +++ /dev/null @@ -1,53 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import qs.components -import qs.services - -ShapePath { - id: root - - required property Wrapper wrapper - required property var sidebar - required property real rounding - readonly property bool flatten: wrapper.height < rounding * 2 - readonly property real roundingY: flatten ? wrapper.height / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathLine { - relativeX: -(root.wrapper.width + root.rounding) - relativeY: 0 - } - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.roundingY * 2 - } - PathArc { - relativeX: root.rounding - relativeY: root.roundingY - radiusX: root.rounding - radiusY: Math.min(root.rounding, root.wrapper.height) - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding * 2 : root.wrapper.width - relativeY: 0 - } - PathArc { - relativeX: root.rounding - relativeY: root.rounding - radiusX: root.rounding - radiusY: root.rounding - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/notifications/Wrapper.qml b/modules/notifications/Wrapper.qml index 2b581e93..97417e9c 100644 --- a/modules/notifications/Wrapper.qml +++ b/modules/notifications/Wrapper.qml @@ -1,6 +1,5 @@ import QtQuick import qs.components -import qs.config Item { id: root @@ -11,27 +10,10 @@ Item { property alias sessionPanel: content.sessionPanel visible: height > 0 + anchors.topMargin: -5 implicitWidth: Math.max(sidebarPanel.width, content.implicitWidth) implicitHeight: content.implicitHeight - states: State { - name: "hidden" - when: root.visibilities.sidebar && Config.sidebar.enabled - - PropertyChanges { - root.implicitHeight: 0 - } - } - - transitions: Transition { - Anim { - target: root - property: "implicitHeight" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - } - Content { id: content diff --git a/modules/osd/Background.qml b/modules/osd/Background.qml deleted file mode 100644 index 330c703e..00000000 --- a/modules/osd/Background.qml +++ /dev/null @@ -1,59 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import qs.components -import qs.services - -ShapePath { - id: root - - required property Wrapper wrapper - required property real rounding - readonly property bool flatten: wrapper.width < rounding * 2 - readonly property real roundingX: flatten ? wrapper.width / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - PathLine { - relativeX: -(root.wrapper.width - root.roundingX * 2) - relativeY: 0 - } - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.rounding * 2 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: root.wrapper.width - root.roundingX * 2 - relativeY: 0 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/osd/Wrapper.qml b/modules/osd/Wrapper.qml index 939b57de..487b611f 100644 --- a/modules/osd/Wrapper.qml +++ b/modules/osd/Wrapper.qml @@ -11,9 +11,13 @@ Item { required property ShellScreen screen required property DrawerVisibilities visibilities + required property bool sidebarOrSessionVisible + property bool hovered readonly property Brightness.Monitor monitor: Brightness.getMonitorForScreen(root.screen) readonly property bool shouldBeActive: visibilities.osd && Config.osd.enabled && !(visibilities.utilities && Config.utilities.enabled) + property real offsetScale: shouldBeActive ? 0 : 1 + property real sidebarOffset: sidebarOrSessionVisible ? 12 : 0 property real volume property bool muted @@ -34,42 +38,19 @@ Item { brightness = root.monitor?.brightness ?? 0; } - visible: width > 0 - implicitWidth: 0 + visible: offsetScale < 1 + anchors.rightMargin: (-implicitWidth - 5 - sidebarOffset) * offsetScale + implicitWidth: content.implicitWidth implicitHeight: content.implicitHeight + opacity: 1 - offsetScale - states: State { - name: "visible" - when: root.shouldBeActive - - PropertyChanges { - root.implicitWidth: content.implicitWidth + Behavior on offsetScale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } - transitions: [ - Transition { - from: "" - to: "visible" - - Anim { - target: root - property: "implicitWidth" - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - }, - Transition { - from: "visible" - to: "" - - Anim { - target: root - property: "implicitWidth" - easing.bezierCurve: Appearance.anim.curves.emphasized - } - } - ] - Connections { function onMutedChanged(): void { root.show(); @@ -119,7 +100,8 @@ Item { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - Component.onCompleted: active = Qt.binding(() => root.shouldBeActive || root.visible) + asynchronous: true + active: root.shouldBeActive || root.visible sourceComponent: Content { monitor: root.monitor diff --git a/modules/session/Background.qml b/modules/session/Background.qml deleted file mode 100644 index a609f460..00000000 --- a/modules/session/Background.qml +++ /dev/null @@ -1,60 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import qs.components -import qs.services -import qs.config - -ShapePath { - id: root - - required property Wrapper wrapper - readonly property real rounding: Config.border.rounding - readonly property bool flatten: wrapper.width < rounding * 2 - readonly property real roundingX: flatten ? wrapper.width / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - PathLine { - relativeX: -(root.wrapper.width - root.roundingX * 2) - relativeY: 0 - } - PathArc { - relativeX: -root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.rounding * 2 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - direction: PathArc.Counterclockwise - } - PathLine { - relativeX: root.wrapper.width - root.roundingX * 2 - relativeY: 0 - } - PathArc { - relativeX: root.roundingX - relativeY: root.rounding - radiusX: Math.min(root.rounding, root.wrapper.width) - radiusY: root.rounding - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/session/Wrapper.qml b/modules/session/Wrapper.qml index 2924f776..0d2d3b42 100644 --- a/modules/session/Wrapper.qml +++ b/modules/session/Wrapper.qml @@ -8,52 +8,33 @@ Item { id: root required property DrawerVisibilities visibilities - required property var panels + required property bool sidebarVisible readonly property real nonAnimWidth: content.implicitWidth - visible: width > 0 - implicitWidth: 0 - implicitHeight: content.implicitHeight + readonly property bool shouldBeActive: visibilities.session && Config.session.enabled + property real offsetScale: shouldBeActive ? 0 : 1 + property real sidebarOffset: sidebarVisible ? 14 : 0 - states: State { - name: "visible" - when: root.visibilities.session && Config.session.enabled + visible: offsetScale < 1 + anchors.rightMargin: (-implicitWidth - 5 - sidebarOffset) * offsetScale + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight || 510 // Hard coded fallback for first open + opacity: 1 - offsetScale - PropertyChanges { - root.implicitWidth: root.nonAnimWidth + Behavior on offsetScale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } - transitions: [ - Transition { - from: "" - to: "visible" - - Anim { - target: root - property: "implicitWidth" - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - }, - Transition { - from: "visible" - to: "" - - Anim { - target: root - property: "implicitWidth" - easing.bezierCurve: root.panels.osd.width > 0 ? Appearance.anim.curves.expressiveDefaultSpatial : Appearance.anim.curves.emphasized - } - } - ] - Loader { id: content anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left - Component.onCompleted: active = Qt.binding(() => (root.visibilities.session && Config.session.enabled) || root.visible) + active: root.shouldBeActive || root.visible sourceComponent: Content { visibilities: root.visibilities diff --git a/modules/sidebar/Background.qml b/modules/sidebar/Background.qml deleted file mode 100644 index c7d42123..00000000 --- a/modules/sidebar/Background.qml +++ /dev/null @@ -1,50 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import qs.components -import qs.services - -ShapePath { - id: root - - required property Wrapper wrapper - required property var panels - required property real rounding - - readonly property real notifsWidthDiff: panels.notifications.width - wrapper.width - readonly property real notifsRoundingX: panels.notifications.height > 0 && notifsWidthDiff < rounding * 2 ? notifsWidthDiff / 2 : rounding - - readonly property real utilsWidthDiff: panels.utilities.width - wrapper.width - readonly property real utilsRoundingX: utilsWidthDiff < rounding * 2 ? utilsWidthDiff / 2 : rounding - - strokeWidth: -1 - fillColor: Colours.palette.m3surface - - PathLine { - relativeX: -root.wrapper.width - root.notifsRoundingX - relativeY: 0 - } - PathArc { - relativeX: root.notifsRoundingX - relativeY: root.rounding - radiusX: root.notifsRoundingX - radiusY: root.rounding - } - PathLine { - relativeX: 0 - relativeY: root.wrapper.height - root.rounding * 2 - } - PathArc { - relativeX: -root.utilsRoundingX - relativeY: root.rounding - radiusX: root.utilsRoundingX - radiusY: root.rounding - } - PathLine { - relativeX: root.wrapper.width + root.utilsRoundingX - relativeY: 0 - } - - Behavior on fillColor { - CAnim {} - } -} diff --git a/modules/sidebar/Wrapper.qml b/modules/sidebar/Wrapper.qml index ad256413..eefd11a0 100644 --- a/modules/sidebar/Wrapper.qml +++ b/modules/sidebar/Wrapper.qml @@ -8,45 +8,23 @@ Item { id: root required property DrawerVisibilities visibilities - required property var panels readonly property Props props: Props {} - visible: width > 0 - implicitWidth: 0 + readonly property bool shouldBeActive: visibilities.sidebar && Config.sidebar.enabled + property real offsetScale: shouldBeActive ? 0 : 1 - states: State { - name: "visible" - when: root.visibilities.sidebar && Config.sidebar.enabled + visible: offsetScale < 1 + anchors.rightMargin: (-implicitWidth - 5) * offsetScale + implicitWidth: Config.sidebar.sizes.width + opacity: 1 - offsetScale - PropertyChanges { - root.implicitWidth: Config.sidebar.sizes.width + Behavior on offsetScale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } - transitions: [ - Transition { - from: "" - to: "visible" - - Anim { - target: root - property: "implicitWidth" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial - } - }, - Transition { - from: "visible" - to: "" - - Anim { - target: root - property: "implicitWidth" - easing.bezierCurve: root.panels.osd.width > 0 || root.panels.session.width > 0 ? Appearance.anim.curves.expressiveDefaultSpatial : Appearance.anim.curves.emphasized - } - } - ] - Loader { id: content @@ -56,8 +34,7 @@ Item { anchors.margins: Appearance.padding.large anchors.bottomMargin: 0 - active: true - Component.onCompleted: active = Qt.binding(() => (root.visibilities.sidebar && Config.sidebar.enabled) || root.visible) + active: root.shouldBeActive || root.visible sourceComponent: Content { implicitWidth: Config.sidebar.sizes.width - Appearance.padding.large * 2 diff --git a/modules/utilities/Wrapper.qml b/modules/utilities/Wrapper.qml index 66a616f0..cb4bcaa0 100644 --- a/modules/utilities/Wrapper.qml +++ b/modules/utilities/Wrapper.qml @@ -4,14 +4,16 @@ import QtQuick import Quickshell import qs.components import qs.config +import qs.modules.sidebar as Sidebar import qs.modules.bar.popouts as BarPopouts Item { id: root required property DrawerVisibilities visibilities - required property Item sidebar + required property Sidebar.Wrapper sidebar required property BarPopouts.Wrapper popouts + property real horizontalStretch readonly property PersistentProperties props: PersistentProperties { property bool recordingListExpanded: false @@ -21,75 +23,64 @@ Item { reloadableId: "utilities" } readonly property bool shouldBeActive: visibilities.sidebar || (visibilities.utilities && Config.utilities.enabled && !(visibilities.session && Config.session.enabled)) + property real offsetScale: shouldBeActive ? 0 : 1 + property real sidebarLerp - visible: height > 0 - implicitHeight: 0 - implicitWidth: sidebar.visible ? sidebar.width : Config.utilities.sizes.width - - onStateChanged: { - if (state === "visible" && timer.running) { - timer.triggered(); - timer.stop(); - } - } + visible: offsetScale < 1 + anchors.bottomMargin: (-implicitHeight - 5) * offsetScale + implicitHeight: content.implicitHeight + content.anchors.margins * 2 + implicitWidth: sidebar.width * (1 - sidebar.offsetScale) * horizontalStretch * sidebarLerp + Config.utilities.sizes.width * (1 - sidebarLerp) + opacity: 1 - offsetScale states: State { - name: "visible" - when: root.shouldBeActive + name: "attachedToSidebar" + when: root.visibilities.sidebar PropertyChanges { - root.implicitHeight: content.implicitHeight + Appearance.padding.large * 2 + root.sidebarLerp: 1 } } transitions: [ Transition { from: "" - to: "visible" Anim { - target: root - property: "implicitHeight" - duration: Appearance.anim.durations.expressiveDefaultSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + property: "sidebarLerp" + duration: Appearance.anim.durations.expressiveDefaultSpatial / 2 + easing.bezierCurve: Appearance.anim.curves.standardAccel } }, Transition { - from: "visible" to: "" Anim { - target: root - property: "implicitHeight" - easing.bezierCurve: Appearance.anim.curves.emphasized + property: "sidebarLerp" + duration: Appearance.anim.durations.expressiveDefaultSpatial / 2 + easing.bezierCurve: Appearance.anim.curves.standardDecel } } ] - Timer { - id: timer - - running: true - interval: Appearance.anim.durations.extraLarge - onTriggered: { - content.active = Qt.binding(() => root.shouldBeActive || root.visible); - content.visible = true; + Behavior on offsetScale { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } Loader { id: content - asynchronous: true anchors.top: parent.top anchors.left: parent.left anchors.margins: Appearance.padding.large - visible: false - active: true + asynchronous: true + active: root.shouldBeActive || root.visible sourceComponent: Content { - implicitWidth: root.implicitWidth - Appearance.padding.large * 2 + implicitWidth: root.implicitWidth - content.anchors.margins * 2 props: root.props visibilities: root.visibilities popouts: root.popouts diff --git a/modules/windowinfo/Buttons.qml b/modules/windowinfo/Buttons.qml index 15f71956..3f0bf4de 100644 --- a/modules/windowinfo/Buttons.qml +++ b/modules/windowinfo/Buttons.qml @@ -120,7 +120,7 @@ ColumnLayout { Loader { asynchronous: true - active: root.client?.lastIpcObject.floating + active: root.client?.lastIpcObject.floating ?? false Layout.fillWidth: active Layout.leftMargin: active ? 0 : -parent.spacing Layout.rightMargin: active ? 0 : -parent.spacing diff --git a/plugin/src/Caelestia/Blobs/CMakeLists.txt b/plugin/src/Caelestia/Blobs/CMakeLists.txt new file mode 100644 index 00000000..f44be008 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/CMakeLists.txt @@ -0,0 +1,19 @@ +qml_module(caelestia-blobs + URI Caelestia.Blobs + SOURCES + blobgroup.cpp + blobshape.cpp + blobrect.cpp + blobinvertedrect.cpp + blobmaterial.cpp + LIBRARIES + Qt::Quick +) + +qt_add_shaders(caelestia-blobs "blob_shaders" + BATCHABLE OPTIMIZED NOHLSL NOMSL + PREFIX "/" + FILES + shaders/blob.frag + shaders/blob.vert +) diff --git a/plugin/src/Caelestia/Blobs/blobgroup.cpp b/plugin/src/Caelestia/Blobs/blobgroup.cpp new file mode 100644 index 00000000..a4703c86 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobgroup.cpp @@ -0,0 +1,104 @@ +#include "blobgroup.hpp" +#include "blobinvertedrect.hpp" +#include "blobshape.hpp" + +BlobGroup::BlobGroup(QObject* parent) + : QObject(parent) {} + +BlobGroup::~BlobGroup() { + for (auto* shape : std::as_const(m_shapes)) + shape->m_group = nullptr; + if (m_invertedRect) + static_cast(m_invertedRect)->m_group = nullptr; +} + +void BlobGroup::setSmoothing(qreal s) { + if (qFuzzyCompare(m_smoothing, s)) + return; + m_smoothing = s; + emit smoothingChanged(); + markDirty(); +} + +void BlobGroup::setColor(const QColor& c) { + if (m_color == c) + return; + m_color = c; + emit colorChanged(); + markDirty(); +} + +void BlobGroup::addShape(BlobShape* shape) { + if (!shape || m_shapes.contains(shape)) + return; + m_shapes.append(shape); + markDirty(); +} + +void BlobGroup::removeShape(BlobShape* shape) { + m_shapes.removeOne(shape); + markDirty(); +} + +void BlobGroup::setInvertedRect(BlobInvertedRect* rect) { + if (m_invertedRect == rect) + return; + m_invertedRect = rect; + markDirty(); +} + +void BlobGroup::clearInvertedRect(BlobInvertedRect* rect) { + if (m_invertedRect != rect) + return; + m_invertedRect = nullptr; + markDirty(); +} + +void BlobGroup::markDirty() { + m_physicsUpdated = false; + for (auto* shape : std::as_const(m_shapes)) { + shape->polish(); + shape->update(); + } + if (m_invertedRect) { + static_cast(m_invertedRect)->polish(); + static_cast(m_invertedRect)->update(); + } +} + +void BlobGroup::markShapeDirty(BlobShape* source) { + m_physicsUpdated = false; + + source->polish(); + source->update(); + + // Use cached padded rects to find spatial neighbors + const float pad = static_cast(m_smoothing) * 2.0f; + const QRectF srcRect(static_cast(source->m_cachedPaddedX - pad), + static_cast(source->m_cachedPaddedY - pad), static_cast(source->m_cachedPaddedW + pad * 2.0f), + static_cast(source->m_cachedPaddedH + pad * 2.0f)); + + for (auto* shape : std::as_const(m_shapes)) { + if (shape == source) + continue; + const QRectF otherRect(static_cast(shape->m_cachedPaddedX), static_cast(shape->m_cachedPaddedY), + static_cast(shape->m_cachedPaddedW), static_cast(shape->m_cachedPaddedH)); + if (srcRect.intersects(otherRect)) { + shape->polish(); + shape->update(); + } + } + + if (m_invertedRect && static_cast(m_invertedRect) != source) { + static_cast(m_invertedRect)->polish(); + static_cast(m_invertedRect)->update(); + } +} + +void BlobGroup::ensurePhysicsUpdated() { + if (m_physicsUpdated) + return; + m_physicsUpdated = true; + for (auto* shape : std::as_const(m_shapes)) + shape->updatePhysics(); +} diff --git a/plugin/src/Caelestia/Blobs/blobgroup.hpp b/plugin/src/Caelestia/Blobs/blobgroup.hpp new file mode 100644 index 00000000..e09125a9 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobgroup.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +class BlobShape; +class BlobInvertedRect; + +class BlobGroup : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(qreal smoothing READ smoothing WRITE setSmoothing NOTIFY smoothingChanged) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged) + +public: + explicit BlobGroup(QObject* parent = nullptr); + ~BlobGroup() override; + + qreal smoothing() const { return m_smoothing; } + + void setSmoothing(qreal s); + + QColor color() const { return m_color; } + + void setColor(const QColor& c); + + void addShape(BlobShape* shape); + void removeShape(BlobShape* shape); + + void setInvertedRect(BlobInvertedRect* rect); + void clearInvertedRect(BlobInvertedRect* rect); + + const QList& shapes() const { return m_shapes; } + + BlobInvertedRect* invertedRect() const { return m_invertedRect; } + + void markDirty(); + void markShapeDirty(BlobShape* source); + void ensurePhysicsUpdated(); + +signals: + void smoothingChanged(); + void colorChanged(); + +private: + qreal m_smoothing = 32.0; + QColor m_color{ 0x44, 0x88, 0xff }; + QList m_shapes; + BlobInvertedRect* m_invertedRect = nullptr; + bool m_physicsUpdated = false; +}; diff --git a/plugin/src/Caelestia/Blobs/blobinvertedrect.cpp b/plugin/src/Caelestia/Blobs/blobinvertedrect.cpp new file mode 100644 index 00000000..46ee73a4 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobinvertedrect.cpp @@ -0,0 +1,184 @@ +#include "blobinvertedrect.hpp" +#include "blobgroup.hpp" +#include "blobmaterial.hpp" + +#include +#include + +#include +#include + +BlobInvertedRect::BlobInvertedRect(QQuickItem* parent) + : BlobShape(parent) {} + +static void setFrameIndices(quint16* idx) { + // Top strip: 0-1-4, 1-5-4 + idx[0] = 0; + idx[1] = 1; + idx[2] = 4; + idx[3] = 1; + idx[4] = 5; + idx[5] = 4; + // Right strip: 1-2-5, 2-6-5 + idx[6] = 1; + idx[7] = 2; + idx[8] = 5; + idx[9] = 2; + idx[10] = 6; + idx[11] = 5; + // Bottom strip: 2-3-6, 3-7-6 + idx[12] = 2; + idx[13] = 3; + idx[14] = 6; + idx[15] = 3; + idx[16] = 7; + idx[17] = 6; + // Left strip: 3-0-7, 0-4-7 + idx[18] = 3; + idx[19] = 0; + idx[20] = 7; + idx[21] = 0; + idx[22] = 4; + idx[23] = 7; +} + +QSGNode* BlobInvertedRect::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { + if (!m_group) { + delete oldNode; + return nullptr; + } + + const float pad = static_cast(m_group->smoothing()); + + // Compute inner hole boundary in local coords + // Inset past the inner border edge by 2x smoothing to cover the blend zone + const float inset = pad * 2.0f; + const float holeLeft = static_cast(m_borderLeft) + inset; + const float holeTop = static_cast(m_borderTop) + inset; + const float holeRight = static_cast(width() - m_borderRight) - inset; + const float holeBot = static_cast(height() - m_borderBottom) - inset; + + // If the hole is too small or invalid, fall back to full quad + if (holeLeft >= holeRight || holeTop >= holeBot) + return BlobShape::updatePaintNode(oldNode, nullptr); + + auto* node = static_cast(oldNode); + + const bool needsRebuild = !node || node->geometry()->vertexCount() != 8; + + if (needsRebuild) { + delete oldNode; + node = new QSGGeometryNode; + + auto* geometry = + new QSGGeometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 8, 24, QSGGeometry::UnsignedShortType); + geometry->setDrawingMode(QSGGeometry::DrawTriangles); + node->setGeometry(geometry); + node->setFlag(QSGNode::OwnsGeometry); + + setFrameIndices(geometry->indexDataAsUShort()); + + auto* material = new BlobMaterial; + material->setFlag(QSGMaterial::Blending); + node->setMaterial(material); + node->setFlag(QSGNode::OwnsMaterial); + } + + // Outer bounds (local coords) + const float x0 = static_cast(m_localPaddedRect.x()); + const float y0 = static_cast(m_localPaddedRect.y()); + const float x1 = x0 + static_cast(m_localPaddedRect.width()); + const float y1 = y0 + static_cast(m_localPaddedRect.height()); + const float w = x1 - x0; + const float h = y1 - y0; + + // Update vertex positions and texture coordinates + auto* v = node->geometry()->vertexDataAsTexturedPoint2D(); + + // Outer corners + v[0].set(x0, y0, 0.0f, 0.0f); + v[1].set(x1, y0, 1.0f, 0.0f); + v[2].set(x1, y1, 1.0f, 1.0f); + v[3].set(x0, y1, 0.0f, 1.0f); + // Inner corners (hole) + v[4].set(holeLeft, holeTop, (holeLeft - x0) / w, (holeTop - y0) / h); + v[5].set(holeRight, holeTop, (holeRight - x0) / w, (holeTop - y0) / h); + v[6].set(holeRight, holeBot, (holeRight - x0) / w, (holeBot - y0) / h); + v[7].set(holeLeft, holeBot, (holeLeft - x0) / w, (holeBot - y0) / h); + + node->markDirty(QSGNode::DirtyGeometry); + + // Update material uniforms + auto* material = static_cast(node->material()); + material->m_paddedX = m_cachedPaddedX; + material->m_paddedY = m_cachedPaddedY; + material->m_paddedW = m_cachedPaddedW; + material->m_paddedH = m_cachedPaddedH; + material->m_smoothFactor = pad; + material->m_myIndex = m_cachedMyIndex; + material->m_color = m_group->color(); + material->m_hasInverted = m_cachedHasInverted ? 1 : 0; + material->m_invertedRadius = m_cachedInvertedRadius; + memcpy(material->m_invertedOuter, m_cachedInvertedOuter, sizeof(m_cachedInvertedOuter)); + memcpy(material->m_invertedInner, m_cachedInvertedInner, sizeof(m_cachedInvertedInner)); + + const int count = static_cast(qMin(m_cachedRects.size(), qsizetype(16))); + material->m_rectCount = count; + for (int i = 0; i < count; ++i) + material->m_rects[i] = m_cachedRects[i]; + + node->markDirty(QSGNode::DirtyMaterial); + + return node; +} + +BlobInvertedRect::~BlobInvertedRect() { + if (m_group) + m_group->clearInvertedRect(this); +} + +void BlobInvertedRect::setBorderLeft(qreal v) { + if (qFuzzyCompare(m_borderLeft, v)) + return; + m_borderLeft = v; + emit borderLeftChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobInvertedRect::setBorderRight(qreal v) { + if (qFuzzyCompare(m_borderRight, v)) + return; + m_borderRight = v; + emit borderRightChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobInvertedRect::setBorderTop(qreal v) { + if (qFuzzyCompare(m_borderTop, v)) + return; + m_borderTop = v; + emit borderTopChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobInvertedRect::setBorderBottom(qreal v) { + if (qFuzzyCompare(m_borderBottom, v)) + return; + m_borderBottom = v; + emit borderBottomChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobInvertedRect::registerWithGroup() { + if (m_group) + m_group->setInvertedRect(this); +} + +void BlobInvertedRect::unregisterFromGroup() { + if (m_group) + m_group->clearInvertedRect(this); +} diff --git a/plugin/src/Caelestia/Blobs/blobinvertedrect.hpp b/plugin/src/Caelestia/Blobs/blobinvertedrect.hpp new file mode 100644 index 00000000..f7fa6c0a --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobinvertedrect.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include "blobshape.hpp" + +#include + +class BlobInvertedRect : public BlobShape { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(qreal borderLeft READ borderLeft WRITE setBorderLeft NOTIFY borderLeftChanged) + Q_PROPERTY(qreal borderRight READ borderRight WRITE setBorderRight NOTIFY borderRightChanged) + Q_PROPERTY(qreal borderTop READ borderTop WRITE setBorderTop NOTIFY borderTopChanged) + Q_PROPERTY(qreal borderBottom READ borderBottom WRITE setBorderBottom NOTIFY borderBottomChanged) + +public: + explicit BlobInvertedRect(QQuickItem* parent = nullptr); + ~BlobInvertedRect() override; + + qreal borderLeft() const { return m_borderLeft; } + + void setBorderLeft(qreal v); + + qreal borderRight() const { return m_borderRight; } + + void setBorderRight(qreal v); + + qreal borderTop() const { return m_borderTop; } + + void setBorderTop(qreal v); + + qreal borderBottom() const { return m_borderBottom; } + + void setBorderBottom(qreal v); + +signals: + void borderLeftChanged(); + void borderRightChanged(); + void borderTopChanged(); + void borderBottomChanged(); + +protected: + bool isInvertedRect() const override { return true; } + + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override; + + void registerWithGroup() override; + void unregisterFromGroup() override; + +private: + qreal m_borderLeft = 0; + qreal m_borderRight = 0; + qreal m_borderTop = 0; + qreal m_borderBottom = 0; +}; diff --git a/plugin/src/Caelestia/Blobs/blobmaterial.cpp b/plugin/src/Caelestia/Blobs/blobmaterial.cpp new file mode 100644 index 00000000..f71ad1bd --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobmaterial.cpp @@ -0,0 +1,96 @@ +#include "blobmaterial.hpp" + +#include + +QSGMaterialType* BlobMaterial::type() const { + static QSGMaterialType s_type; + return &s_type; +} + +QSGMaterialShader* BlobMaterial::createShader(QSGRendererInterface::RenderMode) const { + return new BlobMaterialShader; +} + +int BlobMaterial::compare(const QSGMaterial* other) const { + if (this < other) + return -1; + if (this > other) + return 1; + return 0; +} + +BlobMaterialShader::BlobMaterialShader() { + setShaderFileName(VertexStage, QStringLiteral(":/shaders/blob.vert.qsb")); + setShaderFileName(FragmentStage, QStringLiteral(":/shaders/blob.frag.qsb")); +} + +bool BlobMaterialShader::updateUniformData(RenderState& state, QSGMaterial* newMaterial, QSGMaterial* oldMaterial) { + Q_UNUSED(oldMaterial); + auto* mat = static_cast(newMaterial); + QByteArray* buf = state.uniformData(); + Q_ASSERT(buf->size() >= 1440); + + if (state.isMatrixDirty()) { + const QMatrix4x4 m = state.combinedMatrix(); + memcpy(buf->data(), m.constData(), 64); + } + if (state.isOpacityDirty()) { + const float opacity = state.opacity(); + memcpy(buf->data() + 64, &opacity, 4); + } + + // Padded rect (offset 68) + memcpy(buf->data() + 68, &mat->m_paddedX, 4); + memcpy(buf->data() + 72, &mat->m_paddedY, 4); + memcpy(buf->data() + 76, &mat->m_paddedW, 4); + memcpy(buf->data() + 80, &mat->m_paddedH, 4); + + // Smooth factor (offset 84) + memcpy(buf->data() + 84, &mat->m_smoothFactor, 4); + + // Rect count (offset 88) + memcpy(buf->data() + 88, &mat->m_rectCount, 4); + + // My index (offset 92) + memcpy(buf->data() + 92, &mat->m_myIndex, 4); + + // Color as vec4 (offset 96, 16 bytes) + const float color[4] = { + static_cast(mat->m_color.redF()), + static_cast(mat->m_color.greenF()), + static_cast(mat->m_color.blueF()), + static_cast(mat->m_color.alphaF()), + }; + memcpy(buf->data() + 96, color, 16); + + // Has inverted (offset 112) + memcpy(buf->data() + 112, &mat->m_hasInverted, 4); + + // Inverted radius (offset 116) + memcpy(buf->data() + 116, &mat->m_invertedRadius, 4); + + // Padding at 120-127 (skip) + + // Inverted outer (offset 128, 16 bytes) + memcpy(buf->data() + 128, mat->m_invertedOuter, 16); + + // Inverted inner (offset 144, 16 bytes) + memcpy(buf->data() + 144, mat->m_invertedInner, 16); + + // Rect data (offset 160, each rect = 5 vec4s = 80 bytes) + const int count = qMin(mat->m_rectCount, 16); + for (int i = 0; i < count; ++i) { + const auto& r = mat->m_rects[i]; + const int base = 160 + i * 80; + const float d0[4] = { r.cx, r.cy, r.hw, r.hh }; + const float d1[4] = { 0.0f, r.offsetX, r.offsetY, r.minEig }; + const float d3[4] = { r.screenHalfX, r.screenHalfY, 0.0f, 0.0f }; + memcpy(buf->data() + base, d0, 16); + memcpy(buf->data() + base + 16, d1, 16); + memcpy(buf->data() + base + 32, r.invDeform, 16); + memcpy(buf->data() + base + 48, d3, 16); + memcpy(buf->data() + base + 64, r.radius, 16); + } + + return true; +} diff --git a/plugin/src/Caelestia/Blobs/blobmaterial.hpp b/plugin/src/Caelestia/Blobs/blobmaterial.hpp new file mode 100644 index 00000000..85dbedaa --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobmaterial.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +struct BlobRectData { + float cx = 0, cy = 0, hw = 0, hh = 0; + float offsetX = 0, offsetY = 0; + float minEig = 1.0f; + // Inverse of 2x2 deformation matrix, column-major for GLSL + float invDeform[4] = { 1, 0, 0, 1 }; + // Screen-space AABB half-extents of the deformed rect + float screenHalfX = 0, screenHalfY = 0; + // Effective per-corner radii (tr, br, bl, tl), pre-computed on CPU + float radius[4] = { 0, 0, 0, 0 }; +}; + +class BlobMaterial : public QSGMaterial { +public: + QSGMaterialType* type() const override; + QSGMaterialShader* createShader(QSGRendererInterface::RenderMode) const override; + int compare(const QSGMaterial* other) const override; + + float m_paddedX = 0; + float m_paddedY = 0; + float m_paddedW = 0; + float m_paddedH = 0; + float m_smoothFactor = 32.0f; + int m_rectCount = 0; + int m_myIndex = -2; + QColor m_color{ 0x44, 0x88, 0xff }; + int m_hasInverted = 0; + float m_invertedRadius = 0; + float m_invertedOuter[4] = {}; + float m_invertedInner[4] = {}; + BlobRectData m_rects[16] = {}; +}; + +class BlobMaterialShader : public QSGMaterialShader { +public: + BlobMaterialShader(); + bool updateUniformData(RenderState& state, QSGMaterial* newMaterial, QSGMaterial* oldMaterial) override; +}; diff --git a/plugin/src/Caelestia/Blobs/blobrect.cpp b/plugin/src/Caelestia/Blobs/blobrect.cpp new file mode 100644 index 00000000..fa7b4b1e --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobrect.cpp @@ -0,0 +1,244 @@ +#include "blobrect.hpp" +#include "blobgroup.hpp" + +#include +#include + +BlobRect::BlobRect(QQuickItem* parent) + : BlobShape(parent) {} + +BlobRect::~BlobRect() { + if (m_group) + m_group->removeShape(this); +} + +void BlobRect::updatePolish() { + BlobShape::updatePolish(); + + if (m_physicsActive) { + // Check if deformation is visually imperceptible + float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f); + float totalVel = std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + + if (totalDelta < 0.004f && totalVel < 0.05f) { + // Snap to rest, no visible deformation + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_deformMatrix = QMatrix4x4(); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + m_physicsActive = false; + } else { + QMetaObject::invokeMethod( + this, + [this]() { + if (m_physicsActive && m_group) + m_group->markDirty(); + }, + Qt::QueuedConnection); + } + } +} + +void BlobRect::updatePhysics() { + const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0)); + + if (!m_hasPrevPos) { + m_prevScenePos = scenePos; + m_elapsed.start(); + m_hasPrevPos = true; + return; + } + + const float dt = static_cast(m_elapsed.restart()) / 1000.0f; + if (dt > 0.1f || dt < 0.001f) { + m_prevScenePos = scenePos; + // Still check atRest on skipped frames to avoid getting stuck + if (m_physicsActive) + checkAtRest(0.0f); + return; + } + + const float velX = static_cast(scenePos.x() - m_prevScenePos.x()) / dt; + const float velY = static_cast(scenePos.y() - m_prevScenePos.y()) / dt; + m_prevScenePos = scenePos; + + const float speed = std::sqrt(velX * velX + velY * velY); + + if (!m_physicsActive) { + if (speed < 5.0f) + return; + m_physicsActive = true; + } + + // Compute target deformation matrix from velocity + // R(θ) * diag(stretch, compress) * R(θ)^T + const float kStretchFactor = static_cast(m_deformScale); + constexpr float kMaxStretch = 0.35f; + + float target00 = 1.0f; + float target01 = 0.0f; + float target11 = 1.0f; + + if (speed > 5.0f) { + const float targetStretch = 1.0f + std::min(speed * kStretchFactor, kMaxStretch); + const float targetCompress = 1.0f / targetStretch; + + const float cosA = velX / speed; + const float sinA = velY / speed; + const float cos2 = cosA * cosA; + const float sin2 = sinA * sinA; + const float cs = cosA * sinA; + + target00 = targetStretch * cos2 + targetCompress * sin2; + target01 = (targetStretch - targetCompress) * cs; + target11 = targetStretch * sin2 + targetCompress * cos2; + } + + // Underdamped spring on each matrix component + const float kStiffness = static_cast(m_stiffness); + const float kDamping = static_cast(m_damping); + + const float accel00 = -kStiffness * (m_dm00 - target00) - kDamping * m_dmVel00; + m_dmVel00 += accel00 * dt; + m_dm00 += m_dmVel00 * dt; + + const float accel01 = -kStiffness * (m_dm01 - target01) - kDamping * m_dmVel01; + m_dmVel01 += accel01 * dt; + m_dm01 += m_dmVel01 * dt; + + const float accel11 = -kStiffness * (m_dm11 - target11) - kDamping * m_dmVel11; + m_dmVel11 += accel11 * dt; + m_dm11 += m_dmVel11 * dt; + + m_deformMatrix = QMatrix4x4(m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + checkAtRest(speed); +} + +void BlobRect::setTopLeftRadius(qreal r) { + if (!qFuzzyCompare(m_topLeftRadius, r)) { + m_topLeftRadius = r; + emit topLeftRadiusChanged(); + if (m_group) + m_group->markDirty(); + } +} + +void BlobRect::setTopRightRadius(qreal r) { + if (!qFuzzyCompare(m_topRightRadius, r)) { + m_topRightRadius = r; + emit topRightRadiusChanged(); + if (m_group) + m_group->markDirty(); + } +} + +void BlobRect::setBottomLeftRadius(qreal r) { + if (!qFuzzyCompare(m_bottomLeftRadius, r)) { + m_bottomLeftRadius = r; + emit bottomLeftRadiusChanged(); + if (m_group) + m_group->markDirty(); + } +} + +void BlobRect::setBottomRightRadius(qreal r) { + if (!qFuzzyCompare(m_bottomRightRadius, r)) { + m_bottomRightRadius = r; + emit bottomRightRadiusChanged(); + if (m_group) + m_group->markDirty(); + } +} + +void BlobRect::cornerRadii(float out[4]) const { + const auto base = static_cast(m_radius); + out[0] = m_topRightRadius >= 0 ? static_cast(m_topRightRadius) : base; + out[1] = m_bottomRightRadius >= 0 ? static_cast(m_bottomRightRadius) : base; + out[2] = m_bottomLeftRadius >= 0 ? static_cast(m_bottomLeftRadius) : base; + out[3] = m_topLeftRadius >= 0 ? static_cast(m_topLeftRadius) : base; +} + +bool BlobRect::isExcluded(const BlobShape* other) const { + for (const auto& ptr : m_exclude) { + if (ptr == other) + return true; + } + return false; +} + +QQmlListProperty BlobRect::exclude() { + return QQmlListProperty( + this, nullptr, &excludeAppend, &excludeCount, &excludeAt, &excludeClear, &excludeReplace, &excludeRemoveLast); +} + +void BlobRect::excludeAppend(QQmlListProperty* prop, BlobRect* rect) { + auto* self = static_cast(prop->object); + self->m_exclude.append(rect); + if (self->m_group) + self->m_group->markDirty(); + emit self->excludeChanged(); +} + +qsizetype BlobRect::excludeCount(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + return self->m_exclude.size(); +} + +BlobRect* BlobRect::excludeAt(QQmlListProperty* prop, qsizetype index) { + auto* self = static_cast(prop->object); + return self->m_exclude.at(index); +} + +void BlobRect::excludeClear(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_exclude.isEmpty()) + return; + self->m_exclude.clear(); + if (self->m_group) + self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect::excludeReplace(QQmlListProperty* prop, qsizetype index, BlobRect* rect) { + auto* self = static_cast(prop->object); + self->m_exclude[index] = rect; + if (self->m_group) + self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect::excludeRemoveLast(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_exclude.isEmpty()) + return; + self->m_exclude.removeLast(); + if (self->m_group) + self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect::checkAtRest(float speed) { + constexpr float kEpsilon = 0.002f; + const bool atRest = std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && + std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon && + std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && speed < 5.0f; + + if (atRest) { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_dmVel00 = 0.0f; + m_dmVel01 = 0.0f; + m_dmVel11 = 0.0f; + m_deformMatrix = QMatrix4x4(); // identity + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + m_physicsActive = false; + } +} diff --git a/plugin/src/Caelestia/Blobs/blobrect.hpp b/plugin/src/Caelestia/Blobs/blobrect.hpp new file mode 100644 index 00000000..d2d6ad45 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobrect.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include "blobshape.hpp" + +#include +#include +#include +#include + +class BlobRect : public BlobShape { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(qreal stiffness READ stiffness WRITE setStiffness NOTIFY stiffnessChanged) + Q_PROPERTY(qreal damping READ damping WRITE setDamping NOTIFY dampingChanged) + Q_PROPERTY(qreal deformScale READ deformScale WRITE setDeformScale NOTIFY deformScaleChanged) + Q_PROPERTY(QQmlListProperty exclude READ exclude NOTIFY excludeChanged) + Q_PROPERTY(qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY topLeftRadiusChanged) + Q_PROPERTY(qreal topRightRadius READ topRightRadius WRITE setTopRightRadius NOTIFY topRightRadiusChanged) + Q_PROPERTY(qreal bottomLeftRadius READ bottomLeftRadius WRITE setBottomLeftRadius NOTIFY bottomLeftRadiusChanged) + Q_PROPERTY( + qreal bottomRightRadius READ bottomRightRadius WRITE setBottomRightRadius NOTIFY bottomRightRadiusChanged) + +public: + explicit BlobRect(QQuickItem* parent = nullptr); + ~BlobRect() override; + + qreal stiffness() const { return m_stiffness; } + + void setStiffness(qreal s) { + if (!qFuzzyCompare(m_stiffness, s)) { + m_stiffness = s; + emit stiffnessChanged(); + } + } + + qreal damping() const { return m_damping; } + + void setDamping(qreal d) { + if (!qFuzzyCompare(m_damping, d)) { + m_damping = d; + emit dampingChanged(); + } + } + + qreal deformScale() const { return m_deformScale; } + + void setDeformScale(qreal s) { + if (!qFuzzyCompare(m_deformScale, s)) { + m_deformScale = s; + emit deformScaleChanged(); + } + } + + QQmlListProperty exclude(); + + bool isExcluded(const BlobShape* other) const override; + void cornerRadii(float out[4]) const override; + + qreal topLeftRadius() const { return m_topLeftRadius; } + + void setTopLeftRadius(qreal r); + + qreal topRightRadius() const { return m_topRightRadius; } + + void setTopRightRadius(qreal r); + + qreal bottomLeftRadius() const { return m_bottomLeftRadius; } + + void setBottomLeftRadius(qreal r); + + qreal bottomRightRadius() const { return m_bottomRightRadius; } + + void setBottomRightRadius(qreal r); + +signals: + void stiffnessChanged(); + void dampingChanged(); + void deformScaleChanged(); + void excludeChanged(); + void topLeftRadiusChanged(); + void topRightRadiusChanged(); + void bottomLeftRadiusChanged(); + void bottomRightRadiusChanged(); + +protected: + void updatePolish() override; + void updatePhysics() override; + +private: + void checkAtRest(float speed); + + // Physics state + QPointF m_prevScenePos; + QElapsedTimer m_elapsed; + bool m_physicsActive = false; + bool m_hasPrevPos = false; + + // Symmetric 2x2 deformation matrix components (3 independent: m00, m01, + // m11) Rest state is identity: m00=1, m01=0, m11=1 + float m_dm00 = 1.0f; + float m_dm01 = 0.0f; + float m_dm11 = 1.0f; + + // Spring velocities for each component + float m_dmVel00 = 0.0f; + float m_dmVel01 = 0.0f; + float m_dmVel11 = 0.0f; + + qreal m_stiffness = 200.0; + qreal m_damping = 16.0; + qreal m_deformScale = 0.0005; + + qreal m_topLeftRadius = -1; + qreal m_topRightRadius = -1; + qreal m_bottomLeftRadius = -1; + qreal m_bottomRightRadius = -1; + + QList> m_exclude; + + static void excludeAppend(QQmlListProperty* prop, BlobRect* rect); + static qsizetype excludeCount(QQmlListProperty* prop); + static BlobRect* excludeAt(QQmlListProperty* prop, qsizetype index); + static void excludeClear(QQmlListProperty* prop); + static void excludeReplace(QQmlListProperty* prop, qsizetype index, BlobRect* rect); + static void excludeRemoveLast(QQmlListProperty* prop); +}; diff --git a/plugin/src/Caelestia/Blobs/blobshape.cpp b/plugin/src/Caelestia/Blobs/blobshape.cpp new file mode 100644 index 00000000..718c03e7 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobshape.cpp @@ -0,0 +1,367 @@ +#include "blobshape.hpp" +#include "blobgroup.hpp" +#include "blobinvertedrect.hpp" + +#include +#include + +#include +#include + +static float deformPadding(const QMatrix4x4& dm, float hw, float hh) { + // Bounding box of the deformed shape: |M * corners| + const float dm00 = dm(0, 0), dm01 = dm(0, 1); + const float dm10 = dm(1, 0), dm11 = dm(1, 1); + const float boundX = std::abs(dm00) * hw + std::abs(dm01) * hh; + const float boundY = std::abs(dm10) * hw + std::abs(dm11) * hh; + const float extraX = std::max(boundX - hw, 0.0f) + std::abs(dm(0, 3)); + const float extraY = std::max(boundY - hh, 0.0f) + std::abs(dm(1, 3)); + return std::max(extraX, extraY); +} + +static float cpuSdBox(float px, float py, float cx, float cy, float hw, float hh) { + const float dx = std::abs(px - cx) - hw; + const float dy = std::abs(py - cy) - hh; + const float mdx = std::max(dx, 0.0f); + const float mdy = std::max(dy, 0.0f); + return std::sqrt(mdx * mdx + mdy * mdy) + std::min(std::max(dx, dy), 0.0f); +} + +static float cpuSmoothstep(float edge0, float edge1, float x) { + const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +BlobShape::BlobShape(QQuickItem* parent) + : QQuickItem(parent) { + setFlag(ItemHasContents); +} + +void BlobShape::setGroup(BlobGroup* g) { + if (m_group == g) + return; + if (m_group && isComponentComplete()) + unregisterFromGroup(); + m_group = g; + if (m_group && isComponentComplete()) + registerWithGroup(); + emit groupChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobShape::setRadius(qreal r) { + if (qFuzzyCompare(m_radius, r)) + return; + m_radius = r; + emit radiusChanged(); + if (m_group) + m_group->markDirty(); +} + +void BlobShape::componentComplete() { + QQuickItem::componentComplete(); + if (m_group) + registerWithGroup(); +} + +void BlobShape::geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + updateCenteredDeformMatrix(); + if (m_group) { + // Accumulate sub-pixel drift so slow movements don't desync the shader + m_pendingDx += static_cast(newGeometry.x() - oldGeometry.x()); + m_pendingDy += static_cast(newGeometry.y() - oldGeometry.y()); + const auto dw = std::abs(newGeometry.width() - oldGeometry.width()); + const auto dh = std::abs(newGeometry.height() - oldGeometry.height()); + if (std::abs(m_pendingDx) > 0.5f || std::abs(m_pendingDy) > 0.5f || dw > 0.5 || dh > 0.5) { + m_pendingDx = 0; + m_pendingDy = 0; + m_group->markShapeDirty(this); + } + } +} + +void BlobShape::updateCenteredDeformMatrix() { + const auto cx = static_cast(width()) * 0.5f; + const auto cy = static_cast(height()) * 0.5f; + QMatrix4x4 result; + result.translate(cx, cy); + result *= m_deformMatrix; + result.translate(-cx, -cy); + if (m_centeredDeformMatrix != result) { + m_centeredDeformMatrix = result; + emit deformMatrixChanged(); + } +} + +void BlobShape::cornerRadii(float out[4]) const { + const auto r = static_cast(m_radius); + out[0] = r; + out[1] = r; + out[2] = r; + out[3] = r; +} + +void BlobShape::registerWithGroup() { + if (m_group) + m_group->addShape(this); +} + +void BlobShape::unregisterFromGroup() { + if (m_group) + m_group->removeShape(this); +} + +void BlobShape::updatePolish() { + if (!m_group) + return; + + // Ensure all shapes have up-to-date physics (only once per frame) + m_group->ensurePhysicsUpdated(); + + const QPointF scenePos = mapToScene(QPointF(0, 0)); + const float pad = static_cast(m_group->smoothing()); + + if (isInvertedRect()) { + m_cachedPaddedX = static_cast(scenePos.x()); + m_cachedPaddedY = static_cast(scenePos.y()); + m_cachedPaddedW = static_cast(width()); + m_cachedPaddedH = static_cast(height()); + m_localPaddedRect = QRectF(0, 0, width(), height()); + } else { + const float hw = static_cast(width()) * 0.5f; + const float hh = static_cast(height()) * 0.5f; + const float totalPad = pad + deformPadding(m_deformMatrix, hw, hh); + + m_cachedPaddedX = static_cast(scenePos.x()) - totalPad; + m_cachedPaddedY = static_cast(scenePos.y()) - totalPad; + m_cachedPaddedW = static_cast(width()) + 2.0f * totalPad; + m_cachedPaddedH = static_cast(height()) + 2.0f * totalPad; + m_localPaddedRect = QRectF(static_cast(-totalPad), static_cast(-totalPad), + width() + 2.0 * static_cast(totalPad), height() + 2.0 * static_cast(totalPad)); + } + + // Filter nearby normal rects + m_cachedRects.clear(); + m_cachedMyIndex = -2; + const QRectF myPadded(static_cast(m_cachedPaddedX), static_cast(m_cachedPaddedY), + static_cast(m_cachedPaddedW), static_cast(m_cachedPaddedH)); + + for (BlobShape* other : m_group->shapes()) { + if (other->isInvertedRect()) + continue; + + // Skip zero-size rects + if (other->width() <= 0 || other->height() <= 0) + continue; + + if (isExcluded(other)) + continue; + + const QPointF otherScene = other->mapToScene(QPointF(0, 0)); + + bool include = false; + if (isInvertedRect()) { + include = true; + } else { + const float otherHW = static_cast(other->width()) * 0.5f; + const float otherHH = static_cast(other->height()) * 0.5f; + const float otherPad = pad + deformPadding(other->m_deformMatrix, otherHW, otherHH); + const QRectF otherPadded(otherScene.x() - static_cast(otherPad), + otherScene.y() - static_cast(otherPad), other->width() + 2.0 * static_cast(otherPad), + other->height() + 2.0 * static_cast(otherPad)); + include = myPadded.intersects(otherPadded); + } + + if (include) { + if (other == this) + m_cachedMyIndex = static_cast(m_cachedRects.size()); + + const QMatrix4x4& dm = other->m_deformMatrix; + const float a = dm(0, 0), b = dm(1, 0); + const float c = dm(0, 1), d = dm(1, 1); + + BlobRectData r; + r.cx = static_cast(otherScene.x() + other->width() / 2.0); + r.cy = static_cast(otherScene.y() + other->height() / 2.0); + r.hw = static_cast(other->width() / 2.0); + r.hh = static_cast(other->height() / 2.0); + other->cornerRadii(r.radius); + r.offsetX = dm(0, 3); + r.offsetY = dm(1, 3); + + // Pre-compute inverse deformation matrix + const float det = a * d - c * b; + const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f; + r.invDeform[0] = d * invDet; + r.invDeform[1] = -b * invDet; + r.invDeform[2] = -c * invDet; + r.invDeform[3] = a * invDet; + + // Pre-compute minimum eigenvalue (avoids per-pixel sqrt) + const float halfTr = 0.5f * (a + d); + const float halfDiff = 0.5f * (a - d); + r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c); + + // Pre-compute screen-space AABB half-extents + r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh; + r.screenHalfY = std::abs(b) * r.hw + std::abs(d) * r.hh; + + m_cachedRects.append(r); + } + } + + if (isInvertedRect()) + m_cachedMyIndex = -1; + + // Cache inverted rect data + m_cachedHasInverted = false; + m_cachedInvertedRadius = 0; + memset(m_cachedInvertedOuter, 0, sizeof(m_cachedInvertedOuter)); + memset(m_cachedInvertedInner, 0, sizeof(m_cachedInvertedInner)); + + auto* inv = m_group->invertedRect(); + if (inv) { + const QPointF invScene = inv->mapToScene(QPointF(0, 0)); + const float outerCX = static_cast(invScene.x() + inv->width() / 2.0); + const float outerCY = static_cast(invScene.y() + inv->height() / 2.0); + const float outerHW = static_cast(inv->width() / 2.0); + const float outerHH = static_cast(inv->height() / 2.0); + + const float innerCX = outerCX + static_cast((inv->borderLeft() - inv->borderRight()) / 2.0); + const float innerCY = outerCY + static_cast((inv->borderTop() - inv->borderBottom()) / 2.0); + const float innerHW = outerHW - static_cast((inv->borderLeft() + inv->borderRight()) / 2.0); + const float innerHH = outerHH - static_cast((inv->borderTop() + inv->borderBottom()) / 2.0); + + // Check if this rect is near the border (within 2x smoothing of inner edge) + bool nearBorder = isInvertedRect(); + if (!nearBorder) { + const float margin = pad * 2.0f; + const float myCX = m_cachedPaddedX + m_cachedPaddedW * 0.5f; + const float myCY = m_cachedPaddedY + m_cachedPaddedH * 0.5f; + const float myHW = m_cachedPaddedW * 0.5f; + const float myHH = m_cachedPaddedH * 0.5f; + // Near border if any edge of padded rect is within margin of inner edge + nearBorder = (myCX - myHW < innerCX - innerHW + margin) || (myCX + myHW > innerCX + innerHW - margin) || + (myCY - myHH < innerCY - innerHH + margin) || (myCY + myHH > innerCY + innerHH - margin); + } + + if (nearBorder) { + m_cachedHasInverted = true; + m_cachedInvertedRadius = static_cast(inv->radius()); + + m_cachedInvertedOuter[0] = outerCX; + m_cachedInvertedOuter[1] = outerCY; + m_cachedInvertedOuter[2] = outerHW; + m_cachedInvertedOuter[3] = outerHH; + + m_cachedInvertedInner[0] = innerCX; + m_cachedInvertedInner[1] = innerCY; + m_cachedInvertedInner[2] = innerHW; + m_cachedInvertedInner[3] = innerHH; + } + } + + // Pre-compute effective per-corner radii (moves O(N²) work from GPU to CPU) + const float smoothFactor = pad; + constexpr float minR = 2.0f; + const auto rectCount = m_cachedRects.size(); + for (qsizetype i = 0; i < rectCount; ++i) { + auto& ri = m_cachedRects[i]; + float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f; + + const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh; + const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh; + const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh; + const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh; + + for (qsizetype j = 0; j < rectCount; ++j) { + if (j == i) + continue; + const auto& rj = m_cachedRects[j]; + fTr = std::min(fTr, cpuSmoothstep(0.0f, smoothFactor, cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh))); + fBr = std::min(fBr, cpuSmoothstep(0.0f, smoothFactor, cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh))); + fBl = std::min(fBl, cpuSmoothstep(0.0f, smoothFactor, cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh))); + fTl = std::min(fTl, cpuSmoothstep(0.0f, smoothFactor, cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh))); + } + + if (m_cachedHasInverted) { + const float icx = m_cachedInvertedInner[0]; + const float icy = m_cachedInvertedInner[1]; + const float ihw = m_cachedInvertedInner[2]; + const float ihh = m_cachedInvertedInner[3]; + fTr = std::min(fTr, cpuSmoothstep(0.0f, smoothFactor, -cpuSdBox(cTrX, cTrY, icx, icy, ihw, ihh))); + fBr = std::min(fBr, cpuSmoothstep(0.0f, smoothFactor, -cpuSdBox(cBrX, cBrY, icx, icy, ihw, ihh))); + fBl = std::min(fBl, cpuSmoothstep(0.0f, smoothFactor, -cpuSdBox(cBlX, cBlY, icx, icy, ihw, ihh))); + fTl = std::min(fTl, cpuSmoothstep(0.0f, smoothFactor, -cpuSdBox(cTlX, cTlY, icx, icy, ihw, ihh))); + } + + // Combine base radii with fill factors into effective per-corner radii + ri.radius[0] = std::max(ri.radius[0] * fTr, minR); + ri.radius[1] = std::max(ri.radius[1] * fBr, minR); + ri.radius[2] = std::max(ri.radius[2] * fBl, minR); + ri.radius[3] = std::max(ri.radius[3] * fTl, minR); + } +} + +QSGNode* BlobShape::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { + if (!m_group) { + delete oldNode; + return nullptr; + } + + auto* node = static_cast(oldNode); + if (!node) { + node = new QSGGeometryNode; + + auto* geometry = new QSGGeometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 4); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + node->setGeometry(geometry); + node->setFlag(QSGNode::OwnsGeometry); + + auto* material = new BlobMaterial; + material->setFlag(QSGMaterial::Blending); + node->setMaterial(material); + node->setFlag(QSGNode::OwnsMaterial); + } + + // Update geometry + auto* geometry = node->geometry(); + auto* v = geometry->vertexDataAsTexturedPoint2D(); + + const float x0 = static_cast(m_localPaddedRect.x()); + const float y0 = static_cast(m_localPaddedRect.y()); + const float x1 = x0 + static_cast(m_localPaddedRect.width()); + const float y1 = y0 + static_cast(m_localPaddedRect.height()); + + v[0].set(x0, y0, 0.0f, 0.0f); + v[1].set(x1, y0, 1.0f, 0.0f); + v[2].set(x0, y1, 0.0f, 1.0f); + v[3].set(x1, y1, 1.0f, 1.0f); + + node->markDirty(QSGNode::DirtyGeometry); + + // Update material + auto* material = static_cast(node->material()); + material->m_paddedX = m_cachedPaddedX; + material->m_paddedY = m_cachedPaddedY; + material->m_paddedW = m_cachedPaddedW; + material->m_paddedH = m_cachedPaddedH; + material->m_smoothFactor = static_cast(m_group->smoothing()); + material->m_myIndex = m_cachedMyIndex; + material->m_color = m_group->color(); + material->m_hasInverted = m_cachedHasInverted ? 1 : 0; + material->m_invertedRadius = m_cachedInvertedRadius; + memcpy(material->m_invertedOuter, m_cachedInvertedOuter, sizeof(m_cachedInvertedOuter)); + memcpy(material->m_invertedInner, m_cachedInvertedInner, sizeof(m_cachedInvertedInner)); + + const int count = static_cast(qMin(m_cachedRects.size(), qsizetype(16))); + material->m_rectCount = count; + for (int i = 0; i < count; ++i) + material->m_rects[i] = m_cachedRects[i]; + + node->markDirty(QSGNode::DirtyMaterial); + + return node; +} diff --git a/plugin/src/Caelestia/Blobs/blobshape.hpp b/plugin/src/Caelestia/Blobs/blobshape.hpp new file mode 100644 index 00000000..c05a40d8 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/blobshape.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "blobmaterial.hpp" + +#include +#include +#include + +class BlobGroup; + +class BlobShape : public QQuickItem { + Q_OBJECT + Q_PROPERTY(BlobGroup* group READ group WRITE setGroup NOTIFY groupChanged) + Q_PROPERTY(qreal radius READ radius WRITE setRadius NOTIFY radiusChanged) + Q_PROPERTY(QMatrix4x4 deformMatrix READ deformMatrix NOTIFY deformMatrixChanged) + Q_PROPERTY(QMatrix4x4 rawDeformMatrix READ rawDeformMatrix NOTIFY rawDeformMatrixChanged) + + friend class BlobGroup; + +public: + explicit BlobShape(QQuickItem* parent = nullptr); + ~BlobShape() override = default; + + BlobGroup* group() const { return m_group; } + + void setGroup(BlobGroup* g); + + qreal radius() const { return m_radius; } + + void setRadius(qreal r); + + QMatrix4x4 deformMatrix() const { return m_centeredDeformMatrix; } + + QMatrix4x4 rawDeformMatrix() const { return m_deformMatrix; } + +signals: + void groupChanged(); + void radiusChanged(); + void deformMatrixChanged(); + void rawDeformMatrixChanged(); + +protected: + void componentComplete() override; + void geometryChange(const QRectF& newGeometry, const QRectF& oldGeometry) override; + void updatePolish() override; + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override; + + virtual bool isInvertedRect() const { return false; } + + virtual bool isExcluded(const BlobShape* /*other*/) const { return false; } + + virtual void cornerRadii(float out[4]) const; + + virtual void updatePhysics() {} + + virtual void registerWithGroup(); + virtual void unregisterFromGroup(); + void updateCenteredDeformMatrix(); + + BlobGroup* m_group = nullptr; + qreal m_radius = 0; + QMatrix4x4 m_deformMatrix; // identity by default + QMatrix4x4 m_centeredDeformMatrix; + + // Cached data from updatePolish + float m_cachedPaddedX = 0; + float m_cachedPaddedY = 0; + float m_cachedPaddedW = 0; + float m_cachedPaddedH = 0; + QRectF m_localPaddedRect; + QVector m_cachedRects; + int m_cachedMyIndex = -2; + float m_pendingDx = 0; + float m_pendingDy = 0; + bool m_cachedHasInverted = false; + float m_cachedInvertedRadius = 0; + float m_cachedInvertedOuter[4] = {}; + float m_cachedInvertedInner[4] = {}; +}; diff --git a/plugin/src/Caelestia/Blobs/shaders/blob.frag b/plugin/src/Caelestia/Blobs/shaders/blob.frag new file mode 100644 index 00000000..e78531b6 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/shaders/blob.frag @@ -0,0 +1,223 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float paddedX; + float paddedY; + float paddedW; + float paddedH; + float smoothFactor; + int rectCount; + int myIndex; + vec4 color; + int hasInverted; + float invertedRadius; + vec4 invertedOuter; + vec4 invertedInner; + vec4 rectData[80]; +}; + +float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) { + vec2 d = abs(p - center) - halfSize + vec2(radius); + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - radius; +} + +float sdRoundedBox4(vec2 p, vec2 center, vec2 halfSize, vec4 r) { + // r = (topRight, bottomRight, bottomLeft, topLeft) + p -= center; + r.xy = (p.x > 0.0) ? r.xy : r.wz; + r.x = (p.y > 0.0) ? r.y : r.x; + vec2 q = abs(p) - halfSize + r.x; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x; +} + +float sdBox(vec2 p, vec2 center, vec2 halfSize) { + vec2 d = abs(p - center) - halfSize; + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0); +} + +float smin(float a, float b, float k) { + // Cubic smooth min (C2 continuous — no curvature kinks at blend boundary) + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * h * k * (1.0/6.0); +} + +float smax(float a, float b, float k) { + float h = max(k - abs(a - b), 0.0) / k; + return max(a, b) + h * h * h * k * (1.0/6.0); +} + +float smaxSharpA(float a, float b, float k) { + // smax variant that keeps a's boundary sharp (no inward rounding at a = 0). + // Used for the frame outer edge so it always fills to the edges. + float h = max(k - abs(a - b), 0.0) / k; + float blend = h * h * h * k * (1.0/6.0); + blend *= smoothstep(0.0, k * 0.5, -a); + return max(a, b) + blend; +} + +void main() { + vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); + + float mergedSdf = 1e10; + int owner = -2; + float minDist = 1e10; + + for (int i = 0; i < rectCount; i++) { + vec4 rect = rectData[i * 5]; // cx, cy, hw, hh + vec4 props = rectData[i * 5 + 1]; // radius, offsetX, offsetY, minEig + vec4 invDm = rectData[i * 5 + 2]; // inverse deform matrix + vec4 sh = rectData[i * 5 + 3]; // screenHalfX, screenHalfY, 0, 0 + vec4 radii = rectData[i * 5 + 4]; // effective per-corner radii (tr, br, bl, tl) + + // Offset center for asymmetric deformation + vec2 center = rect.xy + props.yz; + + // AABB early-out: skip rects far from this pixel + vec2 extent = sh.xy + vec2(smoothFactor * 1.5); + if (abs(pixel.x - center.x) > extent.x || abs(pixel.y - center.y) > extent.y) + continue; + + // Apply pre-computed inverse deformation to the evaluation point + mat2 invDeform = mat2(invDm.xy, invDm.zw); + vec2 transformedPixel = center + invDeform * (pixel - center); + + // Use pre-computed effective per-corner radii + float d = sdRoundedBox4(transformedPixel, center, rect.zw, radii); + + // Use pre-computed minimum eigenvalue for SDF correction + d *= max(props.w, 0.01); + + // Scale SDF on the axis facing a nearby border to narrow the smin blend zone + // in that direction only, without reducing k (which would cause sharp edges). + if (hasInverted != 0) { + vec2 screenHalf = sh.xy; + + float distY0 = (center.y + screenHalf.y) - (invertedInner.y - invertedInner.w); + float distY1 = (invertedInner.y + invertedInner.w) - (center.y - screenHalf.y); + float distX0 = (center.x + screenHalf.x) - (invertedInner.x - invertedInner.z); + float distX1 = (invertedInner.x + invertedInner.z) - (center.x - screenHalf.x); + + // 0 = far from border, 1 = at border (max compression) + float yProx = 1.0 - min( + smoothstep(0.0, smoothFactor, distY0), + smoothstep(0.0, smoothFactor, distY1) + ); + float xProx = 1.0 - min( + smoothstep(0.0, smoothFactor, distX0), + smoothstep(0.0, smoothFactor, distX1) + ); + + // Smooth axis weights: gradient-based at corners, face-based inside. + vec2 q = abs(pixel - center) - screenHalf; + vec2 qp = max(q, vec2(0.0)); + float cornerLen = length(qp); + + // Gradient direction in corner region (smooth 90-degree rotation) + float gradX = qp.x / max(cornerLen, 0.001); + float gradY = qp.y / max(cornerLen, 0.001); + + // Smooth face weights for inside/edge (no hard branch) + float faceY = smoothstep(-4.0, 4.0, q.y - q.x); + float faceX = 1.0 - faceY; + + // Blend: gradient in corner region, face-based inside + float t = smoothstep(0.0, 2.0, cornerLen); + float xWeight = mix(faceX, gradX, t); + float yWeight = mix(faceY, gradY, t); + + float boost = 3.0; + float scale = 1.0 + (xProx * xWeight + yProx * yWeight) * boost; + d *= scale; + } + + mergedSdf = smin(mergedSdf, d, smoothFactor); + if (d < smoothFactor && d < minDist) { + minDist = d; + owner = i; + } + } + + if (hasInverted != 0) { + float dOuter = sdBox(pixel, invertedOuter.xy, invertedOuter.zw) - 1.0; + float dInner = sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius); + + // Border sinks: track the opposite rect edge, clamped to border thickness + float innerTop = invertedInner.y - invertedInner.w; + float innerBot = invertedInner.y + invertedInner.w; + float innerLeft = invertedInner.x - invertedInner.z; + float innerRight = invertedInner.x + invertedInner.z; + float outerTop = invertedOuter.y - invertedOuter.w; + float outerBot = invertedOuter.y + invertedOuter.w; + float outerLeft = invertedOuter.x - invertedOuter.z; + float outerRight = invertedOuter.x + invertedOuter.z; + + float sinkValue = 0.0; + for (int i = 0; i < rectCount; i++) { + vec4 rect = rectData[i * 5]; + vec4 sinkProps = rectData[i * 5 + 1]; + vec2 sinkSh = rectData[i * 5 + 3].xy; + + // Screen-space center (with offset) and pre-computed AABB half-extents + vec2 ctr = rect.xy + sinkProps.yz; + + // Delay sink to absorb smin blend depth (cubic smin max = k/6) + float preOff = smoothFactor * (1.0/6.0); + + // Top border: track rect's BOTTOM edge, only within border thickness + float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0, innerTop - outerTop); + + // Bottom border: track rect's TOP edge + float botPen = clamp((ctr.y - sinkSh.y) - innerBot - preOff, 0.0, outerBot - innerBot); + + // Left border: track rect's RIGHT edge + float leftPen = clamp(innerLeft - (ctr.x + sinkSh.x) - preOff, 0.0, innerLeft - outerLeft); + + // Right border: track rect's LEFT edge + float rightPen = clamp((ctr.x - sinkSh.x) - innerRight - preOff, 0.0, outerRight - innerRight); + + // Lateral distance from pixel to rect's extent along each edge + float hLat = max(abs(pixel.x - ctr.x) - sinkSh.x, 0.0); + float vLat = max(abs(pixel.y - ctr.y) - sinkSh.y, 0.0); + + // Perpendicular proximity: full strength in border, fade inside inner area + float topZone = 1.0 - smoothstep(innerTop, innerTop + smoothFactor, pixel.y); + float botZone = smoothstep(innerBot - smoothFactor, innerBot, pixel.y); + float leftZone = 1.0 - smoothstep(innerLeft, innerLeft + smoothFactor, pixel.x); + float rightZone = smoothstep(innerRight - smoothFactor, innerRight, pixel.x); + + float s = smoothFactor * 2.0; + float sink = max( + max(topPen * smoothstep(s, 0.0, hLat) * topZone, + botPen * smoothstep(s, 0.0, hLat) * botZone), + max(leftPen * smoothstep(s, 0.0, vLat) * leftZone, + rightPen * smoothstep(s, 0.0, vLat) * rightZone) + ); + sinkValue = max(sinkValue, sink); + } + + dInner -= sinkValue; + + float dFrame = smaxSharpA(dOuter, -dInner, smoothFactor); + + mergedSdf = smin(mergedSdf, dFrame, smoothFactor); + if (dFrame < minDist) { + owner = -1; + } + } + + // Each renderer only outputs pixels it owns, but allow rendering + // blend zones to prevent gaps (mergedSdf < smoothFactor means in blend) + // myIndex == -1: inverted rect renders border-owned pixels + // myIndex >= 0: individual rect renders its owned pixels + if (owner != myIndex && mergedSdf > smoothFactor) + discard; + + float fw = fwidth(mergedSdf); + float alpha = 1.0 - smoothstep(-fw, fw, mergedSdf); + fragColor = vec4(color.rgb * alpha, alpha) * qt_Opacity; +} diff --git a/plugin/src/Caelestia/Blobs/shaders/blob.vert b/plugin/src/Caelestia/Blobs/shaders/blob.vert new file mode 100644 index 00000000..e71d8104 --- /dev/null +++ b/plugin/src/Caelestia/Blobs/shaders/blob.vert @@ -0,0 +1,29 @@ +#version 440 + +layout(location = 0) in vec4 qt_VertexPosition; +layout(location = 1) in vec2 qt_VertexTexCoord; + +layout(location = 0) out vec2 qt_TexCoord0; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float paddedX; + float paddedY; + float paddedW; + float paddedH; + float smoothFactor; + int rectCount; + int myIndex; + vec4 color; + int hasInverted; + float invertedRadius; + vec4 invertedOuter; + vec4 invertedInner; + vec4 rectData[80]; +}; + +void main() { + gl_Position = qt_Matrix * qt_VertexPosition; + qt_TexCoord0 = qt_VertexTexCoord; +} diff --git a/plugin/src/Caelestia/CMakeLists.txt b/plugin/src/Caelestia/CMakeLists.txt index 426b3acd..211cbb56 100644 --- a/plugin/src/Caelestia/CMakeLists.txt +++ b/plugin/src/Caelestia/CMakeLists.txt @@ -1,4 +1,4 @@ -find_package(Qt6 REQUIRED COMPONENTS Core Qml Gui Quick Concurrent Sql Network DBus) +find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus) find_package(PkgConfig REQUIRED) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) @@ -61,3 +61,4 @@ add_subdirectory(Components) add_subdirectory(Internal) add_subdirectory(Models) add_subdirectory(Services) +add_subdirectory(Blobs) diff --git a/services/VPN.qml b/services/VPN.qml index 31b68319..bc0addce 100644 --- a/services/VPN.qml +++ b/services/VPN.qml @@ -25,7 +25,7 @@ Singleton { } readonly property bool isCustomProvider: typeof providerInput === "object" readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput) - readonly property string interfaceName: isCustomProvider ? (providerInput.iface || "") : "" + readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : "" readonly property var currentConfig: { const name = providerName; const iface = interfaceName; @@ -36,7 +36,7 @@ Singleton { return { connectCmd: custom.connectCmd || defaults.connectCmd, disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd, - interface: custom.iface || defaults.interface, + interface: custom.interface || defaults.interface, displayName: custom.displayName || defaults.displayName }; }