added tray menu + fixed antialising
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user