diff --git a/quickshell/bar/Bar.qml b/quickshell/bar/Bar.qml index 1f0e063..ff4c756 100644 --- a/quickshell/bar/Bar.qml +++ b/quickshell/bar/Bar.qml @@ -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) - } } } } diff --git a/quickshell/bar/BarPill.qml b/quickshell/bar/BarPill.qml index 3cd055d..611c3c5 100644 --- a/quickshell/bar/BarPill.qml +++ b/quickshell/bar/BarPill.qml @@ -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 } } diff --git a/quickshell/bar/Clock.qml b/quickshell/bar/Clock.qml index 08c0ebe..0cbf91c 100644 --- a/quickshell/bar/Clock.qml +++ b/quickshell/bar/Clock.qml @@ -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 { diff --git a/quickshell/bar/MediaPill.qml b/quickshell/bar/MediaPill.qml index aa33344..969b584 100644 --- a/quickshell/bar/MediaPill.qml +++ b/quickshell/bar/MediaPill.qml @@ -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 { diff --git a/quickshell/bar/NotifButton.qml b/quickshell/bar/NotifButton.qml index bd2572e..b0854e8 100644 --- a/quickshell/bar/NotifButton.qml +++ b/quickshell/bar/NotifButton.qml @@ -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 { diff --git a/quickshell/bar/Rail.qml b/quickshell/bar/Rail.qml index c675215..6f62722 100644 --- a/quickshell/bar/Rail.qml +++ b/quickshell/bar/Rail.qml @@ -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) - } } } diff --git a/quickshell/bar/Resources.qml b/quickshell/bar/Resources.qml index 09f8631..04a7e12 100644 --- a/quickshell/bar/Resources.qml +++ b/quickshell/bar/Resources.qml @@ -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, diff --git a/quickshell/bar/Tray.qml b/quickshell/bar/Tray.qml index badd95f..35a394a 100644 --- a/quickshell/bar/Tray.qml +++ b/quickshell/bar/Tray.qml @@ -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 } } } diff --git a/quickshell/bar/Workspaces.qml b/quickshell/bar/Workspaces.qml index bc3fd6c..ae6d433 100644 --- a/quickshell/bar/Workspaces.qml +++ b/quickshell/bar/Workspaces.qml @@ -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 } diff --git a/quickshell/components/MeterBar.qml b/quickshell/components/MeterBar.qml index 39fbef1..658ea04 100644 --- a/quickshell/components/MeterBar.qml +++ b/quickshell/components/MeterBar.qml @@ -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 } } diff --git a/quickshell/components/NotifCard.qml b/quickshell/components/NotifCard.qml index 6c2ef9a..209876c 100644 --- a/quickshell/components/NotifCard.qml +++ b/quickshell/components/NotifCard.qml @@ -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 { diff --git a/quickshell/components/P5Slider.qml b/quickshell/components/P5Slider.qml index 2cb351a..4b1d8e4 100644 --- a/quickshell/components/P5Slider.qml +++ b/quickshell/components/P5Slider.qml @@ -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 } } diff --git a/quickshell/components/Skew.qml b/quickshell/components/Skew.qml new file mode 100644 index 0000000..9fbed52 --- /dev/null +++ b/quickshell/components/Skew.qml @@ -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)); + } + } + } +} diff --git a/quickshell/lock/LockFace.qml b/quickshell/lock/LockFace.qml index 6709e75..bda8486 100644 --- a/quickshell/lock/LockFace.qml +++ b/quickshell/lock/LockFace.qml @@ -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 { diff --git a/quickshell/overlays/Toasts.qml b/quickshell/overlays/Toasts.qml index 4341c59..f00d7bc 100644 --- a/quickshell/overlays/Toasts.qml +++ b/quickshell/overlays/Toasts.qml @@ -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) diff --git a/quickshell/popouts/CalendarPopout.qml b/quickshell/popouts/CalendarPopout.qml index fc51d86..8fe3b97 100644 --- a/quickshell/popouts/CalendarPopout.qml +++ b/quickshell/popouts/CalendarPopout.qml @@ -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 { diff --git a/quickshell/popouts/MediaPopout.qml b/quickshell/popouts/MediaPopout.qml index ef48c22..ca6b32b 100644 --- a/quickshell/popouts/MediaPopout.qml +++ b/quickshell/popouts/MediaPopout.qml @@ -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 } } diff --git a/quickshell/popouts/NetPopout.qml b/quickshell/popouts/NetPopout.qml index 5ca4836..91d4e6c 100644 --- a/quickshell/popouts/NetPopout.qml +++ b/quickshell/popouts/NetPopout.qml @@ -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 { diff --git a/quickshell/popouts/PopoutHeader.qml b/quickshell/popouts/PopoutHeader.qml index 17932b1..57129db 100644 --- a/quickshell/popouts/PopoutHeader.qml +++ b/quickshell/popouts/PopoutHeader.qml @@ -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 } } diff --git a/quickshell/popouts/TrayMenu.qml b/quickshell/popouts/TrayMenu.qml new file mode 100644 index 0000000..a3cf399 --- /dev/null +++ b/quickshell/popouts/TrayMenu.qml @@ -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; + } + } +} diff --git a/quickshell/popouts/TrayMenuList.qml b/quickshell/popouts/TrayMenuList.qml new file mode 100644 index 0000000..6bfbcf2 --- /dev/null +++ b/quickshell/popouts/TrayMenuList.qml @@ -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) + } + } + } + } +} diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 2338158..1a25566 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -1,5 +1,3 @@ -//@ pragma UseQApplication - import QtQuick import Quickshell import "root:/config"