diff --git a/quickshell/README.md b/quickshell/README.md index 04ad801..f7d9627 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -42,7 +42,7 @@ cost 4.4% of the height on top of what the bar already takes. - **bar** — identity and workspaces on the left; the focused window, the player, the CPU/MEM/GPU readout and the clock on the right. - **rail** — network, bluetooth, audio, mic, backlight, battery, tray, - notifications, power. Popouts open leftward, into the screen. + notifications, power. Popouts grow leftward out of it, into the screen. Modules are divided by hairlines. `BarPill` is chromeless by default — a module is *content inside* the hull, not a surface of its own — and marks hover with @@ -72,6 +72,22 @@ assets/ halftone and stripe tiles, generated with ImageMagick callers set implicit sizes from their own layout (`row.implicitWidth + contentPad * 2`), which keeps the shape maths out of binding loops. +## Popouts + +Hover panels (`components/Popout.qml`) are part of the hull, not cards +floating near their trigger. Each one sits flush against the frame edge it +opens from, is painted in the hull colour, joins the frame through concave +fillets, carries the rim round its open side, and slides out from behind the +frame. `PopoutSurface` notices it is inside a `Popout` and leaves the fill and +the frame to the hull; in the launcher and the ask panel it is still a +free-standing panel with corner ticks. + +Placement is explicit: centred on the trigger, then — if that would land the +panel within `dockReach` of one of the opening's corners — docked into the +corner instead, attached to the bar and the rail at once (`dock` is `top`, +`right`, `topRight` or `bottomRight`). Without docking, a panel near the rail +left a thin strip of opening between itself and the rail. + ## Dashboard `dashboard/Dashboard.qml` is a drawer that slides down out of the underside of diff --git a/quickshell/components/Popout.qml b/quickshell/components/Popout.qml index 0f11be2..2c5ed80 100644 --- a/quickshell/components/Popout.qml +++ b/quickshell/components/Popout.qml @@ -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(); } diff --git a/quickshell/popouts/PopoutSurface.qml b/quickshell/popouts/PopoutSurface.qml index f4edf0c..26734f2 100644 --- a/quickshell/popouts/PopoutSurface.qml +++ b/quickshell/popouts/PopoutSurface.qml @@ -8,16 +8,23 @@ P5Panel { property color tone: Theme.primary property bool textured: false - fill: Theme.mantle - fillOpacity: 0.97 + // Inside a Popout the panel is part of the hull, which paints the fill + // and the rim itself; the surface only lays out the content. Elsewhere — + // the launcher, the ask panel — it is a free-standing panel of its own. + readonly property bool attached: parent?.frameAttached ?? false + + fill: attached ? "transparent" : Theme.mantle + // Zero, not a transparent fill: P5Panel re-applies this opacity to the + // fill colour, which turned "transparent" into 97% black. + fillOpacity: attached ? 0 : 0.97 // The frame is structure: a neutral hairline. The tone goes into the // corner ticks, which is where NieR puts the one bright mark on a panel. border: Theme.surfaceAlt - borderWidth: Theme.stroke + borderWidth: attached ? 0 : Theme.stroke halftone: textured halftoneColor: tone halftoneOpacity: 0.06 - ticks: true + ticks: !attached tickColor: tone padding: Theme.padM }