diff --git a/modules/Bar.qml b/modules/Bar.qml index e1efa6f..f200738 100644 --- a/modules/Bar.qml +++ b/modules/Bar.qml @@ -171,7 +171,6 @@ PanelWindow { } M.Volume { visible: M.Modules.volume.enable - bar: bar } } diff --git a/modules/BluetoothMenu.qml b/modules/BluetoothMenu.qml index 0c2cfdb..6d4d1e4 100644 --- a/modules/BluetoothMenu.qml +++ b/modules/BluetoothMenu.qml @@ -3,11 +3,10 @@ import Quickshell import Quickshell.Io import "." as M -M.HoverPanel { +M.PopupPanel { id: menuWindow - popupMode: true - contentWidth: 250 + panelWidth: 250 property var _devices: [] diff --git a/modules/Cpu.qml b/modules/Cpu.qml index 815f2cd..d49a9fc 100644 --- a/modules/Cpu.qml +++ b/modules/Cpu.qml @@ -222,7 +222,7 @@ M.BarSection { anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: parent._f.toFixed(2) - color: M.Theme.base04 + color: parent._barColor font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily width: 32 diff --git a/modules/HoverPanel.qml b/modules/HoverPanel.qml index 377a75d..03e5194 100644 --- a/modules/HoverPanel.qml +++ b/modules/HoverPanel.qml @@ -3,36 +3,18 @@ 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). +// Shared hover/OSD panel PanelWindow — slides down from the bar on hover or +// external trigger. Parent module computes showPanel and reads panelHovered. 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 bool showPanel + required property Item anchorItem required property color accentColor property string panelNamespace: "nova-panel" property real contentWidth: 220 + property bool animateHeight: false + property bool panelHovered: false default property alias content: panelContent.children @@ -47,52 +29,49 @@ PanelWindow { anchors.top: true anchors.left: true - anchors.right: true - anchors.bottom: true margins.top: 0 + implicitWidth: panelContent.width + implicitHeight: panelContent.height + + Behavior on implicitHeight { + enabled: root.animateHeight + NumberAnimation { + duration: 100 + easing.type: Easing.OutCubic + } + } + function _updatePosition() { + const pt = anchorItem.mapToGlobal(anchorItem.width / 2, 0); 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)); + margins.left = Math.max(0, Math.min(Math.round(pt.x - (scr?.x ?? 0) - contentWidth / 2), sw - contentWidth)); } - function _show() { - _updatePosition(); - _winVisible = true; - hideAnim.stop(); - showAnim.start(); - } - - function dismiss() { - showAnim.stop(); - hideAnim.start(); - } - - Component.onCompleted: if (popupMode) - _show() - Timer { id: _hideTimer interval: 50 - onTriggered: if (!root.showPanel) - root.dismiss() + onTriggered: { + if (!root.showPanel) { + console.log("[hp:" + panelNamespace + "] hideTimer fired, starting hideAnim"); + showAnim.stop(); + hideAnim.start(); + } + } } + on_WinVisibleChanged: console.log("[hp:" + panelNamespace + "] _winVisible →", _winVisible) + onShowPanelChanged: { - if (root.popupMode) - return; + console.log("[hp:" + panelNamespace + "] showPanel →", showPanel); if (showPanel) { _hideTimer.stop(); - _show(); + _updatePosition(); + _winVisible = true; + hideAnim.stop(); + showAnim.start(); } else { _hideTimer.restart(); } @@ -101,14 +80,14 @@ PanelWindow { ParallelAnimation { id: showAnim NumberAnimation { - target: panelContainer + target: panelContent property: "opacity" to: 1 duration: 120 easing.type: Easing.OutCubic } NumberAnimation { - target: panelContainer + target: panelContent property: "y" to: 0 duration: 150 @@ -119,81 +98,57 @@ PanelWindow { ParallelAnimation { id: hideAnim NumberAnimation { - target: panelContainer + target: panelContent property: "opacity" to: 0 duration: 150 easing.type: Easing.InCubic } NumberAnimation { - target: panelContainer + target: panelContent property: "y" - to: -panelContainer.height + to: -panelContent.height duration: 150 easing.type: Easing.InCubic } - onFinished: { - root._winVisible = false; - if (root.popupMode) - root.dismissed(); - } + onStarted: console.log("[hp:" + panelNamespace + "] hideAnim started") + onFinished: root._winVisible = false } - // Popup mode: click-outside dismiss (declared first = lowest z = below content) - MouseArea { - anchors.fill: parent - visible: root.popupMode - enabled: root.popupMode - onClicked: root.dismiss() + HoverHandler { + onHoveredChanged: { + console.log("[hp:" + panelNamespace + "] hovered →", hovered); + root.panelHovered = hovered; + } } M.PopupBackground { - x: panelContainer.x - y: panelContainer.y - width: panelContainer.width - height: panelContainer.height - opacity: panelContainer.opacity * Math.max(M.Theme.barOpacity, 0.85) + x: panelContent.x + y: panelContent.y + width: panelContent.width + height: panelContent.height + opacity: panelContent.opacity * Math.max(M.Theme.barOpacity, 0.85) accentColor: root.accentColor } - Item { - id: panelContainer - x: 0 - y: -height + Column { + id: panelContent width: root.contentWidth - height: panelContent.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 - } - - Column { - id: panelContent - width: root.contentWidth - - HoverHandler { - enabled: !root.popupMode - onHoveredChanged: if (!root.popupMode) - root.panelHovered = hovered - } - } + y: -height } - // Border overlay — on top of content so full-bleed items don't cover it + // Border overlay — drawn on top of content so full-bleed items (e.g. album art) don't cover it Rectangle { - x: panelContainer.x - y: panelContainer.y - width: panelContainer.width - height: panelContainer.height + x: panelContent.x + y: panelContent.y + width: panelContent.width + height: panelContent.height color: "transparent" border.color: root.accentColor border.width: 1 bottomLeftRadius: M.Theme.radius bottomRightRadius: M.Theme.radius - opacity: panelContainer.opacity + opacity: panelContent.opacity } } diff --git a/modules/Mpris.qml b/modules/Mpris.qml index 252a3cc..abf2a88 100644 --- a/modules/Mpris.qml +++ b/modules/Mpris.qml @@ -103,6 +103,7 @@ M.BarSection { accentColor: root.accentColor panelNamespace: "nova-mpris" contentWidth: 280 + animateHeight: true // Album art Item { diff --git a/modules/NetworkMenu.qml b/modules/NetworkMenu.qml index 4f8e128..bc6a6d2 100644 --- a/modules/NetworkMenu.qml +++ b/modules/NetworkMenu.qml @@ -3,11 +3,10 @@ import Quickshell import Quickshell.Io import "." as M -M.HoverPanel { +M.PopupPanel { id: menuWindow - popupMode: true - contentWidth: 250 + panelWidth: 250 property var _networks: [] diff --git a/modules/PopupPanel.qml b/modules/PopupPanel.qml new file mode 100644 index 0000000..ed8b5e6 --- /dev/null +++ b/modules/PopupPanel.qml @@ -0,0 +1,111 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import "." as M + +// Shared flyout popup window — slides down from the bar, dismisses on +// click outside. Created on demand via Loader; animates in on creation, +// animates out then emits dismissed() for the Loader to deactivate. +PanelWindow { + id: root + + default property alias content: contentCol.children + required property var screen + required property real anchorX + property real panelWidth: 220 + property color accentColor: M.Theme.base05 + + signal dismissed + + visible: true + color: "transparent" + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusiveZone: 0 + WlrLayershell.namespace: "nova-popup" + + anchors.top: true + anchors.left: true + anchors.right: true + anchors.bottom: true + + Component.onCompleted: showAnim.start() + + function dismiss() { + showAnim.stop(); + hideAnim.start(); + } + + // Click outside → dismiss + MouseArea { + anchors.fill: parent + onClicked: root.dismiss() + } + + Item { + id: panel + + x: Math.max(0, Math.min(Math.round(root.anchorX - contentCol.width / 2), root.width - contentCol.width)) + y: 0 + width: contentCol.width + height: contentCol.height + opacity: 0 + + // Eat clicks inside the panel + MouseArea { + anchors.fill: parent + } + + M.PopupBackground { + anchors.fill: parent + accentColor: root.accentColor + } + + Column { + id: contentCol + width: root.panelWidth + topPadding: 4 + bottomPadding: 4 + spacing: 2 + } + } + + ParallelAnimation { + id: showAnim + NumberAnimation { + target: panel + property: "opacity" + from: 0 + to: 1 + duration: 150 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: panel + property: "y" + from: -panel.height + to: 0 + duration: 200 + easing.type: Easing.OutCubic + } + } + + ParallelAnimation { + id: hideAnim + NumberAnimation { + target: panel + property: "opacity" + to: 0 + duration: 150 + easing.type: Easing.InCubic + } + NumberAnimation { + target: panel + property: "y" + to: -panel.height + duration: 150 + easing.type: Easing.InCubic + } + onFinished: root.dismissed() + } +} diff --git a/modules/PowerMenu.qml b/modules/PowerMenu.qml index b93a5f6..53197ac 100644 --- a/modules/PowerMenu.qml +++ b/modules/PowerMenu.qml @@ -2,11 +2,10 @@ import QtQuick import Quickshell import "." as M -M.HoverPanel { +M.PopupPanel { id: menuWindow - popupMode: true - contentWidth: 180 + panelWidth: 180 signal runCommand(var cmd) @@ -57,7 +56,7 @@ M.HoverPanel { required property var modelData required property int index - width: menuWindow.contentWidth + width: menuWindow.panelWidth height: 32 Rectangle { diff --git a/modules/Volume.qml b/modules/Volume.qml index 1b55d60..9631646 100644 --- a/modules/Volume.qml +++ b/modules/Volume.qml @@ -38,9 +38,18 @@ M.BarSection { return streams; } + property bool _expanded: false property bool _osdActive: false readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered - readonly property bool _showPanel: _anyHover || _osdActive + readonly property bool _showPanel: _anyHover || _expanded || _osdActive + + on_ShowPanelChanged: { + console.log("[vol] showPanel →", _showPanel, "| expanded:", _expanded, "| anyHover:", _anyHover); + if (!_showPanel) + _expanded = false; + } + + on_ExpandedChanged: console.log("[vol] expanded →", _expanded) onVolumeChanged: _flashPanel() onMutedChanged: _flashPanel() @@ -56,8 +65,6 @@ M.BarSection { onTriggered: root._osdActive = false } - required property var bar - M.BarIcon { icon: root._volumeIcon minIcon: "\uF028" @@ -91,7 +98,7 @@ M.BarSection { } } - // OSD panel — hover shows slider only, fixed height, no resize + // Unified volume panel — hover shows slider, click expands to show devices M.HoverPanel { id: hoverPanel showPanel: root._showPanel @@ -100,12 +107,14 @@ M.BarSection { accentColor: root.accentColor panelNamespace: "nova-volume" contentWidth: 220 + animateHeight: true - // Slider row + // Compact: slider row Item { width: parent.width height: 36 + // Mute toggle Text { id: muteIcon anchors.left: parent.left @@ -123,6 +132,7 @@ M.BarSection { } } + // Slider Item { id: slider anchors.left: muteIcon.right @@ -137,11 +147,13 @@ M.BarSection { color: M.Theme.base02 radius: 3 } + Rectangle { width: parent.width * Math.min(1, Math.max(0, root.volume)) height: parent.height color: root._volumeColor radius: 3 + Behavior on width { NumberAnimation { duration: 80 @@ -179,7 +191,7 @@ M.BarSection { } } - // Sink name row — click chevron to open mixer popup + // Sink name — click to expand/collapse device list Item { width: parent.width height: 22 @@ -202,7 +214,7 @@ M.BarSection { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: "\uF078" + text: root._expanded ? "\uF077" : "\uF078" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 3 font.family: M.Theme.iconFontFamily @@ -210,24 +222,17 @@ M.BarSection { TapHandler { cursorShape: Qt.PointingHandCursor - onTapped: mixerLoader.active = true + onTapped: root._expanded = true } } - } - // Mixer popup — separate window, no resize issues - LazyLoader { - id: mixerLoader - active: false + // Expanded: output device list + Column { + id: deviceList + width: parent.width + visible: root._expanded - M.PopupPanel { - accentColor: root.accentColor - screen: root.bar.screen - anchorX: root.mapToGlobal(root.width / 2, 0).x - (root.bar.screen?.x ?? 0) - panelWidth: 220 - onDismissed: mixerLoader.active = false - - // Output devices + // Separator Rectangle { width: parent.width - 16 height: 1 @@ -235,6 +240,7 @@ M.BarSection { color: M.Theme.base03 } + // Header Text { width: parent.width height: 24 @@ -252,7 +258,7 @@ M.BarSection { delegate: Item { required property var modelData - width: 220 + width: deviceList.width height: 28 readonly property bool _active: modelData === root.sink @@ -287,31 +293,38 @@ M.BarSection { TapHandler { onTapped: { Pipewire.preferredDefaultAudioSink = modelData; - mixerLoader.active = false; + root._expanded = false; } } } } - // Streams section - Rectangle { - visible: root._streamList.length > 0 - width: parent.width - 16 - height: visible ? 1 : 0 - anchors.horizontalCenter: parent.horizontalCenter - color: M.Theme.base03 - } - - Text { + // Streams header (only if there are streams) + Item { visible: root._streamList.length > 0 width: parent.width - height: visible ? 24 : 0 - verticalAlignment: Text.AlignVCenter - leftPadding: 12 - text: "Applications" - color: M.Theme.base04 - font.pixelSize: M.Theme.fontSize - 1 - font.family: M.Theme.fontFamily + height: visible ? streamSep.height + streamHeader.height : 0 + + Rectangle { + id: streamSep + width: parent.width - 16 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: M.Theme.base03 + } + + Text { + id: streamHeader + anchors.top: streamSep.bottom + width: parent.width + height: 24 + verticalAlignment: Text.AlignVCenter + leftPadding: 12 + text: "Applications" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + } } Repeater { @@ -321,7 +334,7 @@ M.BarSection { id: streamEntry required property var modelData - width: 220 + width: deviceList.width height: 32 readonly property string _appName: modelData.properties["application.name"] || modelData.description || modelData.name || "Unknown" @@ -409,6 +422,12 @@ M.BarSection { } } } + + // Bottom padding + Item { + width: 1 + height: 4 + } } } } diff --git a/modules/qmldir b/modules/qmldir index 076eec8..c79fa03 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -12,6 +12,7 @@ Clock 1.0 Clock.qml Volume 1.0 Volume.qml Tray 1.0 Tray.qml TrayMenu 1.0 TrayMenu.qml +PopupPanel 1.0 PopupPanel.qml PopupBackground 1.0 PopupBackground.qml HoverPanel 1.0 HoverPanel.qml PowerMenu 1.0 PowerMenu.qml