refactor: unified BarModule base component, click-to-open panels, remove pinning

This commit is contained in:
Damocles 2026-04-25 11:52:20 +02:00
parent 034f0b6d85
commit 26476dc930
33 changed files with 273 additions and 517 deletions

View file

@ -4,33 +4,19 @@ import Quickshell.Wayland
import "." as M
import "../services" as S
// Unified bar panel fullscreen transparent window so content can resize
// freely without triggering Wayland surface resizes.
// 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).
// Parent drives visibility via showPanel. Click-outside or Esc dismisses
// and emits dismissed(). Pass anchorItem for lazy position computation.
PanelWindow {
id: root
property bool popupMode: false
// Hover mode
property bool showPanel: true
property bool showPanel: false
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
@ -43,22 +29,6 @@ PanelWindow {
color: "transparent"
property bool _winVisible: false
property bool _pinned: false
property real _dragStartX: 0
property real _dragStartY: 0
property bool _dragging: false
// When pinned: mask = full panel so content is interactive, desktop accessible outside.
// When dragging: mask = null (full screen) so Niri keeps delivering events when cursor
// leaves the panel bounds mid-drag.
mask: (_pinned && !_dragging) ? _pinMask : null
property Region _pinMask: Region {
x: panelContainer.x
y: panelContainer.y
width: panelContainer.width
height: panelContainer.height
}
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusiveZone: 0
@ -80,38 +50,29 @@ PanelWindow {
if (root.anchorItem) {
const pt = root.anchorItem.mapToGlobal(root.anchorItem.width / 2, 0);
cx = pt.x - (scr?.x ?? 0);
} else {
} 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 auto-close briefly so Niri has time
// to route wl_pointer.enter to the new overlay surface (cursor may be stationary).
// Popup mode gets a longer window (1500ms) so the user can move the cursor to the
// panel content after clicking the bar without accidentally dismissing it.
// 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: root.popupMode ? 1500 : 400
onTriggered: {
root._grace = false;
if (!root.showPanel && !root._pinned)
root.dismiss();
}
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.) to prevent the
// hover-drop-on-resize from closing the panel.
// resize/rebuild (session switch, device list change, etc.).
property bool _contentBusy: false
Timer {
id: _contentBusyTimer
onTriggered: {
root._contentBusy = false;
if (!root.showPanel && !root._grace && !root._pinned)
_hideTimer.restart();
}
onTriggered: root._contentBusy = false
}
function keepOpen(ms) {
_contentBusy = true;
@ -121,13 +82,7 @@ PanelWindow {
function _show() {
_updatePosition();
// Only snap to closed position when genuinely opening from scratch.
// If we are interrupting a hide animation, animate back from the current
// y/opacity so there's no visible jump the showAnim NumberAnimations
// always run from the *current* value to their target.
if (!hideAnim.running) {
// Explicitly set y before animating avoids the y:-height binding (live, depends on
// _panelColumn.height) surviving a 00 no-op animation when layout isn't done yet.
panelContainer.y = -(panelContainer.height > 0 ? panelContainer.height : 400);
panelContainer.opacity = 0;
}
@ -144,12 +99,12 @@ PanelWindow {
}
function dismiss() {
_pinned = false;
if (!_winVisible)
return;
showAnim.stop();
if (S.Theme.reducedMotion) {
_winVisible = false;
if (popupMode)
dismissed();
dismissed();
} else {
hideAnim.start();
}
@ -157,30 +112,12 @@ PanelWindow {
_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();
// Only replay the open animation if the panel is actually closed or
// currently animating away. If it is already visible, stopping the
// hide timer is sufficient calling _show() would reset y/opacity to
// 0 and cause a visible flash when the cursor crosses the gap between
// the bar module and the panel.
if (!_winVisible || hideAnim.running)
_show();
} else {
_hideTimer.restart();
dismiss();
}
}
@ -220,17 +157,15 @@ PanelWindow {
}
onFinished: {
root._winVisible = false;
if (root.popupMode)
root.dismissed();
root.dismissed();
}
}
// Popup mode: click-outside dismiss.
// 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.
// Gated on !_grace so spurious events during the opening window don't dismiss.
Item {
anchors.fill: parent
visible: root.popupMode
TapHandler {
enabled: !root._grace
@ -243,6 +178,13 @@ PanelWindow {
}
}
// Esc dismiss
Shortcut {
sequence: "Escape"
enabled: root._winVisible
onActivated: root.dismiss()
}
M.PopupBackground {
x: panelContainer.x
y: panelContainer.y
@ -260,52 +202,17 @@ PanelWindow {
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.
// Header row: title + action buttons
Item {
id: _headerItem
visible: !root.popupMode || root.panelTitle !== "" || root.titleActionsComponent !== null
visible: root.panelTitle !== "" || root.titleActionsComponent !== null
width: parent.width
height: 24
// Drag header to freely reposition panel while pinned (hover mode only).
// _dragging clears the input mask so Niri keeps delivering events when the
// cursor leaves the panel bounds during a fast drag.
DragHandler {
enabled: root._pinned && !root.popupMode
onActiveChanged: {
root._dragging = active;
if (active) {
root._dragStartX = panelContainer.x;
root._dragStartY = panelContainer.y;
}
}
onActiveTranslationChanged: {
if (active) {
const sw = root.screen?.width ?? 1920;
const sh = root.screen?.height ?? 1080;
panelContainer.x = Math.max(0, Math.min(root._dragStartX + activeTranslation.x, sw - root.contentWidth));
panelContainer.y = Math.max(0, Math.min(root._dragStartY + activeTranslation.y, sh - panelContainer.height));
}
}
}
// Show move cursor on header when pinned
HoverHandler {
enabled: root._pinned && !root.popupMode
cursorShape: Qt.SizeAllCursor
}
Text {
visible: root.panelTitle !== ""
anchors.left: parent.left
@ -318,52 +225,14 @@ PanelWindow {
font.family: S.Theme.fontFamily
}
// Action buttons anchored left of pin button slot
Loader {
id: _titleActionsLoader
anchors.right: _pinBtn.left
anchors.right: parent.right
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
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
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 : S.Theme.base04
font.pixelSize: S.Theme.fontSize - 1
font.family: S.Theme.iconFontFamily
Behavior on color {
ColorAnimation {
duration: 100
}
}
}
}
// Divider at bottom of header
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
@ -391,7 +260,7 @@ PanelWindow {
}
}
// Border overlay on top of content so full-bleed items don't cover it
// Border overlay - on top of content so full-bleed items don't cover it
Rectangle {
x: panelContainer.x
y: panelContainer.y