Compare commits

2 Commits
Author SHA1 Message Date
nvrl ccb54f01d9 added tray menu + fixed antialising 2026-08-11 11:08:44 +02:00
nvrl 58e0ba2f99 fixed tray 2026-08-11 09:40:55 +02:00
22 changed files with 582 additions and 166 deletions
+1 -1
View File
@@ -1 +1 @@
monitor=eDP-1, disable
+1 -6
View File
@@ -128,16 +128,11 @@ PanelWindow {
implicitWidth: Theme.padM * 2
implicitHeight: Theme.barHeight
Rectangle {
Skew {
anchors.centerIn: parent
width: 1
height: 20
color: Theme.alpha(Theme.outline, 0.9)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 20 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
}
}
}
+21 -6
View File
@@ -41,6 +41,13 @@ Item {
readonly property real ruleInset: 5
readonly property real ruleBand: root.standalone ? 0 : root.ruleHeight + root.ruleInset
// Modules with a notion of progress (the player's track position) draw it in
// the same band, so it can never be struck through the content. A module
// cannot do this for itself: anything it declares becomes a child of the
// content holder, whose bottom edge is above the band, and the rule lands
// across the text instead of under it. Negative means "no progress".
property real progress: -1
// Total horizontal inset a module must add on top of its content width.
readonly property real contentPad: standalone
? (panelLoader.item?.contentPad ?? root.padding)
@@ -100,16 +107,24 @@ Item {
height: root.ruleHeight
clip: true
Rectangle {
// Track progress, shown until the pointer arrives and the hover rule
// takes the band over.
Skew {
height: parent.height
width: parent.width * Math.max(0, Math.min(1, root.progress))
color: Theme.accent
visible: root.progress >= 0 && !root.hovered && !root.active
Behavior on width {
NumberAnimation { duration: 480; easing.type: Easing.OutQuad }
}
}
Skew {
height: parent.height
width: root.hovered || root.active ? parent.width : 0
color: root.accent
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 2 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
Behavior on width {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo }
}
+1 -5
View File
@@ -50,15 +50,11 @@ BarPill {
}
}
Rectangle {
Skew {
anchors.verticalCenter: parent.verticalCenter
width: 2
height: 22
color: Theme.alpha(Theme.accent, 0.6)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 22 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
}
SplitText {
+14 -18
View File
@@ -16,6 +16,9 @@ BarPill {
padding: Theme.padS
visible: Media.hasPlayer || width > 1
// Track position, drawn by the pill in its own rule band.
progress: Media.canSeek ? Media.progress : -1
implicitWidth: Media.hasPlayer
? layout.implicitWidth + contentPad * 2
: 0
@@ -83,12 +86,21 @@ BarPill {
}
}
// Title and artist size to the text, up to a cap.
//
// These used to be a flat 130px wide each, which meant a short title
// still reserved 130px and left a hand-sized hole between the artist and
// the equaliser — the pill looked broken rather than compact. They take
// what they need now and only start scrolling past the cap.
Column {
id: meta
anchors.verticalCenter: parent.verticalCenter
spacing: 0
readonly property real cap: 150
Marquee {
width: 130
width: Math.min(meta.cap, implicitWidth)
height: 14
text: Media.title || Media.identity
color: Theme.text
@@ -99,7 +111,7 @@ BarPill {
}
Marquee {
width: 130
width: Math.min(meta.cap, implicitWidth)
height: 12
text: Media.artist || Media.album || Media.identity
color: Theme.muted
@@ -146,22 +158,6 @@ BarPill {
}
}
// Track progress, hairline along the bottom of the pill.
Rectangle {
anchors.bottom: parent.bottom
anchors.bottomMargin: 3
anchors.left: parent.left
anchors.leftMargin: root.leanInset
width: Math.max(0, (root.width - root.leanInset * 2) * Media.progress)
height: 2
color: Theme.accent
visible: Media.canSeek
Behavior on width {
NumberAnimation { duration: 480; easing.type: Easing.OutQuad }
}
}
onVisibleChanged: if (visible) titleGlitch.restart()
Connections {
+3 -6
View File
@@ -35,7 +35,7 @@ BarPill {
}
// Unread badge, chopped like everything else.
Rectangle {
Skew {
visible: Notifs.unread > 0 && !Notifs.dnd
anchors.right: parent.right
anchors.rightMargin: 2
@@ -45,6 +45,8 @@ BarPill {
height: 12
color: Theme.accent
// The badge count stays upright. It used to inherit the shear from the
// transform on the block below, which at 8px left the digits smeared.
Text {
id: badge
anchors.centerIn: parent
@@ -55,11 +57,6 @@ BarPill {
font.weight: Font.Bold
renderType: Text.NativeRendering
}
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 12 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
}
Connections {
+2 -7
View File
@@ -177,17 +177,12 @@ PanelWindow {
implicitWidth: Theme.railWidth
implicitHeight: Theme.padM + 4
Rectangle {
Skew {
anchors.centerIn: parent
width: 22
height: 1
vertical: true
color: Theme.alpha(Theme.outline, 0.9)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, 0, 0, 0,
-Theme.skew, 1, 0, 22 * Theme.skew / 2,
0, 0, 1, 0, 0, 0, 0, 1)
}
}
}
+1 -5
View File
@@ -49,15 +49,11 @@ BarPill {
}
}
component Divider: Rectangle {
component Divider: Skew {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 18
color: Theme.alpha(Theme.outline, 0.8)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 18 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
}
// The CPU/MEM/GPU labels are gone: three characters of chrome per metric,
+9 -9
View File
@@ -5,6 +5,7 @@ import Quickshell.Services.SystemTray
import Quickshell.Widgets
import "root:/config"
import "root:/components"
import "root:/popouts"
// StatusNotifier tray, as a vertical column for the right-hand rail. Left click
// activates, right click opens the app's own menu, middle click is the
@@ -72,7 +73,7 @@ Column {
acceptedButtons: Qt.LeftButton
onTapped: {
if (entry.modelData.onlyMenu) {
menuAnchor.open();
menu.toggle();
} else {
entry.modelData.activate();
}
@@ -81,7 +82,9 @@ Column {
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: menuAnchor.open()
onTapped: {
if (entry.modelData.hasMenu) menu.toggle();
}
}
TapHandler {
@@ -90,13 +93,10 @@ Column {
}
// Menus open to the left, into the screen rather than off its edge.
QsMenuAnchor {
id: menuAnchor
menu: entry.modelData.menu
anchor.item: entry
anchor.edges: Edges.Left
anchor.gravity: Edges.Left
anchor.margins.right: Theme.popoutGap
TrayMenu {
id: menu
anchorItem: entry
menuHandle: entry.modelData.menu
}
}
}
+3 -11
View File
@@ -76,7 +76,7 @@ Item {
}
}
Rectangle {
Skew {
id: tick
anchors.fill: parent
@@ -89,18 +89,10 @@ Item {
if (pip.occupied) return Theme.alpha(Theme.text, 0.5);
return "transparent";
}
border.color: pip.occupied || pip.focused
borderColor: pip.occupied || pip.focused
? "transparent"
: Theme.alpha(Theme.text, 0.35)
border.width: 1
transform: Matrix4x4 {
matrix: Qt.matrix4x4(
1, -Theme.skew, 0, pip.height * Theme.skew,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1)
}
borderWidth: 1
Behavior on color {
ColorAnimation { duration: Theme.durBase }
+3 -9
View File
@@ -1,5 +1,6 @@
import QtQuick
import "root:/config"
import "root:/components"
// A segmented load meter — slanted ticks that light up left to right. Reads as
// a gauge at a glance and keeps the panel language consistent.
@@ -31,9 +32,10 @@ Item {
width: root.segmentWidth
height: root.height
Rectangle {
Skew {
width: root.segmentWidth
height: root.height
lean: root.lean
color: parent.index < root.litCount ? root.activeColor : root.idleColor
// Peak segments glow, so a pegged CPU is visible peripherally.
@@ -41,14 +43,6 @@ Item {
? (parent.index >= root.segments - 2 ? 1.0 : 0.9)
: 1.0
transform: Matrix4x4 {
matrix: Qt.matrix4x4(
1, -root.lean, 0, root.height * root.lean,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1)
}
Behavior on color {
ColorAnimation { duration: Theme.durBase }
}
+12 -10
View File
@@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import "root:/config"
import "root:/components"
import "root:/services"
// One notification, used both in the history list and as a floating toast.
@@ -17,14 +18,20 @@ Item {
signal dismissed()
// The card's own geometry, published so anything drawn along its edges —
// the toast countdown rule — can stay inside the chopped corners instead of
// running out past them.
readonly property real lean: 0.05
readonly property real chop: 12
implicitHeight: panel.height
P5Panel {
id: panel
width: parent.width
height: layout.implicitHeight + Theme.padM * 2
lean: 0.05
chop: 12
lean: root.lean
chop: root.chop
fill: Theme.surface
fillOpacity: 0.97
border: Theme.alpha(root.tone, hover.hovered ? 0.95 : 0.5)
@@ -117,18 +124,13 @@ Item {
implicitWidth: actionLabel.implicitWidth + Theme.padM
implicitHeight: 22
Rectangle {
Skew {
anchors.fill: parent
color: actionHover.hovered
? Theme.alpha(root.tone, 0.3)
: Theme.alpha(root.tone, 0.1)
border.width: 1
border.color: Theme.alpha(root.tone, 0.6)
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(root.tone, 0.6)
}
Text {
+4 -18
View File
@@ -1,5 +1,6 @@
import QtQuick
import "root:/config"
import "root:/components"
// Leaning slider with a chopped handle. Drag or click anywhere on the rail.
Item {
@@ -17,33 +18,23 @@ Item {
readonly property real _lean: Theme.skew * height
// Rail
Rectangle {
Skew {
id: rail
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
height: 6
color: Theme.alpha(Theme.outline, 0.7)
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 6 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
}
// Fill
Rectangle {
Skew {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: Math.max(0, Math.min(1, root.value)) * root.width
height: 6
color: root.enabledControl ? root.accent : Theme.muted
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 6 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
Behavior on width {
enabled: !drag.active
NumberAnimation { duration: Theme.durFast }
@@ -55,7 +46,7 @@ Item {
}
// Handle
Rectangle {
Skew {
id: handle
width: 6
height: hover.hovered || drag.active ? 18 : 14
@@ -63,11 +54,6 @@ Item {
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(1, root.value)) * root.width - width / 2
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, height * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
Behavior on height {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutBack }
}
+55
View File
@@ -0,0 +1,55 @@
import QtQuick
import QtQuick.Shapes
import "root:/config"
import "root:/components"
// A leaning block — the small-scale counterpart to P5Panel.
//
// Every tick, rule, meter segment and slider rail in this shell leans at
// Theme.skew. Shearing a plain Rectangle with a Matrix4x4 does that, but Qt
// rasterises a Rectangle's geometry with no antialiasing at all, so the slanted
// edges come out as a hard pixel staircase — at 18px tall that staircase is
// most of what you see, and it reads as broken rendering rather than as a
// deliberate angle. Drawing the parallelogram as a Shape with the curve
// renderer gets analytic coverage AA on the diagonals instead.
//
// Drop-in for a sheared Rectangle: set width/height as before and use `color`,
// `borderColor` and `borderWidth` the same way.
Shape {
id: root
property color color: "transparent"
property color borderColor: "transparent"
property real borderWidth: 0
// Lean of the vertical edges as a fraction of height, matching P5Panel.
property real lean: Theme.skew
property bool leanLeft: false
// Wide, short pieces — the rail's hairline rules — slant their horizontal
// edges by a fraction of width instead, exactly as P5Panel does for the
// rail slab.
property bool vertical: false
preferredRendererType: Shape.CurveRenderer
asynchronous: false
ShapePath {
fillColor: root.color
strokeColor: root.borderWidth > 0 ? root.borderColor : "transparent"
strokeWidth: root.borderWidth
joinStyle: ShapePath.MiterJoin
capStyle: ShapePath.FlatCap
PathPolyline {
path: {
const w = root.width, h = root.height, l = root.lean;
if (root.vertical)
return Geom.closed(root.leanLeft ? Geom.parallelogramVL(w, h, l)
: Geom.parallelogramV(w, h, l));
return Geom.closed(root.leanLeft ? Geom.parallelogramL(w, h, l)
: Geom.parallelogram(w, h, l));
}
}
}
}
+1 -6
View File
@@ -231,17 +231,12 @@ Item {
Repeater {
model: Math.min(surface.entry.length, 22)
Rectangle {
Skew {
required property int index
width: 10
height: 20
color: surface.failed ? Theme.accent : Theme.primary
transform: Matrix4x4 {
matrix: Qt.matrix4x4(1, -Theme.skew, 0, 20 * Theme.skew,
0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
}
// Each new block snaps in.
Component.onCompleted: pop.start()
NumberAnimation {
+16 -4
View File
@@ -82,12 +82,24 @@ PanelWindow {
}
// Countdown rule that drains along the bottom edge.
Rectangle {
//
// It used to be a full-bleed rectangle across the whole toast,
// which meant it ran straight through the card's chopped
// bottom-left corner and out the other side — a stray bar
// hanging off the card at the one place the silhouette is
// supposed to be cut. It lives inside the cut now, and ends
// where the leaning right edge does.
readonly property real fuseSpan: Math.max(0,
width - card.chop - card.implicitHeight * card.lean)
Skew {
id: fuse
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.bottomMargin: 1
x: card.chop
height: 2
width: parent.width
width: toast.fuseSpan
lean: card.lean
color: Theme.alpha(card.tone, 0.7)
visible: life.running
}
@@ -96,7 +108,7 @@ PanelWindow {
id: life
target: fuse
property: "width"
from: toast.width
from: toast.fuseSpan
to: 0
running: false
duration: Notifs.timeoutFor(toast.modelData)
+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)
}
}
}
}
}