From ec8ce658572077b94621d8341f69d5bdb559ccff Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:15:31 +1000 Subject: [PATCH] feat: m3 expressive menus Also fix the player selector being click through Fixes #1405 Closes #1350 --- components/controls/Menu.qml | 221 ++++++++++++------ components/controls/SplitButton.qml | 24 +- modules/drawers/ContentWindow.qml | 3 + .../src/Caelestia/Config/appearanceconfig.cpp | 4 + .../src/Caelestia/Config/appearanceconfig.hpp | 2 + plugin/src/Caelestia/Config/tokens.cpp | 1 - plugin/src/Caelestia/Config/tokens.hpp | 1 + 7 files changed, 167 insertions(+), 89 deletions(-) diff --git a/components/controls/Menu.qml b/components/controls/Menu.qml index 8bd6830e..482a4d40 100644 --- a/components/controls/Menu.qml +++ b/components/controls/Menu.qml @@ -1,96 +1,187 @@ pragma ComponentBehavior: Bound -import "../effects" import QtQuick import QtQuick.Layouts +import Quickshell import Caelestia.Config import qs.components +import qs.components.effects import qs.services +import qs.modules.drawers -Elevation { +MouseArea { id: root + enum Side { + Top, + Bottom, + Left, + Right + } + + required property Item attachTo + property int attachSideX: Menu.Right + property int attachSideY: Menu.Bottom + property int thisSideX: Menu.Right + property int thisSideY: Menu.Top + property real marginX + property real marginY + property list items property MenuItem active: items[0] ?? null property bool expanded signal itemSelected(item: MenuItem) - radius: Tokens.rounding.small / 2 - level: 2 + parent: { + const win = QsWindow.window; + const contentWin = win as ContentWindow; // If inside the drawer content window, put it inside the interaction wrapper so hover works + return contentWin ? contentWin.interactionWrapper : (win as QsWindow).contentItem; + } + anchors.fill: parent - implicitWidth: Math.max(200, column.implicitWidth) - implicitHeight: root.expanded ? column.implicitHeight : 0 - opacity: root.expanded ? 1 : 0 + enabled: expanded + onClicked: expanded = false - StyledClippingRect { - anchors.fill: parent - radius: parent.radius - color: Colours.palette.m3surfaceContainer + opacity: expanded ? 1 : 0 + layer.enabled: opacity < 1 - ColumnLayout { - id: column + Behavior on opacity { + Anim { + duration: Tokens.anim.durations.small + } + } - anchors.left: parent.left - anchors.right: parent.right - spacing: 0 + TransformWatcher { + id: watcher - Repeater { - model: root.items + a: root.parent + b: root.attachTo + } - StyledRect { - id: item + Elevation { + id: menu - required property int index - required property MenuItem modelData - readonly property bool active: modelData === root.active + x: { + watcher.transform; // mapToItem is not reactive so this forces updates + const item = root.attachTo; + let off = root.attachSideX === Menu.Left ? 0 : item.width; + if (root.thisSideX === Menu.Right) + off -= width; + return item.mapToItem(root.parent, off, 0).x + root.marginX; + } + y: { + watcher.transform; // mapToItem is not reactive so this forces updates + const item = root.attachTo; + let off = root.attachSideY === Menu.Top ? 0 : item.height; + if (root.thisSideY === Menu.Bottom) + off -= height; + return item.mapToItem(root.parent, 0, off).y + root.marginY; + } - Layout.fillWidth: true - implicitWidth: menuOptionRow.implicitWidth + Tokens.padding.normal * 2 - implicitHeight: menuOptionRow.implicitHeight + Tokens.padding.normal * 2 + radius: Tokens.rounding.normal + level: 2 - color: Qt.alpha(Colours.palette.m3secondaryContainer, active ? 1 : 0) + implicitWidth: Math.max(200, column.implicitWidth + column.anchors.margins * 2) + implicitHeight: column.implicitHeight + column.anchors.margins * 2 - StateLayer { - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface - disabled: !root.expanded - onClicked: { - root.itemSelected(item.modelData); - root.active = item.modelData; - item.modelData.clicked(); - root.expanded = false; - } - } + transform: Scale { + yScale: root.expanded ? 1 : 0.1 + origin.y: root.thisSideY === Menu.Bottom ? menu.height : 0 - RowLayout { - id: menuOptionRow + Behavior on yScale { + Anim { + type: Anim.DefaultSpatial + } + } + } - anchors.fill: parent - anchors.margins: Tokens.padding.normal - spacing: Tokens.spacing.small + StyledRect { + anchors.fill: parent + radius: parent.radius + color: Colours.palette.m3surfaceContainerLow - MaterialIcon { - Layout.alignment: Qt.AlignVCenter - text: item.modelData.icon - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant + ColumnLayout { + id: column + + anchors.fill: parent + anchors.margins: Tokens.padding.small + spacing: 0 + + Repeater { + id: repeater + + model: root.items + + StyledRect { + id: item + + required property int index + required property MenuItem modelData + readonly property bool active: modelData === root.active + + Layout.fillWidth: true + implicitWidth: menuOptionRow.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: menuOptionRow.implicitHeight + Tokens.padding.normal * 2 + + radius: active ? 12 : Tokens.rounding.extraSmall // This should use a token, but tokens are currently extremely scuffed + topLeftRadius: index === 0 ? Tokens.rounding.small : radius + topRightRadius: index === 0 ? Tokens.rounding.small : radius + bottomLeftRadius: index === repeater.count - 1 ? Tokens.rounding.small : radius + bottomRightRadius: index === repeater.count - 1 ? Tokens.rounding.small : radius + + color: Qt.alpha(Colours.palette.m3tertiaryContainer, active ? 1 : 0) + + Behavior on radius { + Anim {} } - StyledText { - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - text: item.modelData.text - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + StateLayer { + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + bottomLeftRadius: parent.bottomLeftRadius + bottomRightRadius: parent.bottomRightRadius + + color: item.active ? Colours.palette.m3onTertiaryContainer : Colours.palette.m3onSurface + disabled: !root.expanded + onClicked: { + root.itemSelected(item.modelData); + root.active = item.modelData; + item.modelData.clicked(); + root.expanded = false; + } } - Loader { - asynchronous: true - Layout.alignment: Qt.AlignVCenter - active: item.modelData.trailingIcon.length > 0 - visible: active + RowLayout { + id: menuOptionRow - sourceComponent: MaterialIcon { - text: item.modelData.trailingIcon - color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + anchors.fill: parent + anchors.margins: Tokens.padding.normal + spacing: Tokens.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: item.modelData.icon + color: item.active ? Colours.palette.m3onTertiaryContainer : Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: item.modelData.text + color: item.active ? Colours.palette.m3onTertiaryContainer : Colours.palette.m3onSurface + } + + Loader { + asynchronous: true + Layout.alignment: Qt.AlignVCenter + active: item.modelData.trailingIcon.length > 0 + visible: active + + sourceComponent: MaterialIcon { + text: item.modelData.trailingIcon + color: item.active ? Colours.palette.m3onTertiaryContainer : Colours.palette.m3onSurfaceVariant + } } } } @@ -98,16 +189,4 @@ Elevation { } } } - - Behavior on opacity { - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - } - } - - Behavior on implicitHeight { - Anim { - type: Anim.DefaultSpatial - } - } } diff --git a/components/controls/SplitButton.qml b/components/controls/SplitButton.qml index f4297db9..16d840ef 100644 --- a/components/controls/SplitButton.qml +++ b/components/controls/SplitButton.qml @@ -138,24 +138,14 @@ Row { Behavior on rad { Anim {} } + } - Menu { - id: menu + Menu { + id: menu - states: State { - when: root.menuOnTop - - AnchorChanges { - target: menu - anchors.top: undefined - anchors.bottom: expandBtn.top - } - } - - anchors.top: parent.bottom - anchors.right: parent.right - anchors.topMargin: Tokens.spacing.small - anchors.bottomMargin: Tokens.spacing.small - } + attachTo: expandBtn + attachSideY: root.menuOnTop ? Menu.Top : Menu.Bottom + thisSideY: root.menuOnTop ? Menu.Bottom : Menu.Top + marginY: Tokens.spacing.small * (root.menuOnTop ? -1 : 1) } } diff --git a/modules/drawers/ContentWindow.qml b/modules/drawers/ContentWindow.qml index eb44ebba..aeb96889 100644 --- a/modules/drawers/ContentWindow.qml +++ b/modules/drawers/ContentWindow.qml @@ -17,6 +17,7 @@ StyledWindow { id: root readonly property alias bar: bar + readonly property alias interactionWrapper: interactions readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject.specialWorkspace?.name.length ?? 0) > 0 @@ -229,6 +230,8 @@ StyledWindow { } Interactions { + id: interactions + screen: root.screen popouts: panels.popouts visibilities: visibilities diff --git a/plugin/src/Caelestia/Config/appearanceconfig.cpp b/plugin/src/Caelestia/Config/appearanceconfig.cpp index 6f2433aa..55ab0470 100644 --- a/plugin/src/Caelestia/Config/appearanceconfig.cpp +++ b/plugin/src/Caelestia/Config/appearanceconfig.cpp @@ -28,6 +28,10 @@ void AppearanceRounding::bindTokens(RoundingTokens* tokens) { connectTokenSignals(tokens, this); } +int AppearanceRounding::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + int AppearanceRounding::small() const { return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; } diff --git a/plugin/src/Caelestia/Config/appearanceconfig.hpp b/plugin/src/Caelestia/Config/appearanceconfig.hpp index ccb732fd..61850d62 100644 --- a/plugin/src/Caelestia/Config/appearanceconfig.hpp +++ b/plugin/src/Caelestia/Config/appearanceconfig.hpp @@ -19,6 +19,7 @@ class AppearanceRounding : public ConfigObject { CONFIG_PROPERTY(qreal, scale, 1) + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) Q_PROPERTY(int small READ small NOTIFY valuesChanged) Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) Q_PROPERTY(int large READ large NOTIFY valuesChanged) @@ -30,6 +31,7 @@ public: void bindTokens(RoundingTokens* tokens); + [[nodiscard]] int extraSmall() const; [[nodiscard]] int small() const; [[nodiscard]] int normal() const; [[nodiscard]] int large() const; diff --git a/plugin/src/Caelestia/Config/tokens.cpp b/plugin/src/Caelestia/Config/tokens.cpp index 3d3f48c9..6d171cb3 100644 --- a/plugin/src/Caelestia/Config/tokens.cpp +++ b/plugin/src/Caelestia/Config/tokens.cpp @@ -1,5 +1,4 @@ #include "tokens.hpp" -#include "config.hpp" #include "monitorconfigmanager.hpp" #include diff --git a/plugin/src/Caelestia/Config/tokens.hpp b/plugin/src/Caelestia/Config/tokens.hpp index fd7a0569..7dcb334f 100644 --- a/plugin/src/Caelestia/Config/tokens.hpp +++ b/plugin/src/Caelestia/Config/tokens.hpp @@ -39,6 +39,7 @@ class RoundingTokens : public ConfigObject { Q_OBJECT QML_ANONYMOUS + CONFIG_PROPERTY(int, extraSmall, 4) CONFIG_PROPERTY(int, small, 12) CONFIG_PROPERTY(int, normal, 17) CONFIG_PROPERTY(int, large, 25)