Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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 resolveValueRef(raw) {
|
||||
var s = String(raw || "").replace(/^\s+|\s+$/g, "")
|
||||
var seen = {}
|
||||
while (s.match(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/) && !seen[s]) {
|
||||
seen[s] = true
|
||||
var next = Color.shellValues[s]
|
||||
if (next === undefined || next === null || String(next).length === 0) break
|
||||
s = String(next).replace(/^\s+|\s+$/g, "")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
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(resolveValueRef(value(section, token)), fallbackColor, opacity, legacyGradientRaw)
|
||||
|
||||
return {
|
||||
color: resolved.color,
|
||||
widths: surfaceWidths(section, token, fallbackWidth),
|
||||
gradient: resolved.gradient,
|
||||
}
|
||||
}
|
||||
|
||||
function hyprlandActiveSpec(fallbackColor, fallbackWidth) {
|
||||
var raw = value("hyprland", "active-border")
|
||||
var opacity = alpha("hyprland", "active-border-alpha", 1.0)
|
||||
|
||||
// Existing generated themes predate [hyprland] and already keep the active
|
||||
// border under [notifications]. Use it as the compatibility source until
|
||||
// the next theme refresh writes the shared token.
|
||||
if (String(raw).length === 0) {
|
||||
raw = value("notifications", "border")
|
||||
opacity = alpha("notifications", "border-alpha", opacity)
|
||||
}
|
||||
|
||||
var resolved = borderValue(resolveValueRef(raw), fallbackColor, opacity, "")
|
||||
return {
|
||||
color: resolved.color,
|
||||
widths: Geometry.parseWidthSpec(value("hyprland", "active-border-width"), 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" }
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
.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 appendArc(path, rx, ry, sweep, point) {
|
||||
if (rx > 0 && ry > 0) path.push("A", rx, ry, 0, 0, sweep, point.x, point.y)
|
||||
else path.push("L", point.x, point.y)
|
||||
}
|
||||
|
||||
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)
|
||||
appendArc(p, r.tr.rx, r.tr.ry, 1, { x: right, y: y + r.tr.ry })
|
||||
p.push("V", bottom - r.br.ry)
|
||||
appendArc(p, r.br.rx, r.br.ry, 1, { x: right - r.br.rx, y: bottom })
|
||||
p.push("H", x + r.bl.rx)
|
||||
appendArc(p, r.bl.rx, r.bl.ry, 1, { x: x, y: bottom - r.bl.ry })
|
||||
p.push("V", y + r.tl.ry)
|
||||
appendArc(p, r.tl.rx, r.tl.ry, 1, { x: x + r.tl.rx, y: y })
|
||||
p.push("Z")
|
||||
|
||||
return p.join(" ")
|
||||
}
|
||||
|
||||
function borderBoundary(x, y, w, h, radii) {
|
||||
var r = radii && radii.tl ? radii : normalizeRadii(w, h, radii)
|
||||
var right = x + w
|
||||
var bottom = y + h
|
||||
return {
|
||||
start: [
|
||||
{ x: x + r.tl.rx, y: y },
|
||||
{ x: right, y: y + r.tr.ry },
|
||||
{ x: right - r.br.rx, y: bottom },
|
||||
{ x: x, y: bottom - r.bl.ry },
|
||||
],
|
||||
end: [
|
||||
{ x: right - r.tr.rx, y: y },
|
||||
{ x: right, y: bottom - r.br.ry },
|
||||
{ x: x + r.bl.rx, y: bottom },
|
||||
{ x: x, y: y + r.tl.ry },
|
||||
],
|
||||
corner: [r.tr, r.br, r.bl, r.tl],
|
||||
}
|
||||
}
|
||||
|
||||
function appendForwardCorner(path, boundary, side) {
|
||||
var corner = boundary.corner[side]
|
||||
appendArc(path, corner.rx, corner.ry, 1, boundary.start[(side + 1) % 4])
|
||||
}
|
||||
|
||||
function appendReverseCorner(path, boundary, side) {
|
||||
var corner = boundary.corner[side]
|
||||
appendArc(path, corner.rx, corner.ry, 0, boundary.end[side])
|
||||
}
|
||||
|
||||
function reverseBoundaryPath(boundary) {
|
||||
var p = ["M", boundary.start[0].x, boundary.start[0].y]
|
||||
for (var side = 3; side >= 0; side--) {
|
||||
appendReverseCorner(p, boundary, side)
|
||||
p.push("L", boundary.start[side].x, boundary.start[side].y)
|
||||
}
|
||||
p.push("Z")
|
||||
return p.join(" ")
|
||||
}
|
||||
|
||||
function runPath(outer, inner, start, length) {
|
||||
var previous = (start + 3) % 4
|
||||
var next = (start + length) % 4
|
||||
var p = ["M", outer.end[previous].x, outer.end[previous].y]
|
||||
|
||||
appendForwardCorner(p, outer, previous)
|
||||
for (var offset = 0; offset < length; offset++) {
|
||||
var side = (start + offset) % 4
|
||||
p.push("L", outer.end[side].x, outer.end[side].y)
|
||||
appendForwardCorner(p, outer, side)
|
||||
}
|
||||
|
||||
p.push("L", inner.start[next].x, inner.start[next].y)
|
||||
for (var reverseOffset = length - 1; reverseOffset >= 0; reverseOffset--) {
|
||||
var reverseSide = (start + reverseOffset) % 4
|
||||
appendReverseCorner(p, inner, reverseSide)
|
||||
p.push("L", inner.start[reverseSide].x, inner.start[reverseSide].y)
|
||||
}
|
||||
appendReverseCorner(p, inner, previous)
|
||||
p.push("Z")
|
||||
return p.join(" ")
|
||||
}
|
||||
|
||||
function radiiFit(w, h, r) {
|
||||
return r.tlrx + r.trrx <= w
|
||||
&& r.blrx + r.brrx <= w
|
||||
&& r.tlry + r.blry <= h
|
||||
&& r.trry + r.brry <= h
|
||||
}
|
||||
|
||||
// Internal geometry output used by ringPath and focused topology tests.
|
||||
// Connected enabled-side runs share one closed contour; opposite-only sides
|
||||
// need two. The all-sides case is one compound winding path with a reversed
|
||||
// inner loop. Disabled sides never require touching or epsilon-offset inner
|
||||
// geometry, so a zero/zero rounded corner emits no border pixels.
|
||||
function borderPaths(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 }
|
||||
if (w <= 0 || h <= 0) return []
|
||||
|
||||
var top = Math.max(0, Number(widths.top) || 0)
|
||||
var right = Math.max(0, Number(widths.right) || 0)
|
||||
var bottom = Math.max(0, Number(widths.bottom) || 0)
|
||||
var left = Math.max(0, Number(widths.left) || 0)
|
||||
var enabled = [top > 0, right > 0, bottom > 0, left > 0]
|
||||
if (!enabled[0] && !enabled[1] && !enabled[2] && !enabled[3]) return []
|
||||
|
||||
var outerRadii = normalizeRadii(w, h, {
|
||||
tlrx: radius, tlry: radius,
|
||||
trrx: radius, trry: radius,
|
||||
brrx: radius, brry: radius,
|
||||
blrx: radius, blry: radius,
|
||||
})
|
||||
var outerPath = roundedRectPath(0, 0, w, h, {
|
||||
tlrx: outerRadii.tl.rx, tlry: outerRadii.tl.ry,
|
||||
trrx: outerRadii.tr.rx, trry: outerRadii.tr.ry,
|
||||
brrx: outerRadii.br.rx, brry: outerRadii.br.ry,
|
||||
blrx: outerRadii.bl.rx, blry: outerRadii.bl.ry,
|
||||
})
|
||||
|
||||
var iw = w - left - right
|
||||
var ih = h - top - bottom
|
||||
if (iw <= 0 || ih <= 0) return [outerPath]
|
||||
|
||||
var desiredInnerRadii = {
|
||||
tlrx: Math.max(0, outerRadii.tl.rx - left),
|
||||
tlry: Math.max(0, outerRadii.tl.ry - top),
|
||||
trrx: Math.max(0, outerRadii.tr.rx - right),
|
||||
trry: Math.max(0, outerRadii.tr.ry - top),
|
||||
brrx: Math.max(0, outerRadii.br.rx - right),
|
||||
brry: Math.max(0, outerRadii.br.ry - bottom),
|
||||
blrx: Math.max(0, outerRadii.bl.rx - left),
|
||||
blry: Math.max(0, outerRadii.bl.ry - bottom),
|
||||
}
|
||||
|
||||
// Normalizing an inner radius that cannot fit can move its tangent beyond
|
||||
// the outer rounded boundary. Winding fill may then paint outside the outer
|
||||
// contour. Conservatively treat that rounded interior as consumed instead.
|
||||
if (!radiiFit(iw, ih, desiredInnerRadii)) return [outerPath]
|
||||
|
||||
var innerRadii = normalizeRadii(iw, ih, desiredInnerRadii)
|
||||
var outer = borderBoundary(0, 0, w, h, outerRadii)
|
||||
var inner = borderBoundary(left, top, iw, ih, innerRadii)
|
||||
|
||||
if (enabled[0] && enabled[1] && enabled[2] && enabled[3])
|
||||
return [outerPath + " " + reverseBoundaryPath(inner)]
|
||||
|
||||
var paths = []
|
||||
for (var start = 0; start < 4; start++) {
|
||||
if (!enabled[start] || enabled[(start + 3) % 4]) continue
|
||||
var length = 1
|
||||
while (length < 4 && enabled[(start + length) % 4]) length++
|
||||
paths.push(runPath(outer, inner, start, length))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function ringPath(w, h, radius, widths) {
|
||||
return borderPaths(w, h, radius, widths).join(" ")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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 replace the generated file. Surfaces that
|
||||
// don't appear in shell.toml fall back to the foundational palette.
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
readonly property string home: Quickshell.env("HOME")
|
||||
readonly property string stateHome: home + "/.local/state"
|
||||
readonly property string currentThemePath: stateHome + "/blob/current/theme"
|
||||
|
||||
property color foreground: "#cacccc"
|
||||
property color background: "#101315"
|
||||
property color accent: "#cacccc"
|
||||
property color urgent: "#a55555"
|
||||
property color muted: "#707880"
|
||||
|
||||
// Flat dictionary of "section.key" -> raw string from shell.toml.
|
||||
// Reassigning this whole property is what makes surface bindings below
|
||||
// re-evaluate when the theme swaps; mutating it in place would not.
|
||||
property var shellValues: ({})
|
||||
|
||||
function pick(key, fallback) {
|
||||
var v = shellValues[key]
|
||||
return (typeof v === "string" && v.length > 0) ? v : fallback
|
||||
}
|
||||
|
||||
function pickAlpha(key, fallback) {
|
||||
var v = shellValues[key]
|
||||
if (typeof v !== "string" || v.length === 0) return fallback
|
||||
var n = Number(v)
|
||||
if (!isFinite(n)) return fallback
|
||||
return Util.clampAlpha(n)
|
||||
}
|
||||
|
||||
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 (root.shellValues[role] && root.shellValues[role] !== token) return flatColor(root.shellValues[role], fallback)
|
||||
if (role === "foreground" || role === "text") return root.foreground
|
||||
if (role === "accent") return root.accent
|
||||
if (role === "urgent") return root.urgent
|
||||
if (role === "muted") return root.muted
|
||||
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(flatColor(pick(colorKey, colorFallback), colorFallback), pickAlpha(alphaKey, alphaFallback))
|
||||
}
|
||||
|
||||
readonly property QtObject bar: QtObject {
|
||||
property color background: root.composed("bar.background", "bar.background-alpha", root.background, 1.0)
|
||||
property color text: root.pick("bar.text", root.foreground)
|
||||
property color active: root.pick("bar.active", root.urgent)
|
||||
}
|
||||
readonly property QtObject popups: QtObject {
|
||||
property color background: root.composed("popups.background", "popups.background-alpha", root.background, 1.0)
|
||||
property color text: root.pick("popups.text", root.foreground)
|
||||
property color border: root.composed("popups.border", "popups.border-alpha", root.accent, 1.0)
|
||||
}
|
||||
readonly property QtObject tooltip: QtObject {
|
||||
property color background: root.composed("tooltip.background", "tooltip.background-alpha", root.background, 1.0)
|
||||
property color text: root.pick("tooltip.text", root.foreground)
|
||||
property color border: root.composed("tooltip.border", "tooltip.border-alpha", root.foreground, 1.0)
|
||||
}
|
||||
readonly property QtObject notifications: QtObject {
|
||||
property color background: root.composed("notifications.background", "notifications.background-alpha", root.background, 1.0)
|
||||
property color text: root.pick("notifications.text", root.foreground)
|
||||
property color border: root.composed("notifications.border", "notifications.border-alpha", root.accent, 1.0)
|
||||
property color countdown: root.pick("notifications.countdown", root.accent)
|
||||
}
|
||||
readonly property QtObject menu: QtObject {
|
||||
property color background: root.composed("menu.background", "menu.background-alpha", root.background, 1.0)
|
||||
property color text: root.pick("menu.text", root.foreground)
|
||||
property color border: root.composed("menu.border", "menu.border-alpha", root.foreground, 1.0)
|
||||
property color scrim: root.composed("menu.scrim", "menu.scrim-alpha", root.background, 0.5)
|
||||
property color selectedBackground: root.composed("menu.selected-background", "menu.selected-background-alpha", root.foreground, 0.08)
|
||||
property color selectedText: root.pick("menu.selected-text", root.accent)
|
||||
property color selectedBorder: root.composed("menu.selected-border", "menu.selected-border-alpha", root.foreground, 0.0)
|
||||
}
|
||||
// polkit + lock share a single border-alpha across border / border-active /
|
||||
// border-error: the three states are mutually exclusive in time, so one
|
||||
// companion is enough.
|
||||
readonly property QtObject polkit: QtObject {
|
||||
property color background: root.composed("polkit.background", "polkit.background-alpha", root.background, 1.0)
|
||||
property color text: root.pick("polkit.text", root.foreground)
|
||||
property color textError: root.pick("polkit.text-error", root.urgent)
|
||||
property color border: root.composed("polkit.border", "polkit.border-alpha", root.accent, 1.0)
|
||||
property color borderError: root.composed("polkit.border-error", "polkit.border-alpha", root.urgent, 1.0)
|
||||
property color accent: root.pick("polkit.accent", root.accent)
|
||||
property color scrim: root.composed("polkit.scrim", "polkit.scrim-alpha", root.background, 0.5)
|
||||
}
|
||||
readonly property QtObject lock: QtObject {
|
||||
property color background: root.composed("lock.background", "lock.background-alpha", root.background, 0.8)
|
||||
property color text: root.pick("lock.text", root.foreground)
|
||||
property color placeholder: root.shellValues["lock.placeholder"] ? root.flatColor(root.shellValues["lock.placeholder"], Util.alpha(root.foreground, 0.66)) : Util.alpha(root.foreground, 0.66)
|
||||
property color textError: root.pick("lock.text-error", root.urgent)
|
||||
property color border: root.composed("lock.border", "lock.border-alpha", root.foreground, 1.0)
|
||||
property color borderActive: root.composed("lock.border-active", "lock.border-alpha", root.accent, 1.0)
|
||||
property color borderError: root.composed("lock.border-error", "lock.border-alpha", root.urgent, 1.0)
|
||||
property color selection: root.composed("lock.selection", "lock.selection-alpha", root.accent, 0.45)
|
||||
}
|
||||
// The image picker has no card surface; `scrim` is the full-screen dim
|
||||
// wash, and per-slice dim overlays / text outlines use the foundational
|
||||
// `background` color directly.
|
||||
readonly property QtObject imagePicker: QtObject {
|
||||
property color scrim: root.composed("image-picker.scrim", "image-picker.scrim-alpha", root.background, 0.5)
|
||||
property color text: root.pick("image-picker.text", root.foreground)
|
||||
property color selectedBorder: root.composed("image-picker.selected-border", "image-picker.selected-border-alpha", root.accent, 1.0)
|
||||
property color unselectedBorder: root.composed("image-picker.unselected-border", "image-picker.unselected-border-alpha", root.foreground, 0.28)
|
||||
}
|
||||
|
||||
function loadColors(raw) {
|
||||
var lines = String(raw || "").split("\n")
|
||||
var foundAccent = false
|
||||
var foundMuted = false
|
||||
var loadedForeground = false
|
||||
var loadedBackground = false
|
||||
var color0Value = ""
|
||||
var color4Value = ""
|
||||
var color7Value = ""
|
||||
var color8Value = ""
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var match = lines[i].match(/^\s*([A-Za-z0-9_-]+)\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
|
||||
if (!match) continue
|
||||
if (match[1] === "foreground") { foreground = match[2]; loadedForeground = true }
|
||||
else if (match[1] === "background") { background = match[2]; loadedBackground = true }
|
||||
// Prefer the explicit `accent` key; only fall back to color4 when the
|
||||
// theme doesn't define a separate accent. color4 appears later in the
|
||||
// file so the old single-property approach clobbered accent with it.
|
||||
else if (match[1] === "accent") { accent = match[2]; foundAccent = true }
|
||||
else if (match[1] === "muted") { muted = match[2]; foundMuted = true }
|
||||
else if (match[1] === "color0") color0Value = match[2]
|
||||
else if (match[1] === "color4") color4Value = match[2]
|
||||
else if (match[1] === "color7") color7Value = match[2]
|
||||
else if (match[1] === "color8") color8Value = match[2]
|
||||
else if (match[1] === "red" || match[1] === "color1") urgent = match[2]
|
||||
}
|
||||
if (!loadedBackground && color0Value.length > 0) background = color0Value
|
||||
if (!loadedForeground && color7Value.length > 0) foreground = color7Value
|
||||
if (!foundAccent && color4Value.length > 0) accent = color4Value
|
||||
if (!foundMuted) muted = color8Value.length > 0 ? color8Value : foreground
|
||||
}
|
||||
|
||||
// Last theme-supplied and user-supplied shell.toml dicts, kept separate so
|
||||
// either can be reloaded without re-reading the other. `shellValues` is
|
||||
// always the merge of theme (base) and user (override) — see mergeShell.
|
||||
property var themeShellValues: ({})
|
||||
property var userShellValues: ({})
|
||||
|
||||
// Single TOML walker for shell.toml. Both Color (surface roles) and Style
|
||||
// (typography, spacing, bar, control states) consume the resulting dict.
|
||||
// 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 parseShell(raw) {
|
||||
var parsed = {}
|
||||
var text = String(raw || "")
|
||||
if (text) {
|
||||
var lines = text.split("\n")
|
||||
var section = ""
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].replace(/^\s+|\s+$/g, "")
|
||||
if (!line || line.charAt(0) === "#") continue
|
||||
var sectionMatch = line.match(/^\[([A-Za-z0-9_-]+)\]\s*(#.*)?$/)
|
||||
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 || widthKv || bareKv
|
||||
if (!kv || !section) continue
|
||||
parsed[section + "." + kv[1]] = kv[2]
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// Re-derive `shellValues` from theme base + user override and push it to
|
||||
// Style. User keys win, so a machine-level `~/.config/blob/shell.toml`
|
||||
// survives theme switches (which replace only themeShellValues).
|
||||
function mergeShell() {
|
||||
var merged = {}
|
||||
for (var tk in themeShellValues) merged[tk] = themeShellValues[tk]
|
||||
for (var uk in userShellValues) merged[uk] = userShellValues[uk]
|
||||
shellValues = merged
|
||||
Style.applyShellValues(merged)
|
||||
}
|
||||
|
||||
function loadShell(raw) {
|
||||
themeShellValues = parseShell(raw)
|
||||
mergeShell()
|
||||
}
|
||||
|
||||
function loadUserShell(raw) {
|
||||
userShellValues = parseShell(raw)
|
||||
mergeShell()
|
||||
}
|
||||
|
||||
// Startup load only. Runtime theme switches push the payload explicitly
|
||||
// through shell IPC.
|
||||
property FileView colorsFile: FileView {
|
||||
id: colorsFile
|
||||
path: root.currentThemePath + "/colors.toml"
|
||||
watchChanges: false
|
||||
printErrors: false
|
||||
onLoaded: root.loadColors(text())
|
||||
}
|
||||
property FileView shellFile: FileView {
|
||||
id: shellFile
|
||||
path: root.currentThemePath + "/shell.toml"
|
||||
watchChanges: false
|
||||
printErrors: false
|
||||
onLoaded: root.loadShell(text())
|
||||
onLoadFailed: root.loadShell("")
|
||||
}
|
||||
// Machine-level override, layered on top of whatever theme is active. This
|
||||
// is where `blob display text size` writes `[font] base-size`. Watched so the
|
||||
// CLI takes effect live without restarting the shell; absent by default.
|
||||
property FileView userShellFile: FileView {
|
||||
id: userShellFile
|
||||
path: root.home + "/.config/blob/shell.toml"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.loadUserShell(text())
|
||||
// Re-read on change (including first creation) before loading — `text()`
|
||||
// is stale in the change signal itself, so route both paths through reload
|
||||
// → onLoaded to always parse fresh content.
|
||||
onFileChanged: reload()
|
||||
onLoadFailed: root.loadUserShell("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
// Shared structural style tokens for the shell. Color is the palette
|
||||
// singleton; Style holds everything else themes can influence — corner
|
||||
// rounding, gap to screen edges, state affordances, spacing, typography
|
||||
// scale, and bar dimensions.
|
||||
//
|
||||
// `cornerRadius` mirrors Hyprland's `decoration:rounding`. `gapsOut` is
|
||||
// half of Hyprland's `general:gaps_out` — Hyprland's value works well as
|
||||
// a window-to-window gap but feels too cavernous when used as the
|
||||
// distance from a panel/notification to the screen edge, so the shell
|
||||
// halves it. Themes and user Hyprland config own those values; the
|
||||
// shell picks them up by re-running `hyprctl getoption` on startup and
|
||||
// after theme IPC applies a theme.
|
||||
//
|
||||
// Typography, spacing, and bar size come from theme/shell.toml.
|
||||
// `[font] base-size` is the rem root; every `Style.font.<token>` derives
|
||||
// from it via the scale multipliers below unless the theme pins that
|
||||
// specific token. `[spacing] scale` multiplies shared margins, gaps,
|
||||
// padding, controls, and panel dimensions while preserving each component's
|
||||
// proportions; by default it also tracks `base-size`. `[bar]
|
||||
// size-horizontal` / `size-vertical` set the cross-axis dimension for
|
||||
// top/bottom and left/right bars at the default 12px font size; by default
|
||||
// those dimensions scale with `base-size` so larger fonts don't clip.
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property int cornerRadius: 0
|
||||
property int gapsOut: 5
|
||||
|
||||
// ---------------------------------------------------------- state tokens
|
||||
//
|
||||
// Shared interactive-state tokens for every reusable surface in the kit.
|
||||
// The vocabulary:
|
||||
// normal — idle control chrome
|
||||
// hover-cursor — mouse hover OR panel keyboard cursor (`hasCursor`)
|
||||
// selected — persistent chosen/current state
|
||||
// focus — actual Qt activeFocus, defaulting to hover-cursor
|
||||
//
|
||||
// Each state has a color token plus fill/border alphas. Color tokens
|
||||
// may be palette roles (`foreground`, `accent`, `urgent`, `background`)
|
||||
// or hex colors. Set a state's border width to 0 to drop that border.
|
||||
property var styleOverrides: ({})
|
||||
|
||||
function styleRawNum(key) {
|
||||
var v = styleOverrides[key]
|
||||
var n = Number(v)
|
||||
return isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function styleNum(key, fallback) {
|
||||
var n = styleRawNum(key)
|
||||
return n === null ? fallback : n
|
||||
}
|
||||
|
||||
function styleAlpha(key, fallback) {
|
||||
return Util.clampAlpha(styleNum(key, fallback))
|
||||
}
|
||||
|
||||
function styleString(key, fallback) {
|
||||
var v = styleOverrides[key]
|
||||
if (typeof v !== "string") return fallback
|
||||
v = v.replace(/^\s+|\s+$/g, "")
|
||||
return v.length > 0 ? v : fallback
|
||||
}
|
||||
|
||||
readonly property string normalColorToken: styleString("normal-color", "foreground")
|
||||
readonly property string hoverColorToken: styleString("hover-cursor-color", "foreground")
|
||||
readonly property string selectedColorToken: styleString("selected-color", "foreground")
|
||||
readonly property string pressedColorToken: styleString("pressed-color", hoverColorToken)
|
||||
readonly property string focusColorToken: styleString("focus-color", hoverColorToken)
|
||||
readonly property string selectionColorToken: styleString("selection-color", "foreground")
|
||||
|
||||
readonly property int normalBorderWidth: Math.max(0, Math.round(styleNum("normal-border-width", 1)))
|
||||
readonly property int hoverBorderWidth: Math.max(0, Math.round(styleNum("hover-cursor-border-width", normalBorderWidth)))
|
||||
readonly property int selectedBorderWidth: Math.max(0, Math.round(styleNum("selected-border-width", 0)))
|
||||
readonly property int focusBorderWidth: Math.max(0, Math.round(styleNum("focus-border-width", hoverBorderWidth)))
|
||||
|
||||
readonly property real normalFillAlpha: styleAlpha("normal-fill-alpha", 0.04)
|
||||
readonly property real hoverFillAlpha: styleAlpha("hover-cursor-fill-alpha", 0.08)
|
||||
readonly property real selectedFillAlpha: styleAlpha("selected-fill-alpha", 0.18)
|
||||
readonly property real pressedFillAlpha: styleAlpha("pressed-fill-alpha", 0.22)
|
||||
readonly property real focusFillAlpha: styleAlpha("focus-fill-alpha", hoverFillAlpha)
|
||||
readonly property real selectionFillAlpha: styleAlpha("selection-fill-alpha", 0.35)
|
||||
|
||||
readonly property real normalBorderAlpha: styleAlpha("normal-border-alpha", 0.4)
|
||||
readonly property real hoverBorderAlpha: styleAlpha("hover-cursor-border-alpha", 0.25)
|
||||
readonly property real selectedBorderAlpha: styleAlpha("selected-border-alpha", 1.0)
|
||||
readonly property real focusBorderAlpha: styleAlpha("focus-border-alpha", hoverBorderAlpha)
|
||||
|
||||
function colorFromHex(value, fallback) {
|
||||
var s = String(value || "").replace(/^\s+|\s+$/g, "")
|
||||
var shortHex = s.match(/^#([0-9A-Fa-f]{3})$/)
|
||||
if (shortHex) {
|
||||
var sh = shortHex[1]
|
||||
return Qt.rgba(
|
||||
parseInt(sh.charAt(0) + sh.charAt(0), 16) / 255,
|
||||
parseInt(sh.charAt(1) + sh.charAt(1), 16) / 255,
|
||||
parseInt(sh.charAt(2) + sh.charAt(2), 16) / 255,
|
||||
1)
|
||||
}
|
||||
var hex = s.match(/^#([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$/)
|
||||
if (!hex) return fallback
|
||||
var h = hex[1]
|
||||
return Qt.rgba(
|
||||
parseInt(h.substr(0, 2), 16) / 255,
|
||||
parseInt(h.substr(2, 2), 16) / 255,
|
||||
parseInt(h.substr(4, 2), 16) / 255,
|
||||
hex[2] ? parseInt(hex[2], 16) / 255 : 1)
|
||||
}
|
||||
|
||||
function resolveStateColor(token, foreground, accent, urgent, fallback) {
|
||||
var fb = fallback || foreground || Color.foreground
|
||||
var s = String(token || "").replace(/^\s+|\s+$/g, "")
|
||||
var role = s.toLowerCase()
|
||||
if (role === "foreground" || role === "text") return foreground || Color.foreground
|
||||
if (role === "accent") return accent || Color.accent
|
||||
if (role === "urgent") return urgent || Color.urgent
|
||||
if (role === "background") return Color.background
|
||||
if (role === "transparent") return Qt.rgba(0, 0, 0, 0)
|
||||
return colorFromHex(s, fb)
|
||||
}
|
||||
|
||||
function normalStateColor(foreground, accent, urgent) {
|
||||
return resolveStateColor(normalColorToken, foreground, accent, urgent, foreground || Color.foreground)
|
||||
}
|
||||
|
||||
function hoverStateColor(foreground, accent, urgent) {
|
||||
return resolveStateColor(hoverColorToken, foreground, accent, urgent, foreground || Color.foreground)
|
||||
}
|
||||
|
||||
function selectedStateColor(foreground, accent, urgent) {
|
||||
return resolveStateColor(selectedColorToken, foreground, accent, urgent, foreground || Color.foreground)
|
||||
}
|
||||
|
||||
function pressedStateColor(foreground, accent, urgent) {
|
||||
return resolveStateColor(pressedColorToken, foreground, accent, urgent, hoverStateColor(foreground, accent, urgent))
|
||||
}
|
||||
|
||||
function focusStateColor(foreground, accent, urgent) {
|
||||
var role = String(focusColorToken || "").replace(/^\s+|\s+$/g, "").toLowerCase()
|
||||
if (role === "hover" || role === "hover-cursor" || role === "inherit")
|
||||
return hoverStateColor(foreground, accent, urgent)
|
||||
return resolveStateColor(focusColorToken, foreground, accent, urgent, hoverStateColor(foreground, accent, urgent))
|
||||
}
|
||||
|
||||
function selectionStateColor(foreground, accent, urgent) {
|
||||
return resolveStateColor(selectionColorToken, foreground, accent, urgent, foreground || Color.foreground)
|
||||
}
|
||||
|
||||
function normalFillFor(foreground, accent, urgent) { return Util.alpha(normalStateColor(foreground, accent, urgent), normalFillAlpha) }
|
||||
function hoverFillFor(foreground, accent, urgent) { return Util.alpha(hoverStateColor(foreground, accent, urgent), hoverFillAlpha) }
|
||||
function selectedFillFor(foreground, accent, urgent) { return Util.alpha(selectedStateColor(foreground, accent, urgent), selectedFillAlpha) }
|
||||
function pressedFillFor(foreground, accent, urgent) { return Util.alpha(pressedStateColor(foreground, accent, urgent), pressedFillAlpha) }
|
||||
function focusFillFor(foreground, accent, urgent) { return Util.alpha(focusStateColor(foreground, accent, urgent), focusFillAlpha) }
|
||||
function selectionFillFor(foreground, accent, urgent) { return Util.alpha(selectionStateColor(foreground, accent, urgent), selectionFillAlpha) }
|
||||
|
||||
function normalBorderFor(foreground, accent, urgent) { return Util.alpha(normalStateColor(foreground, accent, urgent), normalBorderAlpha) }
|
||||
function hoverBorderFor(foreground, accent, urgent) { return Util.alpha(hoverStateColor(foreground, accent, urgent), hoverBorderAlpha) }
|
||||
function selectedBorderFor(foreground, accent, urgent) { return Util.alpha(selectedStateColor(foreground, accent, urgent), selectedBorderAlpha) }
|
||||
function focusBorderFor(foreground, accent, urgent) { return Util.alpha(focusStateColor(foreground, accent, urgent), focusBorderAlpha) }
|
||||
|
||||
// Composite helpers for the focus > hover > normal priority chain used by
|
||||
// every form control surface (TextField, NumberField, Dropdown, Toggle,
|
||||
// etc.). Saves callers from re-writing the three-line ternary ladder for
|
||||
// fill / border / border-width on every Rectangle background.
|
||||
function controlFill(focused, hot, foreground, accent) {
|
||||
if (focused) return focusFillFor(foreground, accent)
|
||||
if (hot) return hoverFillFor(foreground, accent)
|
||||
return normalFillFor(foreground, accent)
|
||||
}
|
||||
|
||||
function controlBorder(focused, hot, foreground, accent) {
|
||||
if (focused) return focusBorderFor(foreground, accent)
|
||||
if (hot) return hoverBorderFor(foreground, accent)
|
||||
return normalBorderFor(foreground, accent)
|
||||
}
|
||||
|
||||
function controlBorderWidth(focused, hot) {
|
||||
if (focused) return focusBorderWidth
|
||||
if (hot) return hoverBorderWidth
|
||||
return normalBorderWidth
|
||||
}
|
||||
|
||||
// Convenience colors resolved against the foundational palette.
|
||||
readonly property color normalFill: normalFillFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color hoverFill: hoverFillFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color selectedFill: selectedFillFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color pressedFill: pressedFillFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color focusFillColor: focusFillFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color normalBorderColor: normalBorderFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color hoverBorderColor: hoverBorderFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color selectedBorderColor: selectedBorderFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color focusBorderColor: focusBorderFor(Color.foreground, Color.accent, Color.urgent)
|
||||
readonly property color selectedAccentFill: Util.alpha(Color.accent, selectedFillAlpha)
|
||||
readonly property color selectionFill: selectionFillFor(Color.foreground, Color.accent, Color.urgent)
|
||||
|
||||
// ---------------------------------------------------------- spacing
|
||||
//
|
||||
// The spacing scale is the shell equivalent of rem for margins, gaps,
|
||||
// and padding. Components keep their existing proportions by asking for
|
||||
// the old pixel value through `Style.space(px)` (or `spaceReal(px)` for
|
||||
// fractional geometry); themes can make the shell denser or roomier
|
||||
// with `[spacing] scale`, or pin individual tokens.
|
||||
property real spacingScale: 1.0
|
||||
property bool spacingScaleWithFont: true
|
||||
property var spacingOverrides: ({})
|
||||
readonly property real effectiveSpacingScale: spacingScale * (spacingScaleWithFont ? fontScale : 1)
|
||||
|
||||
function spaceReal(px) {
|
||||
var n = Number(px)
|
||||
if (!isFinite(n) || n <= 0) return 0
|
||||
return n * effectiveSpacingScale
|
||||
}
|
||||
|
||||
function space(px) {
|
||||
var n = spaceReal(px)
|
||||
if (n <= 0) return 0
|
||||
return Math.max(1, Math.round(n))
|
||||
}
|
||||
|
||||
function spacingToken(key, fallback) {
|
||||
var v = spacingOverrides[key]
|
||||
var n = Number(v)
|
||||
return (isFinite(n) && n >= 0) ? Math.round(n) : space(fallback)
|
||||
}
|
||||
|
||||
readonly property QtObject spacing: QtObject {
|
||||
readonly property real scale: root.effectiveSpacingScale
|
||||
|
||||
readonly property int hairline: root.space(1)
|
||||
readonly property int xxs: root.spacingToken("xxs", 2)
|
||||
readonly property int xs: root.spacingToken("xs", 3)
|
||||
readonly property int sm: root.spacingToken("sm", 4)
|
||||
readonly property int md: root.spacingToken("md", 6)
|
||||
readonly property int lg: root.spacingToken("lg", 8)
|
||||
readonly property int xl: root.spacingToken("xl", 10)
|
||||
readonly property int xxl: root.spacingToken("xxl", 12)
|
||||
readonly property int xxxl: root.spacingToken("xxxl", 14)
|
||||
readonly property int huge: root.spacingToken("huge", 18)
|
||||
|
||||
readonly property int controlGap: root.spacingToken("control-gap", 8)
|
||||
readonly property int controlPaddingX: root.spacingToken("control-padding-x", 10)
|
||||
readonly property int controlPaddingY: root.spacingToken("control-padding-y", 6)
|
||||
readonly property int inputPaddingY: root.spacingToken("input-padding-y", 7)
|
||||
readonly property int controlHeight: root.spacingToken("control-height", 28)
|
||||
readonly property int popupRowHeight: root.spacingToken("popup-row-height", 28)
|
||||
readonly property int dropdownWidth: root.spacingToken("dropdown-width", 240)
|
||||
readonly property int searchableDropdownWidth: root.spacingToken("searchable-dropdown-width", 260)
|
||||
readonly property int numberFieldWidth: root.spacingToken("number-field-width", 120)
|
||||
readonly property int searchablePopupMinHeight: root.spacingToken("searchable-popup-min-height", 220)
|
||||
readonly property int rowGap: root.spacingToken("row-gap", 8)
|
||||
readonly property int rowPaddingX: root.spacingToken("row-padding-x", 12)
|
||||
readonly property int labelGap: root.spacingToken("label-gap", 4)
|
||||
readonly property int panelGap: root.spacingToken("panel-gap", 14)
|
||||
readonly property int panelPadding: root.spacingToken("panel-padding", 18)
|
||||
readonly property int popupPadding: root.spacingToken("popup-padding", 14)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- typography
|
||||
//
|
||||
// `fontFamily` defaults to "monospace" so the bar and every qs.Ui
|
||||
// component follows the fontconfig alias `blob-font-set` writes.
|
||||
// Themes can override per-token via [font] in shell.toml, but the
|
||||
// family stays system-wide.
|
||||
property string fontFamily: "monospace"
|
||||
|
||||
// The concrete family `monospace` resolves to right now, e.g.
|
||||
// "JetBrainsMono Nerd Font". Bind `font.family` to `fontFamily` (so the
|
||||
// alias path keeps working when the user runs `blob font set`), but
|
||||
// read `resolvedFontFamily` when you want to *display* what's drawing.
|
||||
property string resolvedFontFamily: "monospace"
|
||||
|
||||
// The only sanity floor is 1px. Themes and users can make this as large
|
||||
// as they like; if the shell gets ridiculous, that's their call.
|
||||
property int fontBaseSize: 12
|
||||
|
||||
property var fontOverrides: ({})
|
||||
property var barOverrides: ({})
|
||||
property bool barScaleWithFont: true
|
||||
readonly property real fontScale: Math.max(1 / 12, fontBaseSize / 12)
|
||||
|
||||
function fontPx(mult) {
|
||||
return Math.max(1, Math.round(fontBaseSize * mult))
|
||||
}
|
||||
|
||||
function fontToken(key, fallback) {
|
||||
var v = fontOverrides[key]
|
||||
var n = Number(v)
|
||||
return (isFinite(n) && n > 0) ? Math.round(n) : fallback
|
||||
}
|
||||
|
||||
function barToken(key, fallback) {
|
||||
var v = barOverrides[key]
|
||||
var n = Number(v)
|
||||
var base = (isFinite(n) && n > 0) ? n : fallback
|
||||
if (barScaleWithFont) base *= fontScale
|
||||
return Math.max(1, Math.round(base))
|
||||
}
|
||||
|
||||
function boolToken(value, fallback) {
|
||||
if (value === undefined || value === null) return fallback
|
||||
var s = String(value).replace(/^\s+|\s+$/g, "").toLowerCase()
|
||||
if (s === "true" || s === "1" || s === "yes" || s === "on") return true
|
||||
if (s === "false" || s === "0" || s === "no" || s === "off") return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
// The menu, polkit, emojis, and clipboard surfaces honor an
|
||||
// BLOB_MENU_FONT override for users who want a different family on the
|
||||
// summoned popups than on the bar. Resolved once at startup; an empty env
|
||||
// value falls back to the shared fontconfig alias.
|
||||
readonly property string menuFontFamily: {
|
||||
var override = Quickshell.env("BLOB_MENU_FONT")
|
||||
return (override && override.length > 0) ? override : fontFamily
|
||||
}
|
||||
|
||||
readonly property QtObject font: QtObject {
|
||||
readonly property string family: root.fontFamily
|
||||
readonly property string resolvedFamily: root.resolvedFontFamily
|
||||
readonly property string menuFamily: root.menuFontFamily
|
||||
readonly property int baseSize: root.fontBaseSize
|
||||
|
||||
readonly property int caption: root.fontToken("caption", root.fontPx(0.833)) // 10
|
||||
readonly property int bodySmall: root.fontToken("body-small", root.fontPx(0.917)) // 11
|
||||
readonly property int body: root.fontToken("body", root.fontPx(1.0)) // 12
|
||||
readonly property int subtitle: root.fontToken("subtitle", root.fontPx(1.083)) // 13
|
||||
readonly property int title: root.fontToken("title", root.fontPx(1.167)) // 14
|
||||
readonly property int heading: root.fontToken("heading", root.fontPx(1.333)) // 16
|
||||
readonly property int display: root.fontToken("display", root.fontPx(2.0)) // 24
|
||||
readonly property int displayLarge: root.fontToken("display-large", root.fontPx(2.333)) // 28
|
||||
|
||||
readonly property int iconSmall: root.fontToken("icon-small", bodySmall)
|
||||
readonly property int icon: root.fontToken("icon", title)
|
||||
readonly property int iconLarge: root.fontToken("icon-large", root.fontPx(1.5)) // 18
|
||||
}
|
||||
|
||||
readonly property QtObject bar: QtObject {
|
||||
readonly property int sizeHorizontal: root.barToken("size-horizontal", 26)
|
||||
readonly property int sizeVertical: root.barToken("size-vertical", 28)
|
||||
readonly property int iconSlot: root.barToken("icon-slot", 27)
|
||||
readonly property int iconCanvas: root.barToken("icon-canvas", 16)
|
||||
readonly property int iconFont: root.barToken("icon-font", 13)
|
||||
readonly property int statusSlot: root.barToken("status-slot", 21)
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
hyprctlProc.running = true
|
||||
gapsOutProc.running = true
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
refreshTimer.restart()
|
||||
}
|
||||
|
||||
function applyRoundingJson(raw) {
|
||||
try {
|
||||
var json = JSON.parse(raw || "{}")
|
||||
var n = Number(json.int)
|
||||
if (isFinite(n) && n >= 0) cornerRadius = n
|
||||
} catch (e) {
|
||||
// hyprctl missing / Hyprland not running — leave the previous value.
|
||||
}
|
||||
}
|
||||
|
||||
function applyGapsOutJson(raw) {
|
||||
try {
|
||||
var json = JSON.parse(raw || "{}")
|
||||
var css = String(json.css || "")
|
||||
var parts = css.match(/-?\d+(?:\.\d+)?/g) || []
|
||||
var n = parts.length > 0 ? Number(parts[0]) : Number(json.int)
|
||||
if (isFinite(n) && n >= 0) gapsOut = Math.max(0, Math.round(n / 2))
|
||||
} catch (e) {
|
||||
// hyprctl missing / Hyprland not running — leave the previous value.
|
||||
}
|
||||
}
|
||||
|
||||
// Pull typography, bar dimensions, state tokens, and spacing out of the
|
||||
// shell.toml dict that Color already parsed. Called by Color.loadShell so
|
||||
// a single parse pass feeds both singletons.
|
||||
function applyShellValues(values) {
|
||||
var fontOut = {}
|
||||
var barOut = {}
|
||||
var styleOut = {}
|
||||
var spacingOut = {}
|
||||
var nextBase = 12
|
||||
var nextSpacingScale = 1.0
|
||||
var nextSpacingScaleWithFont = true
|
||||
var nextBarScaleWithFont = true
|
||||
var v = values || {}
|
||||
for (var fullKey in v) {
|
||||
var dot = fullKey.indexOf(".")
|
||||
if (dot < 0) continue
|
||||
var section = fullKey.substr(0, dot)
|
||||
var key = fullKey.substr(dot + 1)
|
||||
var raw = v[fullKey]
|
||||
if (section === "font") {
|
||||
var ival = parseInt(raw, 10)
|
||||
if (!isFinite(ival)) continue
|
||||
if (key === "base-size") nextBase = ival
|
||||
else fontOut[key] = ival
|
||||
} else if (section === "bar") {
|
||||
if (key === "scale-with-font") {
|
||||
nextBarScaleWithFont = boolToken(raw, nextBarScaleWithFont)
|
||||
} else if (key === "size-horizontal" || key === "size-vertical") {
|
||||
var b = parseInt(raw, 10)
|
||||
if (isFinite(b)) barOut[key] = b
|
||||
}
|
||||
} else if (section === "spacing") {
|
||||
if (key === "scale-with-font") {
|
||||
nextSpacingScaleWithFont = boolToken(raw, nextSpacingScaleWithFont)
|
||||
} else {
|
||||
var fval = parseFloat(raw)
|
||||
if (!isFinite(fval)) continue
|
||||
if (key === "scale") nextSpacingScale = fval
|
||||
else spacingOut[key] = fval
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
// Keep only a 1px sanity floor. Per-token overrides aren't clamped
|
||||
// either — a theme that wants display-large = 64 should be allowed to
|
||||
// ship it.
|
||||
if (!isFinite(nextBase) || nextBase < 1) nextBase = 1
|
||||
if (!isFinite(nextSpacingScale) || nextSpacingScale < 0) nextSpacingScale = 1.0
|
||||
spacingScale = nextSpacingScale
|
||||
spacingScaleWithFont = nextSpacingScaleWithFont
|
||||
fontBaseSize = nextBase
|
||||
fontOverrides = fontOut
|
||||
barOverrides = barOut
|
||||
barScaleWithFont = nextBarScaleWithFont
|
||||
spacingOverrides = spacingOut
|
||||
styleOverrides = styleOut
|
||||
}
|
||||
|
||||
property Process hyprctlProc: Process {
|
||||
id: hyprctlProc
|
||||
command: ["hyprctl", "-j", "getoption", "decoration:rounding"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.applyRoundingJson(text)
|
||||
}
|
||||
}
|
||||
|
||||
property Process gapsOutProc: Process {
|
||||
id: gapsOutProc
|
||||
command: ["hyprctl", "-j", "getoption", "general:gaps_out"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.applyGapsOutJson(text)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the fontconfig alias to a concrete family name. `blob font
|
||||
// set <name>` rewrites ~/.config/fontconfig/fonts.conf and restarts the
|
||||
// shell, but rerun on file change anyway so manual edits propagate too.
|
||||
function resolveFontFamily() {
|
||||
fcMatchProc.running = true
|
||||
}
|
||||
|
||||
property Process fcMatchProc: Process {
|
||||
id: fcMatchProc
|
||||
command: ["fc-match", "-f", "%{family[0]}", "monospace"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var name = String(text || "").trim()
|
||||
if (name.length > 0) root.resolvedFontFamily = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property FileView fontconfigFile: FileView {
|
||||
path: Quickshell.env("HOME") + "/.config/fontconfig/fonts.conf"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: root.resolveFontFamily()
|
||||
onLoaded: root.resolveFontFamily()
|
||||
onLoadFailed: root.resolveFontFamily()
|
||||
}
|
||||
|
||||
// Re-poll Hyprland a beat after either input file changes. Hyprland's
|
||||
// auto-reload runs asynchronously when its sourced .lua files change,
|
||||
// so racing it with an immediate hyprctl gives the old value. 200ms is
|
||||
// generous enough for Hyprland to settle without being user-visible.
|
||||
property Timer refreshTimer: Timer {
|
||||
id: refreshTimer
|
||||
interval: 200
|
||||
repeat: false
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
// `blob toggle window-gaps` creates/removes this flag file. Hyprland
|
||||
// reloads its config when sourced files change, then hyprctl reflects
|
||||
// the new effective value.
|
||||
property FileView windowNoGapsToggle: FileView {
|
||||
path: Quickshell.env("HOME") + "/.local/state/blob/toggles/hypr/window-no-gaps.lua"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: refreshTimer.restart()
|
||||
onLoaded: refreshTimer.restart()
|
||||
onLoadFailed: refreshTimer.restart()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
refresh()
|
||||
resolveFontFamily()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
pragma Singleton
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
// Shared utility helpers used across plugins. Pure functions only — no
|
||||
// state. Anything stateful belongs on Color, Style, or a service.
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
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 wheelSteps(accumulator, delta) {
|
||||
// Some mouse/compositor combinations scale a single notch well beyond
|
||||
// Qt's conventional 120 units. Keep one event to one step while still
|
||||
// accumulating the smaller deltas emitted by touchpads.
|
||||
delta = Math.max(-120, Math.min(120, delta))
|
||||
if (accumulator * delta < 0) accumulator = 0
|
||||
var total = accumulator + delta
|
||||
var steps = total < 0 ? Math.ceil(total / 120) : Math.floor(total / 120)
|
||||
return { steps: steps, remainder: total - steps * 120 }
|
||||
}
|
||||
|
||||
// Compose a base color with an opacity. Accepts a color object or a hex
|
||||
// string; null/undefined yields transparent black at the requested alpha.
|
||||
function alpha(c, opacity) {
|
||||
var a = clampAlpha(opacity)
|
||||
if (!c) return Qt.rgba(0, 0, 0, a)
|
||||
if (typeof c === "string") c = Qt.color(c)
|
||||
return Qt.rgba(c.r, c.g, c.b, a)
|
||||
}
|
||||
|
||||
// file:// URL with each path segment percent-encoded so spaces and
|
||||
// special chars in user paths don't break Image.source.
|
||||
function fileUrl(path) {
|
||||
if (!path) return ""
|
||||
return "file://" + String(path).split("/").map(encodeURIComponent).join("/")
|
||||
}
|
||||
|
||||
// Single-quote a string for bash. The replace handles embedded single
|
||||
// quotes by closing, escaping, and re-opening the literal.
|
||||
function shellQuote(value) {
|
||||
return "'" + String(value || "").replace(/'/g, "'\\''") + "'"
|
||||
}
|
||||
|
||||
function execDetached(command) {
|
||||
Quickshell.execDetached(["bash", "-lc", command])
|
||||
}
|
||||
|
||||
// Run an argv vector without a shell interpreting it: the constant `exec "$@"`
|
||||
// means the args only ever land in positional parameters, which bash expands
|
||||
// without re-tokenizing — so untrusted data ($(id), a filename) stays literal.
|
||||
// The login shell (-l) keeps the PATH/session env GUI targets (tensaku, mpv,
|
||||
// xdg-open) need. Prefer this over execDetached for anything built from input.
|
||||
function execArgv(argv) {
|
||||
Quickshell.execDetached(["bash", "-lc", 'exec "$@"', "bash"].concat(argv))
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function canonicalWidgetId(id) {
|
||||
return String(id || "")
|
||||
}
|
||||
|
||||
// Best-effort base64 decode. Returns "" on parse failure rather than
|
||||
// surfacing garbage downstream.
|
||||
function decodeBase64(value) {
|
||||
var s = String(value || "")
|
||||
if (!s) return ""
|
||||
try { return Qt.atob(s) } catch (e) { return "" }
|
||||
}
|
||||
|
||||
function cloneJson(value) {
|
||||
return JSON.parse(JSON.stringify(value === undefined ? null : value))
|
||||
}
|
||||
|
||||
// Parse the last line of a custom-module / indicator process output as
|
||||
// waybar-style JSON ({text, class, tooltip, ...}). Falls back to {text: raw}
|
||||
// when the output isn't JSON, and {} for empty output.
|
||||
function parseModuleJson(raw) {
|
||||
var text = String(raw || "").trim()
|
||||
if (!text) return {}
|
||||
var lines = text.split("\n")
|
||||
try {
|
||||
return JSON.parse(lines[lines.length - 1])
|
||||
} catch (e) {
|
||||
return { text: text }
|
||||
}
|
||||
}
|
||||
|
||||
// Standard Qt text-editing keys shared by every searchable panel's filter:
|
||||
// Backspace delete previous character
|
||||
// Ctrl+Backspace delete previous word (Qt DeleteStartOfWord)
|
||||
// Ctrl+U clear the whole field
|
||||
// True only when the event would actually change the text, so an empty
|
||||
// filter never swallows the key — panels keep their own empty-filter
|
||||
// fallbacks (e.g. menu back-navigation) in later branches.
|
||||
function editsFilter(event, text) {
|
||||
if (!text) return false
|
||||
// Alt/Meta-modified sequences belong to other shortcuts — never edit here.
|
||||
if (event.modifiers & (Qt.AltModifier | Qt.MetaModifier)) return false
|
||||
if (event.key === Qt.Key_U) // Ctrl+U only (not Ctrl+Shift+U → Unicode input)
|
||||
return event.modifiers === Qt.ControlModifier
|
||||
return event.key === Qt.Key_Backspace // plain, Shift, or Ctrl Backspace
|
||||
}
|
||||
|
||||
// New filter text after applying an edit key. Assumes editsFilter(event, text).
|
||||
function editedFilter(event, text) {
|
||||
if (event.key === Qt.Key_U) return "" // Ctrl+U: clear
|
||||
if (event.modifiers & Qt.ControlModifier) // Ctrl+Backspace: word
|
||||
return text.replace(/\s+$/, "").replace(/\S+$/, "")
|
||||
return text.slice(0, -1) // Backspace: char
|
||||
}
|
||||
|
||||
// Layout normalization shared by bar config consumers
|
||||
// so the two never drift. Entries are deep-cloned to decouple from the
|
||||
// input config; consumers can mutate without leaking back to shell.json.
|
||||
function normalizeLayoutEntry(entry) {
|
||||
if (typeof entry === "string") return { id: canonicalWidgetId(entry) }
|
||||
if (isPlainObject(entry) && entry.id) {
|
||||
var copy = cloneJson(entry)
|
||||
copy.id = canonicalWidgetId(copy.id)
|
||||
return copy
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeLayoutSection(list) {
|
||||
if (!Array.isArray(list)) return []
|
||||
var out = []
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var e = normalizeLayoutEntry(list[i])
|
||||
if (e) out.push(e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeLayout(layout) {
|
||||
var src = isPlainObject(layout) ? layout : {}
|
||||
return {
|
||||
left: normalizeLayoutSection(src.left),
|
||||
center: normalizeLayoutSection(src.center),
|
||||
right: normalizeLayoutSection(src.right)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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
|
||||
Reference in New Issue
Block a user