Files
arthur-os/shell/Ui/KeyboardPanel.qml
T

213 lines
8.8 KiB
QML

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-flyout) 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: 10
property int padding: 14
property int contentWidth: 280
property int contentHeight: 200
property bool open: false
property int gap: 10 // distance between bar edge and panel
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 = whole screen MINUS the bar's strip. Clicks on the
// bar pass through to the bar layer; clicks anywhere else are caught
// by us and either land on the card (no-op) or trigger dismissal.
readonly property real _barStripSize: bar ? bar.barSize : 0
mask: Region {
width: root.screenW
height: root.screenH
Region {
x: root.barPos === "right" ? root.screenW - root._barStripSize : 0
y: root.barPos === "bottom" ? root.screenH - root._barStripSize : 0
width: (root.barPos === "top" || root.barPos === "bottom") ? root.screenW : root._barStripSize
height: (root.barPos === "top" || root.barPos === "bottom") ? root._barStripSize : root.screenH
intersection: Intersection.Subtract
}
}
// 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
// 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 (!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 {
anchors.fill: parent
enabled: root.open
onClicked: 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: 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
}
}
}