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

329 lines
12 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Common
import qs.DCommon.Widgets
import qs.Widgets
Item {
id: root
property var trayItem: null
property var menuHandle: trayItem?.menu ?? null
property var anchorWindow: null
property var bar: null
signal closeRequested()
signal actionTriggered()
implicitWidth: Math.max(Theme.fieldDefaultWidth, menuColumn.implicitWidth)
implicitHeight: menuColumn.implicitHeight
width: implicitWidth
height: implicitHeight
focus: true
property alias entryStack: entryStack
ListModel {
id: entryStack
}
function clearState() {
entryStack.clear();
}
function topEntry() {
return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null;
}
function showSubMenu(entry) {
if (!entry || !entry.hasChildren)
return;
entryStack.append({
handle: entry
});
const h = entry.menu || entry;
if (h && typeof h.updateLayout === "function")
h.updateLayout();
if (root.anchorWindow) {
submenuHydrator.menu = h;
submenuHydrator.open();
Qt.callLater(() => submenuHydrator.close());
}
}
function goBack() {
if (!entryStack.count)
return;
entryStack.remove(entryStack.count - 1);
}
QsMenuAnchor {
id: submenuHydrator
anchor.window: root.anchorWindow
}
QsMenuOpener {
id: rootOpener
menu: root.menuHandle
}
QsMenuOpener {
id: subOpener
menu: {
const e = root.topEntry();
return e ? (e.menu || e) : null;
}
}
Keys.onEscapePressed: {
if (entryStack.count > 0)
root.goBack();
else
root.closeRequested();
}
Column {
id: menuColumn
width: root.width > 0 ? root.width : Theme.fieldDefaultWidth
spacing: 0
Rectangle {
visible: entryStack.count === 0
width: parent.width
height: Theme.menuItemHeight
radius: Theme.cornerRadiusS
color: Theme.withAlpha(Theme.onSurface, visibilityToggleArea.pressed ? Theme.stateLayerPressed : visibilityToggleArea.containsMouse ? Theme.stateLayerHover : 0)
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: {
const itemTitle = root.trayItem?.tooltipTitle || root.trayItem?.id || I18n.tr("Unknown");
if (root.bar && root.bar.isAutoOverflowTrayItem(root.trayItem))
return itemTitle + " · " + I18n.tr("Keep in Bar");
return itemTitle;
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
elide: Text.ElideMiddle
wrapMode: Text.NoWrap
maximumLineCount: 1
width: parent.width - Theme.spacingS * 2 - (Theme.iconSizeSmall + Theme.spacingS)
}
DIcon {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: {
if (root.bar && root.bar.isAutoOverflowTrayItem(root.trayItem))
return "push_pin";
const itemKey = TrayMenuManager.getTrayItemKey(root.trayItem);
const isHidden = root.bar ? root.bar.isManualHiddenTrayItem(root.trayItem) : SessionData.isHiddenTrayId(itemKey);
return isHidden ? "visibility" : "visibility_off";
}
size: Theme.iconSizeSmall
color: Theme.widgetTextColor
}
MouseArea {
id: visibilityToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
const itemKey = TrayMenuManager.getTrayItemKey(root.trayItem);
if (!itemKey)
return;
if (root.bar && root.bar.isAutoOverflowTrayItem(root.trayItem)) {
root.bar.promoteTrayItemToBar(root.trayItem);
} else {
const isHidden = root.bar ? root.bar.isManualHiddenTrayItem(root.trayItem) : SessionData.isHiddenTrayId(itemKey);
if (isHidden)
SessionData.showTrayId(itemKey);
else
SessionData.hideTrayId(itemKey);
}
root.closeRequested();
}
}
}
Rectangle {
visible: entryStack.count === 0
width: parent.width
height: Theme.dividerWidth
color: Theme.outlineHeavy
}
Rectangle {
visible: entryStack.count > 0
width: parent.width
height: Theme.menuItemHeight
radius: Theme.cornerRadiusS
color: Theme.withAlpha(Theme.onSurface, backArea.pressed ? Theme.stateLayerPressed : backArea.containsMouse ? Theme.stateLayerHover : 0)
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
DIcon {
name: "arrow_back"
size: Theme.iconSizeSmall
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Back")
font.pixelSize: Theme.fontSizeSmall
color: Theme.widgetTextColor
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: backArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.goBack()
}
}
Rectangle {
visible: entryStack.count > 0
width: parent.width
height: Theme.dividerWidth
color: Theme.outlineHeavy
}
Repeater {
model: entryStack.count ? (subOpener.children ? subOpener.children : (topEntry()?.children || [])) : rootOpener.children
Rectangle {
id: menuEntryDelegate
required property var modelData
readonly property var menuEntry: modelData
width: menuColumn.width
height: menuEntry?.isSeparator ? Theme.dividerWidth : Theme.menuItemHeight
radius: menuEntry?.isSeparator ? 0 : Theme.cornerRadiusS
color: {
if (menuEntry?.isSeparator)
return Theme.outlineHeavy;
return Theme.withAlpha(Theme.onSurface, entryItemArea.pressed ? Theme.stateLayerPressed : entryItemArea.containsMouse ? Theme.stateLayerHover : 0);
}
MouseArea {
id: entryItemArea
anchors.fill: parent
hoverEnabled: true
enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false)
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!menuEntry || menuEntry.isSeparator)
return;
if (menuEntry.hasChildren) {
root.showSubMenu(menuEntry);
return;
}
if (typeof menuEntry.activate === "function") {
menuEntry.activate();
} else if (typeof menuEntry.triggered === "function") {
menuEntry.triggered();
}
root.actionTriggered();
}
}
RowLayout {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
visible: !menuEntry?.isSeparator
Rectangle {
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0
radius: menuEntry?.buttonType === 2 ? Theme.cornerRadiusFull : Theme.cornerRadiusXXS
border.width: Theme.outlineWidth
border.color: Theme.outline
color: "transparent"
Rectangle {
anchors.centerIn: parent
width: parent.width - Theme.spacingXS - Theme.spacingXXS
height: parent.height - Theme.spacingXS - Theme.spacingXXS
radius: menuEntry?.buttonType === 2 ? Theme.cornerRadiusFull : Theme.cornerRadiusXXS
color: Theme.primary
visible: menuEntry?.checkState === 2
}
DIcon {
anchors.centerIn: parent
name: "check"
size: Theme.iconSizeSmall - Theme.spacingXS - Theme.spacingXXS
color: Theme.primaryText
visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2
}
}
Item {
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: (menuEntry?.icon ?? "") !== ""
Image {
anchors.fill: parent
source: menuEntry?.icon || ""
sourceSize.width: Theme.iconSizeSmall
sourceSize.height: Theme.iconSizeSmall
fillMode: Image.PreserveAspectFit
smooth: true
}
}
StyledText {
text: menuEntry?.text || ""
font.pixelSize: Theme.fontSizeSmall
color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium
elide: Text.ElideRight
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.minimumWidth: 0
wrapMode: Text.NoWrap
}
Item {
Layout.preferredWidth: Theme.iconSizeSmall
Layout.preferredHeight: Theme.iconSizeSmall
Layout.alignment: Qt.AlignVCenter
visible: menuEntry?.hasChildren ?? false
DIcon {
anchors.centerIn: parent
name: "chevron_right"
size: Theme.iconSizeSmall - 2
color: Theme.widgetTextColor
}
}
}
}
}
}
}