nova-shell/shell/modules/HoverPanel.qml

275 lines
7.8 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Wayland
import "." as M
import "../services" as S
// Bar panel - fullscreen transparent window so content can resize freely
// without triggering Wayland surface resizes.
//
// Parent drives visibility via showPanel. Click-outside or Esc dismisses
// and emits dismissed(). Pass anchorItem for lazy position computation.
PanelWindow {
id: root
property bool showPanel: false
property Item anchorItem: null
property real anchorX: -1
signal dismissed
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
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 if (root.anchorX >= 0) {
cx = root.anchorX;
} else {
cx = sw / 2;
}
panelContainer.x = Math.max(0, Math.min(Math.round(cx - root.contentWidth / 2), sw - root.contentWidth));
}
// Grace period: after _show(), suppress click-outside dismiss briefly so
// Niri has time to route wl_pointer.enter to the new overlay surface.
property bool _grace: false
Timer {
id: _graceTimer
interval: 400
onTriggered: root._grace = false
}
// Content-change grace: call keepOpen(ms) when panel content is about to
// resize/rebuild (session switch, device list change, etc.).
property bool _contentBusy: false
Timer {
id: _contentBusyTimer
onTriggered: root._contentBusy = false
}
function keepOpen(ms) {
_contentBusy = true;
_contentBusyTimer.interval = ms ?? 400;
_contentBusyTimer.restart();
}
function _show() {
_updatePosition();
if (!hideAnim.running) {
panelContainer.y = -(panelContainer.height > 0 ? panelContainer.height : 400);
panelContainer.opacity = 0;
}
_winVisible = true;
hideAnim.stop();
if (S.Theme.reducedMotion) {
panelContainer.opacity = 1;
panelContainer.y = 0;
} else {
showAnim.start();
}
_grace = true;
_graceTimer.restart();
}
function dismiss() {
if (!_winVisible)
return;
showAnim.stop();
if (S.Theme.reducedMotion) {
_winVisible = false;
dismissed();
} else {
hideAnim.start();
}
_grace = false;
_graceTimer.stop();
}
onShowPanelChanged: {
if (showPanel) {
if (!_winVisible || hideAnim.running)
_show();
} else {
dismiss();
}
}
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;
root.dismissed();
}
}
// Click-outside dismiss.
// TapHandler fires for all taps; position check skips taps inside panelContainer.
// Gated on !_grace so spurious events during the opening window don't dismiss.
Item {
anchors.fill: parent
TapHandler {
enabled: !root._grace
onTapped: {
const p = point.position;
const pad = 8;
if (p.x < panelContainer.x - pad || p.x > panelContainer.x + panelContainer.width + pad || p.y < panelContainer.y - pad || p.y > panelContainer.y + panelContainer.height + pad)
root.dismiss();
}
}
}
// Esc dismiss
Shortcut {
sequence: "Escape"
enabled: root._winVisible
onActivated: root.dismiss()
}
M.PopupBackground {
x: panelContainer.x
y: panelContainer.y
width: panelContainer.width
height: panelContainer.height
opacity: panelContainer.opacity * Math.max(S.Theme.barOpacity, 0.85)
accentColor: root.accentColor
}
Item {
id: panelContainer
x: 0
y: 0
width: root.contentWidth
height: _panelColumn.height
opacity: 0
Column {
id: _panelColumn
width: root.contentWidth
// Header row: title + action buttons
Item {
id: _headerItem
visible: root.panelTitle !== "" || root.titleActionsComponent !== null
width: parent.width
height: 24
Text {
visible: root.panelTitle !== ""
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.panelTitle
color: root.accentColor
font.pixelSize: S.Theme.fontSize - 1
font.bold: true
font.family: S.Theme.fontFamily
}
Loader {
id: _titleActionsLoader
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
sourceComponent: root.titleActionsComponent
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: S.Theme.base03
}
}
Flickable {
id: _flickable
width: parent.width
height: Math.min(panelContent.height, _maxContentHeight)
contentHeight: panelContent.height
clip: contentHeight > height
boundsBehavior: Flickable.StopAtBounds
readonly property real _maxContentHeight: (root.screen?.height ?? 1080) * 0.6 - (_headerItem.visible ? _headerItem.height : 0)
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
radius: S.Theme.radius
opacity: panelContainer.opacity
}
}