nova-shell/modules/HoverPanel.qml

298 lines
8.4 KiB
QML

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 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
// When pinned, only the pin button area receives input — everything else passes through
mask: _pinned ? _pinMask : null
property Region _pinMask: Region {
x: panelContainer.x + panelContainer.width - 28
y: 0
width: 28
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();
}
}
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.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 (declared first = lowest z = below content)
MouseArea {
anchors.fill: parent
visible: root.popupMode
enabled: root.popupMode
onClicked: 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
// Popup mode: eat clicks on panel background so outer dismiss doesn't fire
MouseArea {
anchors.fill: parent
visible: root.popupMode
enabled: root.popupMode
}
HoverHandler {
enabled: !root.popupMode && !root._pinned
onHoveredChanged: if (!root.popupMode && !root._pinned)
root.panelHovered = hovered
}
Column {
id: _panelColumn
width: root.contentWidth
// Header row: title (optional) + pin button — hover mode only
Item {
visible: !root.popupMode
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: M.Theme.fontSize - 1
font.bold: true
font.family: M.Theme.fontFamily
}
Item {
visible: root.panelHovered || root._pinned
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
opacity: pinHover.hovered || root._pinned ? 1 : 0.35
Behavior on opacity {
NumberAnimation {
duration: 100
}
}
HoverHandler {
id: pinHover
}
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
}
}
}
}
}
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
}
}