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:
parent
d570674224
commit
ab6f5d5dc8
1 changed files with 130 additions and 249 deletions
|
|
@ -411,6 +411,7 @@ in
|
|||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Shapes
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
PanelWindow {
|
||||
|
|
@ -456,27 +457,26 @@ in
|
|||
color: Theme.barBg
|
||||
}
|
||||
|
||||
// The "gap source" for the bar border — dropdown takes priority, then toast
|
||||
property bool hasGap: (activeDropdown && activeDropdown.dropdownHeight > 0)
|
||||
// The "gap source" for the bar border — the morphing chrome
|
||||
// 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)
|
||||
property real gapLeft: activeDropdown && activeDropdown.dropdownHeight > 0
|
||||
? activeDropdown.x
|
||||
property real gapLeft: chrome.visible
|
||||
? chrome.x
|
||||
: toastItem.visible && _toastRect.height > 0
|
||||
? toastItem.x : 0
|
||||
property real gapRight: activeDropdown && activeDropdown.dropdownHeight > 0
|
||||
? activeDropdown.x + activeDropdown.width
|
||||
property real gapRight: chrome.visible
|
||||
? chrome.x + chrome.width
|
||||
: toastItem.visible && _toastRect.height > 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 {
|
||||
id: barBorderLeft
|
||||
// Inset (inside the bar) so it lines up with the inset ear
|
||||
// and dropdown borders; overlaps 8px into the gap to meet
|
||||
// the ear curve's tapered start.
|
||||
x: 0; y: 30 - Theme.borderWidth
|
||||
width: bar.hasGap ? bar.gapLeft + 8 : bar.width
|
||||
x: 0; y: 30 - Theme.borderWidth / 2
|
||||
width: bar.hasGap ? bar.gapLeft : bar.width
|
||||
height: Theme.borderWidth
|
||||
color: Theme.base03
|
||||
}
|
||||
|
|
@ -484,9 +484,9 @@ in
|
|||
// Bar bottom border — right segment (after gap)
|
||||
Rectangle {
|
||||
id: barBorderRight
|
||||
visible: bar.hasGap && !bar.gapAlignRight
|
||||
x: bar.gapRight - 8
|
||||
y: 30 - Theme.borderWidth
|
||||
visible: bar.hasGap
|
||||
x: bar.gapRight
|
||||
y: 30 - Theme.borderWidth / 2
|
||||
width: bar.width - x
|
||||
height: Theme.borderWidth
|
||||
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 {
|
||||
id: dropdown
|
||||
property bool open: false
|
||||
|
|
@ -997,8 +999,8 @@ in
|
|||
property real fullWidth: 200
|
||||
property real fullHeight: 200
|
||||
property int autoCloseMs: 1500
|
||||
property bool alignRight: false
|
||||
property real dropdownHeight: _dropdownRect.height
|
||||
property bool alignRight: false // legacy; all dropdowns now center on their widget
|
||||
property real dropdownHeight: open ? fullHeight : 0
|
||||
default property alias content: dropdownContent.data
|
||||
|
||||
function animateClose() {
|
||||
|
|
@ -1013,16 +1015,11 @@ in
|
|||
if (visible && !closing) _autoClose.restart();
|
||||
}
|
||||
|
||||
// Whole-pixel x — fractional positions (odd widths centered
|
||||
// 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)
|
||||
))
|
||||
x: Math.round(Math.min(bar.width - width, Math.max(0, dropdownX - width / 2)))
|
||||
y: 30
|
||||
width: fullWidth + 16
|
||||
height: fullHeight + 4
|
||||
visible: false
|
||||
width: fullWidth + (alignRight ? 8 : 16)
|
||||
height: fullHeight + 4 + (alignRight ? 8 : 0)
|
||||
|
||||
onVisibleChanged: {
|
||||
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 {
|
||||
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
|
||||
anchors.right: dropdown.alignRight ? parent.right : undefined
|
||||
anchors.horizontalCenter: dropdown.alignRight ? undefined : parent.horizontalCenter
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
width: dropdown.fullWidth
|
||||
height: dropdown.open ? dropdown.fullHeight : 0
|
||||
color: Theme.barBg
|
||||
radius: 8
|
||||
topLeftRadius: 0
|
||||
topRightRadius: 0
|
||||
bottomRightRadius: dropdown.alignRight ? 0 : 8
|
||||
height: Math.min(dropdown.fullHeight, chrome.height)
|
||||
clip: true
|
||||
|
||||
// Border outline (sides + bottom with rounded corners)
|
||||
Canvas {
|
||||
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 }
|
||||
opacity: dropdown.open ? 1 : 0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
Item {
|
||||
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 {
|
||||
visible: dropdown.alignRight && _dropdownRect.height >= 8
|
||||
anchors.right: _dropdownRect.right
|
||||
anchors.top: _dropdownRect.bottom
|
||||
width: 8
|
||||
height: Math.min(8, _dropdownRect.height)
|
||||
clip: true
|
||||
Canvas {
|
||||
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.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();
|
||||
id: chrome
|
||||
property real tX: 0
|
||||
property real tW: 200
|
||||
property real tH: 0
|
||||
property real openH: bar.activeDropdown ? tH : 0
|
||||
|
||||
x: tX
|
||||
y: 30
|
||||
width: tW
|
||||
height: openH
|
||||
visible: height > 0.5
|
||||
|
||||
Binding {
|
||||
target: chrome; property: "tX"
|
||||
value: bar.activeDropdown ? bar.activeDropdown.x : 0
|
||||
when: bar.activeDropdown !== null
|
||||
restoreMode: Binding.RestoreNone
|
||||
}
|
||||
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)
|
||||
y: 30
|
||||
width: _toastLeftEar.width + _toastRect.width + _toastRightEar.width
|
||||
width: _toastRect.width + 16
|
||||
height: _toastRect.height + 4
|
||||
|
||||
Process {
|
||||
|
|
@ -2360,100 +2321,20 @@ in
|
|||
}
|
||||
}
|
||||
|
||||
// Left inverse corner ear
|
||||
Item {
|
||||
id: _toastLeftEar
|
||||
anchors.right: _toastRect.left
|
||||
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();
|
||||
}
|
||||
}
|
||||
// Same single-path silhouette as the dropdown chrome
|
||||
PanelShape {
|
||||
width: toastItem.width
|
||||
height: _toastRect.height
|
||||
}
|
||||
|
||||
// Right inverse corner ear
|
||||
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
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
width: 320
|
||||
height: toastItem.toastOpen ? toastCol.height + 16 : 0
|
||||
color: Theme.barBg
|
||||
radius: 8
|
||||
topLeftRadius: 0
|
||||
topRightRadius: 0
|
||||
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 {
|
||||
NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue