quickshell: caelestia-style morphing dropdown chrome, single-shape silhouette

One shared panel (bg+border as single Shape paths, CurveRenderer) animates
position and size between dropdowns; per-dropdown ears/canvases removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-06-11 19:45:29 +01:00
parent d570674224
commit ab6f5d5dc8

View file

@ -411,6 +411,7 @@ in
import Quickshell.Io import Quickshell.Io
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Shapes
import Qt5Compat.GraphicalEffects import Qt5Compat.GraphicalEffects
PanelWindow { PanelWindow {
@ -456,27 +457,26 @@ in
color: Theme.barBg color: Theme.barBg
} }
// The "gap source" for the bar border dropdown takes priority, then toast // The "gap source" for the bar border the morphing chrome
property bool hasGap: (activeDropdown && activeDropdown.dropdownHeight > 0) // panel takes priority, then the toast. Tracking the animated
// chrome means the border gap follows the morph.
property bool hasGap: chrome.visible
|| (toastItem.visible && _toastRect.height > 0) || (toastItem.visible && _toastRect.height > 0)
property real gapLeft: activeDropdown && activeDropdown.dropdownHeight > 0 property real gapLeft: chrome.visible
? activeDropdown.x ? chrome.x
: toastItem.visible && _toastRect.height > 0 : toastItem.visible && _toastRect.height > 0
? toastItem.x : 0 ? toastItem.x : 0
property real gapRight: activeDropdown && activeDropdown.dropdownHeight > 0 property real gapRight: chrome.visible
? activeDropdown.x + activeDropdown.width ? chrome.x + chrome.width
: toastItem.visible && _toastRect.height > 0 : toastItem.visible && _toastRect.height > 0
? toastItem.x + toastItem.width : 0 ? toastItem.x + toastItem.width : 0
property bool gapAlignRight: activeDropdown ? activeDropdown.alignRight : false
// Bar bottom border left segment (up to gap) // Bar bottom border left segment (up to gap). Centered on
// y=30 so it runs into the panel's edge-centered border stroke.
Rectangle { Rectangle {
id: barBorderLeft id: barBorderLeft
// Inset (inside the bar) so it lines up with the inset ear x: 0; y: 30 - Theme.borderWidth / 2
// and dropdown borders; overlaps 8px into the gap to meet width: bar.hasGap ? bar.gapLeft : bar.width
// the ear curve's tapered start.
x: 0; y: 30 - Theme.borderWidth
width: bar.hasGap ? bar.gapLeft + 8 : bar.width
height: Theme.borderWidth height: Theme.borderWidth
color: Theme.base03 color: Theme.base03
} }
@ -484,9 +484,9 @@ in
// Bar bottom border right segment (after gap) // Bar bottom border right segment (after gap)
Rectangle { Rectangle {
id: barBorderRight id: barBorderRight
visible: bar.hasGap && !bar.gapAlignRight visible: bar.hasGap
x: bar.gapRight - 8 x: bar.gapRight
y: 30 - Theme.borderWidth y: 30 - Theme.borderWidth / 2
width: bar.width - x width: bar.width - x
height: Theme.borderWidth height: Theme.borderWidth
color: Theme.base03 color: Theme.base03
@ -988,7 +988,9 @@ in
} }
} }
// Reusable dropdown component // Dropdown container content, sizing and autoclose only.
// The background, border and ears are drawn once by the shared
// `chrome` panel below, which morphs between dropdowns.
component BarDropdown: Item { component BarDropdown: Item {
id: dropdown id: dropdown
property bool open: false property bool open: false
@ -997,8 +999,8 @@ in
property real fullWidth: 200 property real fullWidth: 200
property real fullHeight: 200 property real fullHeight: 200
property int autoCloseMs: 1500 property int autoCloseMs: 1500
property bool alignRight: false property bool alignRight: false // legacy; all dropdowns now center on their widget
property real dropdownHeight: _dropdownRect.height property real dropdownHeight: open ? fullHeight : 0
default property alias content: dropdownContent.data default property alias content: dropdownContent.data
function animateClose() { function animateClose() {
@ -1013,16 +1015,11 @@ in
if (visible && !closing) _autoClose.restart(); if (visible && !closing) _autoClose.restart();
} }
// Whole-pixel x fractional positions (odd widths centered x: Math.round(Math.min(bar.width - width, Math.max(0, dropdownX - width / 2)))
// on the bar) antialias the ears/borders into fuzzy seams.
x: Math.round(alignRight ? bar.width - width : Math.min(
bar.width - width,
Math.max(0, dropdownX - (fullWidth + 16) / 2)
))
y: 30 y: 30
width: fullWidth + 16
height: fullHeight + 4
visible: false visible: false
width: fullWidth + (alignRight ? 8 : 16)
height: fullHeight + 4 + (alignRight ? 8 : 0)
onVisibleChanged: { onVisibleChanged: {
if (visible) { if (visible) {
@ -1055,155 +1052,119 @@ in
} }
} }
// Left ear // Content clipped to the chrome's animated height so it
// reveals/hides with the morph; fades on open/close.
Item { Item {
anchors.right: _dropdownRect.left
anchors.top: parent.top
width: 8
height: Math.min(8, _dropdownRect.height)
clip: true
visible: _dropdownRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8);
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.closePath(); ctx.fill();
// Border stroke along the curve
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(0, 8, 8 + Theme.borderWidth / 2, 0, -Math.PI / 2, true);
ctx.stroke();
}
}
}
// Right ear (for centered dropdowns)
Item {
anchors.left: _dropdownRect.right
anchors.top: parent.top
width: 8
height: Math.min(8, _dropdownRect.height)
clip: true
visible: _dropdownRect.height >= 8 && !dropdown.alignRight
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0);
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.closePath(); ctx.fill();
// Border stroke along the curve
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(8, 8, 8 + Theme.borderWidth / 2, -Math.PI / 2, Math.PI, true);
ctx.stroke();
}
}
}
Rectangle {
id: _dropdownRect id: _dropdownRect
anchors.right: dropdown.alignRight ? parent.right : undefined anchors.horizontalCenter: parent.horizontalCenter
anchors.horizontalCenter: dropdown.alignRight ? undefined : parent.horizontalCenter
anchors.top: parent.top anchors.top: parent.top
width: dropdown.fullWidth width: dropdown.fullWidth
height: dropdown.open ? dropdown.fullHeight : 0 height: Math.min(dropdown.fullHeight, chrome.height)
color: Theme.barBg
radius: 8
topLeftRadius: 0
topRightRadius: 0
bottomRightRadius: dropdown.alignRight ? 0 : 8
clip: true clip: true
opacity: dropdown.open ? 1 : 0
// Border outline (sides + bottom with rounded corners) Behavior on opacity {
Canvas { NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
id: _dropdownBorder
anchors.fill: parent
onPaint: {
var ctx = getContext("2d");
var w = width, h = height, r = 8;
// o centers the stroke so its outer edge lands on the rect edge
var b = Theme.borderWidth, o = b / 2;
ctx.clearRect(0, 0, w, h);
if (h < 1) return;
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = b;
ctx.beginPath();
// Start just under the bar the ear band tapers
// through the first few px and this fills behind it
ctx.moveTo(o, b);
ctx.lineTo(o, h - r);
// Bottom-left curve arc centered on the corner circle so
// the stroke's outer edge matches the bg corner exactly
ctx.arc(r, h - r, r - o, Math.PI, Math.PI / 2, true);
// Bottom edge
if (dropdown.alignRight) {
// Stop 8px before right edge bottom-right ear continues
ctx.lineTo(w - r, h - o);
} else {
ctx.lineTo(w - r - o, h - o);
// Bottom-right curve
ctx.arc(w - r, h - r, r - o, Math.PI / 2, 0, true);
// Right side up to just under the bar
ctx.lineTo(w - o, b);
}
ctx.stroke();
}
// Repaint when size changes
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
}
Behavior on height {
NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
} }
Item { Item {
id: dropdownContent id: dropdownContent
anchors.fill: parent width: dropdown.fullWidth
height: dropdown.fullHeight
}
} }
} }
// Bottom-right concave ear connects dropdown bottom to right screen edge // Panel silhouette (caelestia-inspired): fill and border are
// each ONE continuous path concave ear, side, rounded bottom
// corners, side, ear so there are no seams or junctions at
// all. The border path is open at the top where the panel
// joins the bar; the bar's own border strips meet it there.
component PanelShape: Shape {
id: pshape
readonly property real ear: 8
readonly property real rr: Math.min(8, Math.max(1, height / 2))
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: Theme.barBg
strokeWidth: -1
startX: 0; startY: 0
PathArc { x: pshape.ear; y: Math.min(pshape.ear, pshape.height); radiusX: pshape.ear; radiusY: pshape.ear; direction: PathArc.Clockwise }
PathLine { x: pshape.ear; y: pshape.height - pshape.rr }
PathArc { x: pshape.ear + pshape.rr; y: pshape.height; radiusX: pshape.rr; radiusY: pshape.rr; direction: PathArc.Counterclockwise }
PathLine { x: pshape.width - pshape.ear - pshape.rr; y: pshape.height }
PathArc { x: pshape.width - pshape.ear; y: pshape.height - pshape.rr; radiusX: pshape.rr; radiusY: pshape.rr; direction: PathArc.Counterclockwise }
PathLine { x: pshape.width - pshape.ear; y: Math.min(pshape.ear, pshape.height) }
PathArc { x: pshape.width; y: 0; radiusX: pshape.ear; radiusY: pshape.ear; direction: PathArc.Clockwise }
PathLine { x: 0; y: 0 }
}
ShapePath {
fillColor: "transparent"
strokeColor: Theme.base03
strokeWidth: Theme.borderWidth
capStyle: ShapePath.FlatCap
startX: 0; startY: 0
PathArc { x: pshape.ear; y: Math.min(pshape.ear, pshape.height); radiusX: pshape.ear; radiusY: pshape.ear; direction: PathArc.Clockwise }
PathLine { x: pshape.ear; y: pshape.height - pshape.rr }
PathArc { x: pshape.ear + pshape.rr; y: pshape.height; radiusX: pshape.rr; radiusY: pshape.rr; direction: PathArc.Counterclockwise }
PathLine { x: pshape.width - pshape.ear - pshape.rr; y: pshape.height }
PathArc { x: pshape.width - pshape.ear; y: pshape.height - pshape.rr; radiusX: pshape.rr; radiusY: pshape.rr; direction: PathArc.Counterclockwise }
PathLine { x: pshape.width - pshape.ear; y: Math.min(pshape.ear, pshape.height) }
PathArc { x: pshape.width; y: 0; radiusX: pshape.ear; radiusY: pshape.ear; direction: PathArc.Clockwise }
}
}
// The shared morphing panel: follows the active dropdown's
// geometry with animation (the caelestia-style morph), snaps
// instantly when opening from closed.
Item { Item {
visible: dropdown.alignRight && _dropdownRect.height >= 8 id: chrome
anchors.right: _dropdownRect.right property real tX: 0
anchors.top: _dropdownRect.bottom property real tW: 200
width: 8 property real tH: 0
height: Math.min(8, _dropdownRect.height) property real openH: bar.activeDropdown ? tH : 0
clip: true
Canvas { x: tX
width: 8; height: 8 y: 30
onPaint: { width: tW
var ctx = getContext("2d"); height: openH
ctx.clearRect(0, 0, 8, 8); visible: height > 0.5
ctx.fillStyle = Theme.barBg;
ctx.beginPath(); Binding {
ctx.moveTo(0, 0); target: chrome; property: "tX"
ctx.lineTo(8, 0); value: bar.activeDropdown ? bar.activeDropdown.x : 0
ctx.lineTo(8, 8); when: bar.activeDropdown !== null
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); restoreMode: Binding.RestoreNone
ctx.fill();
// Border stroke along the curve
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(0, 8, 8 + Theme.borderWidth / 2, 0, -Math.PI / 2, true);
ctx.stroke();
} }
Binding {
target: chrome; property: "tW"
value: bar.activeDropdown ? bar.activeDropdown.width : 0
when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone
} }
Binding {
target: chrome; property: "tH"
value: bar.activeDropdown ? bar.activeDropdown.fullHeight + 4 : 0
when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone
}
Behavior on tX {
enabled: chrome.height > 0.5
NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
Behavior on tW {
enabled: chrome.height > 0.5
NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
Behavior on openH {
NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
PanelShape {
width: chrome.width
height: chrome.height
} }
} }
@ -2293,7 +2254,7 @@ in
x: Math.round(bar.width / 2 - width / 2) x: Math.round(bar.width / 2 - width / 2)
y: 30 y: 30
width: _toastLeftEar.width + _toastRect.width + _toastRightEar.width width: _toastRect.width + 16
height: _toastRect.height + 4 height: _toastRect.height + 4
Process { Process {
@ -2360,100 +2321,20 @@ in
} }
} }
// Left inverse corner ear // Same single-path silhouette as the dropdown chrome
Item { PanelShape {
id: _toastLeftEar width: toastItem.width
anchors.right: _toastRect.left height: _toastRect.height
anchors.top: parent.top
width: 8
height: Math.min(8, _toastRect.height)
clip: true
visible: _toastRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8);
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(0, 8, 8 + Theme.borderWidth / 2, 0, -Math.PI / 2, true);
ctx.stroke();
}
}
} }
// Right inverse corner ear
Item { Item {
id: _toastRightEar
anchors.left: _toastRect.right
anchors.top: parent.top
width: 8
height: Math.min(8, _toastRect.height)
clip: true
visible: _toastRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0);
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(8, 8, 8 + Theme.borderWidth / 2, -Math.PI / 2, Math.PI, true);
ctx.stroke();
}
}
}
Rectangle {
id: _toastRect id: _toastRect
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top anchors.top: parent.top
width: 320 width: 320
height: toastItem.toastOpen ? toastCol.height + 16 : 0 height: toastItem.toastOpen ? toastCol.height + 16 : 0
color: Theme.barBg
radius: 8
topLeftRadius: 0
topRightRadius: 0
clip: true clip: true
// Border outline (sides + bottom with rounded corners)
Canvas {
anchors.fill: parent
onPaint: {
var ctx = getContext("2d");
var w = width, h = height, r = 8;
var b = Theme.borderWidth, o = b / 2;
ctx.clearRect(0, 0, w, h);
if (h < 1) return;
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = b;
ctx.beginPath();
ctx.moveTo(o, b);
ctx.lineTo(o, h - r);
ctx.arc(r, h - r, r - o, Math.PI, Math.PI / 2, true);
ctx.lineTo(w - r - o, h - o);
ctx.arc(w - r, h - r, r - o, Math.PI / 2, 0, true);
ctx.lineTo(w - o, b);
ctx.stroke();
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
}
Behavior on height { Behavior on height {
NumberAnimation { duration: 280; easing.type: Easing.OutExpo } NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
} }