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:
1 parent
ecc7d2884c
commit
196abc2311
3 files changed
+266
-92
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user