Files
Huỳnh Thiện Lộc 8ff7ce2cea feat(island): add system tray slot and overflow menu to compact home (#3780)
* feat(island): add system tray slot and overflow menu to compact home

- Add system tray slot and overflow expander menu in Dynamic Island
- Share tray icon path resolution and DBus context menu fallback in TrayMenuManager
- Support edge-anchored context menu positioning across top, bottom, left, and right
- Lock bottom and edge anchoring in DContextMenu to prevent animation jitter

* fix(island): only instantiate tray menus when tray slot is enabled

* feat(island): expand tray item and caret click area to slot height

* fix(island): use Theme.surfaceTextHover and restore DContextMenu height behavior

* refactor(tray): extract shared TrayMenuContent and TrayOverflowGrid, support tray order and IPC in island

* Revert "refactor(tray): extract shared TrayMenuContent and TrayOverflowGrid, support tray order and IPC in island"

This reverts commit 29888f89cd.

* refactor(tray): use shared TrayMenuManager helpers and TrayItemIcon, support tray order and IPC in island

* refactor(tray): extract shared TrayMenuContent and TrayOverflowGrid

* feat(island): support tray item drag and drop reordering

* fix(island): prevent re-entrant tray drag reordering on model destruction

* fix(tray): remove native submenu anchor hydration to prevent popup stutter

* fix(tray): decouple submenu hydrator from anchor window to avoid stutter

* fix(tray): restore anchor window on submenu hydrator and disable on island

* tray: fix bar overflow surface & reuse max visible items on the island
2026-10-14 16:55:26 -04:00

510 lines
21 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
import qs.DCommon.Widgets
import qs.Widgets
Item {
id: root
visible: false
width: 0
height: 0
property var menuItems: []
property Component customContent: null
property real customContentWidth: 0
property string layerNamespace: "dms:context-menu"
property real menuMargin: Theme.spacingS
property real minMenuWidth: Theme.fieldDefaultWidth
property real itemHeight: Theme.menuItemHeight
property real itemFontSize: Theme.fontSizeSmall
property bool keyboardNavigable: false
property var transientSurfaceTracker: null
property var targetScreen: null
property real anchorX: 0
property real anchorY: 0
property real originX: 0
property real originY: 0
property string menuEdge: "top"
property bool openState: false
property bool renderActive: false
property int selectedMenuIndex: -1
property bool keyboardNavigation: false
readonly property alias contextWindow: menuWindow
readonly property alias customContentItem: customContentLoader.item
readonly property bool blurActive: renderActive && openState && BlurService.enabled
readonly property real maxMenuWidth: Math.max(0, (targetScreen?.width ?? Theme.launcherWidthMicro) - menuMargin * 2)
readonly property real maxMenuHeight: Math.max(0, (targetScreen?.height ?? Theme.launcherHeightDefault) - menuMargin * 2)
readonly property string longestMenuText: {
let longest = "";
for (const menuItem of menuItems) {
const text = menuItem.text || "";
if (text.length > longest.length)
longest = text;
}
return longest;
}
readonly property bool hasTrailingIcons: menuItems.some(menuItem => !!menuItem.trailingIcon)
readonly property real naturalMenuWidth: customContent ? Math.max(minMenuWidth, customContentWidth) : Math.max(minMenuWidth, menuTextMetrics.width + Theme.iconSize + Theme.spacingS * 5 + (hasTrailingIcons ? Theme.iconSizeMedium + Theme.spacingS : 0))
readonly property real effectiveMenuWidth: Math.max(0, Math.min(maxMenuWidth, naturalMenuWidth))
readonly property real naturalMenuHeight: (customContent ? customContentLoader.implicitHeight : menuItemsHeight()) + Theme.spacingS * 2
readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight)
readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5
readonly property int visibleItemCount: menuItems.filter(menuItem => isSelectable(menuItem)).length
signal backdropRightClicked(real x, real y)
onRenderActiveChanged: transientSurfaceTracker?.setActive(root, renderActive, contextWindow)
Component.onDestruction: transientSurfaceTracker?.unregister(root)
Connections {
target: root.transientSurfaceTracker
ignoreUnknownSignals: true
function onCloseRequested() {
root.hide();
}
}
StyledTextMetrics {
id: menuTextMetrics
text: root.longestMenuText
font.pixelSize: root.itemFontSize
}
function menuItemsHeight() {
const shown = menuItems.filter(menuItem => !menuItem.hidden);
let h = 0;
for (const menuItem of shown)
h += itemHeightFor(menuItem);
if (shown.length > 1)
h += (shown.length - 1) * Theme.groupedListGap;
return h;
}
function isSelectable(menuItem) {
return menuItem.type === "item" && !menuItem.hidden;
}
function itemHeightFor(menuItem) {
if (menuItem.hidden)
return 0;
switch (menuItem.type) {
case "separator":
return Theme.spacingXS + Theme.dividerWidth;
case "component":
return menuItem.height ?? 0;
default:
return itemHeight;
}
}
function open(screen, x, y, fromKeyboard) {
targetScreen = screen;
anchorX = x;
anchorY = y;
originX = x;
originY = y;
selectedMenuIndex = fromKeyboard ? 0 : -1;
keyboardNavigation = !!fromKeyboard;
renderActive = true;
openState = true;
Qt.callLater(() => {
menuFlickable.contentY = 0;
if (keyboardNavigable)
keyboardHandler.forceActiveFocus();
ensureSelectedVisible();
});
}
function openFromBar(anchor) {
if (!anchor?.screen)
return;
targetScreen = anchor.screen;
let x = anchor.x - effectiveMenuWidth / 2;
let y = anchor.edge === "bottom" ? anchor.y - effectiveMenuHeight : anchor.y;
if (anchor.isVertical) {
x = anchor.edge === "left" ? anchor.x : anchor.x - effectiveMenuWidth;
y = anchor.y - effectiveMenuHeight / 2;
}
open(anchor.screen, x, y, false);
originX = anchor.x;
originY = anchor.y;
}
function hide() {
if (!renderActive)
return;
openState = false;
// The fullscreen window grabs input until renderActive drops; never leave that to an animation that may not run.
if (!closeFade.running)
renderActive = false;
}
function activate(menuItem) {
if (menuItem?.enabled === false)
return;
if (typeof menuItem?.action === "function")
menuItem.action();
if (!menuItem.keepOpen)
hide();
}
function selectNext() {
if (visibleItemCount === 0)
return;
keyboardNavigation = true;
for (let step = 0; step < visibleItemCount; step++) {
selectedMenuIndex = (selectedMenuIndex + 1) % visibleItemCount;
if (menuItems[selectedDelegateIndex()]?.enabled !== false)
break;
}
ensureSelectedVisible();
}
function selectPrevious() {
if (visibleItemCount === 0)
return;
keyboardNavigation = true;
for (let step = 0; step < visibleItemCount; step++) {
selectedMenuIndex = (selectedMenuIndex - 1 + visibleItemCount) % visibleItemCount;
if (menuItems[selectedDelegateIndex()]?.enabled !== false)
break;
}
ensureSelectedVisible();
}
function selectedDelegateIndex() {
let itemIndex = 0;
for (let i = 0; i < menuItems.length; i++) {
if (!isSelectable(menuItems[i]))
continue;
if (itemIndex === selectedMenuIndex)
return i;
itemIndex++;
}
return -1;
}
function ensureSelectedVisible() {
Qt.callLater(() => {
const delegate = menuRepeater.itemAt(selectedDelegateIndex());
if (!delegate)
return;
const top = delegate.y;
const bottom = top + delegate.height;
const viewTop = menuFlickable.contentY;
const viewBottom = viewTop + menuFlickable.height;
if (top < viewTop) {
menuFlickable.contentY = Math.max(0, top);
return;
}
if (bottom > viewBottom)
menuFlickable.contentY = Math.min(Math.max(0, menuFlickable.contentHeight - menuFlickable.height), bottom - menuFlickable.height);
});
}
function activateSelected() {
const index = selectedDelegateIndex();
if (index >= 0)
activate(menuItems[index]);
}
PanelWindow {
id: menuWindow
screen: root.targetScreen
visible: root.renderActive
color: "transparent"
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: {
if (!root.keyboardNavigable || !root.renderActive)
return WlrKeyboardFocus.None;
if (PopoutManager.screenshotActive || CompositorService.useHyprlandFocusGrab)
return WlrKeyboardFocus.None;
return WlrKeyboardFocus.Exclusive;
}
anchors {
top: true
left: true
right: true
bottom: true
}
WindowBlur {
targetWindow: menuWindow
surfaceColor: menuContainer.color
blurX: root.blurActive ? menuContainer.x + openScaleTransform.origin.x * (1 - menuContainer.openScale) : 0
blurY: root.blurActive ? menuContainer.y + openScaleTransform.origin.y * (1 - menuContainer.openScale) : 0
blurWidth: root.blurActive ? menuContainer.width * menuContainer.openScale : 0
blurHeight: root.blurActive ? menuContainer.height * menuContainer.openScale : 0
blurRadius: Theme.windowRadius * menuContainer.openScale
}
MouseArea {
anchors.fill: parent
z: -1
enabled: root.renderActive
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
root.backdropRightClicked(mouse.x, mouse.y);
return;
}
root.hide();
}
}
Item {
id: keyboardHandler
anchors.fill: parent
focus: root.keyboardNavigable && root.openState
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Down:
root.selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
root.selectPrevious();
event.accepted = true;
return;
case Qt.Key_Return:
case Qt.Key_Enter:
root.activateSelected();
event.accepted = true;
return;
case Qt.Key_Escape:
case Qt.Key_Left:
root.hide();
event.accepted = true;
return;
}
}
Rectangle {
id: menuContainer
x: {
if (root.menuEdge === "right")
return Math.max(root.menuMargin, Math.min(menuWindow.width - width - root.menuMargin, root.anchorX - width));
return Math.max(root.menuMargin, Math.min(menuWindow.width - width - root.menuMargin, root.anchorX));
}
y: {
if (root.menuEdge === "bottom")
return Math.max(root.menuMargin, Math.min(menuWindow.height - height - root.menuMargin, root.anchorY - height));
if (root.menuEdge === "left" || root.menuEdge === "right")
return Math.max(root.menuMargin, Math.min(menuWindow.height - height - root.menuMargin, root.anchorY - height / 2));
return Math.max(root.menuMargin, Math.min(menuWindow.height - height - root.menuMargin, root.anchorY));
}
width: root.effectiveMenuWidth
height: root.effectiveMenuHeight
color: Theme.readableSurface
radius: Theme.windowRadius
border.color: BlurService.borderColor
border.width: BlurService.borderWidth
opacity: root.openState ? 1 : 0
transform: Scale {
id: openScaleTransform
origin.x: root.originX - menuContainer.x
origin.y: root.originY - menuContainer.y
xScale: menuContainer.openScale
yScale: menuContainer.openScale
}
property real openScale: root.openState ? 1 : Theme.effectScaleCollapsed
Behavior on openScale {
NumberAnimation {
duration: SettingsData.reduceMotion ? 0 : Theme.expressiveDurations.expressiveFastSpatial
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial
}
}
Behavior on height {
enabled: root.openState
NumberAnimation {
duration: SettingsData.reduceMotion ? 0 : Theme.expressiveDurations.expressiveFastSpatial
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
}
}
// Fade out only; a fade-in shows the blur through an empty card.
Behavior on opacity {
enabled: menuContainer.opacity > 0
NumberAnimation {
id: closeFade
duration: SettingsData.reduceMotion ? 0 : Theme.shortDuration
easing.type: Theme.emphasizedEasing
onRunningChanged: {
if (!running && !root.openState)
root.renderActive = false;
}
}
}
ElevationShadow {
anchors.fill: parent
z: -1
level: Theme.elevationLevel2
targetRadius: parent.radius
targetColor: "transparent"
shadowEnabled: Theme.elevationEnabled
}
DFlickable {
id: menuFlickable
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
contentWidth: width
contentHeight: menuColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
interactive: root.menuScrolls
Column {
id: menuColumn
width: menuFlickable.width
spacing: Theme.groupedListGap
Loader {
id: customContentLoader
width: menuColumn.width
active: root.customContent !== null
visible: active
sourceComponent: root.customContent
}
Repeater {
id: menuRepeater
model: root.customContent ? [] : root.menuItems
Item {
id: menuItemDelegate
required property var modelData
required property int index
readonly property bool isSeparator: modelData.type === "separator"
readonly property bool isComponent: modelData.type === "component"
readonly property int itemIndex: root.menuItems.slice(0, index).filter(menuItem => root.isSelectable(menuItem)).length
width: menuColumn.width
height: root.itemHeightFor(modelData)
visible: !modelData.hidden
Loader {
anchors.fill: parent
active: menuItemDelegate.isComponent && root.renderActive
sourceComponent: menuItemDelegate.modelData.component ?? null
}
Rectangle {
visible: menuItemDelegate.isSeparator
width: parent.width - Theme.spacingS * 2
height: Theme.outlineWidth
anchors.centerIn: parent
color: Theme.outlineHeavy
}
Rectangle {
id: menuRow
readonly property bool selected: root.keyboardNavigation && root.selectedMenuIndex === menuItemDelegate.itemIndex
readonly property bool destructive: menuItemDelegate.modelData?.isDestructive ?? false
readonly property bool itemEnabled: menuItemDelegate.modelData?.enabled !== false
readonly property color contentColor: {
if (!itemEnabled)
return Theme.onSurface_38;
if (destructive)
return Theme.error;
return selected ? Theme.onSelectedContainer : Theme.onSurface;
}
visible: !menuItemDelegate.isSeparator && !menuItemDelegate.isComponent
anchors.fill: parent
radius: Theme.cornerRadiusS
color: {
if (!itemEnabled)
return "transparent";
if (destructive)
return selected ? Theme.errorSelected : itemMouseArea.containsMouse ? Theme.errorHover : "transparent";
return selected ? Theme.selectedContainer : itemMouseArea.pressed ? Theme.withAlpha(Theme.onSurface, Theme.stateLayerPressed) : itemMouseArea.containsMouse ? Theme.withAlpha(Theme.onSurface, Theme.stateLayerHover) : "transparent";
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DIcon {
name: menuItemDelegate.modelData?.icon ?? ""
size: Theme.iconSizeMedium
color: menuRow.contentColor
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: menuItemDelegate.modelData.text || ""
font.pixelSize: root.itemFontSize
color: menuRow.contentColor
font.weight: Theme.fontWeight
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
width: parent.width - (Theme.iconSizeMedium + Theme.spacingS) * (trailingIcon.visible ? 2 : 1)
}
}
DIcon {
id: trailingIcon
visible: name !== ""
name: menuItemDelegate.modelData?.trailingIcon ?? ""
size: Theme.iconSizeMedium
color: menuRow.contentColor
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
}
DRipple {
id: menuItemRipple
rippleColor: menuRow.contentColor
cornerRadius: Theme.cornerRadiusS
}
MouseArea {
id: itemMouseArea
anchors.fill: parent
enabled: menuRow.itemEnabled
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
root.keyboardNavigation = false;
root.selectedMenuIndex = menuItemDelegate.itemIndex;
}
onPressed: mouse => menuItemRipple.trigger(mouse.x, mouse.y)
onClicked: root.activate(menuItemDelegate.modelData)
}
}
}
}
}
}
}
}
}
}