import QtQuick import Quickshell import Quickshell.Wayland import "." as M // Unified bar panel — fullscreen transparent window so content can resize // freely without triggering Wayland surface resizes. // // Hover mode (popupMode: false, default): // Parent drives visibility via showPanel. Panel auto-closes when showPanel // drops, with a 50ms debounce. Reports panelHovered back to parent. // Pass anchorItem for lazy position computation on each show. // // Popup mode (popupMode: true): // Shows immediately on creation. Stays open until click-outside or // dismiss() call. Emits dismissed() when closed — caller's LazyLoader // sets active: false. Pass anchorX (screen-relative centre x). PanelWindow { id: root property bool popupMode: false // Hover mode property bool showPanel: true property Item anchorItem: null property bool panelHovered: false // Popup mode property real anchorX: -1 signal dismissed // Shared required property color accentColor property string panelTitle: "" property Component titleActionsComponent: null property string panelNamespace: "nova-panel" property real contentWidth: 220 default property alias content: panelContent.children visible: _winVisible color: "transparent" property bool _winVisible: false property bool _pinned: false property real _dragStartX: 0 // When pinned, only the header bar receives input — content passes through to desktop. // Full header width allows drag-to-reposition; pin button tap is within that region too. mask: _pinned ? _pinMask : null property Region _pinMask: Region { x: panelContainer.x y: 0 width: panelContainer.width height: 24 } WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.exclusiveZone: 0 WlrLayershell.namespace: root.panelNamespace property real topMargin: 0 anchors.top: true anchors.left: true anchors.right: true anchors.bottom: true margins.top: topMargin function _updatePosition() { const scr = screen; const sw = scr?.width ?? 1920; let cx; if (root.anchorItem) { const pt = root.anchorItem.mapToGlobal(root.anchorItem.width / 2, 0); cx = pt.x - (scr?.x ?? 0); } else { cx = root.anchorX; } panelContainer.x = Math.max(0, Math.min(Math.round(cx - root.contentWidth / 2), sw - root.contentWidth)); } // Grace period: after _show(), suppress auto-close briefly so Niri has time // to route wl_pointer.enter to the new overlay surface (cursor may be stationary). property bool _grace: false Timer { id: _graceTimer interval: 400 onTriggered: { root._grace = false; if (!root.showPanel && !root._pinned) root.dismiss(); } } // Content-change grace: call keepOpen(ms) when panel content is about to // resize/rebuild (session switch, device list change, etc.) to prevent the // hover-drop-on-resize from closing the panel. property bool _contentBusy: false Timer { id: _contentBusyTimer onTriggered: { root._contentBusy = false; if (!root.showPanel && !root._grace && !root._pinned) root._hideTimer.restart(); } } function keepOpen(ms) { _contentBusy = true; _contentBusyTimer.interval = ms ?? 400; _contentBusyTimer.restart(); } function _show() { _updatePosition(); _winVisible = true; hideAnim.stop(); showAnim.start(); _grace = true; _graceTimer.restart(); } function dismiss() { _pinned = false; showAnim.stop(); hideAnim.start(); _grace = false; _graceTimer.stop(); } Component.onCompleted: if (popupMode) _show() Timer { id: _hideTimer interval: 150 onTriggered: if (!root.showPanel && !root._grace && !root._pinned && !root._contentBusy) root.dismiss() } onShowPanelChanged: { if (root.popupMode) return; if (showPanel) { _hideTimer.stop(); _show(); } else { _hideTimer.restart(); } } ParallelAnimation { id: showAnim NumberAnimation { target: panelContainer property: "opacity" to: 1 duration: 120 easing.type: Easing.OutCubic } NumberAnimation { target: panelContainer property: "y" to: 0 duration: 150 easing.type: Easing.OutCubic } } ParallelAnimation { id: hideAnim NumberAnimation { target: panelContainer property: "opacity" to: 0 duration: 150 easing.type: Easing.InCubic } NumberAnimation { target: panelContainer property: "y" to: -panelContainer.height duration: 150 easing.type: Easing.InCubic } onFinished: { root._winVisible = false; if (root.popupMode) root.dismissed(); } } // Popup mode: click-outside dismiss. // TapHandler fires for all taps; position check skips taps inside panelContainer. // Gated on !_grace so spurious events during the 400ms opening window don't dismiss. Item { anchors.fill: parent visible: root.popupMode TapHandler { enabled: !root._grace onTapped: { const p = point.position; if (p.x < panelContainer.x || p.x > panelContainer.x + panelContainer.width || p.y < panelContainer.y || p.y > panelContainer.y + panelContainer.height) root.dismiss(); } } } M.PopupBackground { x: panelContainer.x y: panelContainer.y width: panelContainer.width height: panelContainer.height opacity: panelContainer.opacity * Math.max(M.Theme.barOpacity, 0.85) accentColor: root.accentColor } Item { id: panelContainer x: 0 y: -height width: root.contentWidth height: _panelColumn.height opacity: 0 HoverHandler { enabled: !root.popupMode && !root._pinned onHoveredChanged: if (!root.popupMode && !root._pinned) root.panelHovered = hovered } Column { id: _panelColumn width: root.contentWidth // Header row: title + action buttons + pin — shown in hover mode always, // and in popup mode when a title or actions are provided. Item { id: _headerItem visible: !root.popupMode || root.panelTitle !== "" || root.titleActionsComponent !== null width: parent.width height: 24 // Drag header to reposition panel while pinned (hover mode only) DragHandler { enabled: root._pinned && !root.popupMode yAxis.enabled: false onActiveChanged: if (active) root._dragStartX = panelContainer.x onActiveTranslationChanged: { if (active) { const sw = root.screen?.width ?? 1920; panelContainer.x = Math.max(0, Math.min(root._dragStartX + activeTranslation.x, sw - root.contentWidth)); } } } // Show move cursor on header when pinned HoverHandler { enabled: root._pinned && !root.popupMode cursorShape: Qt.SizeHorCursor } Text { visible: root.panelTitle !== "" anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: root.panelTitle color: root.accentColor font.pixelSize: M.Theme.fontSize - 1 font.bold: true font.family: M.Theme.fontFamily } // Action buttons — anchored left of pin button slot Loader { id: _titleActionsLoader anchors.right: _pinBtn.left anchors.rightMargin: 4 anchors.verticalCenter: parent.verticalCenter sourceComponent: root.titleActionsComponent } // Pin button — zero-width in popup mode so actions anchor flush to right Item { id: _pinBtn anchors.right: parent.right anchors.rightMargin: root.popupMode ? 0 : 4 anchors.verticalCenter: parent.verticalCenter width: root.popupMode ? 0 : 20 height: 20 visible: !root.popupMode TapHandler { cursorShape: Qt.PointingHandCursor onTapped: { root._pinned = !root._pinned; if (!root._pinned && !root.showPanel) root.dismiss(); } } Text { anchors.centerIn: parent text: root._pinned ? "\uDB81\uDC03" : "\uDB82\uDD31" color: root._pinned ? root.accentColor : M.Theme.base04 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.iconFontFamily Behavior on color { ColorAnimation { duration: 100 } } } } // Divider at bottom of header Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: 1 color: M.Theme.base03 } } Column { id: panelContent width: parent.width } } } // Border overlay — on top of content so full-bleed items don't cover it Rectangle { x: panelContainer.x y: panelContainer.y width: panelContainer.width height: panelContainer.height color: "transparent" border.color: root.accentColor border.width: 1 bottomLeftRadius: M.Theme.radius bottomRightRadius: M.Theme.radius opacity: panelContainer.opacity } }