feat: m3 expressive menus

Also fix the player selector being click through
Fixes #1405
Closes #1350
This commit is contained in:
2 * r + 2 * t 2026-04-19 19:15:31 +10:00
parent 2bb28197a0
commit ec8ce65857
7 changed files with 167 additions and 89 deletions

View file

@ -1,96 +1,187 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import "../effects"
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell
import Caelestia.Config import Caelestia.Config
import qs.components import qs.components
import qs.components.effects
import qs.services import qs.services
import qs.modules.drawers
Elevation { MouseArea {
id: root 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<MenuItem> items property list<MenuItem> items
property MenuItem active: items[0] ?? null property MenuItem active: items[0] ?? null
property bool expanded property bool expanded
signal itemSelected(item: MenuItem) signal itemSelected(item: MenuItem)
radius: Tokens.rounding.small / 2 parent: {
level: 2 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) enabled: expanded
implicitHeight: root.expanded ? column.implicitHeight : 0 onClicked: expanded = false
opacity: root.expanded ? 1 : 0
StyledClippingRect { opacity: expanded ? 1 : 0
anchors.fill: parent layer.enabled: opacity < 1
radius: parent.radius
color: Colours.palette.m3surfaceContainer
ColumnLayout { Behavior on opacity {
id: column Anim {
duration: Tokens.anim.durations.small
}
}
anchors.left: parent.left TransformWatcher {
anchors.right: parent.right id: watcher
spacing: 0
Repeater { a: root.parent
model: root.items b: root.attachTo
}
StyledRect { Elevation {
id: item id: menu
required property int index x: {
required property MenuItem modelData watcher.transform; // mapToItem is not reactive so this forces updates
readonly property bool active: modelData === root.active 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 radius: Tokens.rounding.normal
implicitWidth: menuOptionRow.implicitWidth + Tokens.padding.normal * 2 level: 2
implicitHeight: menuOptionRow.implicitHeight + Tokens.padding.normal * 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 { transform: Scale {
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface yScale: root.expanded ? 1 : 0.1
disabled: !root.expanded origin.y: root.thisSideY === Menu.Bottom ? menu.height : 0
onClicked: {
root.itemSelected(item.modelData);
root.active = item.modelData;
item.modelData.clicked();
root.expanded = false;
}
}
RowLayout { Behavior on yScale {
id: menuOptionRow Anim {
type: Anim.DefaultSpatial
}
}
}
anchors.fill: parent StyledRect {
anchors.margins: Tokens.padding.normal anchors.fill: parent
spacing: Tokens.spacing.small radius: parent.radius
color: Colours.palette.m3surfaceContainerLow
MaterialIcon { ColumnLayout {
Layout.alignment: Qt.AlignVCenter id: column
text: item.modelData.icon
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant 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 { StateLayer {
Layout.alignment: Qt.AlignVCenter topLeftRadius: parent.topLeftRadius
Layout.fillWidth: true topRightRadius: parent.topRightRadius
text: item.modelData.text bottomLeftRadius: parent.bottomLeftRadius
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface 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 { RowLayout {
asynchronous: true id: menuOptionRow
Layout.alignment: Qt.AlignVCenter
active: item.modelData.trailingIcon.length > 0
visible: active
sourceComponent: MaterialIcon { anchors.fill: parent
text: item.modelData.trailingIcon anchors.margins: Tokens.padding.normal
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface 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
}
}
} }

View file

@ -138,24 +138,14 @@ Row {
Behavior on rad { Behavior on rad {
Anim {} Anim {}
} }
}
Menu { Menu {
id: menu id: menu
states: State { attachTo: expandBtn
when: root.menuOnTop attachSideY: root.menuOnTop ? Menu.Top : Menu.Bottom
thisSideY: root.menuOnTop ? Menu.Bottom : Menu.Top
AnchorChanges { marginY: Tokens.spacing.small * (root.menuOnTop ? -1 : 1)
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
}
} }
} }

View file

@ -17,6 +17,7 @@ StyledWindow {
id: root id: root
readonly property alias bar: bar readonly property alias bar: bar
readonly property alias interactionWrapper: interactions
readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen)
readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject.specialWorkspace?.name.length ?? 0) > 0 readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject.specialWorkspace?.name.length ?? 0) > 0
@ -229,6 +230,8 @@ StyledWindow {
} }
Interactions { Interactions {
id: interactions
screen: root.screen screen: root.screen
popouts: panels.popouts popouts: panels.popouts
visibilities: visibilities visibilities: visibilities

View file

@ -28,6 +28,10 @@ void AppearanceRounding::bindTokens(RoundingTokens* tokens) {
connectTokenSignals(tokens, this); connectTokenSignals(tokens, this);
} }
int AppearanceRounding::extraSmall() const {
return m_tokens ? static_cast<int>(m_tokens->extraSmall() * m_scale) : 0;
}
int AppearanceRounding::small() const { int AppearanceRounding::small() const {
return m_tokens ? static_cast<int>(m_tokens->small() * m_scale) : 0; return m_tokens ? static_cast<int>(m_tokens->small() * m_scale) : 0;
} }

View file

@ -19,6 +19,7 @@ class AppearanceRounding : public ConfigObject {
CONFIG_PROPERTY(qreal, scale, 1) CONFIG_PROPERTY(qreal, scale, 1)
Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged)
Q_PROPERTY(int small READ small NOTIFY valuesChanged) Q_PROPERTY(int small READ small NOTIFY valuesChanged)
Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) Q_PROPERTY(int normal READ normal NOTIFY valuesChanged)
Q_PROPERTY(int large READ large NOTIFY valuesChanged) Q_PROPERTY(int large READ large NOTIFY valuesChanged)
@ -30,6 +31,7 @@ public:
void bindTokens(RoundingTokens* tokens); void bindTokens(RoundingTokens* tokens);
[[nodiscard]] int extraSmall() const;
[[nodiscard]] int small() const; [[nodiscard]] int small() const;
[[nodiscard]] int normal() const; [[nodiscard]] int normal() const;
[[nodiscard]] int large() const; [[nodiscard]] int large() const;

View file

@ -1,5 +1,4 @@
#include "tokens.hpp" #include "tokens.hpp"
#include "config.hpp"
#include "monitorconfigmanager.hpp" #include "monitorconfigmanager.hpp"
#include <qqmlengine.h> #include <qqmlengine.h>

View file

@ -39,6 +39,7 @@ class RoundingTokens : public ConfigObject {
Q_OBJECT Q_OBJECT
QML_ANONYMOUS QML_ANONYMOUS
CONFIG_PROPERTY(int, extraSmall, 4)
CONFIG_PROPERTY(int, small, 12) CONFIG_PROPERTY(int, small, 12)
CONFIG_PROPERTY(int, normal, 17) CONFIG_PROPERTY(int, normal, 17)
CONFIG_PROPERTY(int, large, 25) CONFIG_PROPERTY(int, large, 25)