import QtQuick import Quickshell import Quickshell.Wayland import qs.Commons // Layer-shell popup attached to a bar widget icon, designed for // click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon). // // Built on PanelWindow with WlrKeyboardFocus.Exclusive rather than // PopupWindow (xdg-popup). Layer-shell surfaces declared Exclusive get // keyboard focus from Hyprland *at map time*, which is the protocol-level // equivalent of focus-on-launch for xdg-toplevels. xdg-popups don't get // that — they only receive keys after a click/hover routes focus through // their parent surface — so keyboard-summoned popups fell flat without it. // // API is a subset of Common.PopupCard: anchorItem, owner, bar, open, // padding, margin, contentWidth/Height, default contentItem. Missing on // purpose (for now): centerOnBar, triggerMode ("hover"), containsMouse. // Hover-mode popups (system-stats, weather) and centered popups // (calendar week-view) need extra plumbing before migrating; converting // them is a follow-up. // // Positioning: full-screen layer-shell with the card placed inside at // `cardOrigin`. We use the bar window's height/width for the perpendicular // axis (away-from-bar) because mapToItem on the anchor returns // bar-content-relative coords with internal layout offsets baked in // (e.g. ~13px from the bar's vertical centering of its widget row). The // parallel axis (along-the-bar) uses the anchor's content x/y since the // bar spans full screen on that axis. // // Outside-click dismissal: an overlay MouseArea catches clicks, with the // QsWindow.mask subtracting the bar strip so clicks on the bar still // reach the bar widgets (activePopout coordinator hands off to another // popup if the user clicks a different bar icon). PanelWindow { id: root required property Item anchorItem required property QtObject bar property var owner: null property int margin: Style.gapsOut property int padding: Style.spacing.popupPadding property int contentWidth: 280 property int contentHeight: 200 property bool open: false property int gap: Style.gapsOut // distance between bar edge and panel // Item that should take keyboard focus once the panel maps. Typically a // PanelKeyCatcher inside the panel content. Layer-shell grants focus to // the surface at map time, but Qt still needs an active-focus target // inside the surface for Keys.onPressed handlers to fire. Schedule the // focus through Qt.callLater so it runs after the surface is fully // mapped and child items have completed layout. property Item focusTarget: null default property alias contentItem: contentHolder.children readonly property var coordinatorKey: owner || root readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null readonly property string barPos: bar ? bar.position : "top" function closePopout() { if (owner && "closePopout" in owner) owner.closePopout() else root.open = false } // --- screen + lifetime --------------------------------------------------- screen: anchorWindow ? anchorWindow.screen : null visible: open || card.opacity > 0 color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.namespace: "omarchy-keyboard-panel" WlrLayershell.layer: WlrLayer.Overlay // Keyboard focus follows `open` (NOT `visible`). The window remains // mapped during the fade-out so the opacity animation has something to // animate, but keyboard/click ownership must release the moment the // logical close fires — otherwise the user is locked out for 140ms. WlrLayershell.keyboardFocus: open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None // Full-screen layer-shell. The visible card is positioned inside via // `cardOrigin`. The `mask` below makes the bar area click-through (so // the user can click another bar icon while the panel is open and the // activePopout coordinator swaps to that popup); everywhere else, the // overlay catches the click and dismisses via the MouseArea below. anchors { top: true bottom: true left: true right: true } // Clickable region is the whole screen. Clicks in the bar strip are // forwarded to registered bar buttons so switching between panel icons // works in one click even when the overlay surface is above the bar. readonly property real _barStripSize: { if (!bar) return 0 var actual = (root.barPos === "top" || root.barPos === "bottom") ? root.barH : root.barW return Math.max(bar.barSize, actual) + root.gap } mask: Region { width: root.screenW height: root.screenH } // Track every layout change between the bar's contentItem and the // anchor item. `transform` updates whenever any item in that chain // moves/resizes, which is what makes the position binding below // actually reactive — mapToItem on its own is a one-shot. TransformWatcher { id: anchorWatcher a: anchorWindow ? anchorWindow.contentItem : null b: anchorItem } // Anchor item's position within the bar's content surface. For a // full-width top bar, the content x maps directly to screen x; the y // returned here has the bar's internal padding baked in (e.g. ~13px // from vertical centering of the widget row), which is why `cardOrigin` // below uses `barH` for the perpendicular axis instead of this y. readonly property point anchorScreenPos: { anchorWatcher.transform // reactive dependency if (!anchorItem || !anchorWindow) return Qt.point(0, 0) return anchorItem.mapToItem(anchorWindow.contentItem, 0, 0) } readonly property real anchorW: anchorItem ? anchorItem.width : 0 readonly property real anchorH: anchorItem ? anchorItem.height : 0 readonly property real screenW: screen ? screen.width : 0 readonly property real screenH: screen ? screen.height : 0 readonly property real availableCardWidth: screenW > 0 ? Math.max(120, screenW - ((barPos === "left" || barPos === "right") ? barW + gap + margin : margin * 2)) : 0 readonly property real availableCardHeight: screenH > 0 ? Math.max(120, screenH - ((barPos === "top" || barPos === "bottom") ? barH + gap + margin : margin * 2)) : 0 function fittedContentWidth(width, cap) { var desired = Math.max(1, Number(width) || 1) var maxWidth = root.availableCardWidth > 0 ? root.availableCardWidth : desired if (cap !== undefined && Number(cap) > 0) maxWidth = Math.min(maxWidth, Number(cap)) return Math.round(Math.min(desired, maxWidth)) } function fittedContentHeight(implicitHeight, cap) { var desired = Math.max(root.padding * 2, (Number(implicitHeight) || 0) + root.padding * 2) var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired if (cap !== undefined && Number(cap) > 0) maxHeight = Math.min(maxHeight, Number(cap)) return Math.round(Math.min(desired, maxHeight)) } function cappedContentHeight(height) { var desired = Math.max(root.padding * 2, Number(height) || root.padding * 2) var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired return Math.round(Math.min(desired, maxHeight)) } // Desired top-left of the card in screen coordinates. For the // perpendicular axis (away-from-bar) we anchor to the bar window's edge // directly — not the anchor item's y/x — because mapToItem(barContent) // returns coordinates in the bar's content space, which can be offset // from the bar surface's screen-anchored corner by internal layout // (centering wrappers, padding). The bar's surface IS aligned to its // anchored screen edge, so using `barW`/`barH` gives the right edge // regardless of how the bar's internal widgets are positioned. For the // parallel axis (along the bar) the anchor item's reported position is // still consistent with the bar content origin, so it's accurate for // centering the card under the icon. readonly property real barW: anchorWindow ? anchorWindow.width : screenW readonly property real barH: anchorWindow ? anchorWindow.height : 0 readonly property point cardOrigin: { if (!anchorItem || !bar) return Qt.point(margin, margin) var x = 0, y = 0 if (barPos === "bottom") { x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2 y = screenH - barH - contentHeight - gap } else if (barPos === "left") { x = barW + gap y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2 } else if (barPos === "right") { x = screenW - barW - contentWidth - gap y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2 } else { // "top" (default) x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2 y = barH + gap } x = Math.max(margin, Math.min(x, screenW - contentWidth - margin)) y = Math.max(margin, Math.min(y, screenH - contentHeight - margin)) return Qt.point(Math.round(x), Math.round(y)) } // --- popout coordination (same-bar single-popout model) ----------------- // Coordinate on `open`, not `visible`. `visible` lags into the fade-out // animation, which made ownership transfer to a sibling popup race. onOpenChanged: { if (open && focusTarget) Qt.callLater(function() { if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() }) if (!bar) return if (open) bar.requestPopout(coordinatorKey) else if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey) } // --- outside-click dismissal -------------------------------------------- // Catches clicks anywhere in the clickable region (i.e. everywhere on // screen except the bar strip, which is masked out). The card has its // own MouseArea below so clicks on it don't bubble up here. Disabled // during the fade-out so the dying overlay doesn't swallow clicks that // were meant for the apps behind it. MouseArea { id: dismissArea anchors.fill: parent enabled: root.open hoverEnabled: true property bool hoveringBar: false cursorShape: hoveringBar ? Qt.PointingHandCursor : Qt.ArrowCursor function inBarRegion(px, py) { if (root.barPos === "bottom") return py >= root.screenH - root._barStripSize if (root.barPos === "left") return px <= root._barStripSize if (root.barPos === "right") return px >= root.screenW - root._barStripSize return py <= root._barStripSize } function barPoint(px, py) { if (root.barPos === "bottom") return Qt.point(px, py - (root.screenH - root.barH)) if (root.barPos === "right") return Qt.point(px - (root.screenW - root.barW), py) return Qt.point(px, py) } function pressTargetAt(px, py) { if (!root.anchorWindow || !root.anchorWindow.contentItem || !root.bar || !root.bar.clickTargets) return null var p = barPoint(px, py) var targets = root.bar.clickTargets for (var i = targets.length - 1; i >= 0; i--) { var target = targets[i] if (!target || !target.triggerPress || target.visible === false || target.opacity === 0 || !target.mapToItem) continue var pos = root.anchorWindow.itemPosition(target) if (p.x >= pos.x && p.x <= pos.x + target.width && p.y >= pos.y && p.y <= pos.y + target.height) return target } return null } function forwardBarClick(px, py, button) { var target = pressTargetAt(px, py) if (!target) return false target.triggerPress(button) return true } onPositionChanged: function(mouse) { hoveringBar = inBarRegion(mouse.x, mouse.y) } onExited: hoveringBar = false onClicked: function(mouse) { if (inBarRegion(mouse.x, mouse.y) && forwardBarClick(mouse.x, mouse.y, mouse.button)) return root.closePopout() } } // --- card ---------------------------------------------------------------- Rectangle { id: card x: root.cardOrigin.x y: root.cardOrigin.y width: root.contentWidth height: root.contentHeight color: Color.popups.background border.color: Color.popups.border border.width: Math.max(1, Style.space(2)) radius: Style.cornerRadius opacity: root.open ? 1.0 : 0 Behavior on opacity { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } // Swallow clicks on the card so they don't bubble to the dismissal // MouseArea behind us. MouseArea { anchors.fill: parent } Item { id: contentHolder anchors.fill: parent anchors.margins: root.padding } } }