quickshell: SDF shader chrome — true smooth-min liquid junctions with distance-band borders

Bar, frame, panel and toast are one signed-distance field (compiled to
qsb at build time); ears, border gaps and the parametric melt are all
emergent from the math. PanelShape and all melt machinery deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-06-12 09:58:35 +01:00
parent 724cdded4c
commit a514893f3e

View file

@ -47,6 +47,69 @@ in
nmcli = "${pkgs.networkmanager}/bin/nmcli"; nmcli = "${pkgs.networkmanager}/bin/nmcli";
# Follow stylix's monospace choice so a font swap propagates to the bar # Follow stylix's monospace choice so a font swap propagates to the bar
monoFont = osConfig.stylix.fonts.monospace.name; monoFont = osConfig.stylix.fonts.monospace.name;
# Shell chrome fragment shader: the bar, screen frame, dropdown panel
# and toast are one signed-distance field merged with a circular
# smooth-min (caelestia-style liquid junctions); the 2px border is the
# distance band just inside the surface, so it follows every fillet.
# Qt 6 requires shaders precompiled to .qsb — done here at build time.
chromeFragSrc = pkgs.writeText "shell-chrome.frag" ''
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
vec4 cutout; // cx, cy, hw, hh rounded inner screen cutout
vec4 panel; // cx, cy, hw, hh dropdown panel (hw <= 0: none)
vec4 toast; // cx, cy, hw, hh toast (hw <= 0: none)
vec4 fillColor; // straight (non-premultiplied) rgba
vec4 borderColor;
vec2 res;
float cutoutR;
float panelR;
float meltK;
float borderW;
};
float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float r) {
vec2 d = abs(p - center) - halfSize + vec2(r);
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;
}
// Circular smooth min: the blend fillet is a true circular arc of
// radius k tangent to both surfaces.
float smin(float a, float b, float k) {
return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0)));
}
void main() {
vec2 p = qt_TexCoord0 * res;
// Shell = bar band + frame band = everything outside the cutout
float d = -sdRoundedBox(p, cutout.xy, cutout.zw, cutoutR);
if (panel.z > 0.5)
d = smin(d, sdRoundedBox(p, panel.xy, panel.zw, panelR), meltK);
if (toast.z > 0.5)
d = smin(d, sdRoundedBox(p, toast.xy, toast.zw, panelR), meltK);
float fw = fwidth(d);
// 1 inside the union, 0 outside (antialiased)
float edge = 1.0 - smoothstep(-fw, fw, d);
// 1 deeper than the border band, 0 within it
float inner = 1.0 - smoothstep(-borderW - fw, -borderW + fw, d);
vec4 c = mix(borderColor, fillColor, inner);
fragColor = vec4(c.rgb * c.a, c.a) * edge * qt_Opacity;
}
'';
chromeShader = pkgs.runCommand "shell-chrome.frag.qsb" {
nativeBuildInputs = [ pkgs.qt6.qtshadertools ];
} ''
qsb --glsl "300 es,330" --hlsl 50 --msl 12 -o $out ${chromeFragSrc}
'';
# 7-day forecast JSON from Open-Meteo (no API key). Location is # 7-day forecast JSON from Open-Meteo (no API key). Location is
# auto-detected by IP via ipinfo.io, falling back to London. # auto-detected by IP via ipinfo.io, falling back to London.
weatherFetchScript = pkgs.writeShellScript "weather-fetch" '' weatherFetchScript = pkgs.writeShellScript "weather-fetch" ''
@ -461,121 +524,45 @@ in
} }
} }
Rectangle { Item {
id: barBgRect id: barBgRect
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
height: 30 height: 30
color: Theme.barBg
} }
// Screen frame: a bar-coloured band around the monitor. // Shell chrome: bar, frame, panel and toast rendered as
// Fill is one donut path (screen rect minus rounded inner // ONE signed-distance field (caelestia-style). Surfaces merge
// cutout); the inner border is one continuous open path from // via circular smooth-min, and the 2px border is the distance
// the top-left corner the long way around to the bottom-right // band just inside the boundary borders flow through every
// corner. The right column's border is separate so it can // junction fillet by construction, so all of the previous
// open up where a flush-right dropdown merges into it. // ears / border-gaps / melt geometry lives in the math now.
Shape { ShaderEffect {
anchors.fill: parent anchors.fill: parent
preferredRendererType: Shape.CurveRenderer readonly property real panelLeft: chrome.x + 8
readonly property real panelRight: chrome.x + chrome.width + (chrome.flushRight ? 4 : -8)
ShapePath { property vector4d cutout: Qt.vector4d(
fillColor: Theme.barBg bar.width / 2,
strokeWidth: -1 (30 + bar.height - Theme.frameWidth) / 2,
fillRule: ShapePath.OddEvenFill bar.width / 2 - Theme.frameWidth,
startX: 0; startY: 30 (bar.height - Theme.frameWidth - 30) / 2)
PathLine { x: bar.width; y: 30 } property vector4d panel: chrome.visible
PathLine { x: bar.width; y: bar.height } ? Qt.vector4d((panelLeft + panelRight) / 2, 26 + chrome.height / 2,
PathLine { x: 0; y: bar.height } (panelRight - panelLeft) / 2, 4 + chrome.height / 2)
PathLine { x: 0; y: 30 } : Qt.vector4d(0, 0, 0, 0)
PathMove { x: Theme.frameWidth + 8; y: 30 } property vector4d toast: toastItem.visible && _toastRect.height > 0.5
PathLine { x: bar.width - Theme.frameWidth - 8; y: 30 } ? Qt.vector4d(toastItem.x + 8 + _toastRect.width / 2, 26 + _toastRect.height / 2,
PathArc { x: bar.width - Theme.frameWidth; y: 38; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } _toastRect.width / 2, 4 + _toastRect.height / 2)
PathLine { x: bar.width - Theme.frameWidth; y: bar.height - Theme.frameWidth - 8 } : Qt.vector4d(0, 0, 0, 0)
PathArc { x: bar.width - Theme.frameWidth - 8; y: bar.height - Theme.frameWidth; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a)
PathLine { x: Theme.frameWidth + 8; y: bar.height - Theme.frameWidth } property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1)
PathArc { x: Theme.frameWidth; y: bar.height - Theme.frameWidth - 8; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } property vector2d res: Qt.vector2d(width, height)
PathLine { x: Theme.frameWidth; y: 38 } property real cutoutR: 8
PathArc { x: Theme.frameWidth + 8; y: 30; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise } property real panelR: 8
} property real meltK: 12
property real borderW: Theme.borderWidth
ShapePath { fragmentShader: "file://${chromeShader}"
fillColor: "transparent"
strokeColor: Theme.base03
strokeWidth: Theme.borderWidth
capStyle: ShapePath.FlatCap
startX: Theme.frameWidth + 8; startY: 30
PathArc { x: Theme.frameWidth; y: 38; radiusX: 8; radiusY: 8; direction: PathArc.Counterclockwise }
PathLine { x: Theme.frameWidth; y: bar.height - Theme.frameWidth - 8 }
PathArc { x: Theme.frameWidth + 8; y: bar.height - Theme.frameWidth; radiusX: 8; radiusY: 8; direction: PathArc.Counterclockwise }
PathLine { x: bar.width - Theme.frameWidth - 8; y: bar.height - Theme.frameWidth }
PathArc { x: bar.width - Theme.frameWidth; y: bar.height - Theme.frameWidth - 8; radiusX: 8; radiusY: 8; direction: PathArc.Counterclockwise }
}
}
// Frame right-column inner border opens up over a merged
// panel (a border must not slice an open junction) and
// resumes exactly at the panel's bottom flare. Driven by the
// melt progress so it follows the morph smoothly.
Rectangle {
x: bar.width - Theme.frameWidth - Theme.borderWidth / 2
y: 38 + chrome.height * chrome.mergeP
width: Theme.borderWidth
height: Math.max(0, bar.height - Theme.frameWidth - 8 - y)
color: Theme.base03
}
// Frame top-right inner corner fades in sync with the
// panel's geometric melt into the column.
Shape {
opacity: 1 - chrome.mergeP
visible: opacity > 0.01
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "transparent"
strokeColor: Theme.base03
strokeWidth: Theme.borderWidth
capStyle: ShapePath.FlatCap
startX: bar.width - Theme.frameWidth - 8; startY: 30
PathArc { x: bar.width - Theme.frameWidth; y: 38; radiusX: 8; radiusY: 8; direction: PathArc.Clockwise }
}
}
// 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: chrome.visible
? chrome.x
: toastItem.visible && _toastRect.height > 0
? toastItem.x : 0
property real gapRight: chrome.visible
? chrome.x + chrome.width
: toastItem.visible && _toastRect.height > 0
? toastItem.x + toastItem.width : 0
// 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
x: Theme.frameWidth + 8
y: 30 - Theme.borderWidth / 2
width: Math.max(0, (bar.hasGap ? bar.gapLeft : bar.width - Theme.frameWidth - 8) - x)
height: Theme.borderWidth
color: Theme.base03
}
// Bar bottom border right segment (after gap)
Rectangle {
id: barBorderRight
visible: bar.hasGap
x: bar.gapRight
y: 30 - Theme.borderWidth / 2
width: Math.max(0, bar.width - Theme.frameWidth - 8 - x)
height: Theme.borderWidth
color: Theme.base03
} }
property var activeDropdown: null property var activeDropdown: null
@ -1206,87 +1193,6 @@ in
} }
} }
// Panel silhouette: 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.
//
// `merge` (0..1) continuously morphs the RIGHT side between
// floating (concave top ear, convex bottom corner at an 8px
// inset) and merged-into-the-frame-column (edge flush, ear
// collapsed, bottom corner flipped into a concave flare).
// The right side is built from cubics (kappa 0.5523) because
// they degrade gracefully through the zero-radius midpoint,
// where arcs would degenerate.
component PanelShape: Shape {
id: pshape
property real merge: 0
readonly property real ear: 8
readonly property real rr: Math.min(8, Math.max(1, height / 2))
// Right-side morph geometry
readonly property real xr: width - ear * (1 - merge) // right edge x
readonly property real re: ear * (1 - merge) // top ear radius
readonly property real rek: re * 0.5523
readonly property real bey: height - ear + 2 * ear * merge // bottom feature endpoint y
readonly property real bck: ear * 0.5523 * (1 - 2 * merge) // endpoint control offset; sign flips at the melt point
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.xr - pshape.ear; y: pshape.height }
PathCubic {
x: pshape.xr; y: pshape.bey
control1X: pshape.xr - pshape.ear + 4.42; control1Y: pshape.height
control2X: pshape.xr; control2Y: pshape.bey + pshape.bck
}
PathLine { x: pshape.xr; y: Math.min(pshape.re, pshape.height) }
PathCubic {
x: pshape.xr + pshape.re; y: 0
control1X: pshape.xr; control1Y: Math.max(0, Math.min(pshape.re, pshape.height) - pshape.rek)
control2X: pshape.xr + pshape.re - pshape.rek; control2Y: 0
}
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.xr - pshape.ear; y: pshape.height }
PathCubic {
x: pshape.xr; y: pshape.bey
control1X: pshape.xr - pshape.ear + 4.42; control1Y: pshape.height
control2X: pshape.xr; control2Y: pshape.bey + pshape.bck
}
}
// Right edge + ear stroke, fading out with the melt a
// merged junction must not be sliced by its own border.
ShapePath {
fillColor: "transparent"
strokeColor: Qt.alpha(Theme.base03, 1 - pshape.merge)
strokeWidth: Theme.borderWidth
capStyle: ShapePath.FlatCap
startX: pshape.xr; startY: pshape.bey
PathLine { x: pshape.xr; y: Math.min(pshape.re, pshape.height) }
PathCubic {
x: pshape.xr + pshape.re; y: 0
control1X: pshape.xr; control1Y: Math.max(0, Math.min(pshape.re, pshape.height) - pshape.rek)
control2X: pshape.xr + pshape.re - pshape.rek; control2Y: 0
}
}
}
// The shared morphing panel: follows the active dropdown's // The shared morphing panel: follows the active dropdown's
// geometry with animation (the caelestia-style morph), snaps // geometry with animation (the caelestia-style morph), snaps
// instantly when opening from closed. // instantly when opening from closed.
@ -1299,11 +1205,6 @@ in
property real openH: bar.activeDropdown ? tH : 0 property real openH: bar.activeDropdown ? tH : 0
property bool snap: false property bool snap: false
readonly property real stubW: 32 readonly property real stubW: 32
// Merged whenever the (latched) target is a flush dropdown
// and the panel is on screen flush panels are born,
// live and close merged; the melt only animates for
// morphs between flush and centered dropdowns.
readonly property bool mergedRight: visible && flushRight
// Grow-from / shrink-to the widget that owns the dropdown: // Grow-from / shrink-to the widget that owns the dropdown:
// the panel opens as a small stub on the button and // the panel opens as a small stub on the button and
@ -1371,24 +1272,6 @@ in
NumberAnimation { duration: 280; easing.type: Easing.OutExpo } NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
} }
// Continuous geometric melt instead of a shape swap: on
// dock the ear collapses, the edge slides the last 8px
// into the column and the bottom corner flips into the
// flare one shape the whole way.
property real mergeP: mergedRight ? 1 : 0
Behavior on mergeP {
// Only animate while the panel is on screen flush
// panels opening from closed are born merged, no
// visible melt-in at birth.
enabled: chrome.height > 0.5
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
}
PanelShape {
width: chrome.width
height: chrome.height
merge: chrome.mergeP
}
} }
// Context menu // Context menu
@ -2544,12 +2427,6 @@ in
} }
} }
// Same single-path silhouette as the dropdown chrome
PanelShape {
width: toastItem.width
height: _toastRect.height
}
Item { Item {
id: _toastRect id: _toastRect
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter