feat(quickshell): popouts grow out of the frame

Hover panels used to float a gap away from their trigger as free-standing
cards. They are now part of the hull: flush against the frame edge they
open from, painted in the hull colour, joined to it through concave
fillets, with the porthole rim carried round their open side, and they
slide out from behind the frame instead of fading and scaling in.

Popout places itself rather than relying on the compositor's slide
adjustment. A panel is centred on its trigger and, if that lands it near
one of the opening's corners, docked into the corner — attached to the
bar and the rail at once — so it never leaves a thin strip of opening
between itself and the rail. The calendar, network and audio panels dock
top-right; notifications dock bottom-right.

PopoutSurface leaves fill, border and corner ticks to the hull when it is
inside a Popout, and keeps them in the launcher and the ask panel. Its
attached fill opacity is 0 rather than a transparent colour, because
P5Panel re-applies fillOpacity to the colour and turned "transparent"
into 97% black.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nvrlandClaude Opus 5.5 committed 2026-10-04 20:34:17 +02:00
1 parent ecc7d2884c
commit 196abc2311
3 files changed
+266 -92

No files matched your search

+238 -87
View File
@@ -1,10 +1,22 @@
import QtQuick
import QtQuick.Shapes
import Quickshell
import "root:/config"
// Hover-driven panel that hangs off a bar item. Opens on a short dwell, stays
// Hover-driven panel that grows out of the hull. Opens on a short dwell, stays
// open while the pointer is over either the trigger or the panel itself, and
// can be pinned with a click. Slides in from behind the bar with an overshoot.
// can be pinned with a click.
//
// A popout is not a card floating near its trigger; it is more of the frame.
// It sits flush against the edge it opens from — the underside of the bar, or
// the inner edge of the rail — is painted in the hull colour, joins that edge
// through two concave fillets, and carries the porthole's rim round its open
// side. It slides out from behind the frame rather than fading in. This is
// caelestia's idea, and the same construction as the dashboard drawer.
//
// The panel is placed explicitly rather than by the compositor's slide
// adjustment: centred on its trigger, then clamped so the fillets never run
// into the opening's rounded corners or onto the rail.
PopupWindow {
id: root
@@ -19,9 +31,14 @@ PopupWindow {
property Item initialFocusItem: null
property bool remappingPinned: false
// Extra surface around the content so the entry overshoot and glow are not
// clipped by the window edge.
readonly property int bleed: 24
// Fillet radius, and the radius of the panel's own free corners — the same
// as the opening's, so a popout meets the frame the way the opening does.
readonly property real fillet: Theme.frameRadius
readonly property real corner: Theme.frameRadius
// How far the panel reaches back over the frame. One pixel, which covers
// the rim the hull draws along the edge so the seam disappears.
readonly property real seam: 1
default property alias body: contentHolder.data
@@ -31,28 +48,112 @@ PopupWindow {
signal dismissedFully()
// Panels hanging off the vertical rail open sideways, so the entry slide has
// to follow whichever axis the popout actually travels along, and start on
// the side nearest its trigger.
// Panels hanging off the vertical rail open sideways, out of its inner
// edge; everything else drops from the underside of the bar.
readonly property bool horizontal:
fromEdge === Edges.Left || fromEdge === Edges.Right
readonly property string slideProperty: horizontal ? "x" : "y"
readonly property real slideOrigin: bleed + (fromEdge === Edges.Left
|| fromEdge === Edges.Top ? 14 : -14)
// Which edges of the frame the panel is attached to. A panel that would
// land within reach of the opening's corner docks into the corner instead,
// attached to the bar and the rail at once — otherwise it leaves a thin
// sliver of opening between itself and the rail, two walls a few pixels
// apart.
//
// "top" hangs from the bar fillets left and right
// "right" juts from the rail fillets above and below
// "topRight" fills the top-right corner fillets left and below
// "bottomRight" fills the bottom-right one fillets above and left
property string dock: horizontal ? "right" : "top"
// How close to a corner a panel may come before it docks into it.
readonly property real dockReach: 48
// The window: the content, plus a fillet beside each attached edge, plus
// the seam reaching back over the frame.
readonly property real windowWidth: {
switch (dock) {
case "top": return contentWidth + fillet * 2;
case "right": return contentWidth + seam;
default: return contentWidth + fillet + seam;
}
}
readonly property real windowHeight: {
switch (dock) {
case "top": return contentHeight + seam;
case "right": return contentHeight + fillet * 2;
default: return contentHeight + fillet + seam;
}
}
// Where the content sits inside the window.
readonly property real contentX: dock === "right" ? 0 : fillet
readonly property real contentY: dock === "top" || dock === "topRight" ? seam : fillet
// 0 = tucked behind the frame, 1 = fully out.
property real reveal: 0
// Placement, in the trigger's own coordinates. Worked out once per open
// in place(): item positions are not bindable, and a popout should not
// drift while it is up anyway.
property real placeX: 0
property real placeY: 0
function place() {
if (!anchorItem) return;
const p = anchorItem.mapToItem(null, 0, 0);
const winW = anchorItem.Window.width;
const winH = anchorItem.Window.height;
const f = root.fillet, R = Theme.frameRadius;
if (root.horizontal) {
// The rail's window starts under the bar at the opening's right
// edge, so its x = 0 is that edge and its y = 0 is the bar's
// underside.
const h = root.contentHeight + f * 2;
const y = p.y + anchorItem.height / 2 - h / 2;
const lo = R, hi = winH - Theme.frame - R - h;
if (y < lo + root.dockReach) {
root.dock = "topRight";
root.placeY = -root.seam - p.y;
} else if (y > hi - root.dockReach) {
root.dock = "bottomRight";
root.placeY = winH - Theme.frame + root.seam - root.windowHeight - p.y;
} else {
root.dock = "right";
root.placeY = y - p.y;
}
root.placeX = root.seam - root.windowWidth - p.x;
} else {
// The bar's window is the full screen width, so the opening runs
// from `frame` to `width - railWidth`.
const w = root.contentWidth + f * 2;
const x = p.x + anchorItem.width / 2 - w / 2;
const lo = Theme.frame + R - f;
const hi = winW - Theme.railWidth - R + f - w;
if (x > hi - root.dockReach) {
root.dock = "topRight";
root.placeX = winW - Theme.railWidth + root.seam - root.windowWidth - p.x;
} else {
root.dock = "top";
root.placeX = Math.max(lo, x) - p.x;
}
root.placeY = winH - root.seam - p.y;
}
}
anchor.item: anchorItem
anchor.edges: fromEdge
anchor.gravity: fromEdge
anchor.adjustment: horizontal
? PopupAdjustment.SlideY
: PopupAdjustment.SlideX
anchor.margins.top: Theme.popoutGap
anchor.margins.bottom: Theme.popoutGap
anchor.margins.left: Theme.popoutGap
anchor.margins.right: Theme.popoutGap
anchor.rect.x: root.placeX
anchor.rect.y: root.placeY
anchor.rect.w: 1
anchor.rect.h: 1
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Bottom | Edges.Right
anchor.adjustment: PopupAdjustment.None
implicitWidth: contentWidth + bleed * 2
implicitHeight: contentHeight + bleed * 2
implicitWidth: windowWidth
implicitHeight: windowHeight
color: "transparent"
visible: shown
grabFocus: pinned && shown
@@ -97,6 +198,7 @@ PopupWindow {
function open() {
PopupCoordinator.claim(root);
root.place();
exitAnim.stop();
shown = true;
enterAnim.restart();
@@ -152,10 +254,7 @@ PopupWindow {
remappingPinned = false;
pinned = false;
shown = false;
contentHolder.opacity = 0;
contentHolder.scale = 0.94;
contentHolder.x = root.bleed;
contentHolder.y = root.bleed;
root.reveal = 0;
PopupCoordinator.release(root);
root.dismissedFully();
}
@@ -178,83 +277,135 @@ PopupWindow {
}
}
// Everything slides as one sheet, clipped to the window, so the panel
// appears to come out from behind the frame.
Item {
id: contentHolder
focus: root.pinned
x: root.bleed
y: root.bleed
width: root.contentWidth
height: root.contentHeight
anchors.fill: parent
clip: true
opacity: 0
transformOrigin: {
switch (root.fromEdge) {
case Edges.Left: return Item.Right;
case Edges.Right: return Item.Left;
case Edges.Top: return Item.Bottom;
default: return Item.Top;
Item {
id: sheet
width: root.windowWidth
height: root.windowHeight
x: root.horizontal ? root.windowWidth * (1 - root.reveal) : 0
y: root.horizontal ? 0 : -root.windowHeight * (1 - root.reveal)
Shape {
id: silhouette
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
readonly property real w: width
readonly property real h: height
readonly property real f: root.fillet
readonly property real r: root.corner
// The open side only: from the frame, round the panel, back to
// the frame. The rim follows this; the fill closes it back
// along the frame with `closing`.
readonly property string edge: {
switch (root.dock) {
case "right":
// Down the top fillet, round the two free corners on
// the left, out through the bottom fillet.
return `M ${w} 0 A ${f} ${f} 0 0 1 ${w - f} ${f}`
+ ` H ${r} A ${r} ${r} 0 0 0 0 ${f + r}`
+ ` V ${h - f - r} A ${r} ${r} 0 0 0 ${r} ${h - f}`
+ ` H ${w - f} A ${f} ${f} 0 0 1 ${w} ${h}`;
case "topRight":
// Off the bar through the left fillet, round the one
// free corner, onto the rail through the bottom fillet.
return `M 0 0 A ${f} ${f} 0 0 1 ${f} ${f}`
+ ` V ${h - f - r} A ${r} ${r} 0 0 0 ${f + r} ${h - f}`
+ ` H ${w - f} A ${f} ${f} 0 0 1 ${w} ${h}`;
case "bottomRight":
// Off the rail through the top fillet, round the one
// free corner, onto the bottom band through the left.
return `M ${w} 0 A ${f} ${f} 0 0 1 ${w - f} ${f}`
+ ` H ${f + r} A ${r} ${r} 0 0 0 ${f} ${f + r}`
+ ` V ${h - f} A ${f} ${f} 0 0 1 0 ${h}`;
default:
// Hanging from the bar: the dashboard's silhouette.
return `M 0 0 A ${f} ${f} 0 0 1 ${f} ${f}`
+ ` V ${h - r} A ${r} ${r} 0 0 0 ${f + r} ${h}`
+ ` H ${w - f - r} A ${r} ${r} 0 0 0 ${w - f} ${h - r}`
+ ` V ${f} A ${f} ${f} 0 0 1 ${w} 0`;
}
}
// Back along the frame to the start, through the corner the
// panel is docked into if it is docked into one.
readonly property string closing: {
switch (root.dock) {
case "topRight": return ` L ${w} 0 Z`;
case "bottomRight": return ` L ${w} ${h} Z`;
default: return " Z";
}
}
ShapePath {
fillColor: Theme.hull
strokeColor: "transparent"
PathSvg { path: silhouette.edge + silhouette.closing }
}
ShapePath {
fillColor: "transparent"
strokeColor: Theme.rim
strokeWidth: 1
PathSvg { path: silhouette.edge }
}
}
}
scale: 0.94
// The handler has to live on the content item, not the window: a
// HoverHandler parented to the PopupWindow itself latches on and the
// panel never learns the pointer left.
//
// Disabled while the popout is down, which clears `hovered`. An unmapped
// surface gets no leave event, so a handler that was hovered when the
// window went away stayed hovered — `wantsOpen` never went false again
// and the panel came back up and refused to close.
HoverHandler {
id: panelHover
enabled: root.shown
}
Item {
id: contentHolder
focus: root.pinned
x: root.contentX
y: root.contentY
width: root.contentWidth
height: root.contentHeight
Keys.onEscapePressed: event => {
if (root.pinned) {
event.accepted = true;
root.dismissImmediately();
// Read by PopoutSurface, which leaves the fill and the frame to
// the hull when it finds itself in here.
readonly property bool frameAttached: true
// The handler has to live on the content item, not the window:
// a HoverHandler parented to the PopupWindow itself latches on
// and the panel never learns the pointer left.
//
// Disabled while the popout is down, which clears `hovered`. An
// unmapped surface gets no leave event, so a handler that was
// hovered when the window went away stayed hovered — `wantsOpen`
// never went false again and the panel came back up and refused
// to close.
HoverHandler {
id: panelHover
enabled: root.shown
}
Keys.onEscapePressed: event => {
if (root.pinned) {
event.accepted = true;
root.dismissImmediately();
}
}
}
}
}
ParallelAnimation {
NumberAnimation {
id: enterAnim
NumberAnimation {
target: contentHolder; property: "opacity"
to: 1.0; duration: Theme.durBase; easing.type: Easing.OutExpo
}
NumberAnimation {
target: contentHolder; property: "scale"
to: 1.0; duration: Theme.durSlow; easing.type: Easing.OutCubic
}
NumberAnimation {
target: contentHolder; property: root.slideProperty
from: root.slideOrigin; to: root.bleed
duration: Theme.durSlow; easing.type: Easing.OutCubic
}
target: root; property: "reveal"
to: 1; duration: Theme.durSlow; easing.type: Easing.OutCubic
}
ParallelAnimation {
NumberAnimation {
id: exitAnim
NumberAnimation {
target: contentHolder; property: "opacity"
to: 0.0; duration: Theme.durFast; easing.type: Easing.InQuad
}
NumberAnimation {
target: contentHolder; property: "scale"
to: 0.96; duration: Theme.durFast; easing.type: Easing.InQuad
}
NumberAnimation {
target: contentHolder; property: root.slideProperty
to: root.bleed + (root.slideOrigin - root.bleed) * 0.6
duration: Theme.durFast; easing.type: Easing.InQuad
}
target: root; property: "reveal"
to: 0; duration: Theme.durBase; easing.type: Easing.InCubic
onFinished: {
root.shown = false;
root.pinned = false;
contentHolder.x = root.bleed;
contentHolder.y = root.bleed;
PopupCoordinator.release(root);
root.dismissedFully();
}