mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
327 lines
9.0 KiB
QML
327 lines
9.0 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import qs.Commons
|
|
|
|
// Simple context menu PopupWindow (similar to TrayMenu)
|
|
// Designed to be rendered inside a PopupMenuWindow for click-outside-to-close
|
|
// Automatically positions itself to respect screen boundaries
|
|
PopupWindow {
|
|
id: root
|
|
|
|
property alias model: repeater.model
|
|
property real itemHeight: 28 // Match TrayMenu
|
|
property real itemPadding: Style.marginM
|
|
property int verticalPolicy: ScrollBar.AsNeeded
|
|
property int horizontalPolicy: ScrollBar.AsNeeded
|
|
|
|
property var anchorItem: null
|
|
property ShellScreen screen: null
|
|
property real minWidth: 120
|
|
property real calculatedWidth: 180
|
|
|
|
readonly property string barPosition: Settings.data.bar.position
|
|
|
|
signal triggered(string action, var item)
|
|
|
|
implicitWidth: calculatedWidth
|
|
implicitHeight: Math.min(600, flickable.contentHeight + (Style.marginS * 2))
|
|
visible: false
|
|
color: "transparent"
|
|
|
|
NText {
|
|
id: textMeasure
|
|
visible: false
|
|
pointSize: Style.fontSizeS
|
|
wrapMode: Text.NoWrap
|
|
elide: Text.ElideNone
|
|
width: undefined
|
|
}
|
|
|
|
NIcon {
|
|
id: iconMeasure
|
|
visible: false
|
|
icon: "bell"
|
|
pointSize: Style.fontSizeS
|
|
applyUiScale: false
|
|
}
|
|
|
|
onModelChanged: {
|
|
Qt.callLater(calculateWidth);
|
|
}
|
|
|
|
function calculateWidth() {
|
|
let maxWidth = 0;
|
|
if (model && model.length) {
|
|
for (let i = 0; i < model.length; i++) {
|
|
const item = model[i];
|
|
if (item && item.visible !== false) {
|
|
const label = item.label || item.text || "";
|
|
textMeasure.text = label;
|
|
textMeasure.forceLayout();
|
|
|
|
let itemWidth = textMeasure.contentWidth + 8;
|
|
|
|
if (item.icon !== undefined) {
|
|
itemWidth += iconMeasure.width + Style.marginS;
|
|
}
|
|
|
|
itemWidth += Style.marginM * 2;
|
|
|
|
if (itemWidth > maxWidth) {
|
|
maxWidth = itemWidth;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
calculatedWidth = Math.max(maxWidth + (Style.marginS * 2), minWidth);
|
|
}
|
|
|
|
anchor.item: anchorItem
|
|
|
|
anchor.rect.x: {
|
|
if (anchorItem && screen) {
|
|
const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
|
|
|
|
// For right bar: position menu to the left of anchor
|
|
if (root.barPosition === "right") {
|
|
let baseX = -implicitWidth - Style.marginM;
|
|
return baseX;
|
|
}
|
|
|
|
// For left bar: position menu to the right of anchor
|
|
if (root.barPosition === "left") {
|
|
let baseX = anchorItem.width + Style.marginM;
|
|
return baseX;
|
|
}
|
|
|
|
// For top/bottom bar: center horizontally on anchor
|
|
const anchorCenterX = anchorItem.width / 2;
|
|
let baseX = anchorCenterX - (implicitWidth / 2);
|
|
|
|
// Calculate menu position on screen
|
|
const menuScreenX = anchorGlobalPos.x + baseX;
|
|
const menuRight = menuScreenX + implicitWidth;
|
|
|
|
// Adjust if menu would clip on the right
|
|
if (menuRight > screen.width - Style.marginM) {
|
|
const overflow = menuRight - (screen.width - Style.marginM);
|
|
return baseX - overflow;
|
|
}
|
|
// Adjust if menu would clip on the left
|
|
if (menuScreenX < Style.marginM) {
|
|
return baseX + (Style.marginM - menuScreenX);
|
|
}
|
|
return baseX;
|
|
}
|
|
return 0;
|
|
}
|
|
anchor.rect.y: {
|
|
if (anchorItem && screen) {
|
|
// Check if using absolute positioning (small anchor point item)
|
|
const isAbsolutePosition = anchorItem.width <= 1 && anchorItem.height <= 1;
|
|
|
|
if (isAbsolutePosition) {
|
|
// For absolute positioning, show menu directly at anchor Y
|
|
// Only adjust if menu would clip at bottom
|
|
const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
|
|
const menuBottom = anchorGlobalPos.y + implicitHeight;
|
|
|
|
if (menuBottom > screen.height - Style.marginM) {
|
|
// Position above the click point instead
|
|
return -implicitHeight;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
const anchorCenterY = anchorItem.height / 2;
|
|
|
|
// Calculate base Y position based on bar orientation
|
|
let baseY;
|
|
if (root.barPosition === "bottom") {
|
|
// For bottom bar: position menu above the bar
|
|
baseY = -(implicitHeight + Style.marginM);
|
|
} else if (root.barPosition === "top") {
|
|
// For top bar: position menu below bar
|
|
baseY = Style.barHeight + Style.marginM;
|
|
} else {
|
|
// For left/right bar: vertically center on anchor
|
|
baseY = anchorCenterY - (implicitHeight / 2);
|
|
}
|
|
|
|
// Calculate menu position on screen
|
|
const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
|
|
const menuScreenY = anchorGlobalPos.y + baseY;
|
|
|
|
const menuBottom = menuScreenY + implicitHeight;
|
|
|
|
// Adjust if menu would clip at bottom
|
|
if (menuBottom > screen.height - Style.marginM) {
|
|
const overflow = menuBottom - (screen.height - Style.marginM);
|
|
return baseY - overflow;
|
|
}
|
|
|
|
return baseY;
|
|
}
|
|
|
|
// Fallback if no screen
|
|
if (root.barPosition === "bottom") {
|
|
return -implicitHeight - Style.marginM;
|
|
}
|
|
return Style.barHeight;
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
Qt.callLater(calculateWidth);
|
|
}
|
|
|
|
Item {
|
|
anchors.fill: parent
|
|
focus: true
|
|
Keys.onEscapePressed: root.close()
|
|
}
|
|
|
|
Rectangle {
|
|
id: menuBackground
|
|
anchors.fill: parent
|
|
color: Color.mSurface
|
|
border.color: Color.mOutline
|
|
border.width: Style.borderS
|
|
radius: Style.iRadiusM
|
|
opacity: root.visible ? 1.0 : 0.0
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: Style.animationNormal
|
|
easing.type: Easing.OutQuad
|
|
}
|
|
}
|
|
}
|
|
|
|
Flickable {
|
|
id: flickable
|
|
anchors.fill: parent
|
|
anchors.margins: Style.marginS
|
|
contentHeight: columnLayout.implicitHeight
|
|
interactive: true
|
|
opacity: root.visible ? 1.0 : 0.0
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: Style.animationNormal
|
|
easing.type: Easing.OutQuad
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: columnLayout
|
|
width: flickable.width
|
|
spacing: 0
|
|
|
|
Repeater {
|
|
id: repeater
|
|
|
|
delegate: Rectangle {
|
|
id: menuItem
|
|
required property var modelData
|
|
required property int index
|
|
|
|
Layout.preferredWidth: parent.width
|
|
Layout.preferredHeight: modelData.visible !== false ? root.itemHeight : 0
|
|
visible: modelData.visible !== false
|
|
color: "transparent"
|
|
|
|
Rectangle {
|
|
id: innerRect
|
|
anchors.fill: parent
|
|
color: mouseArea.containsMouse ? Color.mHover : "transparent"
|
|
radius: Style.iRadiusS
|
|
opacity: modelData.enabled !== false ? 1.0 : 0.5
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: Style.animationFast
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.marginM
|
|
anchors.rightMargin: Style.marginM
|
|
spacing: Style.marginS
|
|
|
|
NIcon {
|
|
visible: modelData.icon !== undefined
|
|
icon: modelData.icon || ""
|
|
pointSize: Style.fontSizeS
|
|
applyUiScale: false
|
|
color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface
|
|
verticalAlignment: Text.AlignVCenter
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: Style.animationFast
|
|
}
|
|
}
|
|
}
|
|
|
|
NText {
|
|
text: modelData.label || modelData.text || ""
|
|
pointSize: Style.fontSizeS
|
|
color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface
|
|
verticalAlignment: Text.AlignVCenter
|
|
Layout.fillWidth: true
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: Style.animationFast
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: mouseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
enabled: (modelData.enabled !== false) && root.visible
|
|
cursorShape: Qt.PointingHandCursor
|
|
|
|
onClicked: {
|
|
if (menuItem.modelData.enabled !== false) {
|
|
root.triggered(menuItem.modelData.action || menuItem.modelData.key || menuItem.index.toString(), menuItem.modelData);
|
|
// Don't call root.close() here - let the parent PopupMenuWindow handle closing
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to open context menu anchored to an item
|
|
// Position is calculated automatically based on bar position and screen boundaries
|
|
function openAtItem(item, itemScreen) {
|
|
if (!item) {
|
|
Logger.w("NPopupContextMenu", "anchorItem is undefined, won't show menu.");
|
|
return;
|
|
}
|
|
|
|
calculateWidth();
|
|
|
|
anchorItem = item;
|
|
screen = itemScreen || null;
|
|
visible = true;
|
|
}
|
|
|
|
function close() {
|
|
visible = false;
|
|
}
|
|
|
|
function closeMenu() {
|
|
close();
|
|
}
|
|
}
|