Bring on gradient borders

This commit is contained in:
Ryan Hughes
2026-06-02 11:17:13 -04:00
parent 30bafe7bb0
commit 98ba4a9697
48 changed files with 1323 additions and 328 deletions
+210
View File
@@ -0,0 +1,210 @@
pragma Singleton
import QtQuick
import "BorderGeometry.js" as Geometry
// Central border-spec factory for shell surfaces and controls. A spec carries
// color, optional gradient, and top/right/bottom/left widths so renderers can
// choose the cheap Rectangle path or the Shape-ring path without duplicating
// theme parsing logic.
QtObject {
id: root
function none() {
return flat("transparent", 0)
}
function flat(color, width) {
return {
color: color || "transparent",
widths: Geometry.parseWidthSpec(width, 0),
gradient: { colors: [], angle: 0, enabled: false },
}
}
function value(section, key) {
var v = Color.shellValues[section + "." + key]
return (v === undefined || v === null) ? "" : v
}
function valueOr(section, keys) {
for (var i = 0; i < keys.length; i++) {
var v = value(section, keys[i])
if (String(v).length > 0) return v
}
return ""
}
function alpha(section, key, fallback) {
var raw = value(section, key)
if (String(raw).length === 0) return fallback
var n = Number(raw)
return isFinite(n) ? Geometry.clampAlpha(n) : fallback
}
function cssColor(color, opacity) {
var a = opacity === undefined || opacity === null ? 1 : Geometry.clampAlpha(opacity)
if (color && typeof color === "object" && color.r !== undefined) {
if (typeof Qt !== "undefined" && Qt.rgba) {
return Qt.rgba(color.r, color.g, color.b, (color.a === undefined ? 1 : color.a) * a)
}
return "#"
+ Geometry.padHex(color.r * 255)
+ Geometry.padHex(color.g * 255)
+ Geometry.padHex(color.b * 255)
+ Geometry.padHex((color.a === undefined ? 1 : color.a) * a * 255)
}
var s = String(color || "").replace(/^\s+|\s+$/g, "")
var role = s.toLowerCase()
if (role === "foreground" || role === "text") return cssColor(Color.foreground, a)
if (role === "accent") return cssColor(Color.accent, a)
if (role === "urgent") return cssColor(Color.urgent, a)
if (role === "background") return cssColor(Color.background, a)
if (role === "transparent") return "transparent"
return Geometry.canonicalColor(s, a)
}
function resolvedGradient(raw, fallbackColor, opacity) {
var s = String(raw || "").replace(/^\s+|\s+$/g, "")
if (s.length === 0) return { colors: [], angle: 0, enabled: false }
var parts = s.split(/\s+/)
var colors = []
var angle = 0
for (var i = 0; i < parts.length; i++) {
if (parts[i].match(/^-?\d+(?:\.\d+)?deg$/)) angle = Number(parts[i].replace(/deg$/, ""))
else colors.push(cssColor(parts[i], opacity))
}
if (colors.length === 0) colors.push(cssColor(fallbackColor, opacity))
return { colors: colors, angle: angle, enabled: colors.length > 1 }
}
function sameColor(a, b) {
if (typeof Qt === "undefined" || !Qt.color) return String(a) === String(b)
var ca = typeof a === "string" ? Qt.color(a) : a
var cb = typeof b === "string" ? Qt.color(b) : b
if (!ca || !cb || ca.r === undefined || cb.r === undefined) return String(a) === String(b)
return Math.round(ca.r * 255) === Math.round(cb.r * 255)
&& Math.round(ca.g * 255) === Math.round(cb.g * 255)
&& Math.round(ca.b * 255) === Math.round(cb.b * 255)
&& Math.round((ca.a === undefined ? 1 : ca.a) * 255) === Math.round((cb.a === undefined ? 1 : cb.a) * 255)
}
function localOrSurfaceSpec(section, token, localColor, defaultColor, fallbackWidth, alphaKey) {
if (!sameColor(localColor, defaultColor)) return flat(localColor, fallbackWidth)
return surfaceSpec(section, token, localColor, fallbackWidth, alphaKey)
}
function surfaceWidths(section, token, fallbackWidth) {
var base = valueOr(section, token === "border" ? ["border-width"] : [token + "-width", "border-width"])
var widths = Geometry.parseWidthSpec(base, fallbackWidth)
return Geometry.withSideOverrides(
widths,
valueOr(section, token === "border" ? ["border-width-top"] : [token + "-width-top", "border-width-top"]),
valueOr(section, token === "border" ? ["border-width-right"] : [token + "-width-right", "border-width-right"]),
valueOr(section, token === "border" ? ["border-width-bottom"] : [token + "-width-bottom", "border-width-bottom"]),
valueOr(section, token === "border" ? ["border-width-left"] : [token + "-width-left", "border-width-left"])
)
}
function borderValue(raw, fallbackColor, opacity, legacyGradientRaw) {
var fallback = cssColor(fallbackColor, opacity)
var primaryRaw = String(raw || "").replace(/^\s+|\s+$/g, "")
var primary = resolvedGradient(primaryRaw.length > 0 ? primaryRaw : fallbackColor, fallbackColor, opacity)
var color = primary.colors.length > 0 ? primary.colors[0] : fallback
var gradient = primary.enabled ? primary : { colors: [], angle: 0, enabled: false }
// Backward compatibility for existing configs written during the separate
// border-gradient experiment. New configs put solid colors and gradients in
// the same border token.
if (!gradient.enabled && String(legacyGradientRaw || "").replace(/^\s+|\s+$/g, "").length > 0) {
var legacy = resolvedGradient(legacyGradientRaw, color, opacity)
if (legacy.enabled) gradient = legacy
}
return { color: color, gradient: gradient }
}
function surfaceSpec(section, token, fallbackColor, fallbackWidth, alphaKey) {
var opacity = alpha(section, alphaKey || token + "-alpha", 1.0)
var legacyGradientRaw = valueOr(section, token === "border" ? ["border-gradient"] : [token + "-gradient", "border-gradient"])
var resolved = borderValue(value(section, token), fallbackColor, opacity, legacyGradientRaw)
return {
color: resolved.color,
widths: surfaceWidths(section, token, fallbackWidth),
gradient: resolved.gradient,
}
}
function controlPrefix(state) {
if (state === "hover" || state === "hot") return "hover-cursor"
return state || "normal"
}
function controlColor(prefix, foreground, accent, urgent) {
if (prefix === "focus") return Style.focusStateColor(foreground, accent, urgent)
if (prefix === "hover-cursor") return Style.hoverStateColor(foreground, accent, urgent)
if (prefix === "selected") return Style.selectedStateColor(foreground, accent, urgent)
return Style.normalStateColor(foreground, accent, urgent)
}
function controlAlpha(prefix) {
if (prefix === "focus") return Style.focusBorderAlpha
if (prefix === "hover-cursor") return Style.hoverBorderAlpha
if (prefix === "selected") return Style.selectedBorderAlpha
return Style.normalBorderAlpha
}
function controlFallbackWidth(prefix) {
if (prefix === "focus") return Style.focusBorderWidth
if (prefix === "hover-cursor") return Style.hoverBorderWidth
if (prefix === "selected") return Style.selectedBorderWidth
return Style.normalBorderWidth
}
function controlWidths(state) {
var prefix = controlPrefix(state)
var fallbackWidth = controlFallbackWidth(prefix)
var base = Style.styleOverrides[prefix + "-border-width"]
var widths = Geometry.parseWidthSpec(base, fallbackWidth)
return Geometry.withSideOverrides(
widths,
Style.styleOverrides[prefix + "-border-width-top"],
Style.styleOverrides[prefix + "-border-width-right"],
Style.styleOverrides[prefix + "-border-width-bottom"],
Style.styleOverrides[prefix + "-border-width-left"]
)
}
function controlHasWidth(state) {
return Geometry.maxWidth(controlWidths(state)) > 0
}
function controlSpec(state, foreground, accent, urgent) {
var prefix = controlPrefix(state)
var resolved = borderValue(
Style.styleOverrides[prefix + "-border"],
controlColor(prefix, foreground, accent, urgent),
controlAlpha(prefix),
Style.styleOverrides[prefix + "-border-gradient"]
)
return { color: resolved.color, widths: controlWidths(prefix), gradient: resolved.gradient }
}
function withWidth(spec, width) {
if (!spec) return flat("transparent", 0)
return { color: spec.color, gradient: spec.gradient, widths: Geometry.parseWidthSpec(width, 0) }
}
function isNone(spec) { return !spec || Geometry.maxWidth(spec.widths) <= 0 }
function needsOverlay(spec) { return Geometry.needsOverlay(spec) }
function canUseNative(spec) { return Geometry.canUseNative(spec) }
function top(spec) { return spec && spec.widths ? spec.widths.top : 0 }
function right(spec) { return spec && spec.widths ? spec.widths.right : 0 }
function bottom(spec) { return spec && spec.widths ? spec.widths.bottom : 0 }
function left(spec) { return spec && spec.widths ? spec.widths.left : 0 }
function uniformWidth(spec) { return spec && spec.widths ? spec.widths.top : 0 }
function color(spec) { return spec ? spec.color : "transparent" }
}
+257
View File
@@ -0,0 +1,257 @@
.pragma library
function clamp(value, min, max) {
var n = Number(value)
if (!isFinite(n)) return min
return Math.max(min, Math.min(max, n))
}
function clampAlpha(value) {
return clamp(value, 0, 1)
}
function padHex(value) {
var n = clamp(Math.round(Number(value)), 0, 255)
var h = n.toString(16)
return h.length < 2 ? "0" + h : h
}
function qmlHexColor(rgb, alphaByte) {
var rgbPart = String(rgb || "").replace(/^#/, "")
var a = clamp(Math.round(Number(alphaByte)), 0, 255)
if (typeof Qt !== "undefined" && Qt.rgba && rgbPart.length >= 6) {
return Qt.rgba(
parseInt(rgbPart.substring(0, 2), 16) / 255,
parseInt(rgbPart.substring(2, 4), 16) / 255,
parseInt(rgbPart.substring(4, 6), 16) / 255,
a / 255
)
}
var aHex = padHex(a)
return aHex.toLowerCase() === "ff" ? "#" + rgbPart : "#" + rgbPart + aHex
}
function canonicalColor(value, alpha) {
var a = alpha === undefined || alpha === null ? 1 : clampAlpha(alpha)
var s = String(value || "").replace(/^\s+|\s+$/g, "")
var m
m = s.match(/^#([0-9A-Fa-f]{3})$/)
if (m) {
var sh = m[1]
return qmlHexColor(
sh.charAt(0) + sh.charAt(0)
+ sh.charAt(1) + sh.charAt(1)
+ sh.charAt(2) + sh.charAt(2),
a * 255
)
}
m = s.match(/^#([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$/)
if (m) {
var colorAlpha = m[2] ? parseInt(m[2], 16) / 255 : 1
return qmlHexColor(m[1], colorAlpha * a * 255)
}
m = s.match(/^[Rr][Gg][Bb]\(([0-9A-Fa-f]{6})\)$/)
if (m) return qmlHexColor(m[1], a * 255)
m = s.match(/^[Rr][Gg][Bb][Aa]\(([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})\)$/)
if (m) return qmlHexColor(m[1], (parseInt(m[2], 16) / 255) * a * 255)
m = s.match(/^[Rr][Gg][Bb]\(([0-9]+),([0-9]+),([0-9]+)\)$/)
if (m) return qmlHexColor(padHex(m[1]) + padHex(m[2]) + padHex(m[3]), a * 255)
m = s.match(/^[Rr][Gg][Bb][Aa]\(([0-9]+),([0-9]+),([0-9]+),([0-9.]+)\)$/)
if (m) return qmlHexColor(padHex(m[1]) + padHex(m[2]) + padHex(m[3]), clampAlpha(m[4]) * a * 255)
m = s.match(/^0x([0-9A-Fa-f]{2})([0-9A-Fa-f]{6})$/)
if (m) return qmlHexColor(m[2], (parseInt(m[1], 16) / 255) * a * 255)
return s
}
function parseWidthSpec(value, fallback) {
var fb = Number(fallback)
if (!isFinite(fb) || fb < 0) fb = 0
if (value === undefined || value === null || value === "") {
return { top: fb, right: fb, bottom: fb, left: fb }
}
var parts = String(value).match(/-?\d+(?:\.\d+)?/g) || []
var nums = []
for (var i = 0; i < parts.length && i < 4; i++) {
var n = Number(parts[i])
nums.push(isFinite(n) && n > 0 ? n : 0)
}
if (nums.length === 0) nums = [fb]
if (nums.length === 1) return { top: nums[0], right: nums[0], bottom: nums[0], left: nums[0] }
if (nums.length === 2) return { top: nums[0], right: nums[1], bottom: nums[0], left: nums[1] }
if (nums.length === 3) return { top: nums[0], right: nums[1], bottom: nums[2], left: nums[1] }
return { top: nums[0], right: nums[1], bottom: nums[2], left: nums[3] }
}
function withSideOverrides(widths, top, right, bottom, left) {
var out = {
top: Number(widths && widths.top) || 0,
right: Number(widths && widths.right) || 0,
bottom: Number(widths && widths.bottom) || 0,
left: Number(widths && widths.left) || 0,
}
if (top !== undefined && top !== null && top !== "") out.top = Math.max(0, Number(top) || 0)
if (right !== undefined && right !== null && right !== "") out.right = Math.max(0, Number(right) || 0)
if (bottom !== undefined && bottom !== null && bottom !== "") out.bottom = Math.max(0, Number(bottom) || 0)
if (left !== undefined && left !== null && left !== "") out.left = Math.max(0, Number(left) || 0)
return out
}
function parseGradientSpec(value, fallbackColor, alpha) {
var s = String(value || "").replace(/^\s+|\s+$/g, "")
var colors = []
var angle = 0
var parts = s.length > 0 ? s.split(/\s+/) : []
for (var i = 0; i < parts.length; i++) {
var part = parts[i]
var angleMatch = part.match(/^(-?\d+(?:\.\d+)?)deg$/)
if (angleMatch) angle = Number(angleMatch[1])
else colors.push(canonicalColor(part, alpha))
}
if (colors.length === 0 && fallbackColor !== undefined && fallbackColor !== null)
colors.push(canonicalColor(fallbackColor, alpha))
return {
colors: colors,
angle: isFinite(angle) ? angle : 0,
enabled: colors.length > 1,
}
}
function isUniform(widths) {
if (!widths) return true
return widths.top === widths.right && widths.top === widths.bottom && widths.top === widths.left
}
function maxWidth(widths) {
if (!widths) return 0
return Math.max(widths.top || 0, widths.right || 0, widths.bottom || 0, widths.left || 0)
}
function needsOverlay(spec) {
if (!spec) return false
if (maxWidth(spec.widths) <= 0) return false
return !!(spec.gradient && spec.gradient.enabled) || !isUniform(spec.widths)
}
function canUseNative(spec) {
return !!spec && maxWidth(spec.widths) > 0 && !needsOverlay(spec)
}
function normalizeRadii(w, h, r) {
var tl = { rx: Math.max(0, Number(r.tlrx) || 0), ry: Math.max(0, Number(r.tlry) || 0) }
var tr = { rx: Math.max(0, Number(r.trrx) || 0), ry: Math.max(0, Number(r.trry) || 0) }
var br = { rx: Math.max(0, Number(r.brrx) || 0), ry: Math.max(0, Number(r.brry) || 0) }
var bl = { rx: Math.max(0, Number(r.blrx) || 0), ry: Math.max(0, Number(r.blry) || 0) }
var scale = 1
if (tl.rx + tr.rx > w && tl.rx + tr.rx > 0) scale = Math.min(scale, w / (tl.rx + tr.rx))
if (bl.rx + br.rx > w && bl.rx + br.rx > 0) scale = Math.min(scale, w / (bl.rx + br.rx))
if (tl.ry + bl.ry > h && tl.ry + bl.ry > 0) scale = Math.min(scale, h / (tl.ry + bl.ry))
if (tr.ry + br.ry > h && tr.ry + br.ry > 0) scale = Math.min(scale, h / (tr.ry + br.ry))
if (scale < 1) {
tl.rx *= scale; tr.rx *= scale; br.rx *= scale; bl.rx *= scale
tl.ry *= scale; tr.ry *= scale; br.ry *= scale; bl.ry *= scale
}
return { tl: tl, tr: tr, br: br, bl: bl }
}
function roundedRectPath(x, y, w, h, radii) {
if (w <= 0 || h <= 0) return ""
var r = normalizeRadii(w, h, radii)
var right = x + w
var bottom = y + h
var p = []
p.push("M", x + r.tl.rx, y)
p.push("H", right - r.tr.rx)
if (r.tr.rx > 0 || r.tr.ry > 0) p.push("A", r.tr.rx, r.tr.ry, 0, 0, 1, right, y + r.tr.ry)
p.push("V", bottom - r.br.ry)
if (r.br.rx > 0 || r.br.ry > 0) p.push("A", r.br.rx, r.br.ry, 0, 0, 1, right - r.br.rx, bottom)
p.push("H", x + r.bl.rx)
if (r.bl.rx > 0 || r.bl.ry > 0) p.push("A", r.bl.rx, r.bl.ry, 0, 0, 1, x, bottom - r.bl.ry)
p.push("V", y + r.tl.ry)
if (r.tl.rx > 0 || r.tl.ry > 0) p.push("A", r.tl.rx, r.tl.ry, 0, 0, 1, x + r.tl.rx, y)
p.push("Z")
return p.join(" ")
}
function ringPath(w, h, radius, widths) {
w = Math.max(0, Number(w) || 0)
h = Math.max(0, Number(h) || 0)
radius = Math.max(0, Number(radius) || 0)
widths = widths || { top: 0, right: 0, bottom: 0, left: 0 }
var outer = roundedRectPath(0, 0, w, h, {
tlrx: radius, tlry: radius,
trrx: radius, trry: radius,
brrx: radius, brry: radius,
blrx: radius, blry: radius,
})
var ix = widths.left || 0
var iy = widths.top || 0
var iw = w - ix - (widths.right || 0)
var ih = h - iy - (widths.bottom || 0)
if (iw <= 0 || ih <= 0) return outer
var inner = roundedRectPath(ix, iy, iw, ih, {
tlrx: Math.max(0, radius - (widths.left || 0)),
tlry: Math.max(0, radius - (widths.top || 0)),
trrx: Math.max(0, radius - (widths.right || 0)),
trry: Math.max(0, radius - (widths.top || 0)),
brrx: Math.max(0, radius - (widths.right || 0)),
brry: Math.max(0, radius - (widths.bottom || 0)),
blrx: Math.max(0, radius - (widths.left || 0)),
blry: Math.max(0, radius - (widths.bottom || 0)),
})
return outer + " " + inner
}
function gradientEndpoints(w, h, angle) {
w = Math.max(1, Number(w) || 1)
h = Math.max(1, Number(h) || 1)
var rad = (Number(angle) || 0) * Math.PI / 180
var dx = Math.cos(rad)
var dy = Math.sin(rad)
var len = (Math.abs(w * dx) + Math.abs(h * dy)) / 2
var cx = w / 2
var cy = h / 2
return {
x1: cx - dx * len,
y1: cy - dy * len,
x2: cx + dx * len,
y2: cy + dy * len,
}
}
function stopColor(colors, index) {
if (!colors || colors.length === 0) return "transparent"
if (index < colors.length) return colors[index]
return colors[colors.length - 1]
}
function stopPosition(colors, index) {
var count = colors ? colors.length : 0
if (count <= 1) return index === 0 ? 0 : 1
if (index >= count) return 1
return index / (count - 1)
}
+32 -6
View File
@@ -2,11 +2,12 @@ pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import "BorderGeometry.js" as Geometry
// Color surfaces for the shell. Foundational palette (foreground, background,
// accent, urgent) comes from theme/colors.toml. Per-surface roles come from
// theme/shell.toml — generated per theme from default/themed/shell.toml.tpl,
// or shipped directly by a theme to override individual keys. Surfaces that
// or shipped directly by a theme to replace the generated file. Surfaces that
// don't appear in shell.toml fall back to the foundational palette.
QtObject {
id: root
@@ -34,9 +35,32 @@ QtObject {
return Util.clampAlpha(n)
}
// Compose a color from a base-color key and its `-alpha` companion.
function firstColorToken(value) {
var parts = String(value || "").replace(/^\s+|\s+$/g, "").split(/\s+/)
for (var i = 0; i < parts.length; i++) {
if (!parts[i].match(/^-?\d+(?:\.\d+)?deg$/)) return parts[i]
}
return value
}
function flatColor(value, fallback) {
var token = firstColorToken(value)
var role = String(token || "").replace(/^\s+|\s+$/g, "").toLowerCase()
if (role === "foreground" || role === "text") return root.foreground
if (role === "accent") return root.accent
if (role === "urgent") return root.urgent
if (role === "background") return root.background
if (role === "transparent") return Qt.rgba(0, 0, 0, 0)
var color = Geometry.canonicalColor(token, 1)
if (typeof color === "string" && color === token && token.charAt(0) !== "#") return fallback
return color
}
// Compose a color from a base-color key and its `-alpha` companion. If the
// base token is a gradient, color-only consumers use the first stop.
function composed(colorKey, alphaKey, colorFallback, alphaFallback) {
return Util.alpha(pick(colorKey, colorFallback), pickAlpha(alphaKey, alphaFallback))
return Util.alpha(flatColor(pick(colorKey, colorFallback), colorFallback), pickAlpha(alphaKey, alphaFallback))
}
readonly property QtObject bar: QtObject {
@@ -130,8 +154,9 @@ QtObject {
// Single TOML walker for shell.toml. Both Color (surface roles) and Style
// (typography, spacing, bar, control states) consume the resulting dict.
// Accepts quoted strings and bare numeric values; tolerates inline comments.
// Numbers are kept as strings here — readers coerce when they pull a value.
// Accepts quoted strings, bare numeric values, bare width lists, and bare
// role names; tolerates inline comments. Numbers are kept as strings here —
// readers coerce when they pull a value.
function loadShell(raw) {
var parsed = {}
var text = String(raw || "")
@@ -145,8 +170,9 @@ QtObject {
if (sectionMatch) { section = sectionMatch[1]; continue }
var stringKv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*["']([^"']+)["']\s*(#.*)?$/)
var numKv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(-?\d+(?:\.\d+)?)\s*(#.*)?$/)
var widthKv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(-?\d+(?:\.\d+)?(?:\s+-?\d+(?:\.\d+)?){1,3})\s*(#.*)?$/)
var bareKv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*([A-Za-z][A-Za-z0-9_-]*)\s*(#.*)?$/)
var kv = stringKv || numKv || bareKv
var kv = stringKv || numKv || widthKv || bareKv
if (!kv || !section) continue
parsed[section + "." + kv[1]] = kv[2]
}
+2 -1
View File
@@ -414,8 +414,9 @@ QtObject {
if (key === "scale") nextSpacingScale = fval
else spacingOut[key] = fval
}
} else if (section === "controls") {
} else if (section === "controls" || section === "style") {
// Strings are passed through; styleRawNum/styleString coerce on read.
// [style] is the legacy name for [controls].
styleOut[key] = raw
}
}
+1
View File
@@ -1,4 +1,5 @@
module qs.Commons
singleton Border 1.0 Border.qml
singleton Color 1.0 Color.qml
singleton Style 1.0 Style.qml
singleton Util 1.0 Util.qml