added tray menu + fixed antialising

This commit is contained in:
2026-08-11 11:08:44 +02:00
parent 58e0ba2f99
commit ccb54f01d9
22 changed files with 581 additions and 167 deletions
+4 -14
View File
@@ -127,17 +127,12 @@ Popout {
height: 30
// Today gets a leaning crimson slab.
Rectangle {
Skew {
anchors.centerIn: parent
width: 26
height: 24
visible: cell.modelData.today
color: Theme.accent
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 24 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
}
Text {
@@ -229,16 +224,11 @@ Popout {
implicitWidth: 22
implicitHeight: 22
Rectangle {
Skew {
anchors.fill: parent
color: stepHover.hovered ? Theme.alpha(Theme.accent, 0.25) : "transparent"
border.width: 1
border.color: Theme.alpha(Theme.outline, 0.7)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 22 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
borderWidth: 1
borderColor: Theme.alpha(Theme.outline, 0.7)
}
Icon {
+14 -18
View File
@@ -11,7 +11,11 @@ Popout {
id: root
contentWidth: 400
contentHeight: 200 + (Media.players.length > 1 ? 34 : 0)
// The seek row used to sit flush against the bottom edge, where the slab's
// chamfer cuts the corner away — the elapsed time was half outside the
// panel. The extra height is the room that row needs to clear the cut.
contentHeight: 224 + (Media.players.length > 1 ? 34 : 0)
PopoutSurface {
id: surface
@@ -176,8 +180,10 @@ Popout {
id: seekArea
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Theme.padS
anchors.rightMargin: Theme.padS
anchors.bottom: picker.visible ? picker.top : parent.bottom
anchors.bottomMargin: Theme.padS
anchors.bottomMargin: picker.visible ? Theme.padS : Theme.padM
height: 26
visible: Media.hasPlayer
@@ -236,18 +242,13 @@ Popout {
implicitWidth: chipLabel.implicitWidth + Theme.padM
implicitHeight: 22
Rectangle {
Skew {
anchors.fill: parent
color: chip.current
? Theme.alpha(Theme.primary, 0.22)
: (chipHover.hovered ? Theme.alpha(Theme.primary, 0.1) : "transparent")
border.width: 1
border.color: chip.current ? Theme.primary : Theme.alpha(Theme.outline, 0.7)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 22 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
borderWidth: 1
borderColor: chip.current ? Theme.primary : Theme.alpha(Theme.outline, 0.7)
}
Text {
@@ -285,23 +286,18 @@ Popout {
implicitWidth: primary ? 38 : 30
implicitHeight: primary ? 38 : 30
Rectangle {
Skew {
anchors.fill: parent
color: {
if (!tb.enabledControl) return "transparent";
if (tbHover.hovered) return tb.primary ? Theme.accent : Theme.alpha(Theme.primary, 0.22);
return tb.primary ? Theme.alpha(Theme.primary, 0.18) : "transparent";
}
border.width: tb.primary ? 2 : 1
border.color: tb.enabledControl
borderWidth: tb.primary ? 2 : 1
borderColor: tb.enabledControl
? (tbHover.hovered ? Theme.text : Theme.alpha(Theme.primary, 0.6))
: Theme.alpha(Theme.outline, 0.4)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, tb.height * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
Behavior on color {
ColorAnimation { duration: Theme.durFast }
}
+3 -8
View File
@@ -202,16 +202,11 @@ Popout {
implicitHeight: 40
Rectangle {
Skew {
anchors.fill: parent
color: Theme.alpha(rateBlock.tone, 0.08)
border.width: 1
border.color: Theme.alpha(rateBlock.tone, 0.35)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, rateBlock.height * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
borderWidth: 1
borderColor: Theme.alpha(rateBlock.tone, 0.35)
}
Row {
+2 -5
View File
@@ -59,15 +59,12 @@ Item {
color: Theme.alpha(root.accent, 0.8)
}
Rectangle {
Skew {
anchors.bottom: parent.bottom
anchors.right: parent.right
width: 16
height: 2
color: Theme.accent
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -1.4, 0, 2.8,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
lean: 1.4
}
}
+150
View File
@@ -0,0 +1,150 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import "root:/config"
import "root:/components"
// The tray's context menu, on the shell's own slab.
//
// Quickshell can hand a StatusNotifier menu straight to Qt's platform menu
// implementation, but that draws a stock widget popup — square corners, system
// palette, no lean — hanging off the corner of a shell that has none of those
// things. This walks the menu with QsMenuOpener instead and draws it as a
// P5Panel, so a right click on a tray icon produces the same object language as
// everything else on the rail.
//
// Dismissal is a Hyprland focus grab: click anywhere outside and the compositor
// tells us to close.
PopupWindow {
id: root
required property Item anchorItem
property var menuHandle: null
// Room around the slab for the entry overshoot and the offset black copy.
readonly property int bleed: 20
readonly property int minWidth: 160
readonly property int maxWidth: 320
// The slab's own lean and padding, which the entries sit inside. Sizing the
// window to the entry widths alone left every label short by that inset and
// elided perfectly ordinary items.
readonly property real slabLean: 0.06
readonly property real slabPad: Theme.padS
readonly property real contentHeight: list.implicitHeight + slabPad * 2
readonly property real contentWidth:
Math.max(root.minWidth, Math.min(root.maxWidth, list.menuWidth))
+ (slabPad + contentHeight * slabLean) * 2
anchor.item: anchorItem
anchor.edges: Edges.Left
anchor.gravity: Edges.Left
anchor.adjustment: PopupAdjustment.SlideY
anchor.margins.right: Theme.popoutGap
implicitWidth: contentWidth + bleed * 2
implicitHeight: contentHeight + bleed * 2
color: "transparent"
visible: shown
property bool shown: false
function open() {
if (!menuHandle) return;
closeAnim.stop();
shown = true;
openAnim.restart();
}
function close() {
if (!shown) return;
openAnim.stop();
closeAnim.restart();
}
function toggle() {
if (shown) close(); else open();
}
// The grab can only take hold once the popup is actually mapped. Asked for
// in the same tick as `shown`, Hyprland has no surface to hand it and
// clears it immediately — which closed the menu the instant it opened.
Timer {
id: grabDelay
interval: 60
running: root.shown
}
HyprlandFocusGrab {
active: root.shown && !grabDelay.running
windows: [root]
onCleared: root.close()
}
Item {
id: holder
x: root.bleed
y: root.bleed
width: root.contentWidth
height: root.contentHeight
opacity: 0
scale: 0.94
transformOrigin: Item.Right
PopoutSurface {
anchors.fill: parent
tone: Theme.accent
padding: root.slabPad
chop: 12
lean: root.slabLean
TrayMenuList {
id: list
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
// Only walk the menu while it is on screen. Bound unconditionally,
// every tray app would get a GetLayout the moment the shell
// starts, for a menu nobody has asked for yet.
menuHandle: root.shown ? root.menuHandle : null
onActivated: root.close()
}
}
}
ParallelAnimation {
id: openAnim
NumberAnimation {
target: holder; property: "opacity"
to: 1.0; duration: Theme.durBase; easing.type: Easing.OutExpo
}
NumberAnimation {
target: holder; property: "scale"
to: 1.0; duration: Theme.durSlow; easing.type: Easing.OutBack
}
NumberAnimation {
target: holder; property: "x"
from: root.bleed + 14; to: root.bleed
duration: Theme.durSlow; easing.type: Easing.OutBack
}
}
ParallelAnimation {
id: closeAnim
NumberAnimation {
target: holder; property: "opacity"
to: 0.0; duration: Theme.durFast; easing.type: Easing.InQuad
}
NumberAnimation {
target: holder; property: "scale"
to: 0.96; duration: Theme.durFast; easing.type: Easing.InQuad
}
onFinished: {
root.shown = false;
holder.x = root.bleed;
}
}
}
+262
View File
@@ -0,0 +1,262 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import "root:/config"
import "root:/components"
// One level of a StatusNotifier menu, drawn in the shell's own language.
//
// Submenus expand inline rather than opening a second window: a tray menu is
// two or three items deep at most, and a stack of leaning slabs chasing each
// other across the screen would read as clutter. The type nests itself for
// child levels, so this file is the whole menu.
Column {
id: root
// A QsMenuHandle — either the item's root menu or a submenu entry.
required property var menuHandle
// Raised by any level when an entry is actually invoked, so the window can
// close itself.
signal activated()
// What the window should size itself to. Deliberately NOT `implicitWidth`.
//
// A Column takes its implicit width from how wide its children ended up, and
// the rows are stretched to the Column's own width so a hover block can span
// the entry. Sizing the window off that closes the loop — window width feeds
// row width feeds Column implicit width feeds window width — and it does not
// settle: each pass adds a float epsilon, so the shell spins at 100% CPU
// forever. Slack's menu was the first to hit it and it took the whole bar
// down with it, past the reach of anything but SIGKILL. Measuring the
// strings against the font instead keeps sizing entirely out of the layout.
readonly property real menuWidth: {
let w = 0;
for (const e of (opener.children?.values ?? [])) {
if (e.isSeparator) continue;
let ew = fm.advanceWidth(root.clean(e.text)) + Theme.padM * 2 + Theme.padS * 2;
if (e.buttonType !== QsMenuButtonType.None || e.icon !== "")
ew += 16 + Theme.padS;
if (e.hasChildren) ew += 18;
w = Math.max(w, ew);
}
return w;
}
// DBus menus carry Qt's mnemonic underscores; nothing here handles
// accelerators, so they would just be stray marks.
function clean(s: string): string {
return String(s ?? "").replace(/_([^_])/g, "$1");
}
spacing: 0
FontMetrics {
id: fm
font.family: Theme.fontMono
font.pixelSize: Theme.fsSmall
font.weight: Theme.weightBody
}
QsMenuOpener {
id: opener
menu: root.menuHandle
}
Repeater {
model: opener.children
Item {
id: row
required property var modelData
readonly property var entry: modelData
readonly property bool interactive: entry.enabled && !entry.isSeparator
readonly property bool hovered: rowHover.hovered && interactive
// Submenus stay collapsed until asked for.
property bool expanded: false
// Height only. Width is the Column's business — see `menuWidth`.
implicitHeight: entry.isSeparator ? 9 : 26 + sub.height
width: root.width
// ------------------------------------------------------ separator
//
// A horizontal rule slants by a fraction of its own width, so at
// menu width the house lean turns a hairline into a 30px diagonal
// gash straight across the entries above and below it. The rise is
// pinned in pixels instead, matching the rail's dividers.
Skew {
anchors.verticalCenter: parent.verticalCenter
visible: row.entry.isSeparator
width: parent.width - Theme.padM * 2
x: Theme.padM
height: 1
vertical: true
lean: width > 0 ? 5 / width : 0
color: Theme.alpha(Theme.outline, 0.9)
}
// ----------------------------------------------------------- item
Item {
id: line
width: parent.width
height: row.entry.isSeparator ? 0 : 26
visible: !row.entry.isSeparator
// Hover is a solid crimson block, the same statement the
// workspace ticks and the rail icons make.
Skew {
anchors.fill: parent
anchors.leftMargin: Theme.padS
anchors.rightMargin: Theme.padS
color: row.hovered ? Theme.accent : "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durFast }
}
}
// Check mark / radio dot, or the entry's own icon. Both occupy
// the same slot so the labels line up either way.
Item {
id: leading
anchors.left: parent.left
anchors.leftMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
width: row.entry.buttonType !== QsMenuButtonType.None
|| row.entry.icon !== "" ? 16 : 0
height: 12
Skew {
anchors.verticalCenter: parent.verticalCenter
visible: row.entry.buttonType !== QsMenuButtonType.None
width: 10
height: 10
borderWidth: 1
borderColor: row.hovered ? Theme.ink : Theme.alpha(Theme.text, 0.6)
color: row.entry.checkState === Qt.Checked
? (row.hovered ? Theme.ink : Theme.accent)
: "transparent"
}
IconImage {
anchors.verticalCenter: parent.verticalCenter
visible: row.entry.buttonType === QsMenuButtonType.None
&& row.entry.icon !== ""
width: 14
height: 14
source: row.entry.icon
asynchronous: true
// Same flattening the tray icons get — brand colour in
// a two-hue palette is the one thing that breaks it.
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: row.hovered ? Theme.ink : Theme.text
}
}
}
Text {
id: label
anchors.left: leading.right
anchors.leftMargin: leading.width > 0 ? Theme.padS : Theme.padM
anchors.right: arrow.left
anchors.rightMargin: Theme.padS
anchors.verticalCenter: parent.verticalCenter
text: root.clean(row.entry.text)
elide: Text.ElideRight
color: !row.entry.enabled
? Theme.muted
: (row.hovered ? Theme.ink : Theme.text)
font.family: Theme.fontMono
font.pixelSize: Theme.fsSmall
font.weight: Theme.weightBody
renderType: Text.NativeRendering
}
Text {
id: arrow
anchors.right: parent.right
anchors.rightMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
width: row.entry.hasChildren ? 18 : 0
visible: row.entry.hasChildren
text: row.expanded ? "" : ""
color: row.hovered ? Theme.ink : Theme.subtext
font.family: Theme.fontIcon
font.pixelSize: Theme.fsMicro
}
HoverHandler {
id: rowHover
enabled: row.interactive
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: row.interactive
onTapped: {
if (row.entry.hasChildren) {
row.expanded = !row.expanded;
} else {
row.entry.triggered();
root.activated();
}
}
}
}
// ------------------------------------------------------- submenu
Item {
id: sub
anchors.top: line.bottom
width: parent.width
height: subLoader.item ? subLoader.item.implicitHeight : 0
clip: true
visible: height > 0
Behavior on height {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutExpo }
}
// Loaded by URL rather than as an inline component: QML rejects
// a type that instantiates itself, even lazily, and a menu tree
// is the one place recursion is the natural shape.
Loader {
id: subLoader
x: Theme.padM
width: parent.width - Theme.padM
onLoaded: item.activated.connect(root.activated)
}
Connections {
target: row
function onExpandedChanged() {
if (row.expanded)
subLoader.setSource(Qt.resolvedUrl("TrayMenuList.qml"),
{ menuHandle: row.entry });
else
subLoader.source = "";
}
}
Skew {
x: Theme.padS
width: 1
height: parent.height
visible: row.expanded
color: Theme.alpha(Theme.accent, 0.8)
}
}
}
}
}