diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 2cd4120..437778b 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -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 - 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(); - } - } + // 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 { + 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 } }