mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-04 12:47:51 +02:00
291 lines
12 KiB
QML
291 lines
12 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) 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 close() {
|
|
if (owner && "close" in owner) owner.close()
|
|
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
|
|
if (root.bar.targetBelongsToWindow && !root.bar.targetBelongsToWindow(target, root.anchorWindow)) 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.close()
|
|
}
|
|
}
|
|
|
|
// --- 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
|
|
}
|
|
}
|
|
}
|