Fork the desktop off Omarchy as a self-contained system

This commit is contained in:
2026-09-19 23:50:39 -04:00
parent 16a56f49a1
commit 9501f2bb4d
559 changed files with 43273 additions and 0 deletions
+242
View File
@@ -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" }
}
+373
View File
@@ -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)
}
+254
View File
@@ -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("")
}
}
+515
View File
@@ -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()
}
}
+155
View File
@@ -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)
}
}
}
+5
View File
@@ -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
+300
View File
@@ -0,0 +1,300 @@
# Blob shell
`blob-shell` is a single long-running [Quickshell](https://quickshell.org/)
instance that hosts the Blob desktop. Hyprland autostart launches one shell
per graphical session; everything else — the bar, background switcher, panels,
and overlays — runs **inside** the shell as a plugin.
Hosting everything inside one shell means:
- shared services and singletons live once, not once per process
- summoning a panel is an IPC call into a process that is already running,
not a fresh `quickshell -p ...` cold start
- third-party plugins can be loaded from disk without changing any source
code in Blob itself
The runtime layout:
```
shell/
shell.qml entry point (ShellRoot)
services/
PluginRegistry.qml discovers, validates plugins, looks up enabled state in shell.json
BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p)
plugins/
bar/ first-party plugins (see plugins/README.md)
image-picker/
menu/
notifications/
panels/
audio/
bluetooth/
monitor/
network/
power/
weather/
services/
battery/
idle/
osd/
polkit/
```
The plugin discovery path is documented in [plugins/README.md](plugins/README.md).
## Plugin manifest
Every plugin ships a `manifest.json` describing what it is and how the
shell should load it. Minimal example:
```json
{
"schemaVersion": 1,
"id": "my.org.cool-clock",
"name": "Cool clock",
"version": "1.0.0",
"author": "You",
"description": "A clock that does cool things",
"kinds": ["bar-widget"],
"entryPoints": { "barWidget": "Widget.qml" },
"barWidget": {
"displayName": "Cool clock",
"category": "Time",
"allowMultiple": false,
"defaultSection": "left",
"defaults": { "format": "HH:mm" },
"schema": [
{ "key": "format", "type": "string", "label": "Format" }
]
}
}
```
Supported `kinds`:
| Kind | What it is |
|--------------|--------------------------------------------------------------|
| `bar-widget` | A component that the active bar can drop into a section |
| `panel` | A persistent or summoned floating window (e.g. OSD) |
| `overlay` | A fullscreen overlay (e.g. background switcher) |
| `menu` | A summoned menu surface |
| `service` | A headless singleton, no UI |
| `bar` | A full bar option that can replace the built-in `blob.bar` |
Only one `bar` plugin is active at a time. Missing or invalid selections fall
back to the built-in `blob.bar`, so users always have a safe path home.
Panels, overlays, and menus are loaded when summoned. Plugins that need
to outlive a single summon can set `keepLoaded: true` (e.g. the image
picker keeps its overlay window mounted between summons). The same flag
keeps a service mounted across plugin hot-reload, so tearing down a
changed bar widget cannot destroy `blob.lock` while Hyprland still
holds the session lock. The kept instance is not replaced, so code
changes to a `keepLoaded` service itself only take effect on a shell
restart. First-party services are loaded at startup.
Entry points may declare `blobPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry or configuration snapshot cannot mutate host state. Facades do not isolate visual widgets from the parent hierarchy of the shared QML scene, so sensitive state must remain outside that reachable graph.
Widgets rendered by a third-party replacement bar receive a service-less entry facade with target-scoped lifecycle and settings operations. Their live service objects are available only when the trusted built-in bar hosts them; otherwise the replacement bar could request and retain any configured widget's service.
The full schema lives in `services/PluginRegistry.qml`.
## Installing a third-party plugin
A plugin is a **git repo** with a `manifest.json` at its root. Adding one
clones it straight into `~/.config/blob/plugins/<id>/` (named by the
manifest id); updating is a fast-forward pull of that checkout.
```bash
blob plugin add https://github.com/acme/blob-weather.git
blob plugin update acme.weather # fetches, shows a diff, fast-forwards
blob plugin update # updates every git-managed plugin
blob plugin remove acme.weather
```
> ⚠️ **Plugins run as unsandboxed code inside `blob-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces remove direct authentication-service and generic replacement-bar service lookups, but visual plugins still share and can traverse the ordinary host scene. Only add repos whose code you are willing to run.
Add, update, and remove commands confirm in a terminal even when given
arguments; without a terminal they refuse rather than guess. Pass `--yes` to
skip every prompt — this is the path for scripts and AI agents:
```bash
blob plugin add https://github.com/acme/blob-weather.git --enable --yes
blob plugin update --yes
```
The installer never runs plugin code, install hooks, or sudo — it only clones
files, validates the manifest, and toggles enabled state over shell IPC. Since
an installed plugin is a plain git checkout, anything beyond add/update
(pinning a ref, switching branches) is ordinary git in the plugin directory.
### Installing by hand
You can still drop a plugin in without git:
1. Put it in `~/.config/blob/plugins/<plugin-id>/` with a `manifest.json`
plus the QML referenced from its `entryPoints`.
2. `blob-shell shell rescanPlugins`.
3. `blob plugin enable <id>`. Bar widgets start in
`barWidget.defaultSection`, or in the center when it is omitted, and can be
moved with `blob bar move`; a full bar replaces the one in use.
The lower-level IPC equivalents remain available via `blob-shell shell rescanPlugins`,
`blob-shell shell enablePlugin <id> '{}'`, and `blob-shell shell listPlugins`.
The `blob plugin` commands wrap those calls. `blob bar move` and
`blob bar set` edit the persisted widget layout in `shell.json`.
To hack on a built-in plugin safely, clone it into user config instead of
editing the built-in source. The complete plugin directory is copied, including
every declared kind and local dependency. A built-in id such as
`blob.clock` becomes `<username>.clock` (e.g. `dhh.clock`), with `My Clock`
as its display name. The username prefix keeps shared clones from colliding
with each other or with other plugin authors.
```bash
blob plugin clone blob.clock
```
Cloning switches from the built-in to the new personal plugin, preserving an
existing bar widget's position and settings. Setup > Plugins > Clone provides
the interactive picker, then opens the new `<username>.*` directory in `$EDITOR`.
Existing shortcuts and shell IPC calls made to the built-in id are routed to
the enabled clone, so cloning does not require changing its callers. Removing
an active clone switches back to its built-in source.
Saving a file anywhere under `~/.config/blob/plugins/` reloads plugin code
automatically; `blob-shell shell rescanPlugins` remains available to force a reload.
First-party plugins under `shell/plugins/` are discovered the same way and load
by default. Disabling a non-widget records it in `disabledPlugins[]`; disabling
a widget removes it from the bar layout while leaving its component available
to add again. A full bar has no off state and is replaced by enabling another.
## IPC contract
The shell exposes a single `shell` IPC target plus whatever extra targets
individual plugins register (e.g. the bar's `bar` target for refresh
hooks, the image picker's `image-selector` target). `blob-menu` uses the
shell target to summon the first-party `blob.menu` plugin instead of
running a separate Quickshell instance.
| Method | Returns | Effect |
|------------------------------------------|---------|-------------------------------------------------------|
| `ping` | `ok` | health check |
| `summon <id> <payloadJson>` | `ok` / `unknown` | load + open a panel/overlay plugin |
| `hide <id>` | — | close a previously-summoned plugin |
| `toggle <id> <payloadJson>` | — | summon if closed, hide if open |
| `call <id> <method> <arg>` | string | call a method on an already-loaded plugin |
| `rescanPlugins` | — | re-walk plugin dirs and hot-reload plugin code |
| `reloadConfig` | `ok` | reload `~/.config/blob/shell.json` |
| `setPluginEnabled <id> <enabled>` | `ok` / `unknown` | flip the persisted enabled bit (see note) |
| `listPlugins` | JSON | every discovered plugin, sorted by name |
Direct invocation:
```
quickshell ipc -p $BLOB_PATH/shell call shell ping
```
Hyprland autostart launches the shell directly with `quickshell -p
$BLOB_PATH/shell`. Use `blob-shell-restart` to stop every running
instance of that config and launch one fresh shell process.
A convenience wrapper, [`blob-shell`](../bin/blob-shell), forwards IPC
calls to the running shell. It does not start the shell.
```
blob-shell shell ping
blob-shell shell toggle blob.menu '{"menu":"root"}'
blob-shell shell listPlugins
blob-shell shell rescanPlugins
```
**Note on `setPluginEnabled`:** the `enabled` argument is a string. Only the
literal `"true"` enables the plugin; every other value (including `"True"`,
`"1"`, `"yes"`, or omitted) disables it. This keeps the IPC surface
type-stable across QML's `string`-only IPC arguments.
## Persisted state
There is one user config file. Everything that distinguishes your
customization from the shipped defaults lives in it.
| Path | Owner | Purpose |
|-----------------------------------|----------------|--------------------------------------------------------|
| `~/.config/blob/shell.json` | the shell | full layout + per-entry settings + enabled plugin list |
| `~/.config/blob/plugins/<id>/` | user | drop-in third-party plugin source files |
The `config/blob/shell.json` default config describes the
fresh-install state. When the user has no `shell.json`, the shell uses
the defaults verbatim. Once the user customizes anything, `shell.json`
becomes the authoritative file — we do **not** deep-merge defaults back in.
### shell.json shape
```json
{
"version": 1,
"idle": {
"screensaver": 150,
"lock": 300
},
"bar": {
"id": "blob.bar",
"position": "top",
"transparent": false,
"centerAnchor": "blob.clock",
"layout": {
"left": [ { "id": "blob.menu" }, { "id": "blob.workspaces" } ],
"center": [ { "id": "blob.clock", "format": "HH:mm" } ],
"right": [
{ "id": "blob.audio" }
]
}
},
"plugins": []
}
```
### Storage rules
1. **The active bar option is `bar.id`.** Omit it or set it to `blob.bar`
to use the built-in bar. Set it to another plugin id whose manifest declares
`kind: "bar"` to replace the full bar.
2. **Every plugin instance is one entry.** Either in `bar.layout.<section>`
for bar widgets, or in `plugins[]` for panels, overlays, services,
menus, and anything else non-bar.
3. **Settings are inline on the entry.** No `config:` sub-object, no
separate per-plugin settings file, no merge layers. The fields on each
entry are the values the plugin sees.
4. **Built-in widget ids are namespaced.** Use ids such as `blob.clock`,
`blob.audio`, and `blob.network`. The migration rewrites older ids
like `Clock` and `AudioPanel` forward.
5. **Third-party enabled ⇔ present.** A third-party plugin is enabled iff
its id appears somewhere in shell.json. For full bar options, that means
`bar.id`; for bar widgets, plugin enable/disable adds/removes layout entries;
other plugin kinds are enabled the same way. First-party non-bar plugins
are enabled unless listed in `disabledPlugins[]`.
6. **Multiple instances** are allowed when a manifest sets
`allowMultiple: true`. Each instance is independent — e.g. two clock
widgets in different timezones are just two `{"id":"blob.clock", "timezone": ...}`
entries with their own values.
7. **Idle timings are top-level.** `idle.screensaver` and `idle.lock`
are seconds since user idle began, so the default lock fires at 300s
even if the 150s screensaver starts first.
8. **`version: 1` is required** at the top level. The shell will fall back
to defaults rather than load an unknown version.
## Implementation history
Built up in phases on this branch:
- Phase 1 — `blob-shell phase 1: host the existing bar in a single shell`
- Phase 2 — `blob-shell phase 2: plugin registry and bar widget registry`
- Phase 3 — `blob-shell phase 3: fold bar-settings into the shell as a panel plugin`
- Phase 4 — `blob-shell phase 4: absorb background-switcher as a plugin`
- Phase 5 — `blob-shell phase 5: docs, cleanup, and migration crumbs`
- Phase 6 — `blob-shell phase 6: reviewer cleanup (path traversal, collision, races)`
- Phase 7 — `blob-shell phase 7: replace socket with IpcHandler, rename to image-picker`
- Phase 8a — `blob-shell phase 8a: unified shell.json with inline plugin settings`
Shared services and Pipewire/UPower/Hyprland consolidation are explicitly
out of scope here and deferred to a follow-up after a review pass.
+63
View File
@@ -0,0 +1,63 @@
import QtQuick
import Quickshell
import qs.Commons
WidgetButton {
id: root
property Component iconComponent: null
property real slotSize: Style.bar.iconSlot
property real opticalSize: Style.bar.iconCanvas
property bool debugOpticalBounds: Quickshell.env("BLOB_DEBUG_BAR_ICONS") === "1"
readonly property real opticalCenterErrorX: glyph.visible ? glyph.paintedCenterX - opticalCanvas.width / 2 : 0
readonly property real glyphPaintedWidth: glyph.visible ? glyph.tightWidth : 0
readonly property real glyphBaselineY: glyph.visible ? glyph.baselineY : 0
readonly property int glyphFontSize: glyph.visible ? glyph.renderedFontSize : 0
labelVisible: false
hasVisualContent: text !== "" || iconComponent !== null
fontSize: Style.bar.iconFont
fixedWidth: vertical ? -1 : slotSize
fixedHeight: vertical ? slotSize : -1
Item {
id: opticalCanvas
anchors.centerIn: parent
width: root.opticalSize
height: root.opticalSize
OpticalGlyph {
id: glyph
anchors.fill: parent
visible: root.iconComponent === null
text: root.text
fontFamily: root.fontFamily
fontSize: root.fontSize
color: root.active && root.useActiveColor ? root.activeColor : root.foreground
rotation: root.textRotation
debugBounds: root.debugOpticalBounds
}
Loader {
anchors.fill: parent
visible: root.iconComponent !== null
sourceComponent: root.iconComponent
}
Rectangle {
visible: root.debugOpticalBounds && root.iconComponent !== null
anchors.fill: parent
color: "transparent"
border.width: 1
border.color: "#4488ff"
}
}
Rectangle {
visible: root.debugOpticalBounds
anchors.fill: parent
color: "transparent"
border.width: 1
border.color: "#ff4455"
}
}
+51
View File
@@ -0,0 +1,51 @@
import QtQuick
import qs.Commons
BarIconButton {
id: root
property string moduleName: ""
property var settings: ({})
property string activeText: ""
property string inactiveText: activeText
property string activeTooltipText: ""
property string inactiveTooltipText: activeTooltipText
property string indicatorBlock: "single"
property var indicatorHost: null
property var activeOverride: null
readonly property bool effectiveActive: activeOverride === null || activeOverride === undefined ? active : activeOverride === true
readonly property bool belongsInBlock: indicatorBlock === "active" ? effectiveActive : (indicatorBlock === "inactive" ? !effectiveActive : true)
readonly property bool inactiveRevealed: !effectiveActive && !!indicatorHost && indicatorHost.revealInactiveIndicators
function extractData(raw) {
return Util.parseModuleJson(raw)
}
function syncIndicatorOpacity() {
root.opacity = !belongsInBlock ? 0 : (effectiveActive ? 1 : (inactiveRevealed ? 0.45 : 0))
}
Component.onCompleted: syncIndicatorOpacity()
onActiveChanged: syncIndicatorOpacity()
onEffectiveActiveChanged: syncIndicatorOpacity()
onBarChanged: syncIndicatorOpacity()
onBelongsInBlockChanged: syncIndicatorOpacity()
onInactiveRevealedChanged: syncIndicatorOpacity()
onIndicatorBlockChanged: syncIndicatorOpacity()
visible: belongsInBlock && (text !== "" || keepSpace)
text: effectiveActive ? activeText : inactiveText
tooltipText: effectiveActive ? activeTooltipText : inactiveTooltipText
keepSpace: true
dimmed: !effectiveActive
concealed: !effectiveActive && !inactiveRevealed
interactive: belongsInBlock && (effectiveActive || indicatorBlock === "inactive" || inactiveRevealed)
useActiveColor: false
maintainIndicatorReveal: indicatorBlock === "inactive"
revealHost: indicatorHost
fontSize: Style.font.caption
horizontalMargin: 5
verticalPadding: 5
fixedWidth: vertical ? -1 : Style.bar.statusSlot
fixedHeight: vertical ? Style.bar.statusSlot : -1
}
+45
View File
@@ -0,0 +1,45 @@
import QtQuick
import qs.Commons
// Base item every bar widget extends. Codifies the three properties the
// bar host injects into each widget slot:
// bar - the host Bar instance (foreground/background/run/etc).
// moduleName - widget's canonical id, used by the host registry to look
// up settings and to disambiguate inline IPC routes.
// settings - per-widget overrides read from shell.json's layout entry.
//
// Widgets are free to add their own properties, signals, and child items.
Item {
id: root
property QtObject bar: null
property string moduleName: ""
property var settings: ({})
// Bar geometry, lifted off the host. Widgets read these constantly to pick
// between horizontal/vertical layouts; defining them on the base keeps the
// `bar ? bar.x : fallback` ternary out of every widget body.
readonly property bool vertical: bar ? bar.vertical : false
readonly property int barSize: bar ? bar.barSize : Style.bar.sizeHorizontal
// Run `method` on every live instance of this widget. An IPC target only
// ever routes to one handler, but a bar surface exists per monitor, so the
// instance that owns the target relays the call to its peers — otherwise a
// refresh would land on a single screen and leave the others stale.
function broadcast(method) {
var items = bar && typeof bar.moduleWidgets === "function"
? bar.moduleWidgets(moduleName) : [root]
for (var i = 0; i < items.length; i++) {
if (items[i] && typeof items[i][method] === "function") items[i][method]()
}
}
// Read a single value from this widget's inline shell.json entry, with a
// fallback for missing/null values. Every widget that takes user-tunable
// settings needs this; defining it once on the base keeps the wiring
// consistent.
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
}
+54
View File
@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Shapes
import qs.Commons
import "../Commons/BorderGeometry.js" as Geometry
// Visual-only border renderer. It draws closed side-run contours, or a
// compound winding path for all four sides, so asymmetric/gradient borders
// do not require touching odd-even paths. Flat uniform borders use Rectangle.border.
Item {
id: root
property var borderSpec: Border.none()
property real radius: 0
readonly property var _widths: borderSpec && borderSpec.widths ? borderSpec.widths : Geometry.parseWidthSpec(0, 0)
readonly property bool hasBorder: Geometry.maxWidth(_widths) > 0
readonly property var _gradient: borderSpec && borderSpec.gradient ? borderSpec.gradient : ({ colors: [], angle: 0, enabled: false })
readonly property var _colors: _gradient.enabled ? _gradient.colors : [Border.color(borderSpec), Border.color(borderSpec)]
readonly property var _endpoints: Geometry.gradientEndpoints(width, height, _gradient.angle || 0)
readonly property string _path: Geometry.ringPath(width, height, radius, _widths)
visible: hasBorder && width > 0 && height > 0
anchors.fill: parent
z: 100000
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillRule: ShapePath.WindingFill
strokeWidth: 0
fillGradient: LinearGradient {
x1: root._endpoints.x1
y1: root._endpoints.y1
x2: root._endpoints.x2
y2: root._endpoints.y2
GradientStop { position: Geometry.stopPosition(root._colors, 0); color: Geometry.stopColor(root._colors, 0) }
GradientStop { position: Geometry.stopPosition(root._colors, 1); color: Geometry.stopColor(root._colors, 1) }
GradientStop { position: Geometry.stopPosition(root._colors, 2); color: Geometry.stopColor(root._colors, 2) }
GradientStop { position: Geometry.stopPosition(root._colors, 3); color: Geometry.stopColor(root._colors, 3) }
GradientStop { position: Geometry.stopPosition(root._colors, 4); color: Geometry.stopColor(root._colors, 4) }
GradientStop { position: Geometry.stopPosition(root._colors, 5); color: Geometry.stopColor(root._colors, 5) }
GradientStop { position: Geometry.stopPosition(root._colors, 6); color: Geometry.stopColor(root._colors, 6) }
GradientStop { position: Geometry.stopPosition(root._colors, 7); color: Geometry.stopColor(root._colors, 7) }
GradientStop { position: Geometry.stopPosition(root._colors, 8); color: Geometry.stopColor(root._colors, 8) }
GradientStop { position: Geometry.stopPosition(root._colors, 9); color: Geometry.stopColor(root._colors, 9) }
}
PathSvg { path: root._path }
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import QtQuick
import qs.Commons
// Rectangle-compatible surface with Blob border specs. Uses native
// Rectangle.border for cheap flat/uniform borders and BorderOverlay for
// gradients or per-side widths.
Rectangle {
id: root
property var borderSpec: Border.none()
property real padding: 0
property real topPadding: padding
property real rightPadding: padding
property real bottomPadding: padding
property real leftPadding: padding
readonly property real borderTop: Border.top(borderSpec)
readonly property real borderRight: Border.right(borderSpec)
readonly property real borderBottom: Border.bottom(borderSpec)
readonly property real borderLeft: Border.left(borderSpec)
readonly property real contentTopInset: borderTop + topPadding
readonly property real contentRightInset: borderRight + rightPadding
readonly property real contentBottomInset: borderBottom + bottomPadding
readonly property real contentLeftInset: borderLeft + leftPadding
readonly property bool usesOverlayBorder: Border.needsOverlay(borderSpec)
border.color: Border.canUseNative(borderSpec) ? Border.color(borderSpec) : "transparent"
border.width: Border.canUseNative(borderSpec) ? Border.uniformWidth(borderSpec) : 0
Loader {
anchors.fill: parent
active: root.usesOverlayBorder
sourceComponent: BorderOverlay {
anchors.fill: parent
radius: root.radius
borderSpec: root.borderSpec
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
// The button. One component for every clickable thing in the kit.
// States compose independently and are applied in priority order:
//
// pressed (mouse down) pressed fill
// activeFocus (Tab focus) focus fill + focus border token
// hasCursor || hover hover-cursor fill (+ border if `bordered`)
// selected selected fill + optional selected border
// active selected fill
// idle transparent or normal border if `bordered`
//
// All fills/borders come from `qs.Commons.Style` tokens, so themes
// control the look via [controls] in shell.toml.
//
// Emits `hovered(bool)` so panels with their own keyboard cursor model
// can update state on mouse enter/leave.
BorderSurface {
id: root
property string text: ""
property string iconText: ""
property string tooltipText: ""
// State flags (see comment above for paint priority).
property bool selected: false
property bool active: false
property bool hasCursor: false
property bool focusable: false
property bool bordered: false
// Colors. Defaults track the theme; per-instance overrides are honored.
property color foreground: Color.foreground
property color background: "transparent"
property color accent: Color.accent
// Sizing.
property string fontFamily: Style.font.family
property real fontSize: Style.font.body
property real iconSize: Style.font.icon
property real iconRotation: 0
property bool iconSpinning: false
property real horizontalPadding: Style.spacing.controlPaddingX
property real verticalPadding: Style.spacing.controlPaddingY
property bool leftAlign: false
leftPadding: horizontalPadding
rightPadding: horizontalPadding
topPadding: verticalPadding
bottomPadding: verticalPadding
// Tooltip palette. Auto-rendered if tooltipText is set. Defaults pull
// from [tooltip] in shell.toml; override per-instance only when a button
// intentionally wants a tooltip that diverges from the theme.
property color tooltipBackground: Color.tooltip.background
property color tooltipForeground: Color.tooltip.text
property color tooltipBorder: Color.tooltip.border
signal clicked()
signal rightClicked()
signal hovered(bool isHovered)
activeFocusOnTab: focusable
Keys.onReturnPressed: if (focusable) root.clicked()
Keys.onEnterPressed: if (focusable) root.clicked()
Keys.onSpacePressed: if (focusable) root.clicked()
// Reserve the largest border any visual state can paint. Otherwise a
// borderless idle button grows by a pixel per side on hover/focus and
// relayouts neighboring controls.
implicitWidth: row.implicitWidth + horizontalPadding * 2 + _reservedBorderLeft + _reservedBorderRight
implicitHeight: row.implicitHeight + verticalPadding * 2 + _reservedBorderTop + _reservedBorderBottom
radius: Style.cornerRadius
readonly property bool hot: mouseArea.containsMouse || hasCursor
readonly property bool _showFocusRing: focusable && activeFocus
readonly property color _selectedColor: Style.selectedStateColor(root.foreground, root.accent)
readonly property var _tooltipBorderSpec: Border.localOrSurfaceSpec("tooltip", "border", root.tooltipBorder, Color.tooltip.border, Math.max(1, Style.normalBorderWidth))
readonly property var _focusBorderSpec: Border.controlSpec("focus", root.foreground, root.accent)
readonly property var _hoverBorderSpec: Border.controlSpec("hover-cursor", root.foreground, root.accent)
readonly property var _selectedBorderSpec: Border.controlSpec("selected", root.foreground, root.accent)
readonly property var _normalBorderSpec: Border.controlSpec("normal", root.foreground, root.accent)
readonly property real _reservedBorderTop: Math.max(
focusable ? Border.top(_focusBorderSpec) : 0,
Border.top(_hoverBorderSpec),
Border.top(_selectedBorderSpec),
bordered ? Border.top(_normalBorderSpec) : 0)
readonly property real _reservedBorderRight: Math.max(
focusable ? Border.right(_focusBorderSpec) : 0,
Border.right(_hoverBorderSpec),
Border.right(_selectedBorderSpec),
bordered ? Border.right(_normalBorderSpec) : 0)
readonly property real _reservedBorderBottom: Math.max(
focusable ? Border.bottom(_focusBorderSpec) : 0,
Border.bottom(_hoverBorderSpec),
Border.bottom(_selectedBorderSpec),
bordered ? Border.bottom(_normalBorderSpec) : 0)
readonly property real _reservedBorderLeft: Math.max(
focusable ? Border.left(_focusBorderSpec) : 0,
Border.left(_hoverBorderSpec),
Border.left(_selectedBorderSpec),
bordered ? Border.left(_normalBorderSpec) : 0)
readonly property real _reservedContentLeftInset: _reservedBorderLeft + leftPadding
readonly property var _borderSpec: _showFocusRing ? _focusBorderSpec
: hot ? _hoverBorderSpec
: selected ? (Border.controlHasWidth("selected") ? _selectedBorderSpec : (bordered ? _normalBorderSpec : Border.none()))
: bordered ? _normalBorderSpec
: Border.none()
color: mouseArea.pressed ? Style.pressedFillFor(root.foreground, root.accent)
: _showFocusRing ? Style.focusFillFor(root.foreground, root.accent)
: hot ? Style.hoverFillFor(root.foreground, root.accent)
: selected ? Style.selectedFillFor(root.foreground, root.accent)
: active ? Style.selectedFillFor(root.foreground, root.accent)
: background
// Border follows the same state precedence as fill. Buttons stay
// borderless at rest unless `bordered` is set, but hover-cursor/focus
// always use the shared cursor border so the keyboard target is visible
// and consistent with the rest of the kit. Selected borders are off by
// default for plain buttons; explicitly bordered buttons keep their
// normal border when selected unless selected-border-width opts in to a
// dedicated selected border.
borderSpec: _borderSpec
Behavior on color { ColorAnimation { duration: 120 } }
ToolTip {
visible: root.tooltipText !== "" && mouseArea.containsMouse
text: root.tooltipText
delay: 400
padding: 0
background: BorderSurface {
color: root.tooltipBackground
borderSpec: root._tooltipBorderSpec
radius: 0
}
contentItem: Text {
textFormat: Text.PlainText
text: root.tooltipText
color: root.tooltipForeground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
leftPadding: Border.left(root._tooltipBorderSpec) + Style.spacing.controlPaddingX
rightPadding: Border.right(root._tooltipBorderSpec) + Style.spacing.controlPaddingX
topPadding: Border.top(root._tooltipBorderSpec) + Style.spacing.controlPaddingY
bottomPadding: Border.bottom(root._tooltipBorderSpec) + Style.spacing.controlPaddingY
}
}
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
anchors.left: root.leftAlign ? parent.left : undefined
anchors.leftMargin: root.leftAlign ? root._reservedContentLeftInset : 0
anchors.horizontalCenter: root.leftAlign ? undefined : parent.horizontalCenter
spacing: Style.spacing.controlGap
Text {
textFormat: Text.PlainText
visible: root.iconText !== ""
text: root.iconText
color: root.selected ? root._selectedColor : root.foreground
font.family: root.fontFamily
font.pixelSize: root.iconSize
rotation: root.iconSpinning ? 0 : root.iconRotation
transformOrigin: Item.Center
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
from: 0
to: 360
duration: 900
loops: Animation.Infinite
running: root.iconSpinning
}
}
Text {
textFormat: Text.PlainText
visible: root.text !== ""
text: root.text
color: root.selected ? root._selectedColor : root.foreground
font.family: root.fontFamily
font.pixelSize: root.fontSize
font.bold: root.selected
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (root.focusable) root.forceActiveFocus()
if (mouse.button === Qt.RightButton) root.rightClicked()
else root.clicked()
}
}
HoverHandler {
onHoveredChanged: root.hovered(hovered)
}
}
+132
View File
@@ -0,0 +1,132 @@
import QtQuick
import qs.Commons
// Mutually-exclusive row of Buttons — the form-style "pick one of N"
// pattern (bar position top/right/bottom/left, theme preset chips, etc.).
// Emits `changed(value)` when the user activates a different option.
//
// `options` is either a plain string[] (label == value) or an array of
// { value, label, icon?, tooltip? } objects. Mixing is fine.
//
// Keyboard navigation. The group itself is a single Tab stop, not one
// stop per chip — so in a form that walks `activeFocusOnTab` items with
// Tab / j / k, the cursor enters the group as a unit. Once focused,
// h / l / Left / Right walks between chips and Enter / Space activates
// the current one. The selected chip is the default landing point so
// users see their existing choice on arrival.
//
// Panel-cursor consumers (the bar widget panels) drive `cursorIndex`
// directly and listen on `hovered` to sync the mouse — Tab focus and
// `cursorIndex` are independent; either one paints the chip's hot
// state, and the bar widget panels never give Tab focus to a
// ButtonGroup so they only see the cursorIndex path.
Row {
id: root
property var options: []
property string value: ""
property color foreground: Color.foreground
property color background: Color.background
property color accent: Color.accent
property string fontFamily: Style.font.family
property real fontSize: Style.font.body
property bool focusable: true
// -1 disables the external cursor highlight (the panel-cursor case).
// Driven by a containing panel; the group's own Tab-focus h/l
// tracking is internal and lives in _focusedIndex.
property int cursorIndex: -1
// Internal: which chip h / l / Left / Right is currently sitting on
// when the group itself has Tab focus. Reset to the selected option
// each time focus arrives so the user sees their existing choice.
property int _focusedIndex: -1
signal changed(string value)
signal hovered(int index, bool isHovered)
spacing: Style.spacing.md
activeFocusOnTab: focusable
function optionValue(o) {
return (o && typeof o === "object") ? String(o.value) : String(o)
}
function optionLabel(o) {
return (o && typeof o === "object" && o.label !== undefined) ? String(o.label) : String(o)
}
function optionIcon(o) {
return (o && typeof o === "object" && o.icon) ? String(o.icon) : ""
}
function optionTooltip(o) {
return (o && typeof o === "object" && o.tooltip) ? String(o.tooltip) : ""
}
function selectedOptionIndex() {
for (var i = 0; i < options.length; i++)
if (optionValue(options[i]) === value) return i
return -1
}
function activateFocused() {
if (_focusedIndex < 0 || _focusedIndex >= options.length) return
var v = optionValue(options[_focusedIndex])
root.changed(v)
}
onActiveFocusChanged: {
if (activeFocus) {
var idx = selectedOptionIndex()
_focusedIndex = idx < 0 ? 0 : idx
} else {
_focusedIndex = -1
}
}
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Left || event.key === Qt.Key_H
|| event.text === "h") {
_focusedIndex = Math.max(0, (_focusedIndex < 0 ? 0 : _focusedIndex) - 1)
event.accepted = true
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_L
|| event.text === "l") {
var max = options.length - 1
var next = (_focusedIndex < 0 ? 0 : _focusedIndex) + 1
_focusedIndex = Math.min(max, next)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space) {
activateFocused()
event.accepted = true
}
}
Repeater {
model: root.options
delegate: Button {
required property var modelData
required property int index
text: root.optionLabel(modelData)
iconText: root.optionIcon(modelData)
tooltipText: root.optionTooltip(modelData)
selected: root.optionValue(modelData) === root.value
// Chip lights up when either the external panel cursor lands here
// or the group has Tab focus and h/l has walked to this index.
hasCursor: root.cursorIndex === index
|| (root.activeFocus && root._focusedIndex === index)
// Every chip carries the standard bordered-button chrome so the
// group reads as a row of distinct options. selected / hover-cursor /
// focus are all painted by Button from Style's shared state tokens.
bordered: true
foreground: root.foreground
background: root.background
accent: root.accent
fontFamily: root.fontFamily
fontSize: root.fontSize
onClicked: root.changed(root.optionValue(modelData))
onHovered: function(h) { root.hovered(index, h) }
}
}
}
+133
View File
@@ -0,0 +1,133 @@
import QtQuick
import qs.Commons
Item {
id: root
property bool opened: false
property string message: ""
property string cancelText: "Cancel"
property string confirmText: "Confirm"
property int selectedIndex: 1
property color background: Color.background
property color foreground: Color.foreground
property color scrim: Util.alpha(Color.background, 0.7)
property color selectedBackground: Util.alpha(Color.foreground, 0.08)
property color selectedText: Color.accent
property string fontFamily: Style.font.family
property int cornerRadius: Style.cornerRadius
signal canceled()
signal confirmed()
function handleKey(event) {
if (!root.opened) return false
if (event.key === Qt.Key_Escape) {
root.canceled()
return true
} else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
root.selectedIndex = root.selectedIndex === 0 ? 1 : 0
return true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.selectedIndex === 0) root.canceled()
else root.confirmed()
return true
}
return false
}
visible: opened
Rectangle {
anchors.fill: parent
color: root.scrim
MouseArea { anchors.fill: parent; onClicked: root.canceled() }
BorderSurface {
id: card
width: Math.min(parent.width - Style.space(32), Style.space(370))
// Grows with the wrapped message so narrow hosts (like the menu card)
// don't squeeze the text into the buttons.
height: card.contentTopInset + card.contentBottomInset + messageText.implicitHeight + Style.space(20) + Style.space(34)
anchors.centerIn: parent
color: root.background
borderSpec: Border.flat(root.selectedText, Style.normalBorderWidth)
padding: Style.space(18)
radius: root.cornerRadius
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
Text {
id: messageText
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
text: root.message
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
wrapMode: Text.WordWrap
}
Row {
anchors.right: parent.right
anchors.bottom: parent.bottom
spacing: Style.space(10)
Repeater {
model: [root.cancelText, root.confirmText]
BorderSurface {
required property int index
required property string modelData
readonly property bool selected: root.selectedIndex === index
readonly property bool destructive: index === 1
width: Style.space(88)
height: Style.space(34)
color: selected
? (destructive ? Util.alpha(Color.urgent, 0.22) : root.selectedBackground)
: "transparent"
borderSpec: Border.flat(destructive
? (selected ? Color.urgent : Util.alpha(Color.urgent, 0.56))
: (selected ? root.selectedText : Util.alpha(root.foreground, 0.38)), Style.normalBorderWidth)
radius: 0
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: modelData
color: destructive ? (selected ? Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.selectedIndex = index
onClicked: {
if (index === 0) root.canceled()
else root.confirmed()
}
}
}
}
}
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import QtQuick
import qs.Commons
// Shared visual chrome for keyboard-and-mouse-navigable items inside a panel.
// Contract: items must NOT read `containsMouse` for color/border. Mouse
// hover updates the panel's cursor state at the root; visuals derive from
// `hasCursor` / `current`. That's what guarantees a single highlight on
// screen at any time across both keyboard and mouse interaction.
//
// Cursor paint is always the shared hover-cursor fill plus optional
// hover-cursor border. `outline` remains as a compatibility flag for
// callers that used to request border-only rows, but slider rows still
// receive the same hover-cursor background as every other row.
BorderSurface {
id: root
property bool hasCursor: false
property bool current: false
property bool outline: false
property bool bordered: false
property color foreground: Color.foreground
property color accent: Color.accent
property color fill: Style.hoverFillFor(foreground, accent)
property color currentFill: Style.selectedFillFor(foreground, accent)
radius: Style.cornerRadius
color: hasCursor ? fill : (current ? currentFill : "transparent")
borderSpec: root.hasCursor
? Border.controlSpec("hover-cursor", root.foreground, root.accent)
: (root.current
? Border.controlSpec("selected", root.foreground, root.accent)
: (root.bordered
? Border.controlSpec("normal", root.foreground, root.accent)
: Border.none()))
Behavior on color {
ColorAnimation { duration: 60 }
}
}
+244
View File
@@ -0,0 +1,244 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
// Themed single-select dropdown. Trigger row paints with the kit's focus
// chrome; the popup anchors below and uses Color.popups.background +
// Color.popups.border so it reads as a panel surface rather than the
// platform-native ComboBox look.
//
// `options` accepts either a plain string[] or an array of
// { value, label } objects (label is what we render; value is what we
// emit). Mixing is fine — each row is interpreted independently.
//
// Keyboard: Tab to focus the trigger, Enter/Space opens, Esc closes,
// j/k or Up/Down walks options inside the open popup, Enter selects.
// A sibling SearchableDropdown reuses the same visuals but adds an
// embedded filter input — keep the two separate so each stays simple.
Item {
id: root
property string label: ""
property string value: ""
property var options: []
property color foreground: Color.popups.text
property color background: Color.popups.background
property color popupBorder: Color.popups.border
property color accent: Color.accent
readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth)
property string fontFamily: Style.font.family
property int rowHeight: Style.spacing.controlHeight
property int popupRowHeight: Style.spacing.popupRowHeight
property bool showLabel: true
// Panel-cursor flag. When true, the trigger renders the shared
// hover-cursor state. Active Qt focus defaults to the same visuals.
// Emits `hovered(bool)` on pointer enter/leave so the panel can keep
// its cursor state in sync with the mouse.
property bool hasCursor: false
// popupOpen + open()/close()/toggle() let a parent panel know when the
// dropdown owns keys (its embedded ListView is active) and suspend its
// own keyCatcher so j/k inside the popup don't double-drive the panel
// cursor.
readonly property bool popupOpen: popup.opened
function open() { popup.open() }
function close() { popup.close() }
function toggle() { popup.opened ? popup.close() : popup.open() }
signal changed(string value)
signal hovered(bool isHovered)
function optionValue(o) {
return (o && typeof o === "object") ? String(o.value) : String(o)
}
function optionLabel(o) {
return (o && typeof o === "object") ? String(o.label) : String(o)
}
function currentLabel() {
for (var i = 0; i < options.length; i++) {
if (optionValue(options[i]) === value) return optionLabel(options[i])
}
return value
}
implicitWidth: Style.spacing.dropdownWidth
implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight
Column {
anchors.fill: parent
spacing: Style.spacing.labelGap
Text {
textFormat: Text.PlainText
visible: root.showLabel && root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
BorderSurface {
id: trigger
width: parent.width
height: root.rowHeight
radius: Style.cornerRadius
readonly property bool _focused: trigger.activeFocus
readonly property bool _hot: triggerHover.hovered || root.hasCursor
readonly property var _borderSpec: Border.controlSpec(trigger._focused ? "focus" : (trigger._hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent)
borderSpec: _borderSpec
activeFocusOnTab: true
HoverHandler {
id: triggerHover
onHoveredChanged: root.hovered(hovered)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
popup.opened ? popup.close() : popup.open()
event.accepted = true
} else if (event.key === Qt.Key_Escape && popup.opened) {
popup.close(); event.accepted = true
}
}
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: chevron.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: trigger.borderLeft + Style.spacing.controlPaddingX
anchors.rightMargin: trigger.borderRight + Style.spacing.md
text: root.currentLabel()
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: trigger.borderRight + Style.spacing.controlGap
text: "󰅀"
color: Qt.darker(root.foreground, 1.2)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
trigger.forceActiveFocus()
popup.opened ? popup.close() : popup.open()
}
}
Popup {
id: popup
x: 0
y: trigger.height + Style.spacing.xxs
width: trigger.width
implicitHeight: Math.min(root.options.length * root.popupRowHeight + Math.max(0, root.options.length - 1) * Style.spacing.labelGap + Style.spacing.xxs,
root.popupRowHeight * 8 + 7 * Style.spacing.labelGap + Style.spacing.xxs)
padding: Style.spacing.hairline
leftPadding: Border.left(root.popupBorderSpec) + Style.spacing.hairline
rightPadding: Border.right(root.popupBorderSpec) + Style.spacing.hairline
topPadding: Border.top(root.popupBorderSpec) + Style.spacing.hairline
bottomPadding: Border.bottom(root.popupBorderSpec) + Style.spacing.hairline
focus: true
background: BorderSurface {
color: root.background
borderSpec: root.popupBorderSpec
radius: Style.cornerRadius
}
onOpened: {
optionList.currentIndex = Math.max(0, optionList.indexOfValue(root.value))
optionList.forceActiveFocus()
}
contentItem: ListView {
id: optionList
spacing: Style.spacing.labelGap
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) { popup.close(); event.accepted = true }
else if (event.key === Qt.Key_Down || event.text === "j") {
optionList.currentIndex = Math.min(root.options.length - 1, optionList.currentIndex + 1)
event.accepted = true
} else if (event.key === Qt.Key_Up || event.text === "k") {
optionList.currentIndex = Math.max(0, optionList.currentIndex - 1)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
optionList.selectCurrent(); event.accepted = true
}
}
implicitHeight: contentHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.options
currentIndex: -1
function indexOfValue(v) {
for (var i = 0; i < root.options.length; i++)
if (root.optionValue(root.options[i]) === v) return i
return -1
}
function selectCurrent() {
if (currentIndex < 0 || currentIndex >= root.options.length) return
var v = root.optionValue(root.options[currentIndex])
root.value = v
root.changed(v)
popup.close()
}
delegate: Rectangle {
required property var modelData
required property int index
width: optionList.width
height: root.popupRowHeight
color: index === optionList.currentIndex
? Style.hoverFillFor(root.foreground, root.accent)
: "transparent"
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.spacing.controlPaddingX
anchors.rightMargin: Style.spacing.controlPaddingX
text: root.optionLabel(modelData)
color: index === optionList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: optionList.currentIndex = parent.index
onClicked: optionList.selectCurrent()
}
}
}
}
}
}
}
+418
View File
@@ -0,0 +1,418 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
// Layer-shell popup attached to a bar widget icon, designed for
// click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon).
//
// Built on PanelWindow with a brief WlrKeyboardFocus.Exclusive prime followed
// by OnDemand rather than PopupWindow (xdg-popup). The prime acquires focus
// both when the surface maps and when it reopens while still mapped for its
// fade-out. xdg-popups don't get that — they only receive keys after a
// click/hover routes focus through their parent surface — so keyboard-summoned
// popups fell flat without it.
//
// Exclusive would also grant map-time focus, but it makes Hyprland route
// *every* pointer event to the exclusive surface no matter which output
// the cursor is over, which leaves clicks on any other monitor unable to
// reach the dismissal surfaces below.
//
// API is a subset of Common.PopupCard: anchorItem, owner, bar, open,
// padding, margin, contentWidth/Height, centerOnBar, default contentItem.
// Missing on purpose (for now): triggerMode ("hover"), containsMouse.
//
// Positioning: full-screen layer-shell with the card placed inside at
// `cardOrigin`. We use the bar window's height/width for the perpendicular
// axis (away-from-bar) because mapToItem on the anchor returns
// bar-content-relative coords with internal layout offsets baked in
// (e.g. ~13px from the bar's vertical centering of its widget row). The
// parallel axis (along-the-bar) uses the anchor's content x/y since the
// bar spans full screen on that axis.
//
// Outside-click dismissal: an overlay MouseArea catches clicks, with the
// QsWindow.mask subtracting the bar strip so clicks on the bar still
// reach the bar widgets (activePopout coordinator hands off to another
// popup if the user clicks a different bar icon).
PanelWindow {
id: root
required property Item anchorItem
required property QtObject bar
property var owner: null
property int margin: Style.gapsOut
property int padding: Style.spacing.popupPadding
property int contentWidth: Style.space(280)
property int contentHeight: Style.space(200)
property var borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2)))
property bool centerOnBar: false
property bool open: false
property int gap: Style.gapsOut // distance between bar edge and panel
property bool popoutSwitching: false
property bool popoutSwitchClosing: false
property bool focusPrimed: false
// Item that should take keyboard focus once the panel maps. Typically a
// PanelKeyCatcher inside the panel content. Layer-shell grants focus to the
// surface during the Exclusive prime, but Qt still needs an active-focus
// target inside the surface for Keys.onPressed handlers to fire. Schedule
// the focus through Qt.callLater so it runs after the surface is fully
// mapped and child items have completed layout.
property Item focusTarget: null
default property alias contentItem: contentHolder.children
readonly property var coordinatorKey: owner || root
readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null
readonly property string barPos: bar ? bar.position : "top"
function close() {
if (owner && "close" in owner) owner.close()
else root.open = false
}
function beginFocusPrime() {
if (open && backingWindowVisible) focusPrimeTimer.restart()
}
// --- screen + lifetime ---------------------------------------------------
screen: anchorWindow ? anchorWindow.screen : null
visible: open || card.opacity > 0 || popoutSwitching
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "blob-keyboard-panel"
WlrLayershell.layer: WlrLayer.Overlay
// Keyboard focus follows `open` (NOT `visible`). The window remains
// mapped during the fade-out so the opacity animation has something to
// animate, but keyboard/click ownership must release the moment the
// logical close fires — otherwise the user is locked out for 140ms.
//
// Prime with Exclusive on every open, then settle on OnDemand. Hyprland
// focuses OnDemand when a surface first maps, but not when an already-mapped
// fade-out surface changes from None back to OnDemand. Exclusive also takes
// focus when the previously focused application has constrained the pointer.
// The brief prime covers both cases; OnDemand then releases compositor-wide
// pointer hit-testing so clicks can reach the dismissal windows below.
WlrLayershell.keyboardFocus: open
? (focusPrimed ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive)
: WlrKeyboardFocus.None
onBackingWindowVisibleChanged: beginFocusPrime()
// Full-screen layer-shell. The visible card is positioned inside via
// `cardOrigin`. The `mask` below makes the bar area click-through (so
// the user can click another bar icon while the panel is open and the
// activePopout coordinator swaps to that popup); everywhere else, the
// overlay catches the click and dismisses via the MouseArea below.
anchors {
top: true
bottom: true
left: true
right: true
}
// Clickable region is the whole screen. Clicks in the bar strip are
// forwarded to registered bar buttons so switching between panel icons
// works in one click even when the overlay surface is above the bar.
readonly property real _barStripSize: {
if (!bar) return 0
var actual = (root.barPos === "top" || root.barPos === "bottom") ? root.barH : root.barW
return Math.max(bar.barSize, actual) + root.gap
}
mask: Region {
width: root.screenW
height: root.screenH
}
// Track every layout change between the bar's contentItem and the
// anchor item. `transform` updates whenever any item in that chain
// moves/resizes, which is what makes the position binding below
// actually reactive — mapToItem on its own is a one-shot.
TransformWatcher {
id: anchorWatcher
a: anchorWindow ? anchorWindow.contentItem : null
b: anchorItem
}
// Anchor item's position within the bar's content surface. For a
// full-width top bar, the content x maps directly to screen x; the y
// returned here has the bar's internal padding baked in (e.g. ~13px
// from vertical centering of the widget row), which is why `cardOrigin`
// below uses `barH` for the perpendicular axis instead of this y.
readonly property point anchorScreenPos: {
anchorWatcher.transform // reactive dependency
if (!anchorItem || !anchorWindow) return Qt.point(0, 0)
return anchorItem.mapToItem(anchorWindow.contentItem, 0, 0)
}
readonly property real anchorW: anchorItem ? anchorItem.width : 0
readonly property real anchorH: anchorItem ? anchorItem.height : 0
readonly property real screenW: screen ? screen.width : 0
readonly property real screenH: screen ? screen.height : 0
readonly property real availableCardWidth: screenW > 0
? Math.max(120, screenW - ((barPos === "left" || barPos === "right") ? barW + gap + margin : margin * 2))
: 0
readonly property real availableCardHeight: screenH > 0
? Math.max(120, screenH - ((barPos === "top" || barPos === "bottom") ? barH + gap + margin : margin * 2))
: 0
readonly property real verticalContentInset: padding * 2 + Border.top(borderSpec) + Border.bottom(borderSpec)
function fittedContentWidth(width, cap) {
var desired = Math.max(1, Number(width) || 1)
var maxWidth = root.availableCardWidth > 0 ? root.availableCardWidth : desired
if (cap !== undefined && Number(cap) > 0) maxWidth = Math.min(maxWidth, Number(cap))
return Math.round(Math.min(desired, maxWidth))
}
function fittedContentHeight(implicitHeight, cap) {
var desired = Math.max(root.verticalContentInset, (Number(implicitHeight) || 0) + root.verticalContentInset)
var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired
if (cap !== undefined && Number(cap) > 0) maxHeight = Math.min(maxHeight, Number(cap))
return Math.round(Math.min(desired, maxHeight))
}
function cappedContentHeight(height) {
var desired = Math.max(root.padding * 2, Number(height) || root.padding * 2)
var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired
return Math.round(Math.min(desired, maxHeight))
}
// Desired top-left of the card in screen coordinates. For the
// perpendicular axis (away-from-bar) we anchor to the bar window's edge
// directly — not the anchor item's y/x — because mapToItem(barContent)
// returns coordinates in the bar's content space, which can be offset
// from the bar surface's screen-anchored corner by internal layout
// (centering wrappers, padding). The bar's surface IS aligned to its
// anchored screen edge, so using `barW`/`barH` gives the right edge
// regardless of how the bar's internal widgets are positioned. For the
// parallel axis (along the bar) the anchor item's reported position is
// still consistent with the bar content origin, so it's accurate for
// centering the card under the icon.
readonly property real barW: anchorWindow ? anchorWindow.width : screenW
readonly property real barH: anchorWindow ? anchorWindow.height : 0
readonly property point cardOrigin: {
if (!anchorItem || !bar) return Qt.point(margin, margin)
var x = 0, y = 0
if (centerOnBar && (barPos === "top" || barPos === "bottom")) {
x = screenW / 2 - contentWidth / 2
y = barPos === "bottom" ? screenH - barH - contentHeight - gap : barH + gap
} else if (centerOnBar) {
x = barPos === "left" ? barW + gap : screenW - barW - contentWidth - gap
y = screenH / 2 - contentHeight / 2
} else if (barPos === "bottom") {
x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2
y = screenH - barH - contentHeight - gap
} else if (barPos === "left") {
x = barW + gap
y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2
} else if (barPos === "right") {
x = screenW - barW - contentWidth - gap
y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2
} else { // "top" (default)
x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2
y = barH + gap
}
x = Math.max(margin, Math.min(x, screenW - contentWidth - margin))
y = Math.max(margin, Math.min(y, screenH - contentHeight - margin))
return Qt.point(Math.round(x), Math.round(y))
}
// --- popout coordination (same-bar single-popout model) -----------------
// Coordinate on `open`, not `visible`. `visible` lags into the fade-out
// animation, which made ownership transfer to a sibling popup race.
onOpenChanged: {
if (open) {
focusPrimed = false
beginFocusPrime()
if (focusTarget) Qt.callLater(function() {
if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus()
})
} else {
focusPrimeTimer.stop()
focusPrimed = false
}
if (!bar) return
if (open) {
popoutSwitchClosing = false
popoutSwitching = bar.activePopout && bar.activePopout !== coordinatorKey
bar.requestPopout(coordinatorKey)
if (popoutSwitching) popoutSwitchTimer.restart()
} else {
popoutSwitchClosing = !!(owner && owner.popoutSwitchClosing)
popoutSwitching = false
if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey)
if (popoutSwitchClosing) closeSwitchTimer.restart()
}
}
Timer {
id: focusPrimeTimer
// Leave enough time for multiple Qt/Wayland commit cycles after the
// backing window becomes visible while keeping the compositor-wide
// Exclusive phase imperceptibly short. This interval is covered by the
// immediate hide/re-summon acceptance case.
interval: 75
onTriggered: if (root.open) root.focusPrimed = true
}
Timer {
id: popoutSwitchTimer
interval: 150
onTriggered: root.popoutSwitching = false
}
Timer {
id: closeSwitchTimer
interval: 1
onTriggered: root.popoutSwitchClosing = false
}
// --- outside-click dismissal --------------------------------------------
// Catches clicks anywhere in the clickable region (i.e. everywhere on
// screen except the bar strip, which is masked out). The card has its
// own MouseArea below so clicks on it don't bubble up here. Disabled
// during the fade-out so the dying overlay doesn't swallow clicks that
// were meant for the apps behind it.
MouseArea {
id: dismissArea
anchors.fill: parent
enabled: root.open
acceptedButtons: Qt.AllButtons
hoverEnabled: true
property bool hoveringBar: false
cursorShape: hoveringBar ? Qt.PointingHandCursor : Qt.ArrowCursor
function inBarRegion(px, py) {
if (root.barPos === "bottom") return py >= root.screenH - root._barStripSize
if (root.barPos === "left") return px <= root._barStripSize
if (root.barPos === "right") return px >= root.screenW - root._barStripSize
return py <= root._barStripSize
}
function barPoint(px, py) {
if (root.barPos === "bottom") return Qt.point(px, py - (root.screenH - root.barH))
if (root.barPos === "right") return Qt.point(px - (root.screenW - root.barW), py)
return Qt.point(px, py)
}
function pressTargetAt(px, py) {
if (!root.anchorWindow || !root.anchorWindow.contentItem || !root.bar || !root.bar.clickTargets) return null
var p = barPoint(px, py)
var targets = root.bar.clickTargets
for (var i = targets.length - 1; i >= 0; i--) {
var target = targets[i]
if (!target || !target.triggerPress || target.visible === false || target.opacity === 0 || !target.mapToItem) continue
if (root.bar.targetBelongsToWindow && !root.bar.targetBelongsToWindow(target, root.anchorWindow)) continue
var pos = root.anchorWindow.itemPosition(target)
if (p.x >= pos.x && p.x <= pos.x + target.width && p.y >= pos.y && p.y <= pos.y + target.height) return target
}
return null
}
function forwardBarClick(px, py, button) {
if (button !== Qt.LeftButton && button !== Qt.RightButton && button !== Qt.MiddleButton) return false
var target = pressTargetAt(px, py)
if (!target) return false
target.triggerPress(button)
return true
}
onPositionChanged: function(mouse) { hoveringBar = inBarRegion(mouse.x, mouse.y) }
onExited: hoveringBar = false
onClicked: function(mouse) {
// While Exclusive is priming, Hyprland may route a click from another
// output here with translated coordinates. Never interpret that as a
// click on this output's bar.
if (root.focusPrimed && inBarRegion(mouse.x, mouse.y) && forwardBarClick(mouse.x, mouse.y, mouse.button)) return
root.close()
}
}
// The panel surface only spans the anchor's screen, and the compositor
// hit-tests pointer input per output, so `dismissArea` above can never see
// a click on another monitor. Give every other output a transparent twin
// whose only job is to catch that click. They exist only while the panel is
// logically open (not during the fade-out, matching `dismissArea.enabled`).
//
// Keyboard focus is None: these must catch the pointer without taking focus
// from the panel when the cursor merely crosses onto their output.
Variants {
model: root.open ? Quickshell.screens : []
delegate: Component {
PanelWindow {
required property var modelData
screen: modelData
// Compare by output name: the anchor screen must be known before any
// twin maps, or a twin would cover the panel's own output.
visible: root.open && !!root.screen && modelData.name !== root.screen.name
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "blob-keyboard-panel-dismiss"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
anchors {
top: true
bottom: true
left: true
right: true
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
onPressed: root.close()
}
}
}
}
// --- card ----------------------------------------------------------------
BorderSurface {
id: card
x: root.cardOrigin.x
y: root.cardOrigin.y
width: root.contentWidth
height: root.contentHeight
color: Color.popups.background
borderSpec: root.borderSpec
padding: root.padding
radius: Style.cornerRadius
opacity: root.open || root.popoutSwitching ? 1.0 : 0
Behavior on opacity {
enabled: !root.popoutSwitching && !root.popoutSwitchClosing
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
// Swallow clicks on the card so they don't bubble to the dismissal
// MouseArea behind us.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
}
Item {
id: contentHolder
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
opacity: root.popoutSwitching ? (root.open ? 1.0 : 0) : 1.0
Behavior on opacity {
enabled: root.popoutSwitching
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
}
}
}
+623
View File
@@ -0,0 +1,623 @@
import QtQuick
import QtQuick.Controls as QQC
import QtQuick.Window
import Quickshell.Io
import qs.Commons
// Searchable multi-select dropdown. Trigger shape matches Dropdown /
// SearchableDropdown; the popup shows a search field, an optional refresh
// button, and a checkbox list. Click rows to toggle. Use when callers
// need to pick zero or more items from a (possibly long, possibly
// dynamic) list.
//
// Options are either:
// - static `options`: string[] or [{ value, label, description? }]
// - dynamic `optionsCommand`: argv array. The command's stdout is
// parsed as JSON when it trims to start with `[`, otherwise as
// one option per non-empty newline. Re-runs whenever the popup opens
// and via the refresh button.
//
// `values` is the persisted selection — always an array of strings.
// Emits `changed(values)` whenever the selection mutates.
Item {
id: root
property string label: ""
property var values: []
property var options: []
property var optionsCommand: []
property string optionsCommandCwd: ""
property string placeholderText: "Search..."
property string emptyText: "No options"
property string noSelectionText: "None selected"
property string triggerLabel: ""
property bool showLabel: true
property color foreground: Color.popups.text
property color background: Color.popups.background
property color popupBorder: Color.popups.border
property color accent: Color.accent
readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth)
property string fontFamily: Style.font.family
property int rowHeight: Style.spacing.controlHeight
property int popupRowHeight: Style.spacing.popupRowHeight
property int popupMinHeight: Style.spacing.searchablePopupMinHeight
property bool hasCursor: false
readonly property bool popupOpen: popup.opened
function open() { popup.open() }
function close() { popup.close() }
function toggle() { popup.opened ? popup.close() : popup.open() }
signal changed(var values)
signal hovered(bool isHovered)
// Loaded options after merging static + dynamic. Always normalized
// into the [{ value, label, description }] shape for delegate use.
property var resolvedOptions: []
property bool loadingOptions: false
property string optionsError: ""
function normalizeOption(o) {
if (o && typeof o === "object") {
return {
value: String(o.value),
label: String(o.label !== undefined ? o.label : o.value),
description: o.description ? String(o.description) : ""
}
}
var s = String(o)
return { value: s, label: s, description: "" }
}
// QML schema arrays sometimes arrive as JSValue lists that fail
// `Array.isArray`. arrayFrom accepts anything array-like (`.length`
// numeric) and returns a real JS array so the rest of the component can
// rely on standard array operations.
function arrayFrom(v) {
if (!v || typeof v.length !== "number" || typeof v === "string") return []
var out = []
for (var i = 0; i < v.length; i++) out.push(v[i])
return out
}
function normalizeAll(arr) {
var out = []
var src = arrayFrom(arr)
var seen = ({})
for (var i = 0; i < src.length; i++) {
var n = normalizeOption(src[i])
if (!n.value) continue
if (seen[n.value]) continue
seen[n.value] = true
out.push(n)
}
return out
}
function valueSet() {
var set = ({})
var arr = arrayFrom(values)
for (var i = 0; i < arr.length; i++) set[String(arr[i])] = true
return set
}
function isSelected(value) {
var set = valueSet()
return !!set[String(value)]
}
function toggleValue(value) {
var v = String(value)
var arr = arrayFrom(values)
var idx = arr.indexOf(v)
if (idx === -1) arr.push(v)
else arr.splice(idx, 1)
root.values = arr
root.changed(arr)
}
function selectionLabel() {
var arr = arrayFrom(values)
if (arr.length === 0) return ""
var labels = []
var byValue = ({})
for (var i = 0; i < resolvedOptions.length; i++)
byValue[resolvedOptions[i].value] = resolvedOptions[i].label
for (var j = 0; j < arr.length; j++)
labels.push(byValue[String(arr[j])] || String(arr[j]))
if (labels.length <= 3) return labels.join(", ")
return arr.length + " selected"
}
property var filtered: resolvedOptions
function recomputeFiltered() {
var q = searchField.text.toLowerCase()
if (!q) { filtered = resolvedOptions; return }
var out = []
for (var i = 0; i < resolvedOptions.length; i++) {
var o = resolvedOptions[i]
if (o.label.toLowerCase().indexOf(q) !== -1
|| o.description.toLowerCase().indexOf(q) !== -1
|| o.value.toLowerCase().indexOf(q) !== -1) out.push(o)
}
filtered = out
}
function rebuildFromStatic() {
resolvedOptions = normalizeAll(options)
recomputeFiltered()
}
// Parse stdout from a dynamic optionsCommand into `{ options, error }`.
// Output starting with `[` is parsed strictly as JSON — a malformed array
// surfaces as an error rather than silently falling back to newline
// parsing, which would render the broken text as a literal option label.
// Output not starting with `[` is treated as one value per non-empty line.
function parseCommandOutput(text) {
var raw = String(text || "").trim()
if (raw === "") return { options: [], error: "" }
if (raw.charAt(0) === "[") {
try {
var parsed = JSON.parse(raw)
return { options: parsed, error: "" }
} catch (e) {
return { options: [], error: "Options command emitted invalid JSON" }
}
}
var out = []
var lines = raw.split(/\r?\n/)
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (line !== "") out.push(line)
}
return { options: out, error: "" }
}
// Monotonic request id so stale stdout/exit signals from a previous
// refresh can't clobber the resolvedOptions of a newer refresh, and
// a runaway command can be detected after a timeout.
property int refreshSeq: 0
readonly property int refreshTimeoutMs: 6000
function refresh() {
var cmd = arrayFrom(optionsCommand)
if (cmd.length === 0) {
rebuildFromStatic()
return
}
refreshSeq++
loadingOptions = true
optionsError = ""
optionsProcess.command = cmd
optionsProcess.workingDirectory = optionsCommandCwd
optionsProcess.running = false
optionsProcess.running = true
refreshTimeoutTimer.restart()
}
Timer {
id: refreshTimeoutTimer
interval: root.refreshTimeoutMs
repeat: false
onTriggered: {
if (!root.loadingOptions) return
optionsProcess.running = false
root.loadingOptions = false
root.optionsError = "Options command timed out"
}
}
onOptionsChanged: if (arrayFrom(optionsCommand).length === 0) rebuildFromStatic()
onOptionsCommandChanged: refresh()
Component.onCompleted: refresh()
Process {
id: optionsProcess
running: false
command: []
property int seq: 0
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
if (optionsProcess.seq !== root.refreshSeq) return
var result = root.parseCommandOutput(text)
if (result.error) {
root.optionsError = result.error
root.resolvedOptions = root.normalizeAll(root.options)
} else {
var combined = root.arrayFrom(root.options)
for (var j = 0; j < result.options.length; j++) combined.push(result.options[j])
root.resolvedOptions = root.normalizeAll(combined)
}
root.recomputeFiltered()
root.loadingOptions = false
refreshTimeoutTimer.stop()
}
}
onRunningChanged: if (running) seq = root.refreshSeq
onExited: function(exitCode, exitStatus) {
if (seq !== root.refreshSeq) return
refreshTimeoutTimer.stop()
if (exitCode !== 0) {
root.loadingOptions = false
root.optionsError = "Options command exited " + exitCode
}
}
}
implicitWidth: Style.spacing.searchableDropdownWidth
implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight
Column {
anchors.fill: parent
spacing: Style.spacing.labelGap
Text {
textFormat: Text.PlainText
visible: root.showLabel && root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
BorderSurface {
id: trigger
width: parent.width
height: root.rowHeight
radius: Style.cornerRadius
readonly property bool _focused: trigger.activeFocus
readonly property bool _hot: triggerHover.hovered || root.hasCursor
readonly property var _borderSpec: Border.controlSpec(trigger._focused ? "focus" : (trigger._hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent)
borderSpec: _borderSpec
activeFocusOnTab: true
HoverHandler {
id: triggerHover
onHoveredChanged: root.hovered(hovered)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
popup.opened ? popup.close() : popup.open()
event.accepted = true
} else if (event.key === Qt.Key_Escape && popup.opened) {
popup.close(); event.accepted = true
}
}
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: chevron.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: trigger.borderLeft + Style.spacing.controlPaddingX
anchors.rightMargin: trigger.borderRight + Style.spacing.md
text: root.selectionLabel() || root.triggerLabel || root.noSelectionText
color: root.selectionLabel() ? root.foreground : Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: trigger.borderRight + Style.spacing.controlGap
text: "󰅀"
color: Qt.darker(root.foreground, 1.2)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
trigger.forceActiveFocus()
popup.opened ? popup.close() : popup.open()
}
}
QQC.Popup {
id: popup
// Reparent to the window's content item so the popup is free of any
// clipping ancestor. Position
// and available height are recomputed on open and any time the
// trigger's geometry changes, since a binding on mapToItem alone
// won't reliably re-evaluate when ancestors scroll or resize.
parent: trigger.Window.window ? trigger.Window.window.contentItem : trigger
property real _anchorX: 0
property real _anchorY: 0
property real _availableBelow: 0
readonly property real _windowHeight: parent ? parent.height : 0
readonly property real _idealContent: resultList.contentHeight + Style.space(50)
readonly property real _maxRowsHeight: root.popupRowHeight * 6 + 5 * Style.spacing.labelGap + Style.space(50)
function reposition() {
if (!parent) return
var p = trigger.mapToItem(parent, 0, trigger.height + Style.spacing.xxs)
_anchorX = p.x
_anchorY = p.y
_availableBelow = Math.max(0, _windowHeight - _anchorY - Style.space(12))
}
x: _anchorX
y: _anchorY
width: trigger.width
// Clamp to whatever fits below the trigger; don't force popupMinHeight
// when there isn't room, otherwise the popup overflows the window.
implicitHeight: Math.min(_availableBelow, _idealContent, _maxRowsHeight)
padding: Style.spacing.hairline
leftPadding: Border.left(root.popupBorderSpec) + Style.spacing.hairline
rightPadding: Border.right(root.popupBorderSpec) + Style.spacing.hairline
topPadding: Border.top(root.popupBorderSpec) + Style.spacing.hairline
bottomPadding: Border.bottom(root.popupBorderSpec) + Style.spacing.hairline
focus: true
Connections {
target: trigger
function onXChanged() { popup.reposition() }
function onYChanged() { popup.reposition() }
function onWidthChanged() { popup.reposition() }
function onHeightChanged() { popup.reposition() }
}
background: BorderSurface {
color: root.background
borderSpec: root.popupBorderSpec
radius: Style.cornerRadius
}
onOpened: {
reposition()
searchField.text = ""
root.refresh()
root.recomputeFiltered()
Qt.callLater(function() { searchField.forceActiveFocus() })
}
onClosed: searchField.text = ""
contentItem: Column {
spacing: 0
Item {
id: searchHeader
width: parent.width
height: root.popupRowHeight + Style.spacing.controlPaddingX
Row {
anchors.fill: parent
anchors.margins: Style.spacing.md
spacing: Style.spacing.rowGap
TextField {
id: searchField
width: parent.width - refreshButton.width - parent.spacing
height: parent.height
placeholderText: root.placeholderText
foreground: root.foreground
accent: root.accent
font.family: root.fontFamily
font.pixelSize: Style.font.body
onTextChanged: {
root.recomputeFiltered()
if (resultList.count > 0) resultList.currentIndex = 0
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
popup.close(); event.accepted = true
} else if (event.key === Qt.Key_Down) {
if (resultList.count > 0) {
resultList.currentIndex = 0
resultList.forceActiveFocus()
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (resultList.count > 0) {
resultList.currentIndex = 0
resultList.toggleCurrent()
}
event.accepted = true
}
}
}
BorderSurface {
id: refreshButton
visible: root.arrayFrom(root.optionsCommand).length > 0
enabled: !root.loadingOptions
width: parent.height
height: parent.height
radius: Style.cornerRadius
color: refreshHover.hovered
? Style.hoverFillFor(root.foreground, root.accent)
: Style.normalFillFor(root.foreground, root.accent)
borderSpec: refreshHover.hovered
? Border.controlSpec("hover-cursor", root.foreground, root.accent)
: Border.controlSpec("normal", root.foreground, root.accent)
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.loadingOptions ? "󰦖" : "󰑐"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
RotationAnimator on rotation {
running: root.loadingOptions
from: 0; to: 360
duration: 800
loops: Animation.Infinite
}
}
HoverHandler { id: refreshHover }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.refresh()
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Util.alpha(root.foreground, 0.10)
}
Item {
width: parent.width
height: popup.height - searchHeader.height - Style.spacing.xxs - 1
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
visible: resultList.count === 0
text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText)
color: Qt.darker(root.foreground, 1.6)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
ListView {
id: resultList
anchors.fill: parent
spacing: Style.spacing.labelGap
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.filtered
currentIndex: -1
keyNavigationEnabled: false
function toggleCurrent() {
if (currentIndex < 0 || currentIndex >= root.filtered.length) return
root.toggleValue(root.filtered[currentIndex].value)
}
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
popup.close(); event.accepted = true
} else if (event.key === Qt.Key_Down || event.text === "j") {
if (resultList.currentIndex >= resultList.count - 1) {
event.accepted = true; return
}
resultList.currentIndex = resultList.currentIndex + 1
event.accepted = true
} else if (event.key === Qt.Key_Up || event.text === "k") {
if (resultList.currentIndex <= 0) {
searchField.forceActiveFocus()
event.accepted = true; return
}
resultList.currentIndex = resultList.currentIndex - 1
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space) {
resultList.toggleCurrent(); event.accepted = true
}
}
delegate: Rectangle {
required property var modelData
required property int index
readonly property bool selected: root.isSelected(modelData.value)
width: resultList.width
height: Math.max(root.popupRowHeight, rowContent.implicitHeight + Style.spacing.rowPaddingX)
color: index === resultList.currentIndex
? Style.hoverFillFor(root.foreground, root.accent)
: "transparent"
Row {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.spacing.controlPaddingX
anchors.rightMargin: Style.spacing.controlPaddingX
spacing: Style.spacing.rowGap
BorderSurface {
id: checkbox
width: Style.space(16)
height: Style.space(16)
radius: Math.max(2, Style.cornerRadius / 2)
anchors.verticalCenter: parent.verticalCenter
color: selected ? Style.selectedFillFor(root.foreground, root.accent) : "transparent"
borderSpec: selected
? Border.controlSpec("selected", root.foreground, root.accent)
: Border.controlSpec("normal", root.foreground, root.accent)
Text {
anchors.centerIn: parent
visible: selected
text: "✓"
color: Style.selectedStateColor(root.foreground, root.accent)
font.family: root.fontFamily
font.pixelSize: Math.round(checkbox.height * 0.85)
font.bold: true
}
}
Column {
width: parent.width - checkbox.width - parent.spacing
anchors.verticalCenter: parent.verticalCenter
spacing: Style.spacing.xxs
Text {
textFormat: Text.PlainText
text: modelData.label
color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
width: parent.width
}
Text {
textFormat: Text.PlainText
visible: text !== ""
text: modelData.description
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
width: parent.width
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: resultList.currentIndex = parent.index
onClicked: root.toggleValue(modelData.value)
}
}
}
}
}
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
import QtQuick
import QtQuick.Controls as QQC
import qs.Commons
Column {
id: root
property string label: ""
property int value: 0
property int from: 0
property int to: 100
property int stepSize: 1
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
property real fontSize: Style.font.body
property real fieldWidth: Style.spacing.numberFieldWidth
property bool hasCursor: false
property bool _hovered: false
property alias field: spin
signal modified(int value)
signal hovered(bool on)
spacing: Style.spacing.md
Text {
textFormat: Text.PlainText
visible: root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
QQC.SpinBox {
id: spin
width: root.fieldWidth
implicitHeight: Math.max(Style.spacing.controlHeight, root.fontSize + Style.spacing.controlPaddingY * 2)
from: root.from
to: root.to
stepSize: root.stepSize
value: root.value
editable: true
font.family: root.fontFamily
font.pixelSize: root.fontSize
readonly property bool _focused: spin.activeFocus
readonly property bool _hot: root._hovered || root.hasCursor
readonly property var _borderSpec: Border.controlSpec(_focused ? "focus" : (_hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
leftPadding: Border.left(_borderSpec) + Style.spacing.controlPaddingX
rightPadding: Border.right(_borderSpec) + Style.spacing.controlPaddingX
topPadding: Border.top(_borderSpec)
bottomPadding: Border.bottom(_borderSpec)
onValueModified: root.modified(value)
background: BorderSurface {
color: Style.controlFill(spin._focused, spin._hot, root.foreground, root.accent)
borderSpec: spin._borderSpec
radius: Style.cornerRadius
HoverHandler {
onHoveredChanged: {
root._hovered = hovered
root.hovered(hovered)
}
}
}
contentItem: TextInput {
text: spin.displayText
font: spin.font
color: root.foreground
selectionColor: Style.selectionFillFor(root.foreground, root.accent)
selectedTextColor: root.foreground
horizontalAlignment: Qt.AlignHCenter
verticalAlignment: Qt.AlignVCenter
readOnly: !spin.editable
validator: spin.validator
inputMethodHints: Qt.ImhFormattedNumbersOnly
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import QtQuick
import qs.Commons
Item {
id: root
property string text: ""
property string fontFamily: Style.font.family
property real fontSize: Style.font.body
property color color: Color.foreground
property bool debugBounds: false
readonly property int renderedFontSize: Math.max(1, Math.round(fontSize))
readonly property real tightWidth: Math.max(1, glyphMetrics.tightBoundingRect.width)
readonly property real horizontalCorrection: glyph.implicitWidth / 2 - (glyphMetrics.tightBoundingRect.x + tightWidth / 2)
readonly property real paintedCenterX: glyph.x + glyphMetrics.tightBoundingRect.x + tightWidth / 2
readonly property real baselineY: glyph.y + glyph.baselineOffset
TextMetrics {
id: glyphMetrics
font.family: root.fontFamily
font.pixelSize: root.renderedFontSize
text: root.text
}
Text {
id: glyph
textFormat: Text.PlainText
// Keep the shared line box and baseline intact. Correcting only the
// horizontal painted bounds avoids per-glyph vertical drift.
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.horizontalCorrection
text: root.text
color: root.color
font.family: root.fontFamily
font.pixelSize: root.renderedFontSize
renderType: Text.NativeRendering
}
Rectangle {
visible: root.debugBounds
anchors.fill: parent
color: "transparent"
border.width: 1
border.color: "#4488ff"
}
Rectangle {
visible: root.debugBounds
x: 0
y: Math.round(root.baselineY)
width: parent.width
height: 1
color: "#44ff88"
}
}
+59
View File
@@ -0,0 +1,59 @@
import QtQuick
import Quickshell.Io
import qs.Commons
// Base item for plugin popup widgets. Many first-party plugins expose a bar
// button plus a popup from one QML entry point; this base owns the shared
// IPC-backed open/close lifecycle while implementations own button behavior,
// keyboard navigation, and content.
Item {
id: root
property QtObject bar: null
property string moduleName: ""
property var settings: ({})
property string ipcTarget: ""
property bool manageIpc: true
property alias controller: panelController
property bool popoutSwitching: false
property bool popoutSwitchClosing: false
readonly property bool opened: panelController.open
readonly property color barForeground: bar ? bar.barForeground : Color.foreground
function open() { panelController.show() }
function close() { panelController.hide() }
function closeForPopoutSwitch() {
popoutSwitchClosing = true
close()
Qt.callLater(function() { popoutSwitchClosing = false })
}
function toggle() { opened ? close() : open() }
function switchPanel(direction) {
if (bar && typeof bar.switchPanelFrom === "function") return bar.switchPanelFrom(root, direction)
return false
}
// Read a single value from this panel's inline shell.json entry, with a
// fallback for missing/null values. Matches BarWidget.setting().
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
PanelController {
id: panelController
}
IpcHandler {
enabled: root.manageIpc && root.ipcTarget !== ""
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
}
}
+100
View File
@@ -0,0 +1,100 @@
import QtQuick
import qs.Commons
// Small (22×22 by default) icon button used at the right edge of panel rows
// for inline actions — forget network, confirm passphrase, unpair device,
// etc. Two visual modes are supported via `hoverColor`:
// - default: hoverColor === foreground → subtle foreground-tint hover
// - urgent: hoverColor === bar.urgent → red-tint hover for destructive
// actions like forget/unpair
//
// `enabled` gates clicks and dims the icon. The component owns its own
// hover state visuals; mouse hover does NOT update any panel cursor state
// here because action buttons are not cursor targets — the row they live
// in is.
//
// Set `focusable: true` to make the button keyboard-tabbable with the
// shared hover-cursor/focus tokens. Use this
// in form contexts where Tab walks a list
// of controls; leave it false for the right-edge actions on panel rows
// where the row's CursorSurface owns the keyboard cursor.
//
// Set `hasCursor: true` to have the button render the same hover state as
// mouse hover — so a panel's keyboard cursor lands on it identically.
// Use this when a PanelActionButton is itself the cursor target (rather
// than living inside a CursorSurface row). Emits `hovered(bool)` on
// pointer enter/leave so the panel can update its cursor state to match.
BorderSurface {
id: root
property string iconText: ""
property string tooltipText: ""
property color foreground: Color.foreground
property color hoverColor: foreground
property string fontFamily: Style.font.family
property real fontSize: Style.font.icon
property real size: Math.max(Style.space(22), fontSize + Style.spacing.sm * 2)
property bool focusable: false
property bool hasCursor: false
property bool bordered: false
signal clicked()
signal hovered(bool isHovered)
activeFocusOnTab: focusable
Keys.onReturnPressed: if (focusable) root.clicked()
Keys.onEnterPressed: if (focusable) root.clicked()
Keys.onSpacePressed: if (focusable) root.clicked()
implicitWidth: size
implicitHeight: size
radius: Style.cornerRadius
readonly property bool _showFocusRing: focusable && activeFocus
readonly property bool _hot: (mouse.containsMouse || root.hasCursor) && root.enabled
readonly property var _borderSpec: _showFocusRing
? Border.controlSpec("focus", hoverColor, hoverColor)
: (_hot && bordered
? Border.controlSpec("hover-cursor", hoverColor, hoverColor)
: (bordered ? Border.controlSpec("normal", foreground, Color.accent) : Border.none()))
color: _showFocusRing
? Style.focusFillFor(hoverColor, hoverColor)
: (_hot
? Style.hoverFillFor(hoverColor, hoverColor)
: "transparent")
borderSpec: _borderSpec
Behavior on color { ColorAnimation { duration: 60 } }
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.iconText
color: root.enabled
? (root._hot ? root.hoverColor : root.foreground)
: Qt.darker(root.foreground, 2.0)
font.family: root.fontFamily
font.pixelSize: root.fontSize
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: root.enabled
onContainsMouseChanged: root.hovered(containsMouse)
onClicked: {
if (root.focusable) root.forceActiveFocus()
root.clicked()
}
}
PanelToolTip {
visible: root.tooltipText !== "" && mouse.containsMouse
text: root.tooltipText
fontFamily: root.fontFamily
}
}
+16
View File
@@ -0,0 +1,16 @@
import QtQuick
// Stores the open state for a shell panel. Panel owns the public lifecycle
// methods and IPC wiring; this object only keeps state separate from the
// panel implementation's own properties.
//
// Usage:
// PanelController { id: panelController }
QtObject {
id: root
property bool open: false
function toggle() { open = !open }
function show() { if (!open) open = true }
function hide() { open = false }
}
+111
View File
@@ -0,0 +1,111 @@
import QtQuick
import qs.Commons
Item {
id: root
property Component iconComponent: null
property string title: ""
property string meta: ""
property string detail: ""
property color foreground: Color.foreground
property string fontFamily: Style.font.family
property real iconSize: Style.font.display
property real iconOpacity: 1.0
property alias metaOpacity: metaText.opacity
// Optional control pinned to the trailing edge of the hero — a ToggleSwitch,
// a small button. The hero centers it against the labels and reserves the
// space itself, so callers never do the geometry.
property Component trailingControl: null
readonly property color dim: Qt.darker(foreground, 1.4)
readonly property real trailingInset: trailingLoader.item && trailingLoader.item.visible ? trailingLoader.width + Style.space(12) : 0
width: parent ? parent.width : implicitWidth
implicitHeight: Math.max(iconLoader.implicitHeight, heroLabels.implicitHeight, trailingLoader.implicitHeight)
Loader {
id: iconLoader
sourceComponent: root.iconComponent
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
opacity: root.iconOpacity
}
Column {
id: heroLabels
anchors.left: iconLoader.right
anchors.leftMargin: Style.space(14)
anchors.right: parent.right
anchors.rightMargin: root.trailingInset
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Row {
id: titleRow
visible: root.title !== "" || detailPill.visible
width: parent.width
Text {
textFormat: Text.PlainText
visible: root.title !== ""
text: root.title
width: Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0)))
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
}
Item {
width: Math.max(0, parent.width - parent.children[0].width - detailPill.implicitWidth)
height: 1
}
BorderSurface {
id: detailPill
visible: root.detail !== ""
implicitWidth: detailText.implicitWidth + Style.space(10)
implicitHeight: detailText.implicitHeight + Style.space(4)
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
borderSpec: Border.controlSpec("normal", root.foreground, Color.accent)
radius: Style.cornerRadius
Text {
id: detailText
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.detail
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
}
}
Text {
id: metaText
textFormat: Text.PlainText
width: parent.width
text: root.meta.toUpperCase()
visible: text !== ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
}
}
Loader {
id: trailingLoader
sourceComponent: root.trailingControl
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
}
}
+85
View File
@@ -0,0 +1,85 @@
import QtQuick
// Drop-in key dispatcher for keyboard-driven panels. Wraps panel content
// and emits semantic signals so each panel keeps its own state machine
// (focusSection, selectedIndex, action rules) while the boilerplate
// key handling lives here.
//
// Usage:
// Common.KeyboardPanel {
// ...
// PanelKeyCatcher {
// anchors.fill: parent
// onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) }
// onActivateRequested: root.activateCursor()
// onCloseRequested: root.close()
// onDeleteRequested: root.deleteSelected()
// onTextKey: function(t) { if (t === "r") root.refresh() }
//
// Column { ... panel content ... }
// }
// }
//
// Keys.priority: Keys.BeforeItem means this handler gets keys first,
// even when a descendant has activeFocus. That's what lets Up/Down
// arrows drive the cursor instead of being consumed by an inner
// Flickable's built-in scroll handling. When a panel has an inline
// editor (wifi passphrase, gallery TextField demo) the panel must
// set `blocked: editor.activeFocus` so this handler short-circuits
// and the editor receives keys normally.
//
// blocked: when true, ALL keys are forwarded to descendants without
// triggering signals.
Item {
id: root
property bool blocked: false
signal moveRequested(int dx, int dy)
signal activateRequested()
signal returnRequested()
signal closeRequested()
signal deleteRequested()
signal tabRequested(int direction)
signal textKey(string text)
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (blocked) return
if (event.key === Qt.Key_Escape) {
closeRequested(); event.accepted = true; return
}
if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
tabRequested((event.modifiers & Qt.ShiftModifier) || event.key === Qt.Key_Backtab ? -1 : 1)
event.accepted = true
return
}
if (event.key === Qt.Key_Down || event.text === "j") {
moveRequested(0, 1); event.accepted = true; return
}
if (event.key === Qt.Key_Up || event.text === "k") {
moveRequested(0, -1); event.accepted = true; return
}
if (event.key === Qt.Key_Right || event.text === "l") {
moveRequested(1, 0); event.accepted = true; return
}
if (event.key === Qt.Key_Left || event.text === "h") {
moveRequested(-1, 0); event.accepted = true; return
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
returnRequested()
activateRequested(); event.accepted = true; return
}
if (event.key === Qt.Key_Space) {
activateRequested(); event.accepted = true; return
}
if (event.text === "x" || event.text === "X") {
deleteRequested(); event.accepted = true; return
}
if (event.text && event.text.length === 1) {
textKey(event.text)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import QtQuick
import qs.Commons
// Small-caps-style label that introduces a panel section ("DNS provider",
// "Wi-Fi networks", "Output device", "Paired devices"). Sits between a
// PanelSeparator and the content rows.
Text {
id: root
property color foreground: Color.foreground
property string fontFamily: Style.font.family
property real fontSize: Style.font.caption
// Callers bind `text` from outside this file, so the default has to be set
// here. AutoText would let a section title that happens to carry a device or
// network name promote itself to rich text.
textFormat: Text.PlainText
color: Qt.darker(foreground, 1.4)
font.family: fontFamily
font.pixelSize: fontSize
font.bold: true
// Glyphs can paint above the box Text reserves for them: JetBrainsMono Nerd
// Font's outlines run 10% of the em past its own ascent, and a patched or
// user-chosen family can be worse. That sliver is invisible in normal flow,
// but a header sitting at the top of a clipping list — bluetooth's device
// list, network's station list — loses it to the clip and renders beheaded.
// Reserve the overshoot here so every panel is covered at once.
topPadding: Math.ceil(fontSize * 0.15)
}
+18
View File
@@ -0,0 +1,18 @@
import QtQuick
import qs.Commons
// 1px horizontal divider for panel sections. The alpha-on-foreground tint
// keeps the rule legible against the panel background without competing
// with text or borders.
Rectangle {
id: root
property color foreground: Color.foreground
property real strength: 0.12
width: parent ? parent.width : implicitWidth
implicitWidth: 100
implicitHeight: 1
height: 1
color: Qt.rgba(foreground.r, foreground.g, foreground.b, strength)
}
+149
View File
@@ -0,0 +1,149 @@
import QtQuick
import qs.Commons
Item {
id: root
property QtObject bar: null
property real value: 0
property real minimum: 0
property real maximum: 1
property real step: 0.05
property bool integer: false
property color trackColor: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "#333"
property color fillColor: bar ? bar.foreground : Color.foreground
property color knobColor: bar ? bar.foreground : Color.foreground
property bool dragging: false
property real trackHeight: Math.max(4, Math.round(Style.spacing.controlHeight * 0.11))
property real knobSize: Math.max(14, Math.round(Style.spacing.controlHeight * 0.38))
property real liveValue: value
// macOS-style notches. When > 1, that many evenly-spaced tick marks are cut
// into the track (drawn in the panel background color, so only the part
// crossing the track shows). Purely visual — snapping is the caller's job via
// `integer`/`step` or an index-based value. Default 0 leaves the track plain.
property int tickCount: 0
property color tickColor: bar ? bar.background : Color.background
onValueChanged: if (!dragging) liveValue = value
signal moved(real value)
signal released(real value)
// Right-click is a secondary action on the whole track — audio uses it to
// mute the channel the slider belongs to. Dragging stays left-button only.
signal rightClicked()
implicitWidth: Style.space(200)
implicitHeight: Math.max(Style.space(22), knobSize + Style.spacing.md)
readonly property real range: Math.max(0.0001, maximum - minimum)
readonly property real progress: Math.max(0, Math.min(1, (liveValue - minimum) / range))
readonly property bool _hot: mouseArea.containsMouse || root.dragging
Rectangle {
id: track
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
height: root.trackHeight
radius: height / 2
color: root.trackColor
}
Rectangle {
id: fill
anchors.verticalCenter: track.verticalCenter
anchors.left: track.left
height: track.height
radius: track.radius
color: root.fillColor
width: track.width * root.progress
Behavior on width {
enabled: !root.dragging
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
}
Repeater {
model: root.tickCount > 1 ? root.tickCount : 0
Rectangle {
required property int index
width: Math.max(1, Style.space(2))
height: root.trackHeight + Style.space(4)
radius: 1
color: root.tickColor
anchors.verticalCenter: track.verticalCenter
x: Math.max(0, Math.min(track.width - width,
track.width * (index / (root.tickCount - 1)) - width / 2))
}
}
BorderSurface {
id: knob
width: root.knobSize
height: root.knobSize
radius: root.knobSize / 2
color: root.knobColor
borderSpec: Border.flat(root.bar ? root.bar.background : "#101315", Math.max(1, Style.space(2)))
anchors.verticalCenter: track.verticalCenter
x: Math.max(0, Math.min(track.width - width, track.width * root.progress - width / 2))
scale: root._hot ? 1.15 : 1.0
Behavior on x {
enabled: !root.dragging
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
Behavior on scale {
NumberAnimation { duration: 110; easing.type: Easing.OutCubic }
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
function valueFromX(x) {
var clamped = Math.max(0, Math.min(track.width, x))
var raw = root.minimum + (clamped / track.width) * root.range
if (root.integer) raw = Math.round(raw)
return Math.max(root.minimum, Math.min(root.maximum, raw))
}
onPressed: function(mouse) {
if (mouse.button !== Qt.LeftButton) return
root.dragging = true
var next = valueFromX(mouse.x)
root.liveValue = next
root.moved(next)
}
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) root.rightClicked()
}
onPositionChanged: function(mouse) {
if (!root.dragging) return
var next = valueFromX(mouse.x)
root.liveValue = next
root.moved(next)
}
onReleased: function(mouse) {
if (mouse.button !== Qt.LeftButton) return
root.dragging = false
root.released(root.liveValue)
root.liveValue = root.value
}
onWheel: function(wheel) {
var delta = wheel.angleDelta.y > 0 ? root.step : -root.step
var next = Math.max(root.minimum, Math.min(root.maximum, root.liveValue + delta))
if (root.integer) next = Math.round(next)
root.liveValue = next
root.moved(next)
root.released(next)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
// Styled wrapper around Qt Quick Controls ToolTip. Drop-in: declare inside
// the hovered item and bind `visible` to the hover state, e.g.
// PanelToolTip {
// visible: mouse.containsMouse
// text: "Forget network"
// }
//
// Defaults pull from [tooltip] in shell.toml via Color.tooltip.*. Override
// the panel* properties per-instance only when you need a tooltip that
// intentionally diverges from the theme.
//
// Property names are prefixed `panel*` to avoid clashing with ToolTip's
// built-in `background`/`font` properties.
ToolTip {
id: root
property color panelForeground: Color.tooltip.text
property color panelBackground: Color.tooltip.background
property color panelBorder: Color.tooltip.border
property string fontFamily: Style.font.family
property real fontSize: Style.font.bodySmall
readonly property var panelBorderSpec: Border.localOrSurfaceSpec("tooltip", "border", panelBorder, Color.tooltip.border, Style.normalBorderWidth)
delay: 400
padding: 0
background: BorderSurface {
color: root.panelBackground
borderSpec: root.panelBorderSpec
radius: Style.cornerRadius
}
contentItem: Text {
textFormat: Text.PlainText
text: root.text
color: root.panelForeground
font.family: root.fontFamily
font.pixelSize: root.fontSize
leftPadding: Border.left(root.panelBorderSpec) + Style.spacing.controlPaddingX
rightPadding: Border.right(root.panelBorderSpec) + Style.spacing.controlPaddingX
topPadding: Border.top(root.panelBorderSpec) + Style.spacing.controlPaddingY
bottomPadding: Border.bottom(root.panelBorderSpec) + Style.spacing.controlPaddingY
}
}
+87
View File
@@ -0,0 +1,87 @@
import QtQuick
// Bar surface exposed to an installed third-party widget. Scalar presentation
// state is mirrored by Bar.qml and operations are delegated through scoped
// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a
// visual child from the parent hierarchy of the QML scene that renders it.
QtObject {
id: api
required property string pluginId
required property string moduleName
property var shell: null
property color foreground: "transparent"
property color barForeground: "transparent"
property color background: "transparent"
property color urgent: "transparent"
property string fontFamily: ""
property string position: "top"
property bool vertical: false
property int barSize: 0
property bool transparent: false
property bool foregroundAnimationEnabled: true
property bool centerSectionRevealHeld: false
property bool _centerHoverRevealSuppressed: false
readonly property bool centerHoverRevealSuppressed: _centerHoverRevealSuppressed
property var activePopout: null
property var clickTargets: []
property var layoutConfig: ({})
readonly property var foreignPopoutMarker: ({ foreign: true })
property var _showTooltip: null
property var _hideTooltip: null
property var _registerClickTarget: null
property var _unregisterClickTarget: null
property var _requestPopout: null
property var _releasePopout: null
property var _switchPanelFrom: null
property var _targetBelongsToWindow: null
property var _moduleWidgets: null
property var _run: null
property var _setCenterHoverRevealSuppressed: null
function setCenterHoverRevealSuppressed(value) {
if (_setCenterHoverRevealSuppressed) _setCenterHoverRevealSuppressed(!!value)
}
function showTooltip(target, text) {
if (_showTooltip) _showTooltip(target, String(text || ""))
}
function hideTooltip(target) {
if (_hideTooltip) _hideTooltip(target)
}
function registerClickTarget(target) {
if (_registerClickTarget) _registerClickTarget(target)
}
function unregisterClickTarget(target) {
if (_unregisterClickTarget) _unregisterClickTarget(target)
}
function requestPopout(owner) {
if (_requestPopout) _requestPopout(owner)
}
function releasePopout(owner) {
if (_releasePopout) _releasePopout(owner)
}
function switchPanelFrom(owner, direction) {
return _switchPanelFrom ? _switchPanelFrom(owner, direction) : false
}
function targetBelongsToWindow(target, window) {
return _targetBelongsToWindow ? _targetBelongsToWindow(target, window) : false
}
function moduleWidgets(id) {
return _moduleWidgets ? _moduleWidgets(String(id || "")) : []
}
function run(command) {
if (_run) _run(String(command || ""))
}
}
+54
View File
@@ -0,0 +1,54 @@
import QtQuick
// Filters synthetic hover churn from moving delegates under a stationary
// pointer. Call reset() after keyboard/list mutations, then moved() from a
// row MouseArea's onPositionChanged before changing cursor selection. A
// transition known to originate from the pointer can call allowInitialSample()
// so the item under the stationary pointer remains selected.
QtObject {
id: root
property Item referenceItem: null
property real threshold: 1
property bool primed: false
property bool initialSampleAllowed: false
property real lastX: 0
property real lastY: 0
function reset() {
root.primed = false
root.initialSampleAllowed = false
root.lastX = 0
root.lastY = 0
}
function allowInitialSample() {
root.reset()
root.initialSampleAllowed = true
}
function moved(item, mouse) {
if (!item || !mouse) {
root.reset()
return false
}
var target = root.referenceItem || item
var point = item.mapToItem(target, mouse.x, mouse.y)
var firstSample = !root.primed
var didMove = !firstSample
? Math.abs(point.x - root.lastX) > root.threshold || Math.abs(point.y - root.lastY) > root.threshold
: root.initialSampleAllowed
// Keep the previous accepted position while filtering jitter so slow,
// sub-threshold steps accumulate into deliberate pointer movement.
if (firstSample || didMove) {
root.lastX = point.x
root.lastY = point.y
}
root.primed = true
root.initialSampleAllowed = false
return didMove
}
}
+176
View File
@@ -0,0 +1,176 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import qs.Commons
PopupWindow {
id: root
required property Item anchorItem
required property QtObject bar
property var owner: null
property int margin: Style.gapsOut
property int padding: Style.spacing.popupPadding
property int contentWidth: Style.space(280)
property int contentHeight: Style.space(200)
property color borderColor: Color.popups.border
property var borderSpec: Border.localOrSurfaceSpec("popups", "border", borderColor, Color.popups.border, Math.max(1, Style.space(2)))
property bool open: false
property bool centerOnBar: false
// "click" — uses HyprlandFocusGrab so clicking outside dismisses the popup.
// "hover" — passive overlay; the owning widget controls open via hover.
property string triggerMode: "click"
readonly property var coordinatorKey: owner || root
readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null
readonly property var popupScreen: anchorWindow ? anchorWindow.screen : null
readonly property bool containsMouse: cardHover.hovered
readonly property real screenW: popupScreen ? popupScreen.width : 0
readonly property real screenH: popupScreen ? popupScreen.height : 0
readonly property real barW: anchorWindow ? anchorWindow.width : 0
readonly property real barH: anchorWindow ? anchorWindow.height : 0
readonly property real availableCardWidth: screenW > 0
? Math.max(120, screenW - ((bar && (bar.position === "left" || bar.position === "right")) ? barW : 0) - root.margin * 2)
: 0
readonly property real availableCardHeight: screenH > 0
? Math.max(120, screenH - ((bar && (bar.position === "top" || bar.position === "bottom")) ? barH : 0) - root.margin * 2)
: 0
readonly property real verticalContentInset: padding * 2 + Border.top(borderSpec) + Border.bottom(borderSpec)
function fittedContentWidth(width, cap) {
var desired = Math.max(1, Number(width) || 1)
var maxWidth = root.availableCardWidth > 0 ? root.availableCardWidth : desired
if (cap !== undefined && Number(cap) > 0) maxWidth = Math.min(maxWidth, Number(cap))
return Math.round(Math.min(desired, maxWidth))
}
function fittedContentHeight(implicitHeight, cap) {
var desired = Math.max(root.verticalContentInset, (Number(implicitHeight) || 0) + root.verticalContentInset)
var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired
if (cap !== undefined && Number(cap) > 0) maxHeight = Math.min(maxHeight, Number(cap))
return Math.round(Math.min(desired, maxHeight))
}
function cappedContentHeight(height) {
var desired = Math.max(root.padding * 2, Number(height) || root.padding * 2)
var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired
return Math.round(Math.min(desired, maxHeight))
}
function close() {
if (owner && "close" in owner) owner.close()
else root.open = false
}
default property alias contentItem: contentHolder.children
visible: open || card.opacity > 0
color: "transparent"
implicitWidth: contentWidth
implicitHeight: contentHeight
onOpenChanged: {
if (!bar) return
if (open) bar.requestPopout(coordinatorKey)
else if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey)
}
// Outside-click dismissal via Hyprland's focus grab. While `active`, input
// is routed only to the listed windows; clicking anywhere else clears the
// grab and we close the popup. Skipped for hover-mode popups so the cursor
// can move freely between the trigger and the popup.
HyprlandFocusGrab {
active: root.open && root.triggerMode === "click"
windows: root.anchorWindow ? [root, root.anchorWindow] : [root]
onCleared: root.close()
}
anchor {
id: popupAnchor
window: anchorItem ? anchorItem.QsWindow.window : null
adjustment: PopupAdjustment.Slide
edges: Edges.Top | Edges.Left
gravity: Edges.Bottom | Edges.Right
rect.width: 1
rect.height: 1
onAnchoring: {
if (!root.anchorItem || !root.bar) return
var target = root.anchorItem
var popupWidth = root.implicitWidth
var popupHeight = root.implicitHeight
var localX = target.width / 2 - popupWidth / 2
var localY = target.height + root.margin
if (root.bar.position === "bottom") {
localY = -popupHeight - root.margin
} else if (root.bar.position === "left") {
localX = target.width + root.margin
localY = target.height / 2 - popupHeight / 2
} else if (root.bar.position === "right") {
localX = -popupWidth - root.margin
localY = target.height / 2 - popupHeight / 2
}
var window = target.QsWindow.window
if (!window) return
if (root.centerOnBar) {
var cx = 0;
var cy = 0;
if (root.bar.position === "top" || root.bar.position === "bottom") {
cx = window.width / 2 - popupWidth / 2
cy = root.bar.position === "bottom" ? -popupHeight - root.margin : window.height + root.margin
cx = Math.max(root.margin, Math.min(cx, window.width - popupWidth - root.margin))
} else {
cx = root.bar.position === "left" ? window.width + root.margin : -popupWidth - root.margin
cy = window.height / 2 - popupHeight / 2
cy = Math.max(root.margin, Math.min(cy, window.height - popupHeight - root.margin))
}
popupAnchor.rect.x = Math.round(cx)
popupAnchor.rect.y = Math.round(cy)
return
}
var point = window.contentItem.mapFromItem(target, localX, localY)
if (root.bar.position === "top" || root.bar.position === "bottom") {
point.x = Math.max(root.margin, Math.min(point.x, window.width - popupWidth - root.margin))
} else {
point.y = Math.max(root.margin, Math.min(point.y, window.height - popupHeight - root.margin))
}
popupAnchor.rect.x = Math.round(point.x)
popupAnchor.rect.y = Math.round(point.y)
}
}
BorderSurface {
id: card
anchors.fill: parent
color: Color.popups.background
borderSpec: root.borderSpec
padding: root.padding
radius: Style.cornerRadius
opacity: root.open ? 1.0 : 0
Behavior on opacity {
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
Item {
id: contentHolder
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
}
HoverHandler {
id: cardHover
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import QtQuick
// Hyprland leaves an already-mapped layer surface at its old global position
// when its monitor moves within the layout: undocking disables the internal
// panel, the external monitor shifts to x=0, and long-lived surfaces such as
// the bar and background keep rendering at the old offset — or entirely
// off-screen — until they are unmapped and remapped. Watch the screen's
// origin and pulse `remapping` when it moves; the owning window folds that
// into its `visible` binding so the compositor re-places the surface at the
// monitor's new origin.
Item {
id: root
required property var window
readonly property var screen: window ? window.screen : null
// Fold into the window's binding: visible: <shown> && !guard.remapping
property bool remapping: false
visible: false
// A layout reshuffle can move the monitor more than once before it lands.
// Let the positions settle before the single remap pulse.
Timer {
id: settleTimer
interval: 200
onTriggered: root.remapping = true
}
// Hold the surface unmapped for a beat so the compositor processes the
// unmap before the remap instead of coalescing them into a no-op.
Timer {
interval: 50
running: root.remapping
onTriggered: root.remapping = false
}
Connections {
target: root.screen
function onXChanged() { settleTimer.restart() }
function onYChanged() { settleTimer.restart() }
}
}
+353
View File
@@ -0,0 +1,353 @@
import QtQuick
import QtQuick.Controls as QQC
import qs.Commons
// Searchable single-select dropdown. Same trigger shape as Dropdown, but
// the popup leads with an embedded TextField that filters the option
// list in real time. Use for pickers with enough options that scanning
// is friction.
//
// Filtering is case-insensitive substring against each option's label.
// Options can be string[] or [{ value, label, description? }] — the same
// shape Dropdown accepts. The filter clears whenever the popup closes.
//
// Keyboard: Tab to focus the trigger, Enter/Space opens (search focused
// immediately). Down arrow from the search jumps to the first match;
// Up from the first match returns to the search. Enter selects, Esc
// closes (and clears the filter).
Item {
id: root
property string label: ""
property string value: ""
property var options: []
property string placeholderText: "Search..."
property string emptyText: "No matches"
property string triggerLabel: ""
property color foreground: Color.popups.text
property color background: Color.popups.background
property color popupBorder: Color.popups.border
property color accent: Color.accent
readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth)
property string fontFamily: Style.font.family
property int rowHeight: Style.spacing.controlHeight
property int popupRowHeight: Style.spacing.popupRowHeight
property int popupMinHeight: Style.spacing.searchablePopupMinHeight
property bool showLabel: true
// Panel-cursor flag. When true, the trigger renders the shared
// hover-cursor state. Active Qt focus defaults to the same visuals.
// Emits `hovered(bool)` on pointer enter/leave so the panel can keep
// its cursor state in sync with the mouse.
property bool hasCursor: false
// popupOpen + open()/close()/toggle() let a parent panel know when the
// dropdown owns keys (search field + result list are active) and
// suspend its own keyCatcher so typing into the filter doesn't drive
// the panel cursor.
readonly property bool popupOpen: popup.opened
function open() { popup.open() }
function close() { popup.close() }
function toggle() { popup.opened ? popup.close() : popup.open() }
signal changed(string value)
signal hovered(bool isHovered)
function optionValue(o) {
return (o && typeof o === "object") ? String(o.value) : String(o)
}
function optionLabel(o) {
return (o && typeof o === "object") ? String(o.label) : String(o)
}
function optionDescription(o) {
return (o && typeof o === "object" && o.description) ? String(o.description) : ""
}
function currentLabel() {
for (var i = 0; i < options.length; i++) {
if (optionValue(options[i]) === value) return optionLabel(options[i])
}
return value
}
property var filtered: options
function recomputeFiltered() {
var q = searchField.text.toLowerCase()
if (!q) { filtered = options; return }
var out = []
for (var i = 0; i < options.length; i++) {
var lbl = optionLabel(options[i]).toLowerCase()
var desc = optionDescription(options[i]).toLowerCase()
if (lbl.indexOf(q) !== -1 || desc.indexOf(q) !== -1) out.push(options[i])
}
filtered = out
}
onOptionsChanged: recomputeFiltered()
implicitWidth: Style.spacing.searchableDropdownWidth
implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight
Column {
anchors.fill: parent
spacing: Style.spacing.labelGap
Text {
textFormat: Text.PlainText
visible: root.showLabel && root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
BorderSurface {
id: trigger
width: parent.width
height: root.rowHeight
radius: Style.cornerRadius
readonly property bool _focused: trigger.activeFocus
readonly property bool _hot: triggerHover.hovered || root.hasCursor
readonly property var _borderSpec: Border.controlSpec(trigger._focused ? "focus" : (trigger._hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent)
borderSpec: _borderSpec
activeFocusOnTab: true
HoverHandler {
id: triggerHover
onHoveredChanged: root.hovered(hovered)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
popup.opened ? popup.close() : popup.open()
event.accepted = true
} else if (event.key === Qt.Key_Escape && popup.opened) {
popup.close(); event.accepted = true
}
}
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: chevron.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: trigger.borderLeft + Style.spacing.controlPaddingX
anchors.rightMargin: trigger.borderRight + Style.spacing.md
text: root.currentLabel() || root.triggerLabel || root.placeholderText
color: (root.currentLabel() || root.triggerLabel) ? root.foreground : Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: trigger.borderRight + Style.spacing.controlGap
text: "󰅀"
color: Qt.darker(root.foreground, 1.2)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
trigger.forceActiveFocus()
popup.opened ? popup.close() : popup.open()
}
}
QQC.Popup {
id: popup
x: 0
y: trigger.height + Style.spacing.xxs
width: trigger.width
implicitHeight: Math.max(root.popupMinHeight,
Math.min(resultList.contentHeight + Style.space(50),
root.popupRowHeight * 6 + 5 * Style.spacing.labelGap + Style.space(50)))
padding: Style.spacing.hairline
leftPadding: Border.left(root.popupBorderSpec) + Style.spacing.hairline
rightPadding: Border.right(root.popupBorderSpec) + Style.spacing.hairline
topPadding: Border.top(root.popupBorderSpec) + Style.spacing.hairline
bottomPadding: Border.bottom(root.popupBorderSpec) + Style.spacing.hairline
focus: true
background: BorderSurface {
color: root.background
borderSpec: root.popupBorderSpec
radius: Style.cornerRadius
}
onOpened: {
searchField.text = ""
root.recomputeFiltered()
Qt.callLater(function() { searchField.forceActiveFocus() })
}
onClosed: searchField.text = ""
contentItem: Column {
spacing: 0
Item {
id: searchHeader
width: parent.width
height: root.popupRowHeight + Style.spacing.controlPaddingX
TextField {
id: searchField
anchors.fill: parent
anchors.margins: Style.spacing.md
placeholderText: root.placeholderText
foreground: root.foreground
accent: root.accent
font.family: root.fontFamily
font.pixelSize: Style.font.body
onTextChanged: {
root.recomputeFiltered()
if (resultList.count > 0) resultList.currentIndex = 0
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
popup.close(); event.accepted = true
} else if (event.key === Qt.Key_Down) {
if (resultList.count > 0) {
resultList.currentIndex = 0
resultList.forceActiveFocus()
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (resultList.count > 0) {
resultList.currentIndex = 0
resultList.selectCurrent()
}
event.accepted = true
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Util.alpha(root.foreground, 0.10)
}
Item {
width: parent.width
height: popup.height - searchHeader.height - Style.spacing.xxs - 1
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
visible: resultList.count === 0
text: root.emptyText
color: Qt.darker(root.foreground, 1.6)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
ListView {
id: resultList
anchors.fill: parent
spacing: Style.spacing.labelGap
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.filtered
currentIndex: -1
keyNavigationEnabled: false
function selectCurrent() {
if (currentIndex < 0 || currentIndex >= root.filtered.length) return
var v = root.optionValue(root.filtered[currentIndex])
root.value = v
root.changed(v)
popup.close()
}
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
popup.close(); event.accepted = true
} else if (event.key === Qt.Key_Down || event.text === "j") {
if (resultList.currentIndex >= resultList.count - 1) {
event.accepted = true; return
}
resultList.currentIndex = resultList.currentIndex + 1
event.accepted = true
} else if (event.key === Qt.Key_Up || event.text === "k") {
if (resultList.currentIndex <= 0) {
searchField.forceActiveFocus()
event.accepted = true; return
}
resultList.currentIndex = resultList.currentIndex - 1
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
resultList.selectCurrent(); event.accepted = true
}
}
delegate: Rectangle {
required property var modelData
required property int index
width: resultList.width
height: Math.max(root.popupRowHeight, rowContent.implicitHeight + Style.spacing.rowPaddingX)
color: index === resultList.currentIndex
? Style.hoverFillFor(root.foreground, root.accent)
: "transparent"
Column {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.spacing.controlPaddingX
anchors.rightMargin: Style.spacing.controlPaddingX
spacing: Style.spacing.xxs
Text {
textFormat: Text.PlainText
text: root.optionLabel(modelData)
color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
width: parent.width
}
Text {
textFormat: Text.PlainText
visible: text !== ""
text: root.optionDescription(modelData)
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
width: parent.width
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: resultList.currentIndex = parent.index
onClicked: resultList.selectCurrent()
}
}
}
}
}
}
}
}
}
+412
View File
@@ -0,0 +1,412 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Ui
// Centered speed test overlay shared by the network and disk speed tests. No
// card: like the Tucson's floating cluster, the two dials sit directly on a
// darkened scrim -- open 270° arcs, faint tick rings, hubless gradient
// needles, and a digital readout in the middle. Esc, the scrim, or the corner
// dismiss close it; the needles sweep to full scale and back on open, then
// track the live readings. Callers name the dials, the unit, and the scale.
PanelWindow {
id: root
required property string fontFamily
required property bool running
required property string leftLabel
required property string rightLabel
property string unit: "Mbps"
property string title: ""
property string layerNamespace: "blob-speed-test"
property string runAgainTooltip: "Measure again"
property real leftValue: 0
property real rightValue: 0
property bool leftLive: false
property bool rightLive: false
property string error: ""
property bool open: false
// Full-scale latch points for the dials, smallest first. The first stop is
// the base scale a fresh run starts from.
property var scaleStops: [100, 250, 500, 1000, 2500, 5000, 10000]
property real fullScale: scaleStops[0]
signal closeRequested()
signal runAgainRequested()
readonly property bool failed: error !== ""
function resetScale() {
fullScale = scaleStops[0]
}
function expandScale(value) {
// Either reading ranges the entire cluster upward. Keeping this latch on
// the overlay ensures both dials always describe the same scale.
for (var i = 0; i < scaleStops.length; i++) {
if (value <= scaleStops[i] * 0.92) {
if (scaleStops[i] > fullScale) fullScale = scaleStops[i]
return
}
}
fullScale = scaleStops[scaleStops.length - 1]
}
onRunningChanged: if (running) resetScale()
onScaleStopsChanged: resetScale()
onLeftValueChanged: expandScale(leftValue)
onRightValueChanged: expandScale(rightValue)
Behavior on fullScale {
NumberAnimation { duration: 400; easing.type: Easing.OutCubic }
}
// The scrim below is a fixed near-black regardless of theme, so text and
// ticks on it need a fixed light palette, not the themed bar.foreground.
readonly property color onScrim: "white"
readonly property color onScrimDim: Qt.rgba(1, 1, 1, 0.55)
readonly property color onScrimUrgent: "#ff6b6b"
visible: open
// The window is instantiated hidden, so re-acquire focus after mapping and
// fire the ignition sweep once the surface is actually on screen.
onOpenChanged: {
if (open) Qt.callLater(function() {
if (!root.open) return
keyCatcher.forceActiveFocus()
leftDial.ignite()
rightDial.ignite()
})
}
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
// Deep scrim: with no card behind them, the floating dials need the
// backdrop to carry the contrast on any wallpaper, like the near-black
// panel behind a real cluster.
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.78)
MouseArea {
anchors.fill: parent
onClicked: root.closeRequested()
}
}
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.closeRequested()
Keys.onReturnPressed: if (!root.running) root.runAgainRequested()
Keys.onEnterPressed: if (!root.running) root.runAgainRequested()
Item {
id: cluster
anchors.centerIn: parent
width: content.implicitWidth
height: content.implicitHeight
// Narrow or heavily scaled outputs: shrink the whole cluster rather
// than clipping it at the screen edge.
scale: Math.min(1,
(keyCatcher.width - Style.space(32)) / Math.max(1, width),
(keyCatcher.height - Style.space(32)) / Math.max(1, height))
// Swallow clicks so only the scrim outside the cluster dismisses.
MouseArea { anchors.fill: parent; onClicked: {} }
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.space(16)
Text {
textFormat: Text.PlainText
visible: root.title !== ""
text: root.title.toUpperCase()
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 2
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
Row {
spacing: Style.space(48)
Layout.alignment: Qt.AlignHCenter
SpeedDial {
id: leftDial
label: root.leftLabel
value: root.leftValue
live: root.leftLive
}
SpeedDial {
id: rightDial
label: root.rightLabel
value: root.rightValue
live: root.rightLive
}
}
// Centered on the dial pair. Fades rather than unmounts while a run
// is in flight, so the cluster never shifts.
Button {
text: "Run Again"
tooltipText: root.runAgainTooltip
bordered: true
enabled: !root.running
opacity: root.running ? 0 : 1
foreground: root.onScrim
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(14)
verticalPadding: Style.space(4)
Layout.alignment: Qt.AlignHCenter
onClicked: root.runAgainRequested()
Behavior on opacity {
NumberAnimation { duration: 240; easing.type: Easing.OutCubic }
}
}
Text {
textFormat: Text.PlainText
visible: root.failed
text: root.error
color: root.onScrimUrgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap
Layout.fillWidth: true
Layout.maximumWidth: Style.space(440)
horizontalAlignment: Text.AlignHCenter
}
}
}
}
// One floating cluster dial: an open 270° scale with the gap at the
// bottom, a faint tick ring, a glowing accent value arc, a hubless needle
// that fades toward the pivot, and a digital readout in the middle. All
// writes to the needle funnel through `shown` so the ignition sweep and
// live readings share one animation.
component SpeedDial: Item {
id: dial
required property string label
required property real value
required property bool live
readonly property real diameter: Style.space(210)
// 0° = 3 o'clock, increasing clockwise (PathAngleArc's convention).
readonly property real dialStart: 135
readonly property real dialSweep: 270
readonly property int tickCount: 46
readonly property real arcWidth: Style.space(4)
readonly property real arcRadius: diameter / 2 - arcWidth
readonly property color trackColor: Qt.rgba(1, 1, 1, 0.14)
readonly property color minorTickColor: Qt.rgba(1, 1, 1, 0.12)
readonly property color majorTickColor: Qt.rgba(1, 1, 1, 0.3)
// The dial that isn't measuring yet sits dimmed until it gets a figure.
readonly property bool engaged: live || value > 0
property real shown: 0
// The digital readout stays on the real figure while the ignition sweep
// drives the needle -- a cluster sweeps its gauges, not its numerals.
readonly property real reading: ignition.running ? value : shown
readonly property real fullScale: root.fullScale
readonly property real fraction: fullScale > 0 ? Math.max(0, Math.min(1, shown / fullScale)) : 0
readonly property bool arcVisible: fraction > 0.004
width: diameter
height: diameter
opacity: engaged ? 1 : 0.5
Behavior on opacity {
NumberAnimation { duration: 240; easing.type: Easing.OutCubic }
}
// Live readings land once a second; glide between them rather than snap.
Behavior on shown {
enabled: !ignition.running
NumberAnimation { duration: 600; easing.type: Easing.OutCubic }
}
onValueChanged: {
if (!ignition.running) shown = value
}
function ignite() {
ignition.restart()
}
// Car-cluster power-on: needle sweeps to full scale and falls back before
// the live figures take over.
SequentialAnimation {
id: ignition
NumberAnimation { target: dial; property: "shown"; to: dial.fullScale; duration: 550; easing.type: Easing.InOutCubic }
NumberAnimation { target: dial; property: "shown"; to: 0; duration: 650; easing.type: Easing.OutCubic }
onFinished: dial.shown = dial.value
}
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
// Track: the full scale, always visible, dim.
ShapePath {
strokeWidth: dial.arcWidth
strokeColor: dial.trackColor
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep
}
}
// Soft under-glow beneath the value arc, standing in for the backlit
// ring of a real cluster. Both arcs go transparent at rest, or their
// round caps would leave a stray dot at the foot of the scale.
ShapePath {
strokeWidth: dial.arcWidth * 3
strokeColor: dial.arcVisible ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.18) : "transparent"
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep * dial.fraction
}
}
// Value: fills behind the needle.
ShapePath {
strokeWidth: dial.arcWidth
strokeColor: dial.arcVisible ? Color.accent : "transparent"
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep * dial.fraction
}
}
}
// Faint tick ring just inside the arc; every fifth tick is a major.
Repeater {
model: dial.tickCount
Item {
required property int index
readonly property bool major: index % 5 === 0
anchors.fill: parent
rotation: dial.dialStart + (index / (dial.tickCount - 1)) * dial.dialSweep - 270
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: dial.arcWidth * 2 + (parent.major ? 0 : Style.space(2))
width: parent.major ? Math.max(2, Style.space(2)) : 1
height: parent.major ? Style.space(10) : Style.space(6)
radius: width / 2
color: parent.major ? dial.majorTickColor : dial.minorTickColor
}
}
}
// Hubless needle: a slender sliver that fades out toward the pivot, so
// it reads as floating like the rest of the cluster.
Item {
anchors.fill: parent
rotation: dial.dialStart + dial.fraction * dial.dialSweep - 270
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: dial.arcWidth * 2 + Style.space(10)
width: Math.max(2, Style.space(3))
height: dial.diameter * 0.32
radius: width / 2
gradient: Gradient {
GradientStop { position: 0.0; color: Color.accent }
GradientStop { position: 0.55; color: Color.accent }
GradientStop { position: 1.0; color: "transparent" }
}
}
}
Column {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.verticalCenter
anchors.topMargin: Style.space(14)
spacing: 0
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
// Both branches go through the locale: a reading is a measurement, so
// its separators follow the system's number conventions rather than the
// interface language. toFixed would have hardcoded a dot below 10 while
// everything above it was already grouped for the locale.
text: dial.reading < 10
? dial.reading.toLocaleString(Qt.locale(), 'f', 1)
: Math.round(dial.reading).toLocaleString(Qt.locale(), 'f', 0)
color: root.onScrim
font.family: root.fontFamily
font.pixelSize: Style.font.display
font.bold: true
}
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
text: root.unit
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// The 90° gap at the bottom of the scale is where a cluster prints its
// unit; here it names the direction.
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
text: dial.label
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.5
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
// Single-line text input with the kit's focus + selection styling. Inherits
// from Qt Quick Controls TextField so the underlying type's API (text,
// placeholderText, accepted, editingFinished, validator, ...) is available
// to callers without re-exposing each property.
//
// Defaults bind to qs.Commons.Color so a caller with no theme overrides
// just works; foreground / accent / selectionTint can be overridden per
// instance. activeFocus and mouse hover / panel cursor use the same
// hover-cursor defaults, so text inputs match Button, Toggle, and Dropdown.
//
// Sizing is driven by font.pixelSize + verticalPadding. The default 30px
// implicitHeight fits dialog forms; inline callers (wifi's row-embedded
// passphrase prompt) drop verticalPadding to match a 22-26px row.
TextField {
id: root
property color foreground: Color.foreground
property color accent: Color.accent
property color selectionTint: Style.selectionFillFor(foreground, accent)
property bool password: false
property real horizontalPadding: Style.spacing.controlPaddingX
property real verticalPadding: Style.spacing.inputPaddingY
// Panel-cursor flag. When true (and the field isn't already focused),
// the background paints the shared hover/cursor state.
// For mouse-enter/leave the consumer reads QQC TextField's inherited
// `hovered` property (via onHoveredChanged) — we don't add a sibling
// signal because the inherited property would shadow it.
property bool hasCursor: false
readonly property bool _focused: activeFocus
readonly property bool _hot: hovered || hasCursor
readonly property var _borderSpec: Border.controlSpec(_focused ? "focus" : (_hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
echoMode: password ? TextInput.Password : TextInput.Normal
font.family: Style.font.family
font.pixelSize: Style.font.body
color: foreground
selectionColor: selectionTint
selectedTextColor: foreground
placeholderTextColor: Qt.darker(foreground, 1.6)
leftPadding: horizontalPadding + Border.left(_borderSpec)
rightPadding: horizontalPadding + Border.right(_borderSpec)
topPadding: verticalPadding + Border.top(_borderSpec)
bottomPadding: verticalPadding + Border.bottom(_borderSpec)
background: BorderSurface {
color: Style.controlFill(root._focused, root._hot, root.foreground, root.accent)
borderSpec: root._borderSpec
radius: Style.cornerRadius
}
}
+117
View File
@@ -0,0 +1,117 @@
import QtQuick
import qs.Commons
// Labeled toggle row: title + optional description on the left, a
// `ToggleSwitch` on the right. Clicking anywhere on the row emits `clicked()`;
// consumers flip `checked` in response (the component is stateless about the
// actual value so it composes cleanly with model-driven UI).
//
// Cursor and focus styling match the rest of the kit: hasCursor / mouse
// hover and activeFocus share the hover-cursor defaults.
//
// `rounded` is forwarded to the switch, which auto-detects from
// Style.cornerRadius: pill shape when Hyprland corners are rounded, square on
// sharp. Callers can override per-instance.
BorderSurface {
id: root
property string label: ""
property string description: ""
property bool checked: false
// Panel-cursor flag. Same role as Button.hasCursor:
// panels with their own keyboard cursor bind this to drive the highlight
// separately from activeFocus. Visuals use the same hover-cursor tokens.
property bool hasCursor: false
// Switch shape follows the theme by default: pill on round, square on sharp.
// Override per-instance if a caller wants the opposite.
property bool rounded: Style.cornerRadius > 0
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
property real titleSize: Style.font.subtitle
property real descriptionSize: Style.font.caption
signal clicked()
signal hovered(bool isHovered)
activeFocusOnTab: true
Keys.onReturnPressed: root.clicked()
Keys.onEnterPressed: root.clicked()
Keys.onSpacePressed: root.clicked()
implicitHeight: Math.max(54, content.implicitHeight + Style.spacing.huge)
implicitWidth: Style.space(240)
radius: Style.cornerRadius
readonly property bool _hot: hasCursor || mouse.containsMouse
readonly property var _borderSpec: Border.controlSpec(activeFocus ? "focus" : (_hot ? "hover-cursor" : "normal"), foreground, accent)
color: Style.controlFill(activeFocus, _hot, foreground, accent)
borderSpec: _borderSpec
Behavior on color { ColorAnimation { duration: 100 } }
Row {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: root.borderLeft + Style.spacing.rowPaddingX
anchors.rightMargin: root.borderRight + Style.spacing.rowPaddingX
spacing: Style.spacing.rowPaddingX
Column {
width: parent.width - track.width - parent.spacing
spacing: Style.spacing.xs
anchors.verticalCenter: parent.verticalCenter
Text {
textFormat: Text.PlainText
text: root.label
color: root.foreground
font.family: root.fontFamily
font.pixelSize: root.titleSize
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
textFormat: Text.PlainText
visible: root.description !== ""
text: root.description
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: root.descriptionSize
wrapMode: Text.WordWrap
width: parent.width
}
}
// The row owns the click, so the switch is presentation only here.
ToggleSwitch {
id: track
checked: root.checked
rounded: root.rounded
foreground: root.foreground
accent: root.accent
interactive: false
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
HoverHandler {
onHoveredChanged: root.hovered(hovered)
}
}
+111
View File
@@ -0,0 +1,111 @@
import QtQuick
import qs.Commons
// Bare on/off switch: a track with a sliding knob and no label. This is the
// switch `Toggle` parks at the end of its labeled row, factored out so panel
// headers and other compact controls render the identical thing.
//
// The caller owns the value: bind `checked` to real state and flip it in
// response to `toggled()`. Services that already track a desired state
// optimistically (see the Tailscale service's `_desired`) get an instant knob
// throw for free, because `checked` is already the optimistic value.
//
// `busy` marks an operation in flight and swallows further clicks, but leaves
// hover, cursor, and tooltips alone so the control does not flicker every time
// a background refresh runs.
//
// The cursor is a ring drawn outside the track rather than a state on the
// track itself: themes give normal chrome a stronger border than hover-cursor
// (0.4 vs 0.25 by default), which is right for controls that are borderless at
// rest but would make a bordered track go *fainter* under the cursor. On the
// panel background the ring reads immediately. `cursorRing` follows
// `interactive` — a switch whose surrounding row owns the click owns the
// cursor too.
//
// `rounded` auto-detects from Style.cornerRadius so the switch follows the
// theme: pill shape when Hyprland corners are rounded, square on sharp.
Item {
id: root
property bool checked: false
property bool busy: false
// Off when the surrounding row owns the click, as in `Toggle`.
property bool interactive: true
// Panel-cursor flag. Same role as Button.hasCursor: panels with their own
// keyboard cursor bind this to drive the highlight separately from hover.
property bool hasCursor: false
property bool cursorRing: interactive
property int cursorPad: Style.space(6)
property bool rounded: Style.cornerRadius > 0
property color foreground: Color.foreground
property color accent: Color.accent
signal toggled()
signal hovered(bool isHovered)
readonly property alias containsMouse: mouse.containsMouse
readonly property bool hot: hasCursor || mouse.containsMouse
// `trackHeight` is settable so a compact placement — a switch riding a panel
// section header, say — can ask for a genuinely smaller control instead of
// scaling a big one down, which lands the track and knob on fractional pixels
// and blurs their edges. The derived sizes only carry floors low enough to
// stay out of an override's way; at the default track height each one is
// already above its floor, so nothing about the normal switch changes.
property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55))
property int trackWidth: Math.round(trackHeight * 1.9)
property int knobSize: Math.max(6, Math.round(trackHeight * 0.72))
property int knobInset: Math.max(1, Math.round((trackHeight - knobSize) / 2))
readonly property int _pad: cursorRing ? cursorPad : 0
implicitWidth: trackWidth + _pad * 2
implicitHeight: trackHeight + _pad * 2
BorderSurface {
anchors.fill: parent
visible: root.cursorRing && root.hot
color: "transparent"
radius: Style.cornerRadius
borderSpec: Border.controlSpec("hover-cursor", root.foreground, root.accent)
}
BorderSurface {
id: track
width: root.trackWidth
height: root.trackHeight
anchors.centerIn: parent
radius: root.rounded ? height / 2 : 0
color: root.checked
? Style.selectedFillFor(root.foreground, root.accent)
: Style.normalFillFor(root.foreground, root.accent)
borderSpec: Border.controlSpec(root.checked ? "selected" : "normal", root.foreground, root.accent)
Behavior on color { ColorAnimation { duration: 120 } }
Rectangle {
width: root.knobSize
height: root.knobSize
radius: root.rounded ? height / 2 : 0
x: root.checked ? track.width - width - root.knobInset : root.knobInset
anchors.verticalCenter: parent.verticalCenter
color: root.checked ? Style.selectedStateColor(root.foreground, root.accent) : Qt.darker(root.foreground, 1.25)
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 120 } }
}
}
MouseArea {
id: mouse
anchors.fill: parent
enabled: root.interactive
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: root.hovered(containsMouse)
onClicked: if (!root.busy) root.toggled()
}
}
+119
View File
@@ -0,0 +1,119 @@
import QtQuick
import qs.Commons
Item {
id: root
property var bar: null
property string text: ""
property string fontFamily: bar ? bar.fontFamily : Style.font.family
property real fontSize: Style.font.body
property color foreground: bar ? bar.barForeground : Color.foreground
property color activeColor: bar ? bar.urgent : Color.urgent
property bool active: false
property real horizontalMargin: 8.5
property real verticalPadding: 6
property real fixedWidth: -1
property real fixedHeight: -1
property real textRotation: 0
property bool keepSpace: false
property bool dimmed: false
property bool concealed: false
property bool interactive: true
property bool pressable: true
property bool useActiveColor: true
property bool maintainIndicatorReveal: false
property bool labelVisible: true
property bool hasVisualContent: text !== ""
property var revealHost: bar
property string tooltipText: ""
property var registeredBar: null
signal pressed(int button)
signal wheelMoved(int delta)
function triggerPress(button) {
if (root.bar) root.bar.hideTooltip(root)
root.pressed(button)
}
function hideOwnTooltip() {
if (root.bar) root.bar.hideTooltip(root)
}
function syncClickRegistration() {
if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(root)
registeredBar = root.bar
if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(root)
}
onBarChanged: syncClickRegistration()
onVisibleChanged: if (!visible) hideOwnTooltip()
onInteractiveChanged: if (!interactive) hideOwnTooltip()
onConcealedChanged: if (concealed) hideOwnTooltip()
Component.onCompleted: syncClickRegistration()
Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(root)
readonly property bool vertical: bar ? bar.vertical : false
readonly property int barSize: bar ? bar.barSize : Style.bar.sizeHorizontal
readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin)
readonly property real scaledVerticalPadding: Style.spaceReal(verticalPadding)
readonly property bool tooltipHovered: visible && interactive && !concealed && mouseArea.containsMouse
// Width of the painted label, for bar chrome that wants to line up with the
// text rather than with the slot it sits in. Zero on icon-only buttons.
readonly property real labelWidth: label.visible ? label.implicitWidth : 0
visible: hasVisualContent || keepSpace
opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1)
implicitWidth: fixedWidth > 0 ? fixedWidth : (vertical ? barSize : Math.max(12, label.implicitWidth + scaledHorizontalMargin * 2))
implicitHeight: fixedHeight > 0 ? fixedHeight : (vertical ? Math.max(12, label.implicitHeight + scaledVerticalPadding * 2) : barSize)
Behavior on opacity {
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
Text {
id: label
textFormat: Text.PlainText
visible: root.labelVisible
anchors.centerIn: parent
text: root.text
color: root.active && root.useActiveColor ? root.activeColor : root.foreground
font.family: root.fontFamily
font.pixelSize: root.fontSize
renderType: Text.NativeRendering
rotation: root.textRotation
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
Behavior on color {
enabled: !root.bar || root.bar.foregroundAnimationEnabled
ColorAnimation { duration: 160 }
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
enabled: root.interactive
hoverEnabled: true
cursorShape: root.pressable ? Qt.PointingHandCursor : Qt.ArrowCursor
onEntered: {
if (root.bar) {
root.bar.showTooltip(root, root.tooltipText)
}
if (root.maintainIndicatorReveal && root.revealHost && root.revealHost.setIndicatorItemHovered)
root.revealHost.setIndicatorItemHovered(true)
}
onExited: {
if (root.bar) {
root.bar.hideTooltip(root)
}
if (root.maintainIndicatorReveal && root.revealHost && root.revealHost.setIndicatorItemHovered)
root.revealHost.setIndicatorItemHovered(false)
}
onClicked: function(mouse) { if (root.pressable) root.triggerPress(mouse.button) }
onWheel: function(wheel) { root.wheelMoved(wheel.angleDelta.y) }
}
}
+35
View File
@@ -0,0 +1,35 @@
module qs.Ui
BarIndicator 1.0 BarIndicator.qml
BarIconButton 1.0 BarIconButton.qml
BarWidget 1.0 BarWidget.qml
BorderOverlay 1.0 BorderOverlay.qml
BorderSurface 1.0 BorderSurface.qml
Button 1.0 Button.qml
ButtonGroup 1.0 ButtonGroup.qml
ConfirmDialog 1.0 ConfirmDialog.qml
CursorSurface 1.0 CursorSurface.qml
Dropdown 1.0 Dropdown.qml
KeyboardPanel 1.0 KeyboardPanel.qml
MultiSelect 1.0 MultiSelect.qml
NumberField 1.0 NumberField.qml
OpticalGlyph 1.0 OpticalGlyph.qml
Panel 1.0 Panel.qml
PanelActionButton 1.0 PanelActionButton.qml
PanelController 1.0 PanelController.qml
PanelKeyCatcher 1.0 PanelKeyCatcher.qml
PanelHero 1.0 PanelHero.qml
PanelSectionHeader 1.0 PanelSectionHeader.qml
PanelSeparator 1.0 PanelSeparator.qml
PanelSlider 1.0 PanelSlider.qml
PanelToolTip 1.0 PanelToolTip.qml
PluginBarApi 1.0 PluginBarApi.qml
PointerMoveGate 1.0 PointerMoveGate.qml
ScreenMoveRemap 1.0 ScreenMoveRemap.qml
PopupCard 1.0 PopupCard.qml
SearchableDropdown 1.0 SearchableDropdown.qml
SpeedTestOverlay 1.0 SpeedTestOverlay.qml
TextField 1.0 TextField.qml
Toggle 1.0 Toggle.qml
ToggleSwitch 1.0 ToggleSwitch.qml
WidgetButton 1.0 WidgetButton.qml
+117
View File
@@ -0,0 +1,117 @@
# First-party plugins
These plugins ship with Blob and are discovered by the shell at startup.
They use the same `manifest.json` contract as third-party plugins; the
only difference is that the shell flags them with `__isFirstParty: true`.
First-party non-bar plugins are enabled unless listed in `disabledPlugins[]`;
`blob.bar` is the default bar option and becomes inactive only while another
`kind: "bar"` plugin is selected. Services and keep-loaded panels are mounted
at startup; other panels, overlays, and menus are loaded on demand.
User-installed plugins live alongside these conceptually but on disk under
`~/.config/blob/plugins/<plugin-id>/` rather than in this directory.
| Plugin | id | kinds | entry point |
|---------------|---------------------------|-------------------------|---------------------------------------|
| Bar | `blob.bar` | `bar` | `bar/Bar.qml` |
| Image picker | `blob.image-picker` | `overlay` | `image-picker/ImagePicker.qml` |
| Emojis | `blob.emojis` | `overlay` | `emojis/Emojis.qml` |
| Clipboard mgr | `blob.clipboard` | `overlay` | `clipboard/Clipboard.qml` |
| Reminders | `blob.reminders` | `overlay` | `reminders/ReminderFlow.qml` |
| Blob menu | `blob.menu` | `menu`, `bar-widget` | `menu/Menu.qml`, `menu/BarWidget.qml` |
| Notifications | `blob.notifications` | `service` | `notifications/Service.qml` |
| Audio | `blob.audio` | `bar-widget` | `panels/audio/Panel.qml` |
| Bluetooth | `blob.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` |
| Clock | `blob.clock` | `bar-widget` | `panels/clock/BarWidget.qml` |
| Monitor | `blob.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
| Network | `blob.network` | `bar-widget` | `panels/network/Panel.qml` |
| Power | `blob.power` | `bar-widget` | `panels/power/Panel.qml` |
| Weather | `blob.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
| Media | `blob.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
| Battery | `blob.battery` | `service` | `services/battery/Service.qml` |
| Idle | `blob.idle` | `service` | `services/idle/Service.qml` |
| Night light | `blob.nightlight` | `service` | `services/nightlight/Service.qml` |
| Lock screen | `blob.lock` | `service` | `lock/Service.qml` |
| OSD | `blob.osd` | `panel` | `osd/Osd.qml` |
| Polkit agent | `blob.polkit` | `service` | `polkit/PolkitAgent.qml` |
First-party bar-only widgets also carry manifests next to their QML files,
e.g. `bar/widgets/Workspaces.manifest.json`. Rich popup widgets live in their
own plugin directories, each with its own `manifest.json`.
## Bar
The built-in status bar and default full-bar option. Layout lives in the
top-level `bar:` subtree of `~/.config/blob/shell.json` (with the shell
providing [`config/blob/shell.json`](../../config/blob/shell.json) when
the user has no file). See [`bar/README.md`](bar/README.md) for the widget catalogue
and customization schema.
## Image picker
Fullscreen image-grid selector overlay. Used by `blob-menu-images`
(wallpaper picker) and `blob-theme-switcher` (theme picker) and any
other caller that wants to present a directory of images with previews.
Two ways to drive it:
- Shell-level summon: `blob-shell shell summon blob.image-picker '<jsonPayload>'`.
The payload can carry `imageDirs`, `imageRows`, `selectedImage`,
`selectionFile`, `doneFile`, `showLabels`, `filterable`. Best for
in-shell callers that already speak JSON.
- Direct IPC target: `blob-shell image-selector open <imageDirs> <imageRowsB64> <selectedImage> <selectionFile> <doneFile> <showLabels> <filterable>`.
Positional args; `imageRowsB64` is base64-encoded so embedded newlines /
tabs survive the bash argv handoff. This is what `blob-menu-images`
uses. Colors come from the central shell theme singleton; there is no
per-call override surface.
The selection round-trip remains file-based: callers create a
`selection_file` and `done_file` (both `mktemp`), pass the paths, and
poll `done_file` for existence. The plugin writes the chosen path into
`selection_file` and touches `done_file` when it's done. `cancel` IPC
clears it without writing a selection.
The plugin has `keepLoaded: true` so the layer-shell window survives
between summons within a single shell session.
## Lock screen
Session-lock surface using Quickshell's native `WlSessionLock` and two
separate PAM services: `blob-lock-password` for password auth and,
only when fingerprints are enrolled, `blob-lock-fingerprint` for
fingerprint auth. It mirrors the previous lock screen field dimensions,
colors, blurred wallpaper, placeholder, and Hyprland-driven corners.
The plugin sets `keepLoaded: true` so a plugin hot-reload (for example
an installed bar widget changing on disk) does not destroy the lock
client while Hyprland still holds the session lock.
## Polkit agent
Theme-aware authentication dialog for privileged actions. It uses
Quickshell's native `Quickshell.Services.Polkit.PolkitAgent` backend and
runs inside the long-lived `blob-shell` process, replacing the old
`polkit-gnome-authentication-agent-1` autostart.
## Blob menu
Quickshell-powered Blob command menu.
The menu UI lives in `menu/Menu.qml` as a first-party `menu` plugin and is
summoned through the shell (`blob-shell shell summon blob.menu ...`),
so it shares the long-running `blob-shell` process instead of starting a
second Quickshell instance.
The menu definition lives outside the shell host code:
- defaults: `default/blob/blob-menu.jsonc`
- user extensions: `~/.config/blob/extensions/blob-menu.jsonc`
The shell parses both JSONC files at startup (with `watchChanges: true`
so edits take effect without a restart), evaluates `when:` / `checked:`
bash expressions in a single batched subprocess, and executes the
selected `action:` string directly via `Quickshell.execDetached`. The
long-running shell process keeps the parsed menu in memory, so the
keybind → IPC → visible path costs ~30ms cold.
## Coming soon
- `blob.theme-switcher` — folds theme switching into the shell.
+323
View File
@@ -0,0 +1,323 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Commons
import qs.Ui
Item {
id: root
readonly property string home: Quickshell.env("HOME")
readonly property string stateHome: home + "/.local/state"
readonly property string currentBackgroundLink: stateHome + "/blob/current/background"
property string currentBackground: ""
property string displayedBackground: ""
property string incomingBackground: ""
property string oldBackground: ""
property bool finishingTransition: false
property int backgroundVersion: 0
property int revealStartedVersion: -1
property int pendingThemeVersion: -1
property string pendingColorsRaw: ""
property string pendingShellRaw: ""
property real revealProgress: 1
function imageUrl(path) {
return Util.fileUrl(path)
}
function refreshBackground() {
if (!readlinkProc.running) readlinkProc.running = true
}
function setBackground(path, instant) {
transitionBackground("", path, path, instant, false)
}
function transitionBackground(fromPath, path, finalPath, instant, force) {
path = String(path || "").trim()
finalPath = String(finalPath || path).trim()
fromPath = String(fromPath || "").trim()
if (!path || (!force && finalPath === currentBackground)) return
currentBackground = finalPath
backgroundVersion += 1
revealStartedVersion = -1
revealAnimation.stop()
finishingTransition = false
if (instant || !displayedBackground) {
oldBackground = ""
incomingBackground = ""
displayedBackground = path
revealProgress = 1
return
}
oldBackground = fromPath || displayedBackground
incomingBackground = path
revealProgress = 0
}
function setPendingTheme(colorsB64, shellB64) {
pendingColorsRaw = Util.decodeBase64(colorsB64)
pendingShellRaw = Util.decodeBase64(shellB64)
pendingThemeVersion = backgroundVersion
pendingThemeFallbackTimer.restart()
}
function applyPendingTheme() {
// Background polling can advance backgroundVersion while a theme switch is
// pending; the latest theme payload should still apply.
if (pendingThemeVersion < 0) return
pendingThemeFallbackTimer.stop()
Color.loadColors(pendingColorsRaw)
// Color.loadShell also refreshes Style so the type scale flips with the
// background reveal instead of waiting for a separate reload path.
Color.loadShell(pendingShellRaw)
Style.scheduleRefresh()
pendingThemeVersion = -1
pendingColorsRaw = ""
pendingShellRaw = ""
}
function transitionBackgroundWithTheme(fromPath, path, finalPath, colorsB64, shellB64) {
transitionBackground(fromPath, path, finalPath, false, true)
setPendingTheme(colorsB64, shellB64)
if (!incomingBackground || revealProgress >= 1) applyPendingTheme()
}
function startReveal(panel) {
if (!incomingBackground) return
panel.maskReady = true
if (revealStartedVersion === backgroundVersion) return
revealStartedVersion = backgroundVersion
applyPendingTheme()
revealAnimation.restart()
}
function openSelector() {
if (!bgSwitchProc.running) bgSwitchProc.running = true
}
function openThemeSwitcher() {
if (!themeSwitchProc.running) themeSwitchProc.running = true
}
Process {
id: bgSwitchProc
command: ["bash", "-c", "background=$(blob-bg-switcher); [[ -n $background ]] && blob-bg-set \"$background\""]
onExited: root.refreshBackground()
}
Process {
id: themeSwitchProc
command: ["bash", "-c", "theme=$(blob-theme-switcher); [[ -n $theme ]] && blob-theme-set \"$theme\" >/dev/null 2>&1 &"]
onExited: root.refreshBackground()
}
Process {
id: readlinkProc
command: ["readlink", "-f", root.currentBackgroundLink]
stdout: StdioCollector {
onStreamFinished: root.setBackground(String(text || "").trim(), false)
}
}
IpcHandler {
target: "background"
function refresh(): void {
root.refreshBackground()
}
function set(path: string): void {
root.setBackground(path, false)
}
function setInstant(path: string): void {
root.setBackground(path, true)
}
function transition(fromPath: string, path: string): void {
root.transitionBackground(fromPath, path, path, false, false)
}
function themeTransition(fromPath: string, path: string, finalPath: string, colorsB64: string, shellB64: string): void {
root.transitionBackgroundWithTheme(fromPath, path, finalPath, colorsB64, shellB64)
}
}
Timer {
id: pendingThemeFallbackTimer
interval: 300
repeat: false
onTriggered: root.applyPendingTheme()
}
NumberAnimation {
id: revealAnimation
target: root
property: "revealProgress"
from: 0
to: 1
duration: 420
easing.type: Easing.InOutCubic
onFinished: {
if (root.incomingBackground) {
root.displayedBackground = root.currentBackground || root.incomingBackground
root.finishingTransition = true
}
root.revealProgress = 1
}
}
Component.onCompleted: refreshBackground()
Variants {
model: Quickshell.screens
PanelWindow {
id: panel
required property var modelData
screen: modelData
visible: !remapGuard.remapping
anchors { top: true; bottom: true; left: true; right: true }
ScreenMoveRemap {
id: remapGuard
window: panel
}
color: "transparent"
// Keep render updates enabled. The background layer has been observed to
// lose its committed buffer while parked with updatesEnabled=false,
// leaving a black desktop until blob-shell is restarted. The wallpaper
// itself is static, so this favors correctness over a small render-loop
// optimization.
updatesEnabled: true
property bool maskReady: false
function maybeStartReveal() {
if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
Qt.callLater(function() {
if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
root.startReveal(panel)
})
}
WlrLayershell.namespace: "blob-background"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Image {
id: base
anchors.fill: parent
source: root.imageUrl(root.displayedBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
onStatusChanged: {
if (status === Image.Ready && root.finishingTransition) {
root.incomingBackground = ""
root.oldBackground = ""
root.finishingTransition = false
}
}
}
Image {
id: oldFrame
anchors.fill: parent
source: root.imageUrl(root.oldBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
smooth: true
mipmap: true
visible: root.oldBackground !== "" && root.revealProgress < 1
onStatusChanged: panel.maybeStartReveal()
}
Item {
id: incomingLayer
anchors.fill: parent
visible: root.incomingBackground !== "" && incomingFrame.status === Image.Ready && (root.revealProgress >= 1 || panel.maskReady)
layer.enabled: root.incomingBackground !== "" && root.revealProgress < 1
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: revealMask
maskThresholdMin: 0.5
maskSpreadAtMin: 0.02
}
Image {
id: incomingFrame
anchors.fill: parent
source: root.imageUrl(root.incomingBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
smooth: true
mipmap: true
onStatusChanged: panel.maybeStartReveal()
}
}
Item {
id: revealMask
anchors.fill: parent
visible: false
layer.enabled: true
readonly property real slant: -0.18
readonly property real centerTop: width / 2 - slant * height / 2
readonly property real centerBottom: width / 2 + slant * height / 2
readonly property real reach: width / 2 + Math.abs(slant) * height / 2 + 4
readonly property real spread: reach * root.revealProgress
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "white"
strokeColor: "transparent"
startX: revealMask.centerTop - revealMask.spread; startY: 0
PathLine { x: revealMask.centerTop + revealMask.spread; y: 0 }
PathLine { x: revealMask.centerBottom + revealMask.spread; y: revealMask.height }
PathLine { x: revealMask.centerBottom - revealMask.spread; y: revealMask.height }
PathLine { x: revealMask.centerTop - revealMask.spread; y: 0 }
}
}
}
Connections {
target: root
function onIncomingBackgroundChanged() {
panel.maskReady = false
panel.maybeStartReveal()
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onDoubleClicked: function(mouse) {
if (mouse.button === Qt.RightButton) root.openThemeSwitcher()
else root.openSelector()
mouse.accepted = true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.background",
"name": "Background",
"version": "1.0.0",
"author": "Blob",
"description": "Desktop background renderer with click handling and transitions",
"kinds": [
"service"
],
"entryPoints": {
"service": "Background.qml"
}
}
File diff suppressed because it is too large Load Diff
+231
View File
@@ -0,0 +1,231 @@
function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function normalizePosition(value) {
var next = String(value || "").trim()
return /^(top|bottom|left|right)$/.test(next) ? next : "top"
}
function entrySettings(entry) {
if (!isPlainObject(entry)) return {}
var copy = {}
for (var key in entry) {
if (key === "id") continue
copy[key] = entry[key]
}
return copy
}
function entryId(entry) {
if (typeof entry === "string") return entry
if (isPlainObject(entry)) {
var id = entry["id"]
if (id !== undefined && id !== null && String(id) !== "") return String(id)
}
return ""
}
function pinTrayToInner(entries, section) {
var trayEntry = null
var result = []
var values = Array.isArray(entries) ? entries : []
for (var i = 0; i < values.length; i++) {
if (entryId(values[i]) === "blob.tray") trayEntry = values[i]
else result.push(values[i])
}
if (trayEntry) {
if (section === "right") result.unshift(trayEntry)
else result.push(trayEntry)
}
return result
}
function moduleString(entry, key, fallback) {
var settings = entrySettings(entry)
var value = settings[key]
return value === undefined || value === null ? fallback : String(value)
}
function entryIndex(entries, name) {
if (!Array.isArray(entries)) return -1
for (var i = 0; i < entries.length; i++) {
if (entryId(entries[i]) === name) return i
}
return -1
}
function entriesBefore(entries, name) {
var index = entryIndex(entries, name)
return index <= 0 ? [] : entries.slice(0, index)
}
function entriesAfter(entries, name) {
var index = entryIndex(entries, name)
return index === -1 ? [] : entries.slice(index + 1)
}
// A shell.json write that only changes inline widget settings (the battery
// percentage toggle, a clock format change) must not rebuild the bar.
// Compare two normalized layouts: when the structure is unchanged — same
// entry ids in the same order per region — return the settings-only changes
// as {region, index, entry}. Return null when the change is structural, or
// touches an entry a live settings push cannot safely reach: custom modules
// read their entry directly rather than an injected settings property, and
// a duplicated id makes the push ambiguous.
function inlineSettingsDelta(current, next) {
if (!isPlainObject(current) || !isPlainObject(next)) return null
var regions = ["left", "center", "right"]
var counts = {}
for (var r = 0; r < regions.length; r++) {
var entries = Array.isArray(next[regions[r]]) ? next[regions[r]] : []
for (var i = 0; i < entries.length; i++) {
var id = entryId(entries[i])
counts[id] = (counts[id] || 0) + 1
}
}
var changes = []
for (var s = 0; s < regions.length; s++) {
var region = regions[s]
var a = Array.isArray(current[region]) ? current[region] : []
var b = Array.isArray(next[region]) ? next[region] : []
if (a.length !== b.length) return null
for (var j = 0; j < a.length; j++) {
if (entryId(a[j]) !== entryId(b[j])) return null
if (JSON.stringify(a[j]) === JSON.stringify(b[j])) continue
if (customModuleType(a[j]) || customModuleType(b[j])) return null
if (counts[entryId(b[j])] > 1) return null
changes.push({ region: region, index: j, entry: b[j] })
}
}
return changes
}
function expandPath(value, home) {
var path = String(value || "")
if (path === "") return ""
if (path.indexOf("~/") === 0) return home + path.substring(1)
if (path.indexOf("$HOME/") === 0) return home + path.substring(5)
return path
}
function customModuleSafeName(name) {
var value = String(name || "")
return value !== "" && value.indexOf("..") === -1 && value[0] !== "/"
}
function customModuleType(entry) {
var settings = entrySettings(entry)
var type = String(settings.type || "")
if (type) return type
if (settings.exec) return "command"
if (settings.source) return "qml"
return ""
}
function customModulePath(entry, home, configDir) {
var settings = entrySettings(entry)
var name = entryId(entry)
var source = settings.source ? expandPath(settings.source, home) : ""
if (!source && customModuleSafeName(name))
source = String(configDir || "") + "/bar/modules/" + String(name) + ".qml"
return source
}
// A center module is mounted twice once an anchor is set: the copy that is
// actually drawn, and a zero-size placeholder holding its place in the flow
// beside the anchor. Panel routing has to pick the drawn one — it is the only
// one that can anchor a popup, carry the open-panel mark, or be found again
// by switchPanelFrom — and fall back to the placeholder only when nothing is
// on screen. The order the two are registered in is not stable across a live
// bar reconfiguration, so picking the first match is not good enough.
function isDrawnSlot(slot) {
return !!slot && slot.visible === true && slot.width > 0 && slot.height > 0
}
function pickDrawnSlot(slots) {
var placeholder = null
var list = slots || []
for (var i = 0; i < list.length; i++) {
if (!list[i]) continue
if (isDrawnSlot(list[i])) return list[i]
if (!placeholder) placeholder = list[i]
}
return placeholder
}
// A bar surface is built per monitor, so a panel hotkey has several live
// copies of the same widget to route to, and the panel opens on whichever
// monitor's copy answers. Candidates are `{ slot, screenName, opened }`.
//
// An open copy wins first: hide and toggle have to reach the panel the user
// can actually see, wherever it was opened from. Otherwise the focused
// monitor's copy wins, so a summon lands where the user is working instead of
// on whichever output registered its slot first. Neither narrowing applies on
// a single monitor, or when the focused output has no bar of its own.
function pickPanelSlot(candidates, focusedScreen) {
var rows = Array.isArray(candidates) ? candidates : []
var pool = rows.filter(function(row) { return row && row.opened === true })
if (pool.length === 0) pool = rows.filter(function(row) { return !!row })
var focused = String(focusedScreen || "")
if (focused) {
var onFocused = pool.filter(function(row) { return row.screenName === focused })
if (onFocused.length > 0) pool = onFocused
}
return pickDrawnSlot(pool.map(function(row) { return row.slot }))
}
// Resolve a pointer anywhere along the bar to the closest insertion edge.
// Requiring the pointer to sit inside another widget makes the empty space
// around a centered group a dead zone, even though it visually reads as the
// most natural place to drop.
function nearestDropTarget(candidates, point, vertical) {
var rows = Array.isArray(candidates) ? candidates : []
var axis = vertical ? Number(point && point.y) : Number(point && point.x)
if (!isFinite(axis)) return null
var best = null
var bestDistance = Infinity
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
if (!row || !row.slot) continue
var start = Number(vertical ? row.y : row.x)
var size = Number(vertical ? row.height : row.width)
if (!isFinite(start) || !isFinite(size) || size <= 0) continue
var beforeDistance = Math.abs(axis - start)
var afterDistance = Math.abs(axis - (start + size))
var after = afterDistance < beforeDistance
var distance = after ? afterDistance : beforeDistance
if (distance < bestDistance) {
best = { slot: row.slot, after: after }
bestDistance = distance
}
}
return best
}
if (typeof module !== "undefined") {
module.exports = {
isDrawnSlot: isDrawnSlot,
pickDrawnSlot: pickDrawnSlot,
pickPanelSlot: pickPanelSlot,
nearestDropTarget: nearestDropTarget,
normalizePosition: normalizePosition,
entrySettings: entrySettings,
entryId: entryId,
pinTrayToInner: pinTrayToInner,
moduleString: moduleString,
entryIndex: entryIndex,
entriesBefore: entriesBefore,
entriesAfter: entriesAfter,
inlineSettingsDelta: inlineSettingsDelta,
expandPath: expandPath,
customModuleSafeName: customModuleSafeName,
customModuleType: customModuleType,
customModulePath: customModulePath
}
}
+181
View File
@@ -0,0 +1,181 @@
# Blob bar
This is the Quickshell implementation of the Blob status bar. It is
shipped as a first-party plugin of [`blob-shell`](../../README.md), the
long-running shell host. The bar is mounted at startup and lives inside
the shell for its whole session.
- `manifest.json` declares the plugin (`id: blob.bar`, `kind: bar`) and points at `Bar.qml` as the entry point.
- `Bar.qml` is Blob-owned bar engine code, loaded by the blob-shell host. Users should not edit it directly.
- `widgets/` holds simple first-party bar widgets with sibling manifests.
- Feature plugins such as `../panels/audio/`, `../panels/network/`, and `../panels/power/` provide richer popup bar plugins.
- The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/blob/shell.json` (or `config/blob/shell.json` when the user has no file).
- `blob bar position` updates only the user shell.json file.
## Customizing
The bar config lives under the `bar:` key of [`~/.config/blob/shell.json`](../../README.md#shelljson-shape). Out of the box the shell uses [`config/blob/shell.json`](../../../config/blob/shell.json). Once you customize anything via the bar gestures, `blob bar ...`, or by editing shell.json directly, your file is canonical — there is no deep-merge.
The bar is configured directly on the bar itself: drag empty bar space (or click-and-hold) to move the bar to another screen edge, double-left-click empty center-bar space to toggle transparency, and drag widgets to reorder them. The `blob bar position`, `blob bar transparent`, `blob bar move`, and `blob bar set` commands do the same from scripts. Enable or disable widgets with `blob plugin enable` and `blob plugin disable` (widget ids come from `blob plugin list`).
Example `shell.json` (bar subtree only shown):
```json
{
"version": 1,
"bar": {
"position": "top",
"transparent": false,
"centerAnchor": "blob.clock",
"layout": {
"left": [
{ "id": "blob.menu" },
{ "id": "blob.spacer", "size": 12 },
{ "id": "blob.workspaces" }
],
"center": [
{ "id": "blob.media" },
{ "id": "blob.clock", "format": "HH:mm" }
],
"right": [
{ "id": "blob.audio" },
{ "id": "blob.power" }
]
}
}
}
```
`centerAnchor` pins one center module to the exact horizontal/vertical center and flanks others around it. Set to an empty string to disable anchoring (the center list is centered as a group).
## Module catalogue
### First-party interactive widgets
| Name | What it does | Interactions |
|---|---|---|
| `blob.menu` | Blob menu launcher | left = menu · right = terminal |
| `blob.workspaces` | Hyprland workspace switcher | left = focus workspace |
| `blob.clock` | Date/time label + popup with a month grid, ISO week numbers, and month stepping | left = popup · right = cycle label format · middle = timezone selector |
| `blob.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup |
| `blob.indicators` | Manual state indicators | left = indicator action |
| `blob.system-update` | Available update indicator | left = update |
| `blob.tray` | System tray | hover = reveal drawer · right on chevron = manage |
| `blob.weather` | Weather icon + popup with forecast | left = popup · right = full notification |
| `blob.microphone` | Mic icon + scroll volume | left = mute toggle · middle = audio panel · scroll = source volume |
| `blob.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume |
| `blob.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup |
| `blob.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup · right = toggle percentage |
| `blob.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio |
| `blob.monitor` | Brightness and laptop display controls | left = popup |
The `blob.indicators` widget loads individual bar indicators from `indicators/`. Omit `items` (or set it to an empty array) to show all indicators in the default order, or set `items` to a subset such as `["Dnd", "Reminder", "NightLight"]`. Set `alwaysShow` to `true` to keep inactive indicators visible instead of revealing them only on hover. Multiple `blob.indicators` instances are allowed, so different sections can show different subsets.
## Orientation
All widgets work in `top`, `bottom`, `left`, and `right` positions. Popups anchor on the side opposite the bar edge, sliding into the workspace. Vertical bars use 28px width; widgets that show text fall back to compact icon-only forms (e.g. `media` hides its scrolling label).
## Custom user modules
The schema accepts arbitrary module ids that you provide. Set `type` to `command` for shell-driven output or `qml` for a custom QML widget. Both still go under `bar.layout.<section>` in `shell.json`.
Command module:
```json
{
"version": 1,
"bar": {
"layout": {
"right": [
{ "id": "blob.tray" },
{ "id": "vpn", "type": "command", "exec": "~/.config/blob/bar/scripts/vpn-status", "interval": 5, "tooltip": "VPN", "onClick": "nm-connection-editor" },
{ "id": "blob.audio" }
]
}
}
}
```
The command may print plain text or Waybar-style JSON, for example:
```json
{"text":"󰌆","tooltip":"Work VPN","class":"active"}
```
QML module:
```json
{
"version": 1,
"bar": {
"layout": {
"right": [
{ "id": "gpu", "type": "qml" },
{ "id": "blob.audio" }
]
}
}
}
```
Then create `~/.config/blob/bar/modules/gpu.qml`. If you want to store it elsewhere, add a `source` path.
Custom QML modules should be an `Item` with `implicitWidth` and `implicitHeight`. They may optionally define these properties, which the bar fills after loading:
```qml
import QtQuick
Item {
property var bar
property string moduleName
property var settings
implicitWidth: 28
implicitHeight: bar ? bar.barSize : 26
Text {
anchors.centerIn: parent
text: "GPU"
color: bar ? bar.foreground : "white"
font.family: bar ? bar.fontFamily : "monospace"
font.pixelSize: 12
}
MouseArea {
anchors.fill: parent
onClicked: if (bar) bar.run("blob-launch-or-focus-tui btop")
}
}
```
## Bar properties available to widgets
Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (object) injected at load time. The bar exposes:
- `bar.foreground`, `bar.background`, `bar.urgent` — theme colors (live-updated)
- `bar.fontFamily` — current monospace family
- `bar.position``"top" | "bottom" | "left" | "right"`
- `bar.vertical` — boolean shortcut
- `bar.barSize` — 26 horizontal / 28 vertical
- `bar.run(command)` — fire-and-forget bash exec
- `bar.shellQuote(value)` — safe shell-quote a string
- `bar.showTooltip(target, text)` / `bar.hideTooltip(target)` — shared tooltip popup
- `bar.requestPopout(owner)` / `bar.releasePopout(owner)` — one-popup-at-a-time coordinator
First-party bar widgets are manifest-backed just like third-party widgets.
Simple widgets carry sibling manifests such as `widgets/Workspaces.manifest.json`;
richer popup plugins live in feature directories such as `../panels/audio/`,
and `../panels/network/`; and feature plugins such as
`blob.menu` and `blob.media` declare their bar-widget entry points in their own
`manifest.json`. Bar layout ids are namespaced, e.g. `blob.audio`,
`blob.network`, and `blob.clock`. Older UpperCamelCase ids such as
`AudioPanel` and `Clock` are migrated forward; new configs should use the
namespaced ids.
Third-party widgets ship as separate plugins under
`~/.config/blob/plugins/<plugin-id>/` with their own `manifest.json`
declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See
[../../README.md](../../README.md) for the manifest schema. Rescan, enable,
and place third-party plugins with `blob-shell shell rescanPlugins`,
`blob plugin enable`, and `blob bar move`.
@@ -0,0 +1,38 @@
import QtQuick
import Quickshell.Io
import qs.Ui
BarIndicator {
id: root
property string state: "idle"
property string icon: ""
active: state === "recording"
activeText: icon
inactiveText: "󰍬"
activeTooltipText: state
inactiveTooltipText: "Dictate"
function update(raw) {
var data = extractData(raw)
state = String(data.alt || data.class || "idle")
if (state === "recording") icon = "󰍬"
else if (state === "transcribing") icon = "󰔟"
else icon = ""
}
Process {
command: ["bash", "-c", "blob-voxtype-status"]
running: true
stdout: SplitParser {
onRead: function(data) { root.update(data) }
}
}
onPressed: function() {
if (!root.bar) return
root.bar.run("blob-voxtype-config")
}
}
+22
View File
@@ -0,0 +1,22 @@
import QtQuick
import qs.Commons
import qs.Ui
BarIndicator {
id: root
readonly property var notificationService: bar?.shell?.firstPartyServiceFor("blob.notifications")
readonly property bool dnd: notificationService ? notificationService.doNotDisturb : false
active: dnd
activeText: "󰂛"
inactiveText: "󰂛"
activeTooltipText: "Allow Notifications"
inactiveTooltipText: "Silence Notifications"
onPressed: function() {
if (root.notificationService) {
root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb)
}
}
}
@@ -0,0 +1,20 @@
import QtQuick
import qs.Ui
BarIndicator {
id: root
readonly property var nightlightService: bar?.shell?.firstPartyServiceFor("blob.nightlight")
active: nightlightService ? nightlightService.enabled : false
activeText: "󰔎"
inactiveText: "󰔎"
activeTooltipText: "Day Light"
inactiveTooltipText: "Night Light"
function toggle() {
if (root.nightlightService) root.nightlightService.setNightlight(!root.active)
}
onPressed: function() { root.toggle() }
}
+59
View File
@@ -0,0 +1,59 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Ui
BarIndicator {
id: root
property int reminderCount: 0
property string tooltip: ""
active: reminderCount > 0
activeText: "󰢌"
inactiveText: "󰢌"
activeTooltipText: tooltip
inactiveTooltipText: tooltip
function refresh() {
if (!jsonProc.running) jsonProc.running = true
}
function openReminderFlow() {
Quickshell.execDetached(["blob-reminder", "-i"])
}
function update(raw) {
var data = extractData(raw)
reminderCount = Number(data.count || 0)
tooltip = String(data.tooltip || "")
}
Component.onCompleted: refresh()
Connections {
target: root.indicatorHost
ignoreUnknownSignals: true
function onRefreshRequested() { root.refresh() }
}
Process {
id: jsonProc
command: ["blob-reminder", "show", "--json"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.update(text)
}
onExited: function(exitCode) {
if (exitCode !== 0) {
root.reminderCount = 0
root.tooltip = ""
}
}
}
onPressed: function() {
if (root.reminderCount > 0) Quickshell.execDetached(["blob-reminder", "show"])
else root.openReminderFlow()
}
}
@@ -0,0 +1,43 @@
import QtQuick
import Quickshell.Io
import qs.Ui
BarIndicator {
id: root
property bool recording: false
active: recording
activeText: "󰻂"
inactiveText: "󰻂"
activeTooltipText: "Stop recording"
inactiveTooltipText: "Screen Recording"
function refresh() {
if (!root.bar || statusProc.running) return
statusProc.command = ["pgrep", "--quiet", "-f", "^gpu-screen-recorder"]
statusProc.running = true
}
onBarChanged: refresh()
Component.onCompleted: refresh()
Connections {
target: root.indicatorHost
ignoreUnknownSignals: true
function onRefreshRequested() { root.refresh() }
}
Process {
id: statusProc
onExited: function(exitCode) {
root.recording = exitCode === 0
}
}
onPressed: function() {
if (root.bar) {
root.bar.run(root.recording ? "blob-capture-record --stop-recording" : "blob-menu toggle trigger.capture.screenrecord")
}
}
}
@@ -0,0 +1,20 @@
import QtQuick
import qs.Ui
BarIndicator {
id: root
readonly property var idleService: bar?.shell?.firstPartyServiceFor("blob.idle")
active: idleService ? idleService.stayAwake : false
activeText: "󰅶"
inactiveText: "󰅶"
activeTooltipText: "Allow Idle Lock & Screensaver"
inactiveTooltipText: "Stay Awake"
function toggle() {
if (root.idleService) root.idleService.setIdleEnabled(root.active)
}
onPressed: function() { root.toggle() }
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.bar",
"name": "Bar",
"version": "1.0.0",
"author": "Blob",
"description": "Status bar with widgets",
"kinds": [
"bar"
],
"entryPoints": {
"bar": "Bar.qml"
}
}
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "blob.active-window",
"name": "Active window",
"version": "1.0.0",
"author": "Blob",
"description": "Title of the focused window",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "ActiveWindow.qml"
},
"barWidget": {
"displayName": "Active window",
"description": "Title of the focused window",
"category": "Compositor",
"allowMultiple": false,
"defaultSection": "left"
}
}
@@ -0,0 +1,64 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.active-window"
readonly property var toplevel: ToplevelManager.activeToplevel
readonly property string title: toplevel ? (toplevel.title || toplevel.appId || "") : ""
readonly property int maxLabelWidth: Number(setting("maxWidth", 280))
visible: title !== "" && !vertical
implicitWidth: visible ? Math.min(maxLabelWidth, labelText.implicitWidth) + Style.spacing.controlPaddingX * 2 : 0
implicitHeight: barSize
Behavior on implicitWidth {
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
}
Item {
anchors.fill: parent
anchors.leftMargin: Style.space(8)
anchors.rightMargin: Style.space(8)
clip: true
Text {
id: labelText
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: parent.width
text: root.title
color: root.bar ? root.bar.barForeground : Color.foreground
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body
elide: Text.ElideRight
opacity: 0.85
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function(mouse) {
if (!root.toplevel) return
if (mouse.button === Qt.MiddleButton) {
root.toplevel.close()
} else if (mouse.button === Qt.RightButton) {
root.toplevel.close()
} else {
root.toplevel.activate()
}
}
onEntered: if (root.bar) root.bar.showTooltip(root, root.title)
onExited: if (root.bar) root.bar.hideTooltip(root)
}
}
@@ -0,0 +1,78 @@
{
"schemaVersion": 1,
"id": "blob.indicators",
"name": "Indicators",
"version": "1.0.0",
"author": "Blob",
"description": "Manual state indicators",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Indicators.qml"
},
"barWidget": {
"displayName": "Indicators",
"description": "Manual state indicators",
"category": "Status",
"allowMultiple": true,
"schema": [
{
"key": "items",
"type": "multiselect",
"label": "Indicators",
"description": "Choose which indicators this widget instance should show. Leave empty to show all indicators.",
"noSelectionText": "All indicators",
"placeholderText": "Search indicators...",
"emptyText": "No indicators",
"options": [
{
"value": "Dictation",
"label": "Dictation",
"description": "Voice typing status"
},
{
"value": "ScreenRecording",
"label": "Screen recording",
"description": "GPU screen recorder status"
},
{
"value": "Reminder",
"label": "Reminder",
"description": "Queued reminder status"
},
{
"value": "NightLight",
"label": "Night light",
"description": "Blue-light filter"
},
{
"value": "Dnd",
"label": "Do not disturb",
"description": "Notification silencing"
},
{
"value": "StayAwake",
"label": "Stay awake",
"description": "Idle lock and screensaver override"
}
]
},
{
"key": "alwaysShow",
"type": "boolean",
"label": "Always Show",
"description": "Show inactive indicators without waiting for hover.",
"defaultValue": false
}
]
},
"blob": {
"clonePaths": [
{
"source": "../indicators",
"target": "indicators"
}
]
}
}
+471
View File
@@ -0,0 +1,471 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.indicators"
readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
property var activeIndicatorIds: []
property var indicatorActiveStates: ({})
property bool indicatorAreaHovered: false
property bool indicatorItemHovered: false
readonly property bool alwaysShowIndicators: setting("alwaysShow", false) === true
readonly property bool revealInactiveIndicators: alwaysShowIndicators || indicatorAreaHovered || indicatorItemHovered || (bar && bar.centerSectionRevealHeld === true && bar.centerHoverRevealSuppressed !== true)
signal refreshRequested()
ListModel { id: activeIndicatorModel }
function entryId(entry) {
if (typeof entry === "string") return entry
if (Util.isPlainObject(entry)) {
var id = entry["id"]
if (id !== undefined && id !== null && String(id) !== "") return String(id)
}
return ""
}
function entrySettings(entry) {
if (!Util.isPlainObject(entry)) return {}
var copy = {}
for (var key in entry) {
if (key === "id") continue
copy[key] = entry[key]
}
return copy
}
function indicatorEntriesFromSettings(settings) {
var source = defaultIndicatorEntries
if (settings.items && typeof settings.items.length === "number" && settings.items.length > 0) source = settings.items
else if (settings.indicators && typeof settings.indicators.length === "number" && settings.indicators.length > 0) source = settings.indicators
var result = []
for (var i = 0; i < source.length; i++) {
var item = source[i]
if (typeof item !== "string" && item !== null && typeof item === "object") {
try {
item = JSON.parse(JSON.stringify(item))
} catch (error) {
}
}
var id = entryId(item)
if (id !== "") result.push(item)
}
return result
}
function setIndicatorAreaHovered(hovered) {
indicatorAreaHovered = hovered
if (hovered) indicatorHideTimer.stop()
else indicatorHideTimer.restart()
}
function setIndicatorItemHovered(hovered) {
if (hovered) {
indicatorItemHovered = true
indicatorHideTimer.stop()
} else {
indicatorHideTimer.restart()
}
}
function hasIndicatorId(id) {
for (var i = 0; i < indicatorEntries.length; i++) {
if (entryId(indicatorEntries[i]) === id) return true
}
return false
}
function entryForId(id) {
for (var i = 0; i < indicatorEntries.length; i++) {
var entry = indicatorEntries[i]
if (entryId(entry) === id) return entry
}
return { id: id }
}
function activeModelIndex(id) {
for (var i = 0; i < activeIndicatorModel.count; i++) {
if (activeIndicatorModel.get(i).activeId === id) return i
}
return -1
}
function copyActiveStates() {
var states = {}
for (var id in indicatorActiveStates) {
if (indicatorActiveStates[id] === true) states[id] = true
}
return states
}
function orderedActiveIds(states, preferredOrder) {
var ids = []
for (var i = 0; i < preferredOrder.length; i++) {
var id = preferredOrder[i]
if (ids.indexOf(id) === -1 && hasIndicatorId(id) && states[id] === true) ids.push(id)
}
return ids
}
function syncActiveIndicatorModel() {
for (var i = activeIndicatorModel.count - 1; i >= 0; i--) {
if (activeIndicatorIds.indexOf(activeIndicatorModel.get(i).activeId) === -1)
activeIndicatorModel.remove(i)
}
for (var j = 0; j < activeIndicatorIds.length; j++) {
var id = activeIndicatorIds[j]
var index = activeModelIndex(id)
if (index === -1) activeIndicatorModel.insert(j, { activeId: id })
else if (index !== j) activeIndicatorModel.move(index, j, 1)
}
}
function setIndicatorActive(entry, active) {
var id = entryId(entry)
if (id === "") return
var states = copyActiveStates()
if (active) states[id] = true
else delete states[id]
indicatorActiveStates = states
var ids = orderedActiveIds(states, activeIndicatorIds)
// The active block sits closest to the clock, so newcomers go on the far
// side of it. Appending would shove everything already showing sideways.
if (active && ids.indexOf(id) === -1 && hasIndicatorId(id)) ids.unshift(id)
activeIndicatorIds = ids
syncActiveIndicatorModel()
}
function syncActiveIndicatorOrder() {
activeIndicatorIds = orderedActiveIds(indicatorActiveStates, activeIndicatorIds)
syncActiveIndicatorModel()
}
function refresh() { root.refreshRequested() }
onIndicatorEntriesChanged: syncActiveIndicatorOrder()
implicitWidth: root.vertical
? Math.max(activeVerticalBlock.implicitWidth, inactiveVerticalArea.implicitWidth)
: activeHorizontalBlock.implicitWidth + inactiveHorizontalArea.implicitWidth
implicitHeight: root.vertical
? activeVerticalBlock.implicitHeight + inactiveVerticalArea.implicitHeight
: Math.max(activeHorizontalBlock.implicitHeight, inactiveHorizontalArea.implicitHeight)
IpcHandler {
target: "blob.indicators"
function refresh(): void {
root.broadcast("refresh")
}
}
Timer {
id: indicatorHideTimer
interval: 120
onTriggered: {
if (!root.indicatorAreaHovered)
root.indicatorItemHovered = false
}
}
Component.onCompleted: root.refreshRequested()
Row {
id: horizontalIndicators
visible: !root.vertical
spacing: 0
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
Item {
id: inactiveHorizontalArea
implicitWidth: root.revealInactiveIndicators ? inactiveHorizontalBlock.implicitWidth : 0
implicitHeight: Math.max(inactiveHorizontalBlock.implicitHeight, root.barSize)
width: implicitWidth
height: implicitHeight
clip: true
IndicatorBlock {
id: inactiveHorizontalBlock
anchors.verticalCenter: parent.verticalCenter
indicatorsModule: root
indicatorEntries: root.indicatorEntries
indicatorBlock: "inactive"
horizontal: true
reportActiveState: !root.vertical
}
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
}
ActiveIndicatorBlock {
id: activeHorizontalBlock
indicatorsModule: root
indicatorModel: activeIndicatorModel
horizontal: true
reportActiveState: !root.vertical
}
}
Column {
id: verticalIndicators
visible: root.vertical
spacing: 0
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
Item {
id: inactiveVerticalArea
implicitWidth: Math.max(inactiveVerticalBlock.implicitWidth, root.barSize)
implicitHeight: root.revealInactiveIndicators ? inactiveVerticalBlock.implicitHeight : 0
width: implicitWidth
height: implicitHeight
clip: true
IndicatorBlock {
id: inactiveVerticalBlock
anchors.horizontalCenter: parent.horizontalCenter
indicatorsModule: root
indicatorEntries: root.indicatorEntries
indicatorBlock: "inactive"
horizontal: false
reportActiveState: root.vertical
}
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
}
ActiveIndicatorBlock {
id: activeVerticalBlock
indicatorsModule: root
indicatorModel: activeIndicatorModel
horizontal: false
reportActiveState: root.vertical
}
}
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
component ActiveIndicatorBlock: Item {
id: activeIndicatorBlockRoot
property var indicatorModel: null
property var indicatorsModule: null
property bool horizontal: true
property bool reportActiveState: false
implicitWidth: blockLoader.item ? blockLoader.item.implicitWidth : 0
implicitHeight: blockLoader.item ? blockLoader.item.implicitHeight : 0
width: implicitWidth
height: implicitHeight
Loader {
id: blockLoader
anchors.centerIn: parent
sourceComponent: activeIndicatorBlockRoot.horizontal ? horizontalActiveIndicatorBlock : verticalActiveIndicatorBlock
}
Component {
id: horizontalActiveIndicatorBlock
Row {
spacing: 0
Repeater {
model: activeIndicatorBlockRoot.indicatorModel
IndicatorLoader {
required property string activeId
indicatorsModule: activeIndicatorBlockRoot.indicatorsModule
entry: activeIndicatorBlockRoot.indicatorsModule.entryForId(activeId)
indicatorBlock: "active"
reportActiveState: activeIndicatorBlockRoot.reportActiveState
}
}
}
}
Component {
id: verticalActiveIndicatorBlock
Column {
spacing: 0
Repeater {
model: activeIndicatorBlockRoot.indicatorModel
IndicatorLoader {
required property string activeId
indicatorsModule: activeIndicatorBlockRoot.indicatorsModule
entry: activeIndicatorBlockRoot.indicatorsModule.entryForId(activeId)
indicatorBlock: "active"
reportActiveState: activeIndicatorBlockRoot.reportActiveState
}
}
}
}
}
component IndicatorBlock: Item {
id: indicatorBlockRoot
property var indicatorEntries: []
property var indicatorsModule: null
property string indicatorBlock: "active"
property bool horizontal: true
property bool reportActiveState: false
implicitWidth: blockLoader.item ? blockLoader.item.implicitWidth : 0
implicitHeight: blockLoader.item ? blockLoader.item.implicitHeight : 0
width: implicitWidth
height: implicitHeight
Loader {
id: blockLoader
anchors.centerIn: parent
sourceComponent: indicatorBlockRoot.horizontal ? horizontalIndicatorBlock : verticalIndicatorBlock
}
Component {
id: horizontalIndicatorBlock
Row {
spacing: 0
Repeater {
model: indicatorBlockRoot.indicatorEntries
IndicatorLoader {
required property var modelData
indicatorsModule: indicatorBlockRoot.indicatorsModule
entry: modelData
indicatorBlock: indicatorBlockRoot.indicatorBlock
reportActiveState: indicatorBlockRoot.reportActiveState
}
}
}
}
Component {
id: verticalIndicatorBlock
Column {
spacing: 0
Repeater {
model: indicatorBlockRoot.indicatorEntries
IndicatorLoader {
required property var modelData
indicatorsModule: indicatorBlockRoot.indicatorsModule
entry: modelData
indicatorBlock: indicatorBlockRoot.indicatorBlock
reportActiveState: indicatorBlockRoot.reportActiveState
}
}
}
}
}
component IndicatorLoader: Item {
id: indicatorSlot
required property var entry
property var indicatorsModule: null
required property string indicatorBlock
property bool reportActiveState: false
property bool activeStateObserved: false
readonly property string indicatorId: root.entryId(entry)
readonly property var indicatorSettings: root.entrySettings(entry)
readonly property var barRef: root.bar
implicitWidth: indicatorSource.item && indicatorSource.item.visible ? indicatorSource.item.implicitWidth : 0
implicitHeight: indicatorSource.item && indicatorSource.item.visible ? indicatorSource.item.implicitHeight : 0
width: implicitWidth
height: implicitHeight
onEntryChanged: {
activeStateObserved = false
injectProps()
syncActiveState()
}
onIndicatorBlockChanged: injectProps()
onIndicatorSettingsChanged: injectProps()
onIndicatorsModuleChanged: {
injectProps()
syncActiveState()
}
onReportActiveStateChanged: syncActiveState()
onBarRefChanged: injectProps()
Loader {
id: indicatorSource
anchors.fill: parent
source: indicatorSlot.indicatorId ? Qt.resolvedUrl("../indicators/" + indicatorSlot.indicatorId + ".qml") : ""
onLoaded: {
indicatorSlot.injectProps()
indicatorSlot.syncActiveState()
}
onStatusChanged: if (status === Loader.Error) console.warn("Indicator loader error", indicatorSlot.indicatorId, source)
}
Connections {
target: indicatorSource.item
ignoreUnknownSignals: true
function onActiveChanged() { indicatorSlot.syncActiveState() }
}
function injectProps() {
var target = indicatorSource.item
if (!target) return
if ("bar" in target) target.bar = root.bar
if ("moduleName" in target) target.moduleName = indicatorId
if ("settings" in target) target.settings = indicatorSettings
if ("indicatorBlock" in target) target.indicatorBlock = indicatorBlock
if ("indicatorHost" in target) target.indicatorHost = root
if ("activeOverride" in target) target.activeOverride = indicatorBlock === "active" ? true : null
}
function syncActiveState() {
if (!reportActiveState || !indicatorsModule || !indicatorsModule.setIndicatorActive) return
var active = !!indicatorSource.item && indicatorSource.item.active === true
if (indicatorBlock === "active") {
if (active) activeStateObserved = true
else if (!activeStateObserved) return
}
indicatorsModule.setIndicatorActive(entry, active)
}
}
}
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "blob.keyboard-layout",
"name": "Keyboard layout",
"version": "1.0.0",
"author": "Blob",
"description": "Current xkb layout, click cycles",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "KeyboardLayout.qml"
},
"barWidget": {
"displayName": "Keyboard layout",
"description": "Current xkb layout, click cycles",
"category": "Compositor",
"allowMultiple": false
},
"blob": {
"clonePaths": [
{
"source": "KeyboardLayoutModel.js",
"target": "KeyboardLayoutModel.js"
}
]
}
}
@@ -0,0 +1,218 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import qs.Ui
import qs.Commons
import "KeyboardLayoutModel.js" as KeyboardLayoutModel
BarWidget {
id: root
moduleName: "blob.keyboard-layout"
property string layoutFull: ""
// The keyboard the last reading spoke for, which is the one a click switches,
// and separately the one activelayout named as being typed on. A reading
// confirms the first is really there, so the click has a keyboard to reach
// from the first reading onwards rather than only after a switch, and stops
// naming one that has been unplugged.
property string keyboardName: ""
property string typedKeyboardName: ""
// Keyboards on the seat, buttons and virtual ones excluded, and whether the
// last reading left that shape in doubt.
property int keyboardCount: 0
property bool keyboardUnresolved: false
// Nothing to read or switch on the single-layout install most people run, so
// the widget ships on the bar and stays out of the way until there are two.
// An older Hyprland that doesn't report the list keeps showing the label.
property bool multipleLayouts: true
// Short language code per layout description ("English (US)": "en"), read from
// xkb's own table rather than maintained by hand.
property var layoutBriefs: ({})
readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs)
// A query already in flight was started before this event, so it may read the
// layout the switch replaced. Remember the request and re-run once it lands
// rather than dropping it; nothing else would correct the label afterwards.
property bool refreshPending: false
function refresh() {
if (queryProc.running) {
refreshPending = true
return
}
refreshPending = false
queryProc.running = true
}
// Keyboards someone can actually type on, which is not everything Hyprland
// calls a keyboard.
function typedKeyboards(keyboards) {
return keyboards.filter(k => KeyboardLayoutModel.isTypedKeyboard(k.name))
}
// The main flag names no keyboard for long: fcitx5 takes it with the virtual
// keyboard it binds to inject, which leaves no typed keyboard holding it and
// nothing to read at all, and once that unbinds it lands on whichever device
// Hyprland saw last, a power button included. Go by layout progress instead,
// and by the keyboard activelayout named.
function selectKeyboard(typed) {
return KeyboardLayoutModel.selectKeyboard(typed, root.typedKeyboardName)
}
// switchxkblayout is a hyprctl command rather than a dispatcher, so it has to
// be run rather than sent over the dispatch socket. It switches the keyboard
// the last reading spoke for, so a click always advances the device the label
// is describing. Switching the seat together would reach the typed keyboard
// without having to name it, but it would also carry the buttons along, and
// the whole read depends on those staying where they started: once a button
// has been advanced too, a toggle that wraps the keyboard back to the first
// layout leaves the button reading as the furthest along, and the label
// follows the button.
function cycleLayout() {
if (!root.keyboardName || !root.bar) return
root.bar.run("hyprctl switchxkblayout " + Util.shellQuote(root.keyboardName) + " next")
refreshTimer.restart()
}
Component.onCompleted: {
briefsProc.running = true
refresh()
}
Connections {
target: Hyprland
function onRawEvent(event) {
if (!event || !event.name) return
var name = String(event.name)
// The event names the keyboard that switched ahead of the layout it moved
// to, and that is the keyboard being typed on whatever holds the main flag.
if (name === "activelayout") {
const named = KeyboardLayoutModel.eventKeyboardName(event)
if (named) root.typedKeyboardName = named
}
// A reload that adds a layout to kb_layout decides whether the widget
// shows at all, and leaves every keyboard on the layout it was already
// reading, so it raises no activelayout to notice it by.
if (name.indexOf("activelayout") !== -1 || name === "configreloaded") root.refresh()
}
}
Process {
id: queryProc
command: ["hyprctl", "-j", "devices"]
onRunningChanged: {
if (running) {
stallTimer.restart()
return
}
stallTimer.stop()
if (root.refreshPending) root.refresh()
}
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
let listed
try {
listed = JSON.parse(text || "{}").keyboards
} catch (e) {
return
}
// A query the watchdog killed reports nothing at all, and an empty
// string parses into the same shape a seat with no keyboards would.
// Tell them apart by the list itself, so only a reading that reached
// hyprctl gets to speak for the seat.
if (!Array.isArray(listed)) return
const typed = root.typedKeyboards(listed)
const kb = root.selectKeyboard(typed)
if (!kb || !kb.active_keymap) {
// Either the last keyboard has been unplugged, which the label has to
// stop describing and the click has to stop naming, or keyboards are
// there and none of them reports a keymap. Both leave the shape in
// doubt, so keep asking rather than letting a count from before it
// changed settle the poll.
root.keyboardUnresolved = true
if (typed.length === 0) {
root.layoutFull = ""
root.keyboardName = ""
}
return
}
root.keyboardUnresolved = false
root.keyboardCount = typed.length
root.keyboardName = String(kb.name || "")
root.multipleLayouts = kb.layout === undefined || String(kb.layout).indexOf(",") !== -1
root.layoutFull = kb.active_keymap
}
}
}
// The table only changes when xkb data is upgraded, so read it at startup and
// leave it alone. The bar is built per monitor, so this runs once per widget.
// The exotic rulesets cover layouts like trans (IPA) that ship in the same xkb
// package and set just as well, so load them or those labels lose their code.
Process {
id: briefsProc
command: ["xkbcli", "list", "--load-exotic"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.layoutBriefs = KeyboardLayoutModel.layoutBriefs(text)
}
}
Timer {
id: refreshTimer
interval: 600
onTriggered: root.refresh()
}
// A query that never returns would freeze the label until the shell restarts,
// since a Process that is already running can't be re-run. Give up on one that
// overstays so the next refresh gets through, and ask again: the reading it
// never delivered may have been the only one due on a settled seat, and
// nothing else would come back for it.
Timer {
id: stallTimer
interval: 5000
onTriggered: {
queryProc.running = false
refreshTimer.restart()
}
}
// Which keyboard on a crowded seat the label is describing can change without
// Hyprland announcing it, since a device arriving or leaving raises no event
// of its own, and that can only be learned by asking. Poll while there is that
// ambiguity, until a first reading lands so a query that failed at login still
// recovers, and while a reading has left the seat's shape in doubt. The
// one-keyboard install has none of those, and is left alone rather than
// spawning hyprctl forever for an answer that cannot change.
Timer {
interval: 10000
running: !root.keyboardName || root.keyboardUnresolved || root.keyboardCount > 1
repeat: true
onTriggered: root.refresh()
}
visible: layoutLabel !== "" && multipleLayouts
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.layoutLabel
fontSize: Style.font.caption
horizontalMargin: 6
tooltipText: root.layoutFull
onPressed: function() { root.cycleLayout() }
}
}
@@ -0,0 +1,125 @@
// Label math for the keyboard layout widget, kept Qt-free so it can be unit
// tested under node (test/shell.d/keyboard-layout-test.sh).
// xkbcli list prints YAML, and every layout and variant block pairs a brief with
// the description hyprctl reports as the active keymap:
//
// - layout: 'us'
// variant: ''
// brief: 'en'
// description: English (US)
//
// The models and option groups it also prints carry no brief of their own, and
// a brief never carries past the block it was printed in, so neither reaches
// the table.
function layoutBriefs(text) {
var briefs = {}
var brief = ""
String(text || "").split("\n").forEach(function (line) {
if (/^\s*- /.test(line)) brief = ""
var field = line.match(/^ (brief|description): (.*)$/)
if (!field) return
if (field[1] === "brief") {
brief = field[2].replace(/^'|'$/g, "")
} else if (brief) {
briefs[field[2]] = brief
brief = ""
}
})
return briefs
}
// The brief is a short language code rather than a country one, which keeps the
// label sensible for the layouts named after a language: Esperanto reads EO and
// Arabic reads AR. It is the same code GNOME shows in its own indicator.
//
// Layouts missing from the table fall back to the first word of the description,
// which reads as ENG/POR but at least says something.
//
// Nearly every brief is a bare two-letter code, but a few tack a script onto it
// (Burmese (Zawgyi) is my-zwg) and the custom layout's is a word, so drop the
// script and cap the result at the same three characters the fallback gets.
// The widget sits between fixed neighbours on the bar and has no room to grow.
function shortLabel(description, briefs) {
if (!description) return ""
// A description like "constructor" reaches an inherited member rather than a
// brief, so take the lookup only when it hands back the string it promises.
var brief = (briefs || {})[description]
var label = typeof brief === "string" && brief ? brief.split("-")[0] : description.split(/\s+/)[0]
return label.substring(0, 3).toUpperCase()
}
// Hyprland's activelayout event pairs the keyboard that switched with the layout
// it moved to. Quickshell cuts the event into that many fields, so a description
// carrying a comma of its own stays in one piece; a binding old enough to hand
// back only the raw string gets split by hand. The virtual keyboard fcitx5 binds
// to inject announces switches too, and names a keyboard nobody types on.
function eventKeyboardName(event) {
var parts
try {
if (event && event.parse) parts = event.parse(2)
} catch (error) {
}
if (!parts) parts = String(event && event.data ? event.data : "").split(",")
var name = String(parts[0] || "")
return name.indexOf("hl-virtual-keyboard") === 0 ? "" : name
}
// Hyprland reports more than keyboards as keyboards. fcitx5 binds a virtual one
// to inject through, which keeps the us layout the input method gave it, and the
// ACPI power button, lid switch and sleep key each arrive carrying the seat's
// layout list without anyone ever typing on them. Both answer to switchxkblayout
// and both can hold the main flag, so a widget that reads or switches whatever
// the seat hands it ends up describing a button. Leave them out and what remains
// is keyboards, which is what the rest of this file can then assume.
//
// Missing a name here costs the accuracy the seat had before, never a keyboard:
// anything unrecognised stays in the list.
var UNTYPED_KEYBOARDS = /^(hl-virtual-keyboard|power-button|sleep-button|lid-switch|video-bus)/
function isTypedKeyboard(name) {
return !UNTYPED_KEYBOARDS.test(String(name || ""))
}
// Every keyboard on the seat carries the same layout list unless one was given
// its own, but only the one being typed on advances through it. So the
// furthest-advanced is the one worth reading, and a switch names the keyboard it
// moved, which settles a seat holding two real keyboards outright.
//
// The name is taken whenever a keyboard still answers to it, wherever that
// keyboard sits in the list. Comparing positions instead would read the wrong
// keyboard the moment one wrapped from the last layout back to the first, which
// is the ordinary way round a pair of them. Applying a layout to the whole seat
// names a keyboard too, but leaves every one of them on the same layout, so the
// label reads the same whichever of them the name settles on.
function selectKeyboard(typed, namedByEvent) {
var keyboards = typed || []
return keyboards.find(function (keyboard) {
return keyboard.name === namedByEvent
}) || keyboards.reduce(function (furthest, keyboard) {
return layoutIndex(keyboard) > layoutIndex(furthest) ? keyboard : furthest
}, keyboards[0])
}
function layoutIndex(keyboard) {
return (keyboard && keyboard.active_layout_index) || 0
}
if (typeof module !== "undefined") {
module.exports = {
eventKeyboardName: eventKeyboardName,
isTypedKeyboard: isTypedKeyboard,
layoutBriefs: layoutBriefs,
selectKeyboard: selectKeyboard,
shortLabel: shortLabel
}
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.microphone",
"name": "Microphone",
"version": "1.0.0",
"author": "Blob",
"description": "Mic input state and mute toggle",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Microphone.qml"
},
"barWidget": {
"displayName": "Microphone",
"description": "Mic input state and mute toggle",
"category": "Audio",
"allowMultiple": false
}
}
+54
View File
@@ -0,0 +1,54 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.Ui
BarWidget {
id: root
moduleName: "blob.microphone"
readonly property var source: Pipewire.defaultAudioSource
readonly property bool muted: source && source.audio ? source.audio.muted : true
readonly property real volume: source && source.audio ? source.audio.volume : 0
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
readonly property var activeStreams: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i]
if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node)
}
return list
}
readonly property bool inUse: activeStreams.length > 0 && !muted
visible: source !== null
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
function toggleMute() {
if (source && source.audio) source.audio.muted = !source.audio.muted
}
PwObjectTracker { objects: root.source ? [root.source] : [] }
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.muted ? "󰍭" : "󰍬"
active: root.inUse
tooltipText: root.muted ? "Microphone muted" : (root.inUse ? "Microphone in use" : "Microphone live")
onPressed: function(b) {
if (b === Qt.MiddleButton) root.bar.run("blob-shell shell toggle blob.audio")
else root.toggleMute()
}
onWheelMoved: function(delta) {
if (!root.source || !root.source.audio) return
var step = 0.05
root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step)))
}
}
}
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "blob.spacer",
"name": "Spacer",
"version": "1.0.0",
"author": "Blob",
"description": "Configurable blank space",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Spacer.qml"
},
"barWidget": {
"displayName": "Spacer",
"description": "Configurable blank space",
"category": "Layout",
"allowMultiple": true,
"settingsForm": "spacerSettings"
}
}
+13
View File
@@ -0,0 +1,13 @@
import QtQuick
import qs.Ui
BarWidget {
id: root
moduleName: "blob.spacer"
readonly property int span: settings && settings.size !== undefined ? Number(settings.size) : 12
implicitWidth: vertical ? barSize : span
implicitHeight: vertical ? span : barSize
visible: span > 0
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.system-update",
"name": "Blob update",
"version": "1.0.0",
"author": "Blob",
"description": "Indicates available Blob updates",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "SystemUpdate.qml"
},
"barWidget": {
"displayName": "Blob update",
"description": "Indicates available Blob updates",
"category": "System",
"allowMultiple": false
}
}
@@ -0,0 +1,65 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.system-update"
property bool updateAvailable: false
function refresh() {
if (!updateProc.running) updateProc.running = true
}
function clear() { updateAvailable = false }
function runUpdate() {
if (root.bar) root.bar.run("blob-launch-floating blob-update")
}
visible: updateAvailable
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
IpcHandler {
target: "blob.system-update"
function refresh(): void {
root.broadcast("refresh")
}
function clear(): void {
root.broadcast("clear")
}
}
Process {
id: updateProc
command: ["blob-update-available"]
onExited: function(exitCode) {
root.updateAvailable = exitCode === 0
}
}
Timer {
interval: 21600000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: "\uf021"
slotSize: Style.bar.statusSlot
fontSize: Style.font.caption
tooltipText: "Pending Blob Updates"
onPressed: root.runUpdate()
}
}
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "blob.tray",
"name": "System tray",
"version": "1.0.0",
"author": "Blob",
"description": "Status notifier items",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Tray.qml"
},
"barWidget": {
"displayName": "System tray",
"description": "Status notifier items",
"category": "Status",
"allowMultiple": false
},
"blob": {
"clonePaths": [
{
"source": "TrayModel.js",
"target": "TrayModel.js"
}
]
}
}
+850
View File
@@ -0,0 +1,850 @@
import Quickshell
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell.Services.SystemTray
import qs.Commons
import qs.Ui
import "TrayModel.js" as TrayModel
BarWidget {
id: root
moduleName: "blob.tray"
property bool expanded: false
property bool managePopupOpen: false
property bool trayMenuOpen: false
property var activeTrayItem: null
property var activeTrayAnchor: null
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property var pinnedIds: settings.pinned instanceof Array ? settings.pinned : []
readonly property var hiddenIds: settings.hidden instanceof Array ? settings.hidden : []
readonly property var pinnedItems: bucket("pinned")
readonly property var drawerItems: bucket("drawer")
readonly property var allItems: bucket("all")
readonly property int drawerCount: drawerItems.length
readonly property int trayItemExtent: Style.bar.iconSlot
readonly property int trayItemGap: 0
readonly property int trayJoinGap: 0
readonly property int drawerExtent: drawerCount > 0 ? drawerCount * trayItemExtent + (drawerCount - 1) * trayItemGap : 0
// Match Waybar's group/tray-expander drawer transition-duration.
readonly property int animationDuration: 600
property real revealProgress: expanded ? 1 : 0
readonly property real revealExtent: drawerExtent * revealProgress
// Submenu drill-down state. QsMenuEntry.display() renders a *platform* menu,
// which Quickshell refuses unless the shell root sets `//@ pragma
// UseQApplication` - blob's shell.qml does not, so every submenu click was
// a silent no-op ("Cannot display PlatformMenuEntry as quickshell was not
// started in QApplication mode" in the shell log) and apps whose whole UI is
// submenus, e.g. radiotray-ng's station list, were unusable. QsMenuEntry
// inherits QsMenuHandle, so a child entry can feed a nested QsMenuOpener and
// render inside this popup instead of going through the platform. Each level
// keeps its own live opener: a child entry is owned by its parent opener's
// model, so collapsing the stack to a single opener would destroy the very
// entry being displayed (submenu turns up empty).
property var submenuStack: []
readonly property int submenuDepth: submenuStack.length
readonly property string currentTitle: submenuDepth > 0 ? submenuStack[submenuDepth - 1].title : ""
readonly property var currentChildren: submenuDepth > 0
? submenuStack[submenuDepth - 1].opener.children
: trayMenuOpener.children
// Changing level rebuilds the row delegates synchronously, so the next
// row lands under a cursor that hasn't moved. Submenu clicks used to be
// silent no-ops, which trained users to click them twice, and that second
// click would now fire whatever entry took the spot. Ignore row clicks for
// a beat after each level change; a deliberate follow-up click is slower.
property bool menuLevelSettling: false
Component {
id: submenuOpenerComponent
QsMenuOpener {}
}
Timer {
id: menuLevelSettleTimer
interval: 250
onTriggered: root.menuLevelSettling = false
}
function settleMenuLevel() {
menuLevelSettling = true
menuLevelSettleTimer.restart()
}
function resetTrayMenu() {
menuLevelSettling = false
menuLevelSettleTimer.stop()
// Flickable keeps its offset across a model swap whenever the new content
// is still tall enough to hold it, so a menu dismissed while scrolled
// would otherwise reopen part-way down with its first entries off screen.
trayMenuFlick.contentY = 0
// Clear the reactive stack before tearing anything down, so no binding can
// read a partially-destroyed opener while this runs. Then destroy deepest
// first: an inner opener's menu entry is owned by its parent's children
// model, so destroying a parent first would invalidate an entry a still-
// live child opener references.
var openers = submenuStack
submenuStack = []
for (var i = openers.length - 1; i >= 0; i--) openers[i].opener.destroy()
}
function enterSubmenu(entry, title) {
var opener = submenuOpenerComponent.createObject(root, { menu: entry })
if (!opener) return
var stack = submenuStack.slice()
stack.push({ opener: opener, title: title })
submenuStack = stack
settleMenuLevel()
}
function leaveSubmenu() {
if (submenuStack.length === 0) return
var stack = submenuStack.slice()
var top = stack.pop()
submenuStack = stack
top.opener.destroy()
settleMenuLevel()
}
function close() {
managePopupOpen = false
trayMenuOpen = false
}
function openTrayMenu(item, anchorItem, mouse) {
if (!item || !item.menu) {
var point = anchorItem.QsWindow.contentItem.mapFromItem(anchorItem, mouse.x, mouse.y)
item.display(anchorItem.QsWindow.window, point.x, point.y)
return
}
// Reset before switching items: trayMenuOpener.menu binds to
// activeTrayItem.menu, so assigning a new item invalidates the old root's
// children immediately, before any nested opener referencing them would
// otherwise get torn down.
resetTrayMenu()
activeTrayItem = item
activeTrayAnchor = anchorItem
trayMenuOpen = true
}
function trayIconSource(icon) {
// Quickshell already resolves the tray icon into a ready-to-use image://
// URL, including a "?path=" fallback search dir for apps that ship their
// tray icon outside a standard theme (e.g. Steam's flat public/ dir). Hand
// it straight to IconImage; guessing a theme sub-directory here only broke
// apps whose layout didn't match the guess.
return String(icon || "")
}
// Symbolic icons ship a fixed fill (often near-white) that the host is meant
// to recolor to its foreground; detect them by the freedesktop "-symbolic"
// name suffix so they can be tinted instead of rendered as-is.
function iconIsSymbolic(icon) {
var name = String(icon || "").split("?")[0]
return name.slice(-9) === "-symbolic"
}
function trayTooltip(item) {
return item.tooltipTitle || item.title || item.id || ""
}
function classifyItem(item) {
var iid = String(item.id || "")
if (hiddenIds.indexOf(iid) !== -1) return "hidden"
if (pinnedIds.indexOf(iid) !== -1) return "pinned"
return "drawer"
}
function ownedByBlob(item) {
var layout = root.bar && root.bar.layoutConfig ? root.bar.layoutConfig : null
return TrayModel.ownedByBlob(item, layout)
}
function bucket(category) {
var values = SystemTray.items.values
var result = []
for (var i = 0; i < values.length; i++) {
var item = values[i]
if (item.status === Status.Passive) continue
if (ownedByBlob(item)) continue
if (category === "all") {
result.push(item)
continue
}
if (classifyItem(item) === category) result.push(item)
}
return result
}
function persistTrayState(pinned, hidden) {
if (!root.bar || !root.bar.shell || typeof root.bar.shell.updateEntryInline !== "function") return
var id = root.moduleName || "blob.tray"
root.bar.shell.updateEntryInline(id, { id: id, pinned: pinned, hidden: hidden })
}
function togglePin(iid) {
var p = pinnedIds.slice(), h = hiddenIds.slice()
var idx = p.indexOf(iid)
if (idx !== -1) p.splice(idx, 1)
else {
p.push(iid)
var hi = h.indexOf(iid)
if (hi !== -1) h.splice(hi, 1)
}
persistTrayState(p, h)
}
function toggleHide(iid) {
var p = pinnedIds.slice(), h = hiddenIds.slice()
var idx = h.indexOf(iid)
if (idx !== -1) h.splice(idx, 1)
else {
h.push(iid)
var pi = p.indexOf(iid)
if (pi !== -1) p.splice(pi, 1)
}
persistTrayState(p, h)
}
visible: pinnedItems.length > 0 || drawerCount > 0
clip: false
implicitWidth: root.vertical ? root.barSize : trayContent.implicitWidth
implicitHeight: root.vertical ? trayContent.implicitHeight : root.barSize
Behavior on revealProgress {
NumberAnimation { duration: root.animationDuration; easing.type: Easing.OutCubic }
}
Loader {
id: trayContent
anchors.fill: parent
sourceComponent: root.vertical ? verticalTray : horizontalTray
}
Component {
id: horizontalTray
Item {
id: horizontalTrayRoot
readonly property int pinnedWidth: pinnedRow.implicitWidth
readonly property int drawerBlockWidth: root.allItems.length > 0 ? expandIcon.implicitWidth + root.drawerExtent : 0
implicitWidth: pinnedWidth + drawerBlockWidth
implicitHeight: root.barSize
// Mask out the empty area the collapsed drawer reserves for its slide-in,
// so hovering it doesn't trigger expand and clicks pass through.
containmentMask: QtObject {
function contains(point: point): bool {
if (point.y < 0 || point.y > horizontalTrayRoot.height) return false
// Drawer reveals leftward; chevron sits at the right end when collapsed
// and slides left as it opens. The visible region starts at the chevron.
var chevronX = root.drawerExtent - root.revealExtent
if (point.x >= chevronX && point.x <= horizontalTrayRoot.drawerBlockWidth) return true
// Pinned items, placed to the right of the drawer block.
var pinnedStart = horizontalTrayRoot.drawerBlockWidth
return point.x >= pinnedStart && point.x <= horizontalTrayRoot.implicitWidth
}
}
Item {
id: drawerArea
x: 0
width: horizontalTrayRoot.drawerBlockWidth
height: root.barSize
visible: root.allItems.length > 0
HoverHandler {
onHoveredChanged: root.expanded = hovered
}
BarIconButton {
id: expandIcon
bar: root.bar
width: implicitWidth
height: implicitHeight
x: root.drawerExtent - root.revealExtent
text: "\uf053"
onPressed: function(button) {
if (button === Qt.RightButton) root.managePopupOpen = !root.managePopupOpen
}
}
Item {
id: trayClip
x: expandIcon.width
anchors.verticalCenter: parent.verticalCenter
width: root.drawerExtent
height: root.barSize
clip: true
Row {
id: trayIcons
x: root.drawerExtent - root.revealExtent
anchors.verticalCenter: parent.verticalCenter
spacing: root.trayItemGap
layer.enabled: true
Repeater {
model: root.drawerItems
TrayItem {}
}
}
}
}
Row {
id: pinnedRow
x: drawerArea.x + horizontalTrayRoot.drawerBlockWidth
anchors.verticalCenter: parent.verticalCenter
spacing: root.trayItemGap
leftPadding: root.pinnedItems.length > 0 && root.allItems.length > 0 ? root.trayJoinGap : 0
Repeater {
model: root.pinnedItems
TrayItem {}
}
}
}
}
Component {
id: verticalTray
Item {
id: verticalTrayRoot
readonly property int pinnedHeight: pinnedCol.implicitHeight
readonly property int drawerBlockHeight: root.allItems.length > 0 ? expandIcon.implicitHeight + root.drawerExtent : 0
implicitWidth: root.barSize
implicitHeight: pinnedHeight + drawerBlockHeight
containmentMask: QtObject {
function contains(point: point): bool {
if (point.x < 0 || point.x > verticalTrayRoot.width) return false
var chevronY = root.drawerExtent - root.revealExtent
if (point.y >= chevronY && point.y <= verticalTrayRoot.drawerBlockHeight) return true
var pinnedStart = verticalTrayRoot.drawerBlockHeight
return point.y >= pinnedStart && point.y <= verticalTrayRoot.implicitHeight
}
}
Item {
id: drawerArea
y: 0
width: root.barSize
height: verticalTrayRoot.drawerBlockHeight
visible: root.allItems.length > 0
HoverHandler {
onHoveredChanged: root.expanded = hovered
}
BarIconButton {
id: expandIcon
bar: root.bar
width: implicitWidth
height: implicitHeight
y: root.drawerExtent - root.revealExtent
text: "\uf053"
textRotation: 90
onPressed: function(button) {
if (button === Qt.RightButton) root.managePopupOpen = !root.managePopupOpen
}
}
Item {
id: trayClip
y: expandIcon.height
anchors.horizontalCenter: parent.horizontalCenter
width: root.barSize
height: root.drawerExtent
clip: true
Column {
id: trayIcons
y: root.drawerExtent - root.revealExtent
anchors.horizontalCenter: parent.horizontalCenter
spacing: root.trayItemGap
layer.enabled: true
Repeater {
model: root.drawerItems
TrayItem {}
}
}
}
}
Column {
id: pinnedCol
y: drawerArea.y + verticalTrayRoot.drawerBlockHeight
anchors.horizontalCenter: parent.horizontalCenter
spacing: root.trayItemGap
topPadding: root.pinnedItems.length > 0 && root.allItems.length > 0 ? root.trayJoinGap : 0
Repeater {
model: root.pinnedItems
TrayItem {}
}
}
}
}
PopupCard {
id: managePopup
anchorItem: root
owner: root
bar: root.bar
open: root.managePopupOpen
contentWidth: managePopup.fittedContentWidth(Style.space(300))
contentHeight: managePopup.fittedContentHeight(manageColumn.implicitHeight)
Column {
id: manageColumn
anchors.fill: parent
spacing: Style.space(8)
Text {
text: "Tray icons"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
Text {
text: "Pinned icons stay visible. Hidden icons never show."
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
width: parent.width
}
Text {
visible: root.allItems.length === 0
text: "No tray items reporting."
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.italic: true
}
Repeater {
model: root.allItems
delegate: Item {
id: rowRoot
required property var modelData
required property int index
width: manageColumn.width
implicitHeight: 28
readonly property string itemId: String(modelData.id || "")
readonly property string displayName: {
var t = String(modelData.title || "").trim()
if (t) return t
var tt = String(modelData.tooltipTitle || "").trim()
if (tt) return tt
var id = String(modelData.id || "")
var slash = id.lastIndexOf("/")
return slash !== -1 ? id.substring(slash + 1) : (id || "Unknown")
}
readonly property bool isPinned: root.pinnedIds.indexOf(itemId) !== -1
readonly property bool isHidden: root.hiddenIds.indexOf(itemId) !== -1
TrayIcon {
id: rowIcon
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: 16
height: 16
icon: rowRoot.modelData.icon
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.left: rowIcon.right
anchors.leftMargin: Style.space(10)
anchors.right: rowHideBtn.left
anchors.rightMargin: Style.space(8)
text: rowRoot.displayName
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
Button {
id: rowPinBtn
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
iconText: "\uf08d"
text: rowRoot.isPinned ? "Unpin" : "Pin"
foreground: root.foreground
horizontalPadding: 8
verticalPadding: 3
iconSize: Style.font.bodySmall
fontSize: Style.font.bodySmall
onClicked: root.togglePin(rowRoot.itemId)
}
Button {
id: rowHideBtn
anchors.verticalCenter: parent.verticalCenter
anchors.right: rowPinBtn.left
anchors.rightMargin: Style.space(6)
iconText: "\uf06e"
text: rowRoot.isHidden ? "Show" : "Hide"
foreground: root.foreground
horizontalPadding: 8
verticalPadding: 3
iconSize: Style.font.bodySmall
fontSize: Style.font.bodySmall
onClicked: root.toggleHide(rowRoot.itemId)
}
}
}
}
}
QsMenuOpener {
id: trayMenuOpener
menu: root.activeTrayItem ? root.activeTrayItem.menu : null
}
PopupCard {
id: trayMenuPopup
anchorItem: root.activeTrayAnchor || root
owner: root
bar: root.bar
open: root.trayMenuOpen
// The card fades out over 140ms (visible stays true for that whole time --
// see PopupCard's own visible: open || card.opacity > 0), so resetting on
// "open" would swap a live submenu for the root menu mid-fade: a visible
// flash, and a resize/reposition if the two have different geometry. Wait
// for the fade to actually finish. Switching to a different tray item
// still resets immediately, from openTrayMenu() itself.
onVisibleChanged: if (!visible) root.resetTrayMenu()
padding: Style.space(8)
borderColor: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.45)
contentWidth: trayMenuPopup.fittedContentWidth(Style.space(232))
contentHeight: trayMenuPopup.fittedContentHeight(menuHeaderHeight + trayMenuColumn.implicitHeight, Style.space(420))
// Column skips invisible children but keeps reporting their height, so
// read the header's extent through its own visibility.
readonly property int menuHeaderHeight: menuHeader.visible ? menuHeader.implicitHeight : 0
Column {
id: trayMenuLayout
anchors.fill: parent
spacing: 0
// Header for a drilled-into submenu: names where we are and walks back
// out. Pinned above the Flickable rather than scrolling with the rows,
// so the way back stays reachable in a submenu taller than the card.
// Only present below the root level, so the root menu is unchanged.
Column {
id: menuHeader
visible: root.submenuDepth > 0
width: trayMenuLayout.width
spacing: 0
Item {
id: menuBackRow
width: menuHeader.width
implicitHeight: Style.space(30)
Rectangle {
anchors.fill: parent
radius: Math.max(2, Style.cornerRadius)
color: backMouse.containsMouse ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent"
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: Style.space(22)
horizontalAlignment: Text.AlignHCenter
text: "\u2039"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Style.space(28)
anchors.right: parent.right
anchors.rightMargin: Style.space(10)
text: root.currentTitle
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
MouseArea {
id: backMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.menuLevelSettling) return
// Reset before the model swap so the parent level shows from
// the top (same ordering as the row delegate below).
trayMenuFlick.contentY = 0
root.leaveSubmenu()
}
}
}
Item {
width: menuHeader.width
implicitHeight: Style.space(11)
Rectangle {
anchors.left: parent.left
anchors.leftMargin: Style.space(10)
anchors.right: parent.right
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
height: 1
color: Color.popups.border
opacity: 0.45
}
}
}
Flickable {
id: trayMenuFlick
width: trayMenuLayout.width
height: trayMenuLayout.height - trayMenuPopup.menuHeaderHeight
contentWidth: width
contentHeight: trayMenuColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
interactive: contentHeight > height
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: trayMenuColumn
width: trayMenuFlick.width
spacing: 0
Repeater {
model: root.currentChildren
delegate: Item {
id: menuRow
required property var modelData
required property int index
readonly property string rowText: String(modelData.text || "")
readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : ""
// Both only ever describe the root menu; inside a submenu the first
// rows are real entries and must not be swallowed.
readonly property bool atRoot: root.submenuDepth === 0
readonly property bool rootTitleEntry: atRoot && index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase()
readonly property bool leadingSeparator: atRoot && modelData.isSeparator && index <= 1
readonly property bool hiddenRow: rootTitleEntry || leadingSeparator
visible: !hiddenRow
width: trayMenuColumn.width
implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30))
opacity: modelData.enabled ? 1.0 : 0.45
Rectangle {
visible: menuRow.modelData.isSeparator
anchors.left: parent.left
anchors.leftMargin: Style.space(10)
anchors.right: parent.right
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
height: 1
color: Color.popups.border
opacity: 0.45
}
Rectangle {
visible: !menuRow.modelData.isSeparator
anchors.fill: parent
radius: Math.max(2, Style.cornerRadius)
color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent"
}
Text {
textFormat: Text.PlainText
visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: Style.space(22)
horizontalAlignment: Text.AlignHCenter
text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Image {
id: menuIcon
visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== ""
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Style.space(24)
width: Style.space(16)
height: Style.space(16)
fillMode: Image.PreserveAspectFit
// Decode at physical pixels: IconImage uses the logical size,
// which leaves PNG icons upscaled and blurry on HiDPI displays.
sourceSize.width: width * Screen.devicePixelRatio
sourceSize.height: height * Screen.devicePixelRatio
source: menuRow.modelData.icon
}
Text {
textFormat: Text.PlainText
visible: !menuRow.modelData.isSeparator
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28)
anchors.right: submenuGlyph.left
anchors.rightMargin: Style.space(8)
text: menuRow.rowText
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
Text {
id: submenuGlyph
visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: Style.space(10)
text: "\u203a"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.menuLevelSettling) return
if (menuRow.modelData.hasChildren) {
// Reset scroll BEFORE swapping the model: the swap destroys
// this delegate synchronously and ids stop resolving after.
trayMenuFlick.contentY = 0
root.enterSubmenu(menuRow.modelData, menuRow.rowText)
} else {
menuRow.modelData.triggered()
root.close()
}
}
}
}
}
}
}
}
}
// Renders a tray icon, recoloring symbolic icons to the bar foreground so
// they stay visible on any theme (a raw symbolic icon keeps its baked-in
// fill and disappears against a matching background).
component TrayIcon: Item {
id: trayIconRoot
required property var icon
readonly property bool symbolic: root.iconIsSymbolic(icon)
Image {
id: trayIconImage
anchors.fill: parent
fillMode: Image.PreserveAspectFit
// Decode at physical pixels: IconImage uses the logical size,
// which leaves PNG icons upscaled and blurry on HiDPI displays.
sourceSize.width: Math.round(Math.min(width, height) * Screen.devicePixelRatio)
sourceSize.height: Math.round(Math.min(width, height) * Screen.devicePixelRatio)
source: root.trayIconSource(trayIconRoot.icon)
// Kept as a hidden layer so the effect can sample it as a texture.
visible: !trayIconRoot.symbolic
layer.enabled: trayIconRoot.symbolic
}
MultiEffect {
anchors.fill: trayIconImage
source: trayIconImage
visible: trayIconRoot.symbolic
colorization: 1.0
colorizationColor: root.foreground
}
}
component TrayItem: Item {
id: trayItemRoot
required property var modelData
visible: modelData.status !== Status.Passive
implicitWidth: visible ? root.trayItemExtent : 0
implicitHeight: visible ? root.trayItemExtent : 0
function displayMenu(mouse) {
root.openTrayMenu(trayItemRoot.modelData, trayItemRoot, mouse)
}
TrayIcon {
anchors.centerIn: parent
width: Style.space(12)
height: Style.space(12)
icon: trayItemRoot.modelData.icon
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: if (root.bar) root.bar.showTooltip(trayItemRoot, root.trayTooltip(modelData))
onExited: if (root.bar) root.bar.hideTooltip(trayItemRoot)
onPressed: function(mouse) {
if (mouse.button === Qt.RightButton) {
trayItemRoot.displayMenu(mouse)
mouse.accepted = true
}
}
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
mouse.accepted = true
} else if (mouse.button === Qt.MiddleButton) {
trayItemRoot.modelData.secondaryActivate()
} else if (trayItemRoot.modelData.onlyMenu) {
trayItemRoot.displayMenu(mouse)
} else {
trayItemRoot.modelData.activate()
}
}
onWheel: function(wheel) {
trayItemRoot.modelData.scroll(wheel.angleDelta.y, false)
}
}
readonly property bool tooltipHovered: visible && opacity > 0 && mouseArea.containsMouse
}
}
+47
View File
@@ -0,0 +1,47 @@
function text(value) {
return String(value || "").toLowerCase()
}
function itemNamed(item, name) {
if (!item) return false
return text(item.id).indexOf(name) !== -1
|| text(item.title).indexOf(name) !== -1
|| text(item.tooltipTitle).indexOf(name) !== -1
}
function entryId(entry) {
if (typeof entry === "string") return entry
if (entry && typeof entry === "object") {
var id = entry.id
if (id !== undefined && id !== null && String(id) !== "") return String(id)
}
return ""
}
function layoutHasWidget(layout, id) {
var sections = ["left", "center", "right"]
for (var s = 0; s < sections.length; s++) {
var entries = layout && layout[sections[s]]
if (!Array.isArray(entries)) continue
for (var i = 0; i < entries.length; i++) {
if (entryId(entries[i]) === id) return true
}
}
return false
}
// LocalSend's item shows no state, offers only Open and Quit, and its primary
// click is a no-op, so Share > Receive is the whole surface. Hiding it by hand
// doesn't stick either: LocalSend picks a fresh tray id every launch.
function ownedByBlob(item, layout) {
return itemNamed(item, "localsend")
}
if (typeof module !== "undefined") {
module.exports = {
itemNamed: itemNamed,
entryId: entryId,
layoutHasWidget: layoutHasWidget,
ownedByBlob: ownedByBlob
}
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.workspaces",
"name": "Workspaces",
"version": "1.0.0",
"author": "Blob",
"description": "Workspace number indicators",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Workspaces.qml"
},
"barWidget": {
"displayName": "Workspaces",
"description": "Workspace number indicators",
"category": "Compositor",
"allowMultiple": false
}
}
+72
View File
@@ -0,0 +1,72 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Hyprland
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.workspaces"
function workspaceById(id) {
var values = Hyprland.workspaces.values
for (var i = 0; i < values.length; i++) {
if (values[i].id === id) return values[i]
}
return null
}
function workspaceIds() {
var ids = [1, 2, 3, 4, 5, 6, 7, 8, 9]
var values = Hyprland.workspaces.values
for (var i = 0; i < values.length; i++) {
var id = values[i].id
if (id > 0 && id <= 10 && ids.indexOf(id) === -1) ids.push(id)
}
ids.sort(function(left, right) { return left - right })
return ids
}
function focusWorkspace(id) {
if (!root.bar) return
root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })"))
}
readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5)
implicitWidth: grid.implicitWidth + trailingGap
implicitHeight: grid.implicitHeight
GridLayout {
id: grid
anchors.fill: parent
anchors.rightMargin: root.trailingGap
columns: root.vertical ? 1 : root.workspaceIds().length
columnSpacing: root.vertical ? 0 : Style.space(1)
rowSpacing: root.vertical ? Style.space(2) : 0
Repeater {
model: root.workspaceIds()
WidgetButton {
required property int modelData
readonly property var workspace: root.workspaceById(modelData)
readonly property bool occupied: workspace !== null && workspace.toplevels.values.length > 0
readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === modelData
bar: root.bar
text: focused ? "\uDB85\uDCFB" : (modelData === 10 ? "0" : String(modelData))
opacity: occupied || focused ? 1 : 0.5
horizontalMargin: 6
verticalPadding: 6
fixedWidth: root.vertical ? root.barSize : Style.space(20)
fixedHeight: root.barSize
onPressed: function() { root.focusWorkspace(modelData) }
}
}
}
}
+613
View File
@@ -0,0 +1,613 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import qs.Ui
import "ClipboardHistory.js" as ClipboardHistory
Item {
id: root
property string blobPath: Quickshell.env("BLOB_PATH")
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: false
property bool clearConfirmOpen: false
property var history: []
property string historyPath: Quickshell.env("HOME") + "/.local/state/blob/clipboard-history.json"
property string captureScript: root.blobPath + "/shell/plugins/clipboard/capture.sh"
// Shares the [menu] surface tokens — themes that style the menu also
// style the clipboard. Selected-row colors composed in the
// singleton so consumers drop them straight into Rectangle bindings.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
property color scrim: Color.menu.scrim
property color selectedBackground: Color.menu.selectedBackground
property color selectedText: Color.menu.selectedText
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Style.font.menuFamily
property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int contentSpacing: Style.spacing.md
property int cardWidth: Math.min(Style.space(875), panel.width - Style.gapsOut * 2)
property int cardHeight: Math.min(Style.space(600), panel.height - Style.gapsOut * 2)
property int rowHeight: Math.max(Style.space(50), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2)
property int historyLimit: 300
function open(payloadJson) {
root.opened = true
root.filterText = ""
root.selectedIndex = 0
root.cursorActive = true
root.disarmPointer()
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.cancelClearHistory()
root.opened = false
}
function toggle() {
if (root.opened) root.close()
else root.open("{}")
}
function normalizeEntry(value) {
return ClipboardHistory.normalizeEntry(value)
}
function entryKey(entry) {
return ClipboardHistory.entryKey(entry)
}
function loadHistory(raw) {
root.history = ClipboardHistory.parseHistory(raw)
if (root.opened) root.rebuildDisplay()
}
function saveHistory() {
historyFile.setText(JSON.stringify(root.history.slice(0, root.historyLimit), null, 2) + "\n")
}
function addClipboardEntry(entry) {
var normalized = ClipboardHistory.normalizeEntry(entry)
if (!normalized) return
root.history = ClipboardHistory.addEntry(root.history, normalized, root.historyLimit)
root.saveHistory()
if (root.opened) root.rebuildDisplay()
}
function addClipboardJson(line) {
root.addClipboardEntry(ClipboardHistory.parseEntryJson(line))
}
function requestClearHistory() {
if (root.history.length === 0) return
clearConfirm.selectedIndex = 1
root.clearConfirmOpen = true
}
function cancelClearHistory() {
root.clearConfirmOpen = false
root.disarmPointer()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function confirmClearHistory() {
root.history = ClipboardHistory.clearHistory()
root.saveHistory()
root.selectedIndex = 0
root.cursorActive = false
root.disarmPointer()
root.clearConfirmOpen = false
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function removeDisplayIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.history = ClipboardHistory.removeEntryAt(root.history, row.historyIndex)
root.saveHistory()
if (displayModel.count <= 1) {
root.selectedIndex = 0
root.cursorActive = false
} else if (root.selectedIndex >= displayModel.count - 1) {
root.selectedIndex = displayModel.count - 2
}
root.disarmPointer()
root.rebuildDisplay()
}
function rebuildDisplay() {
var rows = ClipboardHistory.displayRows(root.history, root.filterText, 50)
displayModel.clear()
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
displayModel.append({
entryType: row.entryType,
fullText: row.fullText,
previewText: row.previewText,
previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "",
path: row.path,
mime: row.mime,
historyIndex: row.index
})
}
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
root.disarmPointer()
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
} else {
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
}
resultList.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function selectAbsolute(index) {
if (displayModel.count === 0) return
root.disarmPointer()
root.cursorActive = true
root.selectedIndex = Math.max(0, Math.min(index, displayModel.count - 1))
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = true
root.disarmPointer()
root.rebuildDisplay()
}
function disarmPointer() {
pointerGate.reset()
}
function selectFromPointer(index, item, mouse) {
if (!pointerGate.moved(item, mouse)) return
root.cursorActive = true
root.selectedIndex = index
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row)
}
function copyIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.copySelected(row)
}
function openIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.openSelected(row)
}
function applySelected(row) {
if (!row) return
root.opened = false
if (row.entryType === "image") {
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-file", row.mime, row.path])
} else if (row.fullText) {
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-text", "--shift-insert", "--history-index", String(row.historyIndex)])
}
}
function copySelected(row) {
if (!row) return
root.opened = false
if (row.entryType === "image") {
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-file", "--copy-only", row.mime, row.path])
} else if (row.fullText) {
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-text", "--copy-only", "--history-index", String(row.historyIndex)])
}
}
function openSelected(row) {
if (!row) return
root.opened = false
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-open", "--history-index", String(row.historyIndex)])
}
Component.onCompleted: initProc.running = true
ListModel { id: displayModel }
PointerMoveGate {
id: pointerGate
referenceItem: card
}
FileView {
id: historyFile
path: root.historyPath
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: root.loadHistory(text())
onLoadFailed: root.loadHistory("[]")
onFileChanged: reload()
}
// Reap watchers left behind by a previous shell instance, then start our
// own. The pdeathsig on the watchers makes the kernel kill them whenever
// the shell exits, however it exits, so no further lifecycle management.
Process {
id: initProc
command: ["pkill", "-f", "wl-paste .*--watch .*/shell/plugins/clipboard/capture\\.sh"]
onExited: {
currentProc.running = true
textWatchProc.running = true
imageWatchProc.running = true
}
}
Process {
id: currentProc
command: [root.captureScript]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.addClipboardJson(text)
}
}
Process {
id: textWatchProc
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "text", "--watch", root.captureScript, "text"]
onExited: watchRestartTimer.restart()
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
}
}
Process {
id: imageWatchProc
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "image/png", "--watch", root.captureScript, "image/png"]
onExited: watchRestartTimer.restart()
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
}
}
// A watcher that dies takes clipboard history with it, silently: copying still
// works, the picker still opens, and the old entries are all still there, so
// nothing recorded until the next shell reload. Bring it back instead.
Timer {
id: watchRestartTimer
interval: 1000
repeat: false
onTriggered: {
if (!textWatchProc.running) textWatchProc.running = true
if (!imageWatchProc.running) imageWatchProc.running = true
}
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-clipboard"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
BorderSurface {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
z: root.clearConfirmOpen ? 20 : 0
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (root.clearConfirmOpen) {
if (clearConfirm.handleKey(event)) event.accepted = true
return
}
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.close()
event.accepted = true
} else if (Util.editsFilter(event, root.filterText)) {
root.setFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
} else if (event.key === Qt.Key_Delete) {
if (event.modifiers & Qt.ShiftModifier) root.requestClearHistory()
else root.removeDisplayIndex(root.selectedIndex)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.select(-6)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.select(6)
event.accepted = true
} else if (event.key === Qt.Key_Home) {
root.selectAbsolute(0)
event.accepted = true
} else if (event.key === Qt.Key_End) {
root.selectAbsolute(displayModel.count - 1)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.cursorActive && (event.modifiers & Qt.AltModifier)) root.openIndex(root.selectedIndex)
else if (root.cursorActive && (event.modifiers & Qt.ShiftModifier)) root.copyIndex(root.selectedIndex)
else if (root.cursorActive) root.activateIndex(root.selectedIndex)
else if (displayModel.count > 0) root.cursorActive = true
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
ConfirmDialog {
id: clearConfirm
anchors.fill: parent
opened: root.clearConfirmOpen
z: 10
message: "Delete entire clipboard history?"
confirmText: "Delete"
background: root.background
foreground: root.foreground
scrim: root.scrim
selectedBackground: root.selectedBackground
selectedText: root.selectedText
fontFamily: root.fontFamily
cornerRadius: root.cornerRadius
onCanceled: root.cancelClearHistory()
onConfirmed: root.confirmClearHistory()
}
}
Column {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || "Search clipboard…"
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: Style.font.heading
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.headerHeight - root.contentSpacing
Row {
anchors.fill: parent
spacing: 0
Item {
width: parent.width / 2
height: parent.height
clip: true
ListView {
id: resultList
anchors.fill: parent
anchors.rightMargin: root.contentMargin
model: displayModel
clip: true
spacing: Style.space(4)
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
id: row
required property int index
required property string entryType
required property string previewText
required property string fullText
required property string previewImage
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
width: ListView.view.width
height: root.rowHeight
radius: root.cornerRadius
color: hasCursor ? root.selectedBackground : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.topMargin: Style.space(8)
anchors.bottomMargin: Style.space(8)
spacing: Style.space(10)
Image {
visible: parent.parent.previewImage.length > 0
width: visible ? parent.height : 0
height: parent.height
source: parent.parent.previewImage
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
}
Text {
textFormat: Text.PlainText
width: parent.width - (parent.parent.previewImage.length > 0 ? parent.height + parent.spacing : 0)
height: parent.height
text: parent.parent.previewText
color: parent.parent.hasCursor ? root.selectedText : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
opacity: parent.parent.entryType === "image" || parent.parent.entryType === "file" ? 0.72 : 1.0
elide: Text.ElideRight
wrapMode: Text.NoWrap
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: function(mouse) {
root.selectFromPointer(row.index, row, mouse)
}
onClicked: {
root.cursorActive = true
root.selectedIndex = row.index
root.activateIndex(row.index)
}
}
}
}
}
Item {
width: parent.width / 2
height: parent.height
clip: true
property var activeRow: displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: Style.normalBorderWidth
color: Util.alpha(root.border, 0.28)
}
Text {
textFormat: Text.PlainText
visible: parent.activeRow && !parent.activeRow.previewImage
anchors.fill: parent
anchors.leftMargin: root.contentMargin
anchors.rightMargin: 0
anchors.topMargin: 0
anchors.bottomMargin: 0
text: parent.activeRow ? parent.activeRow.fullText : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
wrapMode: Text.WrapAnywhere
elide: Text.ElideRight
verticalAlignment: Text.AlignTop
}
Image {
visible: parent.activeRow && parent.activeRow.previewImage
anchors.fill: parent
anchors.leftMargin: root.contentMargin
anchors.rightMargin: 0
anchors.topMargin: 0
anchors.bottomMargin: 0
source: parent.activeRow ? parent.activeRow.previewImage : ""
fillMode: Image.PreserveAspectFit
verticalAlignment: Image.AlignTop
asynchronous: true
smooth: true
}
}
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0
Text {
text: "󰅌"
color: root.selectedText
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
textFormat: Text.PlainText
text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
}
}
}
+225
View File
@@ -0,0 +1,225 @@
function normalizeEntry(value) {
if (typeof value === "string")
return value.trim().length > 0 ? { type: "text", text: value } : null
if (!value || typeof value !== "object") return null
var type = String(value.type || value.kind || "")
if (type === "text") {
var text = String(value.text || "")
return text.trim().length > 0 ? { type: "text", text: text } : null
}
if (type === "image") {
var path = String(value.path || "")
if (!path) return null
var entry = {
type: "image",
path: path,
mime: String(value.mime || "image/png")
}
if (value.capturedAt !== undefined && value.capturedAt !== null)
entry.capturedAt = String(value.capturedAt)
return entry
}
return null
}
function entryKey(entry) {
if (!entry) return ""
if (entry.type === "image") return "image:" + String(entry.path || "")
return "text:" + String(entry.text || "")
}
function parseHistory(raw) {
try {
var parsed = JSON.parse(String(raw || "[]"))
var next = []
if (!Array.isArray(parsed)) return next
for (var i = 0; i < parsed.length; i++) {
var entry = normalizeEntry(parsed[i])
if (entry) next.push(entry)
}
return next
} catch (e) {
return []
}
}
function addEntry(history, entry, limit) {
var normalized = normalizeEntry(entry)
var max = limit === undefined || limit === null ? 100 : Number(limit)
if (isNaN(max)) max = 100
max = Math.max(0, max)
if (!normalized) return Array.isArray(history) ? history.slice(0, max) : []
if (max === 0) return []
var key = entryKey(normalized)
var next = [normalized]
var values = Array.isArray(history) ? history : []
for (var i = 0; i < values.length && next.length < max; i++) {
var existing = normalizeEntry(values[i])
if (!existing || entryKey(existing) === key) continue
next.push(existing)
}
return next
}
function removeEntryAt(history, index) {
var values = Array.isArray(history) ? history : []
var target = Number(index)
if (isNaN(target) || target < 0 || target >= values.length) return values.slice()
var next = values.slice()
next.splice(target, 1)
return next
}
function clearHistory() {
return []
}
function parseEntryJson(line) {
var raw = String(line || "").trim()
if (!raw) return null
try { return normalizeEntry(JSON.parse(raw)) } catch (e) { return null }
}
function searchableText(entry) {
if (!entry) return ""
if (entry.type === "image") return "image screenshot " + String(entry.mime || "") + " " + String(entry.capturedAt || "")
return String(entry.text || "") + " " + fileEntryText(entry)
}
function decodeFileUri(uri) {
var value = String(uri || "").trim()
if (value.indexOf("file://") !== 0) return ""
var path = value.substring(7)
if (path.indexOf("localhost/") === 0) path = path.substring(9)
if (path.charAt(0) !== "/") return ""
try { return decodeURIComponent(path) } catch (e) { return path }
}
function filePaths(entry) {
if (!entry || entry.type !== "text") return []
var lines = String(entry.text || "").split(/\r?\n/)
var paths = []
for (var i = 0; i < lines.length; i++) {
var path = decodeFileUri(lines[i])
if (path) paths.push(path)
}
return paths
}
function fileName(path) {
var parts = String(path || "").split("/")
return parts.length > 0 ? parts[parts.length - 1] : String(path || "")
}
function isImagePath(path) {
return /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i.test(String(path || ""))
}
function fileEntryText(entry) {
var paths = filePaths(entry)
if (paths.length === 0) return ""
if (paths.length === 1) return fileName(paths[0])
return paths.length + " files"
}
function imagePreviewText(entry) {
var timestamp = String(entry && entry.capturedAt || "")
if (!timestamp) return "Image"
var label = String(entry && entry.mime || "") === "image/png" ? "Screenshot" : "Image"
return label + " from " + timestamp
}
function previewText(entry) {
if (!entry) return ""
if (entry.type === "image") return imagePreviewText(entry)
var fileText = fileEntryText(entry)
if (fileText) return fileText
return String(entry.text || "").replace(/\s+/g, " ")
}
function fullText(entry) {
if (!entry) return ""
var paths = filePaths(entry)
if (paths.length > 0) return paths.join("\n")
return String(entry.text || "")
}
// The picker only ever searches and renders a prefix of an entry, so scan and
// render just that much. A single huge paste otherwise costs hundreds of
// megabytes of string work on every keystroke and stalls the whole shell.
// Pasting reads the full entry back from history by index, so nothing is lost.
var displayTextLimit = 8192
function cappedEntry(entry) {
if (!entry || entry.type !== "text" || entry.text.length <= displayTextLimit) return entry
// Cut on a line break so a file:// URI never truncates into a bogus path.
var cut = entry.text.lastIndexOf("\n", displayTextLimit)
return { type: "text", text: entry.text.slice(0, cut > 0 ? cut : displayTextLimit) }
}
function displayRows(history, query, limit) {
var values = Array.isArray(history) ? history : []
var needle = String(query || "").trim().toLowerCase()
var max = limit === undefined || limit === null ? 50 : Number(limit)
if (isNaN(max)) max = 50
max = Math.max(0, max)
if (max === 0) return []
var rows = []
for (var i = 0; i < values.length; i++) {
var entry = cappedEntry(normalizeEntry(values[i]))
if (!entry) continue
if (needle && searchableText(entry).toLowerCase().indexOf(needle) < 0) continue
var paths = filePaths(entry)
var isFile = paths.length > 0
var isImage = entry.type === "image"
var previewPath = isImage ? String(entry.path || "") : (isFile && paths.length === 1 && isImagePath(paths[0]) ? paths[0] : "")
rows.push({
entryType: isFile ? "file" : entry.type,
fullText: isImage ? "" : fullText(entry),
previewText: previewText(entry),
previewImage: previewPath,
path: isImage ? String(entry.path || "") : (isFile && paths.length === 1 ? paths[0] : ""),
mime: isImage ? String(entry.mime || "image/png") : "text/plain",
index: i
})
if (rows.length >= max) break
}
return rows
}
if (typeof module !== "undefined") {
module.exports = {
normalizeEntry: normalizeEntry,
entryKey: entryKey,
parseHistory: parseHistory,
addEntry: addEntry,
removeEntryAt: removeEntryAt,
clearHistory: clearHistory,
parseEntryJson: parseEntryJson,
searchableText: searchableText,
previewText: previewText,
imagePreviewText: imagePreviewText,
filePaths: filePaths,
fileEntryText: fileEntryText,
fullText: fullText,
displayRows: displayRows
}
}
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# Captures the current clipboard as a JSON entry on stdout. In watch mode,
# wl-paste invokes this with the payload on stdin and the mime as $1. Without
# arguments, it snapshots the current selection itself.
set -o pipefail
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/blob"
IMAGE_DIR="$STATE_DIR/clipboard-images"
mkdir -p "$IMAGE_DIR"
types=$(wl-paste --list-types 2>/dev/null || true)
if [[ ${CLIPBOARD_STATE:-} == "sensitive" ]] || grep -qx 'x-kde-passwordManagerHint' <<<"$types"; then
exit 0
fi
emit_image() {
local mime="$1"
local ext tmp hash file
ext=${mime#image/}
[[ $ext == jpeg ]] && ext=jpg
tmp=$(mktemp --tmpdir="$IMAGE_DIR" clipboard.XXXXXX) || return 0
cat >"$tmp"
if [[ ! -s $tmp ]]; then
rm -f "$tmp"
return 0
fi
hash=$(sha256sum "$tmp" | awk '{print $1}')
file="$IMAGE_DIR/$hash.$ext"
if [[ -e $file ]]; then
rm -f "$tmp"
else
mv "$tmp" "$file"
fi
jq -cn --arg mime "$mime" --arg path "$file" --arg captured_at "$(date +'%A %H:%M')" \
'{type:"image", mime:$mime, path:$path, capturedAt:$captured_at}'
}
emit_text() {
perl -MEncode=decode,FB_CROAK,LEAVE_SRC -MJSON::PP=encode_json -0777 -e '
my $raw = <STDIN>;
exit unless length $raw;
my $encoding;
my $heuristic_encoding = 0;
if ($raw =~ /^(?:\xFF\xFE|\xFE\xFF)/) {
$encoding = "UTF-16";
} elsif (length($raw) % 2 == 0 && index($raw, "\0") >= 0) {
my $units = length($raw) / 2;
my $nuls = $raw =~ tr/\0/\0/;
# Neither byte lane can reach the padding threshold when the entire
# payload contains fewer NULs than that, so avoid two full string passes.
if ($nuls * 4 >= $units * 3) {
my $even_bytes = $raw;
$even_bytes =~ s/(.)./$1/sg;
my $even_nuls = $even_bytes =~ tr/\0/\0/;
undef $even_bytes;
my $odd_bytes = $raw;
$odd_bytes =~ s/.(.)/$1/sg;
my $odd_nuls = $odd_bytes =~ tr/\0/\0/;
# BOM-less UTF-16 is indistinguishable from NUL-separated bytes. Decode
# only when at least three quarters of the code units have consistent
# padding and fewer than one quarter have NULs in the opposite byte.
if ($odd_nuls * 4 >= $units * 3 && $even_nuls * 4 < $units) {
$encoding = "UTF-16LE";
$heuristic_encoding = 1;
} elsif ($even_nuls * 4 >= $units * 3 && $odd_nuls * 4 < $units) {
$encoding = "UTF-16BE";
$heuristic_encoding = 1;
}
}
}
my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK | LEAVE_SRC) } : undef;
if ($heuristic_encoding && defined($text) && $text =~ /[\x00-\x08\x0E-\x1A\x1C-\x1F]/) {
$text = undef;
}
$text = decode("UTF-8", $raw) unless defined $text;
print "{\"type\":\"text\",\"text\":", encode_json($text), "}\n";
'
}
case "${1:-}" in
text) emit_text; exit 0 ;;
image/*) emit_image "$1"; exit 0 ;;
esac
for mime in image/png image/jpeg image/webp image/gif image/bmp image/tiff; do
if grep -qx "$mime" <<<"$types"; then
timeout 2s wl-paste --type "$mime" 2>/dev/null | emit_image "$mime"
exit 0
fi
done
if grep -q '^text/' <<<"$types" || grep -qx 'UTF8_STRING' <<<"$types" || grep -qx 'STRING' <<<"$types"; then
wl-paste --type text --no-newline 2>/dev/null | emit_text
fi
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.clipboard",
"name": "Clipboard",
"version": "1.0.0",
"author": "Blob",
"description": "A clipboard manager to view and paste history",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "Clipboard.qml"
}
}
+46
View File
@@ -0,0 +1,46 @@
function parseEmojis(raw) {
try {
var data = JSON.parse(String(raw || ""))
return Array.isArray(data) ? data : []
} catch (e) {
return []
}
}
function normalizedQuery(query) {
return String(query || "").trim().toLowerCase()
}
function keywordText(item) {
return String((item && item.k) || "").toLowerCase()
}
function filterEmojis(emojis, query, limit) {
var values = Array.isArray(emojis) ? emojis : []
var needle = normalizedQuery(query)
var max = limit === undefined || limit === null ? 1000 : Number(limit)
if (isNaN(max)) max = 1000
max = Math.max(0, max)
if (max === 0) return []
var out = []
for (var i = 0; i < values.length; i++) {
var item = values[i]
if (!item || !item.e) continue
if (!needle || keywordText(item).indexOf(needle) >= 0) {
out.push(item)
if (out.length >= max) break
}
}
return out
}
if (typeof module !== "undefined") {
module.exports = {
parseEmojis: parseEmojis,
normalizedQuery: normalizedQuery,
filterEmojis: filterEmojis
}
}
+345
View File
@@ -0,0 +1,345 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import qs.Ui
import "EmojiSearch.js" as EmojiSearch
Item {
id: root
property string blobPath: Quickshell.env("BLOB_PATH")
property var shell: null
property var manifest: null
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: false
property var emojis: []
property var filteredEmojis: []
// Shares the [menu] surface tokens — themes that style the menu also
// style emojis. Selected-cell colors composed in the
// singleton so consumers drop them straight into Rectangle bindings.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
property color scrim: Color.menu.scrim
property color selectedBackground: Color.menu.selectedBackground
property color selectedText: Color.menu.selectedText
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Style.font.menuFamily
property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int contentSpacing: Style.spacing.md
property int cardWidth: Math.min(Style.space(400), panel.width - Style.gapsOut * 2)
property int cardHeight: Math.min(Style.space(500), panel.height - Style.gapsOut * 2)
property int cellWidth: Math.max(Style.space(44), Style.font.display + Style.spacing.md)
property int cellHeight: Math.max(Style.space(44), Style.font.display + Style.spacing.md)
property int columns: Math.floor((cardWidth - contentMargin * 2) / cellWidth)
function open(payloadJson) {
root.opened = true
root.filterText = ""
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function dismiss() {
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "blob.emojis")
}
function toggle() {
if (root.opened) root.dismiss()
else root.open("{}")
}
function loadEmojis(raw) {
root.emojis = EmojiSearch.parseEmojis(raw)
if (root.opened) root.rebuildDisplay()
}
function rebuildDisplay() {
var out = EmojiSearch.filterEmojis(root.emojis, root.filterText, 1000)
root.filteredEmojis = out
displayModel.clear()
for (var j = 0; j < out.length; j++) {
displayModel.append({ emoji: out[j].e, index: j })
}
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
cursorActive = displayModel.count > 0
Qt.callLater(function() {
if (displayModel.count > 0) resultGrid.positionViewAtIndex(root.selectedIndex, GridView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
} else {
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
}
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function selectRow(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
return
}
var newIndex = selectedIndex + delta * columns
if (newIndex < 0) newIndex = 0
if (newIndex >= displayModel.count) newIndex = displayModel.count - 1
selectedIndex = newIndex
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function selectPage(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
return
}
var visibleRows = Math.max(1, Math.floor(resultGrid.height / cellHeight))
var newIndex = selectedIndex + delta * columns * visibleRows
if (newIndex < 0) newIndex = 0
if (newIndex >= displayModel.count) newIndex = displayModel.count - 1
selectedIndex = newIndex
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row.emoji)
}
function applySelected(emoji) {
if (!emoji) return
root.dismiss()
Quickshell.execDetached([root.blobPath + "/bin/blob-menu-emoji", emoji])
}
ListModel { id: displayModel }
FileView {
path: root.blobPath + "/shell/plugins/emojis/emojis.json"
onLoaded: root.loadEmojis(text())
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-emojis"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
BorderSurface {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.dismiss()
event.accepted = true
} else if (Util.editsFilter(event, root.filterText)) {
root.setFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
} else if (event.key === Qt.Key_Left) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Right) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.selectRow(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.selectRow(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.selectPage(-1)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.selectPage(1)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.cursorActive) root.activateIndex(root.selectedIndex)
else if (displayModel.count > 0) root.cursorActive = true
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
}
Column {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || "Search emojis…"
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: Style.font.heading
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.headerHeight - root.contentSpacing
GridView {
id: resultGrid
anchors.fill: parent
model: displayModel
clip: true
cellWidth: root.cellWidth
cellHeight: root.cellHeight
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
required property int index
required property string emoji
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
width: root.cellWidth
height: root.cellHeight
radius: root.cornerRadius
color: hasCursor ? root.selectedBackground : "transparent"
Text {
textFormat: Text.PlainText
text: parent.emoji
font.family: root.fontFamily
font.pixelSize: Style.font.display
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) {
root.cursorActive = true
root.selectedIndex = index
}
onClicked: {
root.cursorActive = true
root.selectedIndex = index
root.activateIndex(index)
}
}
}
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0
Text {
text: "󰈉"
color: root.selectedText
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
textFormat: Text.PlainText
text: "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
}
}
}
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.emojis",
"name": "Emojis",
"version": "1.0.0",
"author": "Blob",
"description": "Search, copy, and type emojis",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "Emojis.qml"
}
}
+582
View File
@@ -0,0 +1,582 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Commons
import "ImagePickerModel.js" as ImagePickerModel
Item {
id: root
// Injected by blob-shell; defaults to the session BLOB_PATH.
property string blobPath: Quickshell.env("BLOB_PATH")
property string stateHome: Quickshell.env("HOME") + "/.local/state"
property string imageDirs: Quickshell.env("BLOB_IMAGE_SELECTOR_DIRS") || Quickshell.env("BLOB_IMAGE_SELECTOR_DIR") || Quickshell.env("BLOB_STOCK_BACKGROUNDS_DIR") || (stateHome + "/blob/current/theme/backgrounds")
property string imageRows: ""
property string loadedImageRows: ""
property string selectionFile: Quickshell.env("BLOB_IMAGE_SELECTOR_SELECTION_FILE") || Quickshell.env("BLOB_BACKGROUND_SELECTION_FILE")
property string selectedImage: Quickshell.env("BLOB_IMAGE_SELECTOR_SELECTED")
property int selectedIndex: 0
property bool imagesLoaded: false
property bool opened: false
property bool showLabels: false
property bool filterable: false
property bool layoutSettled: false
property bool requestActive: false
property int requestSerial: 0
property int applySerial: 0
property string doneFile: ""
property string filterText: ""
property var doneFilesToRelease: []
// Bound to the central [image-picker] section in shell.toml via Color.qml.
// `dimColor` tints unselected slices and text outlines on top of the scrim;
// it intentionally tracks the foundational background, not a surface role.
property color dimColor: Color.background
property color foreground: Color.imagePicker.text
property color scrim: Color.imagePicker.scrim
property color selectedBorder: Color.imagePicker.selectedBorder
property color unselectedBorder: Color.imagePicker.unselectedBorder
property int expandedWidth: 768
property int expandedHeight: 475
property int sliceWidth: 108
property int sliceHeight: 432
property int sliceSpacing: -30
property int skewOffset: 28
property int bottomChromeHeight: showLabels ? (filterable ? 104 : 74) : (filterable ? 60 : 30)
onOpenedChanged: if (!opened) layoutSettled = false
function scriptPath(name) {
return blobPath + "/shell/plugins/image-picker/" + name
}
function focusPicker() {
if (root.opened && root.imagesLoaded && root.layoutSettled)
carousel.forceActiveFocus()
}
function revealWhenSettled(serial) {
Qt.callLater(function() {
if (serial === root.requestSerial && root.opened && root.imagesLoaded && root.imageArray.length > 0) {
root.layoutSettled = true
root.focusPicker()
}
})
}
function currentPath() {
if (imageArray.length === 0 || !itemMatches(selectedIndex)) return ""
return imageArray[selectedIndex].filePath
}
function nameForPath(path) {
return ImagePickerModel.nameForPath(path)
}
function labelForPath(path) {
return ImagePickerModel.labelForPath(path)
}
function currentLabel() {
var path = currentPath()
if (!path) return filterText ? "No matches" : ""
return labelForPath(path)
}
function itemMatches(index) {
return ImagePickerModel.itemMatches(imageArray, index, filterText)
}
function firstMatchingIndex() {
return ImagePickerModel.firstMatchingIndex(imageArray, filterText)
}
function filteredPosition(index) {
return ImagePickerModel.filteredPosition(imageArray, index, filterText)
}
function selectedFilteredPosition() {
return ImagePickerModel.selectedFilteredPosition(imageArray, selectedIndex, filterText)
}
function select(index, immediate) {
if (imageArray.length === 0) return
if (index < 0) index = 0
else if (index >= imageArray.length) index = imageArray.length - 1
if (!itemMatches(index)) return
if (index === selectedIndex && immediate !== true) return
selectedIndex = index
}
function selectAdjacent(direction) {
var count = imageArray.length
if (count === 0) return
var index = selectedIndex
for (var i = 0; i < count; i++) {
index = (index + direction + count) % count
if (itemMatches(index)) {
select(index)
return
}
}
}
function updateFilter(nextFilterText) {
filterText = nextFilterText
if (!itemMatches(selectedIndex)) {
var first = ImagePickerModel.nextSelectedIndexForFilter(imageArray, selectedIndex, filterText)
if (first >= 0) selectedIndex = first
}
}
function releaseNextDoneFile() {
if (releaseProc.running || doneFilesToRelease.length === 0) return
var path = doneFilesToRelease.shift()
releaseProc.command = ["bash", "-c", ": > " + Util.shellQuote(path)]
releaseProc.running = true
}
function finishDoneFile(path) {
if (!path) return
doneFilesToRelease.push(path)
releaseNextDoneFile()
}
function applySelected() {
var path = currentPath()
if (!path || !selectionFile) {
cancel()
return
}
var activeSelectionFile = selectionFile
var activeDoneFile = doneFile
applySerial = requestSerial
requestActive = false
selectionFile = ""
doneFile = ""
applyProc.command = ["bash", "-c", "printf '%s\\n' " + Util.shellQuote(path) + " > " + Util.shellQuote(activeSelectionFile) + "; : > " + Util.shellQuote(activeDoneFile)]
applyProc.running = true
}
function cancel() {
if (requestActive)
finishDoneFile(doneFile)
requestActive = false
selectionFile = ""
doneFile = ""
root.opened = false
}
function closeSelector(nextDoneFile) {
requestSerial += 1
if (requestActive)
finishDoneFile(doneFile)
if (nextDoneFile && nextDoneFile !== doneFile)
finishDoneFile(nextDoneFile)
requestActive = false
selectionFile = ""
doneFile = ""
filterText = ""
root.opened = false
}
function loadRows(rows, reveal) {
var newImages = ImagePickerModel.loadRows(rows)
root.loadedImageRows = rows
root.selectedIndex = root.indexForSelectedImage(newImages)
root.imageArray = newImages
root.imagesLoaded = true
if (reveal !== false) {
root.opened = true
root.revealWhenSettled(root.requestSerial)
}
}
function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) {
if (requestActive && doneFile && doneFile !== nextDoneFile)
finishDoneFile(doneFile)
requestSerial += 1
imageDirs = nextImageDirs
imageRows = nextImageRows
selectedImage = nextSelectedImage
selectionFile = nextSelectionFile
doneFile = nextDoneFile
requestActive = !!doneFile
showLabels = nextShowLabels === true || nextShowLabels === "true"
filterable = nextFilterable === true || nextFilterable === "true"
filterText = ""
layoutSettled = false
if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) {
root.select(root.selectedImageIndex(), true)
imagesLoaded = true
opened = true
root.revealWhenSettled(requestSerial)
return
}
if (imageRows) {
var rowsToLoad = imageRows
var rowsSerial = requestSerial
imageArray = []
selectedIndex = 0
imagesLoaded = true
opened = true
Qt.callLater(function() {
if (rowsSerial === root.requestSerial)
root.loadRows(rowsToLoad, true)
})
return
}
imageArray = []
selectedIndex = 0
imagesLoaded = false
opened = false
startImageScan(requestSerial, imageDirs)
}
property var imageArray: []
function startImageScan(serial, dirs) {
if (loadImagesProc.running) {
loadImagesProc.queuedSerial = serial
loadImagesProc.queuedDirs = dirs
return
}
loadImagesProc.activeSerial = serial
loadImagesProc.queuedSerial = 0
loadImagesProc.queuedDirs = ""
loadImagesProc.command = [root.scriptPath("list.sh"), dirs]
loadImagesProc.running = true
}
function indexForSelectedImage(images) {
return ImagePickerModel.indexForSelectedImage(images, selectedImage)
}
function selectedImageIndex() {
return indexForSelectedImage(imageArray)
}
Process {
id: loadImagesProc
property int activeSerial: 0
property int queuedSerial: 0
property string queuedDirs: ""
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
if (loadImagesProc.activeSerial === root.requestSerial)
root.loadRows(String(text || ""), true)
}
}
onExited: {
var serial = queuedSerial
var dirs = queuedDirs
activeSerial = 0
queuedSerial = 0
queuedDirs = ""
if (serial > 0 && serial === root.requestSerial)
root.startImageScan(serial, dirs)
}
}
// Lifecycle hooks invoked by blob-shell summon/hide. shell.summon(id,
// payloadJson) hands the JSON to open() here; shell.hide(id) calls close().
// The shell host owns the stable `image-selector` IPC target and forwards
// those lower-level positional calls here.
function open(payload) {
var args = {}
if (payload) {
try { args = JSON.parse(payload) || {} } catch (e) { args = {} }
}
var dirs = String(args.imageDirs || imageDirs)
var rows = String(args.imageRows || "")
var sel = String(args.selectedImage || selectedImage)
var selFile = String(args.selectionFile || "")
var doneF = String(args.doneFile || "")
var labels = args.showLabels === true || args.showLabels === "true"
var filter = args.filterable === true || args.filterable === "true"
openSelector(dirs, rows, sel, selFile, doneF, labels, filter)
}
function close() {
cancel()
}
function preloadRows(nextImageRows, nextSelectedImage, nextShowLabels, nextFilterable) {
// Theme/background set hooks can warm selector rows after a picker was
// dismissed. Ignore those preloads while a user-visible request is open;
// otherwise the preload resets layoutSettled without revealing again,
// leaving only the fullscreen scrim.
if (opened || requestActive) return
requestSerial += 1
imageRows = nextImageRows
selectedImage = nextSelectedImage
showLabels = nextShowLabels === true || nextShowLabels === "true"
filterable = nextFilterable === true || nextFilterable === "true"
filterText = ""
layoutSettled = false
if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) {
selectedIndex = selectedImageIndex()
imagesLoaded = true
} else if (imageRows) {
loadRows(imageRows, false)
}
}
Process {
id: applyProc
onExited: {
if (root.applySerial === root.requestSerial)
root.opened = false
}
}
Process {
id: releaseProc
onExited: root.releaseNextDoneFile()
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-image-selector"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.opened && root.imagesLoaded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
visible: root.opened && root.imagesLoaded
color: root.scrim
}
MouseArea {
anchors.fill: parent
enabled: root.opened && root.imagesLoaded
onClicked: root.cancel()
}
Item {
id: card
visible: root.opened && root.imagesLoaded && root.layoutSettled && root.imageArray.length > 0
width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40)
height: root.expandedHeight + Style.space(30) + root.bottomChromeHeight
anchors.centerIn: parent
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: carousel
anchors.top: parent.top
anchors.topMargin: Style.space(30)
anchors.bottom: parent.bottom
anchors.bottomMargin: root.bottomChromeHeight
anchors.horizontalCenter: parent.horizontalCenter
width: root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing)
clip: false
focus: true
readonly property real itemStep: root.sliceWidth + root.sliceSpacing
readonly property real previewX: (width - root.expandedWidth) / 2
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) {
root.updateFilter("")
} else {
root.cancel()
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.applySelected()
event.accepted = true
} else if (root.filterable && Util.editsFilter(event, root.filterText)) {
root.updateFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
} else if (event.key === Qt.Key_Left || (event.key === Qt.Key_Tab && event.modifiers & Qt.ShiftModifier) || event.key === Qt.Key_Backtab) {
root.selectAdjacent(-1)
event.accepted = true
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Tab) {
root.selectAdjacent(1)
event.accepted = true
} else if (root.filterable && event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) {
root.updateFilter(root.filterText + event.text)
event.accepted = true
}
}
Component.onCompleted: forceActiveFocus()
Repeater {
model: root.imageArray.length
delegate: Item {
id: item
required property int index
readonly property var imageData: root.imageArray[index]
readonly property string filePath: imageData ? imageData.filePath : ""
readonly property string fileName: imageData ? imageData.fileName : ""
readonly property string thumbnailPath: imageData ? imageData.thumbnailPath : ""
readonly property bool matched: root.itemMatches(index)
readonly property int relativeIndex: root.filteredPosition(index) - root.selectedFilteredPosition()
readonly property bool selected: matched && index === root.selectedIndex
readonly property bool nearby: matched && Math.abs(relativeIndex) <= 16
property bool sourceActivated: nearby
onNearbyChanged: if (nearby) sourceActivated = true
visible: nearby
x: selected ? carousel.previewX : (relativeIndex < 0 ? carousel.previewX + relativeIndex * carousel.itemStep : carousel.previewX + root.expandedWidth + root.sliceSpacing + (relativeIndex - 1) * carousel.itemStep)
width: selected ? root.expandedWidth : root.sliceWidth
height: selected ? root.expandedHeight : root.sliceHeight
y: selected ? 0 : (root.expandedHeight - root.sliceHeight) / 2
z: selected ? 100 : 50 - Math.min(Math.abs(relativeIndex), 40)
readonly property real skAbs: Math.abs(root.skewOffset)
readonly property real topLeft: root.skewOffset >= 0 ? skAbs : 0
readonly property real topRight: root.skewOffset >= 0 ? width : width - skAbs
readonly property real bottomRight: root.skewOffset >= 0 ? width - skAbs : width
readonly property real bottomLeft: root.skewOffset >= 0 ? 0 : skAbs
Item {
id: maskShape
anchors.fill: parent
visible: false
layer.enabled: true
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "white"
strokeColor: "transparent"
startX: item.topLeft; startY: 0
PathLine { x: item.topRight; y: 0 }
PathLine { x: item.bottomRight; y: item.height }
PathLine { x: item.bottomLeft; y: item.height }
PathLine { x: item.topLeft; y: 0 }
}
}
}
Item {
anchors.fill: parent
layer.enabled: true
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: maskShape
maskThresholdMin: 0.3
maskSpreadAtMin: 0.3
}
Image {
id: image
anchors.fill: parent
// Load only the initial/visited nearby images, but keep the
// source once activated so Qt does not tear textures down as
// selection moves through the carousel.
source: item.sourceActivated && item.thumbnailPath ? Util.fileUrl(item.thumbnailPath) : ""
fillMode: Image.PreserveAspectCrop
asynchronous: false
cache: true
smooth: true
}
Rectangle {
anchors.fill: parent
color: Util.alpha(root.dimColor, item.selected ? 0 : 0.42)
}
}
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "transparent"
strokeColor: item.selected ? root.selectedBorder : root.unselectedBorder
strokeWidth: item.selected ? 3 : 1
startX: item.topLeft; startY: 0
PathLine { x: item.topRight; y: 0 }
PathLine { x: item.bottomRight; y: item.height }
PathLine { x: item.bottomLeft; y: item.height }
PathLine { x: item.topLeft; y: 0 }
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: item.selected ? root.applySelected() : root.select(index)
}
}
}
}
Text {
id: selectedLabel
textFormat: Text.PlainText
visible: root.showLabels
anchors.top: carousel.bottom
anchors.topMargin: Style.space(16)
anchors.horizontalCenter: carousel.horizontalCenter
width: root.expandedWidth
text: root.currentLabel()
color: root.foreground
style: Text.Outline
styleColor: Util.alpha(root.dimColor, 0.7)
font.pixelSize: Style.font.display
font.weight: Font.DemiBold
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Text {
textFormat: Text.PlainText
visible: root.filterable && root.filterText
anchors.top: selectedLabel.bottom
anchors.topMargin: Style.space(8)
anchors.horizontalCenter: carousel.horizontalCenter
width: root.expandedWidth
text: root.filterText
color: root.foreground
opacity: 0.85
style: Text.Outline
styleColor: Util.alpha(root.dimColor, 0.7)
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
}
}
@@ -0,0 +1,97 @@
function nameForPath(path) {
return String(path || "").split("/").pop().replace(/\.[^/.]+$/, "")
}
function labelForPath(path) {
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
}
function loadRows(rows) {
var images = []
var seen = {}
var paths = String(rows || "").split("\n")
for (var i = 0; i < paths.length; i++) {
var row = paths[i]
if (!row) continue
var columns = row.split("\t")
var path = columns[0]
if (!path) continue
var fileName = path.split("/").pop()
if (seen[fileName]) continue
seen[fileName] = true
images.push({
filePath: path,
fileName: fileName,
thumbnailPath: columns[1] || path
})
}
return images
}
function itemMatches(images, index, filterText) {
if (!Array.isArray(images) || index < 0 || index >= images.length) return false
var needle = String(filterText || "").toLowerCase()
if (!needle) return true
var path = String(images[index].filePath || "")
return nameForPath(path).toLowerCase().indexOf(needle) !== -1
|| labelForPath(path).toLowerCase().indexOf(needle) !== -1
}
function firstMatchingIndex(images, filterText) {
var values = Array.isArray(images) ? images : []
for (var i = 0; i < values.length; i++) {
if (itemMatches(values, i, filterText)) return i
}
return -1
}
function filteredPosition(images, index, filterText) {
if (!filterText) return index
var position = 0
for (var i = 0; i < index; i++) {
if (itemMatches(images, i, filterText)) position++
}
return position
}
function selectedFilteredPosition(images, selectedIndex, filterText) {
if (!filterText) return selectedIndex
return itemMatches(images, selectedIndex, filterText) ? filteredPosition(images, selectedIndex, filterText) : 0
}
function indexForSelectedImage(images, selectedImage) {
var values = Array.isArray(images) ? images : []
for (var i = 0; i < values.length; i++) {
if (values[i].filePath === selectedImage) return i
}
return 0
}
function nextSelectedIndexForFilter(images, selectedIndex, filterText) {
if (itemMatches(images, selectedIndex, filterText)) return selectedIndex
return firstMatchingIndex(images, filterText)
}
if (typeof module !== "undefined") {
module.exports = {
nameForPath: nameForPath,
labelForPath: labelForPath,
loadRows: loadRows,
itemMatches: itemMatches,
firstMatchingIndex: firstMatchingIndex,
filteredPosition: filteredPosition,
selectedFilteredPosition: selectedFilteredPosition,
indexForSelectedImage: indexForSelectedImage,
nextSelectedIndexForFilter: nextSelectedIndexForFilter
}
}
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
image_dirs=${1:-}
cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/blob/image-selector
index_file="$cache_dir/index.tsv"
mkdir -p "$cache_dir"
thumbnail_for() {
local image="$1"
local signature hash thumbnail legacy_hash
signature=$(stat -Lc '%s:%Y' "$image") || return
hash=$(awk -F '\t' -v path="$image" -v sig="$signature" '$1 == path && $2 == sig { print $3; exit }' "$index_file" 2>/dev/null)
if [[ -z $hash ]]; then
hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1)
fi
thumbnail="$cache_dir/$hash.jpg"
if [[ ! -f $thumbnail ]]; then
# Older on-demand picker code keyed fallback thumbnails by file content.
# Keep finding those if a user still has them cached.
legacy_hash=$(md5sum "$image" 2>/dev/null | cut -d ' ' -f 1)
[[ -n $legacy_hash && -f $cache_dir/$legacy_hash.jpg ]] && thumbnail="$cache_dir/$legacy_hash.jpg"
fi
if [[ -f $thumbnail ]]; then
printf '%s' "$thumbnail"
else
printf '%s' "$image"
fi
}
while IFS= read -r dir; do
[[ -n $dir && -d $dir ]] || continue
find -L "$dir" -maxdepth 1 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \
-print0 2>/dev/null
done <<<"$image_dirs" | sort -z | while IFS= read -r -d '' image; do
thumbnail=$(thumbnail_for "$image")
[[ -n $thumbnail ]] || continue
printf '%s\t%s\n' "$image" "$thumbnail"
done
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.image-picker",
"name": "Image picker",
"version": "1.0.0",
"author": "Blob",
"description": "Image-grid selector overlay used for wallpapers, themes, and any other directory of images",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "ImagePicker.qml"
}
}
+41
View File
@@ -0,0 +1,41 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
readonly property string home: Quickshell.env("HOME")
readonly property string brandingPath: home + "/.config/blob/branding/screensaver.txt"
readonly property string palettePath: home + "/.local/state/blob/current/theme/colors.toml"
property string brandingText: ""
property string paletteColor4: ""
function readPaletteColor4(raw) {
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var match = lines[i].match(/^\s*color4\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
if (match) return match[1]
}
return ""
}
FileView {
path: root.brandingPath
watchChanges: true
printErrors: false
onLoaded: root.brandingText = text()
onLoadFailed: root.brandingText = ""
onFileChanged: reload()
}
FileView {
path: root.palettePath
watchChanges: true
printErrors: false
onLoaded: root.paletteColor4 = root.readPaletteColor4(text())
onLoadFailed: root.paletteColor4 = ""
onFileChanged: reload()
}
}
+249
View File
@@ -0,0 +1,249 @@
import QtQuick
import QtQuick.Effects
import qs.Commons
import qs.Ui
Item {
id: root
property string backgroundPath: ""
property int backgroundVersion: 0
property bool fingerprintConfigured: false
property bool authenticatingPassword: false
property string brandingText: ""
property string paletteColor4: ""
property string failureMessage: ""
property int failedAttempts: 0
property bool inputEnabled: true
property bool loadBackground: true
property string passwordText: ""
property bool syncingPasswordText: false
readonly property string placeholderText: "Enter Password"
readonly property int fieldWidth: 381
readonly property int fieldHeight: 67
readonly property int outlineThickness: 2
readonly property int fieldRadius: 0
readonly property int fieldFontSize: Math.round(Style.font.heading * 1.125)
readonly property int passwordDotFontSize: Math.round(Style.font.heading * 1.33)
readonly property int passwordDotLetterSpacing: Math.round(Style.font.heading * 0.19)
// Space to keep clear on each side of the field for the fingerprint icon
// (icon width plus a gap) so the centered dots never run under it.
readonly property real fingerprintReserve: fingerprintConfigured ? Math.round(fingerprintIcon.implicitWidth + 12) : 0
// Shrink the dots to fit once the password outgrows the field, so every
// keystroke stays visible — otherwise long passwords clip with no feedback.
readonly property real passwordDotScale: dotMetrics.advanceWidth > 0
? Math.min(1, (passwordInput.width - 4) / dotMetrics.advanceWidth)
: 1
readonly property bool showPasswordCursor: inputEnabled && !authenticatingPassword && failureMessage.length === 0
readonly property bool errorState: failureMessage.length > 0
readonly property int brandingGap: Style.space(48)
readonly property int brandingMaxWidth: 1100
readonly property int brandingMaxFontSize: Math.round(Style.font.heading * 1.5)
readonly property bool inputActive: passwordText.length > 0 || authenticatingPassword
readonly property color inputRestingBorder: paletteColor4.length > 0 ? paletteColor4 : Color.accent
readonly property color inputBorderColor: errorState
? Color.urgent
: (inputActive ? Color.accent : Util.alpha(root.inputRestingBorder, 0.5))
readonly property color inputBackground: Util.alpha(Color.background, 0.6)
readonly property var inputBorderSpec: Border.flat(root.inputBorderColor, root.outlineThickness)
signal submitPassword(string password)
signal passwordTextEdited(string password)
signal clearFailureRequested()
signal wakeRequested()
// Cache-busts the lock background by appending `?v=`. Adding a query
// string keeps Image's loader happy while forcing it to reload when the
// user picks a new background mid-session.
function fileUrl(path) {
if (!path) return ""
var encoded = String(path).split("/").map(encodeURIComponent).join("/")
return "file://" + encoded + "?v=" + backgroundVersion
}
function forcePasswordFocus() {
passwordInput.forceActiveFocus()
}
function clearPassword() {
passwordTextEdited("")
}
function syncPasswordText() {
if (passwordInput.text === passwordText) return
syncingPasswordText = true
passwordInput.text = passwordText
syncingPasswordText = false
}
onPasswordTextChanged: syncPasswordText()
onInputEnabledChanged: {
if (inputEnabled) Qt.callLater(forcePasswordFocus)
}
Component.onCompleted: {
syncPasswordText()
if (inputEnabled) Qt.callLater(forcePasswordFocus)
}
// Measures the masked password at full size; passwordDotScale compares this
// against the field width to decide how far the dots must shrink to fit.
TextMetrics {
id: dotMetrics
font.family: Style.font.family
font.pixelSize: root.passwordDotFontSize
font.letterSpacing: root.passwordDotLetterSpacing
text: "●".repeat(passwordInput.text.length)
}
Rectangle {
anchors.fill: parent
color: Color.background
Image {
id: wallpaper
anchors.fill: parent
source: root.loadBackground ? root.fileUrl(root.backgroundPath) : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
sourceSize.width: width
sourceSize.height: height
}
MultiEffect {
anchors.fill: wallpaper
source: wallpaper
autoPaddingEnabled: false
blurEnabled: root.loadBackground && wallpaper.status === Image.Ready
blur: 1.0
blurMax: 128
blurMultiplier: 1.25
contrast: -0.08
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: { root.wakeRequested(); root.forcePasswordFocus() }
onPositionChanged: root.wakeRequested()
}
Text {
id: branding
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: inputField.top
anchors.bottomMargin: root.brandingGap
width: Math.min(parent.width * 0.86, root.brandingMaxWidth)
height: Math.max(0, inputField.y - root.brandingGap * 2)
visible: root.brandingText.length > 0 && height > 0
text: root.brandingText
textFormat: Text.PlainText
color: Color.lock.text
font.family: Style.font.family
font.pixelSize: root.brandingMaxFontSize
minimumPixelSize: 4
fontSizeMode: Text.Fit
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignBottom
}
BorderSurface {
id: inputField
width: root.fieldWidth
height: root.fieldHeight
anchors.centerIn: parent
color: root.inputBackground
borderSpec: root.inputBorderSpec
radius: root.fieldRadius
clip: true
TextInput {
id: passwordInput
anchors.fill: parent
anchors.topMargin: inputField.borderTop
// Reserve the fingerprint icon's width on both sides so the centered
// dots stay symmetric and never slide under the icon as they grow.
anchors.rightMargin: inputField.borderRight + 18 + root.fingerprintReserve
anchors.bottomMargin: inputField.borderBottom
anchors.leftMargin: inputField.borderLeft + 18 + root.fingerprintReserve
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
activeFocusOnPress: true
clip: true
enabled: root.inputEnabled && !root.authenticatingPassword
readOnly: root.authenticatingPassword
echoMode: TextInput.Password
passwordCharacter: "\u25CF"
passwordMaskDelay: 0
color: Color.lock.text
selectionColor: Color.lock.selection
selectedTextColor: Color.lock.text
font.family: Style.font.family
font.pixelSize: text.length > 0 ? Math.max(1, Math.floor(root.passwordDotFontSize * root.passwordDotScale)) : root.fieldFontSize
font.letterSpacing: text.length > 0 ? root.passwordDotLetterSpacing * root.passwordDotScale : 0
cursorVisible: activeFocus && root.showPasswordCursor && text.length > 0
cursorDelegate: Rectangle {
width: 2
color: Color.lock.text
visible: passwordInput.cursorVisible
}
onTextChanged: {
if (!root.syncingPasswordText) root.passwordTextEdited(text)
if (text.length > 0) {
root.wakeRequested()
}
if (text.length > 0 && root.failureMessage.length > 0) root.clearFailureRequested()
}
onAccepted: {
var submitted = root.passwordText
root.passwordTextEdited("")
if (submitted.length > 0) root.submitPassword(submitted)
}
Keys.onPressed: function(event) {
root.wakeRequested()
if (event.key === Qt.Key_Escape || (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_U)) {
root.passwordTextEdited("")
event.accepted = true
}
}
}
Text {
textFormat: Text.PlainText
anchors.fill: passwordInput
text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText)
visible: passwordInput.text.length === 0
color: root.authenticatingPassword ? Color.lock.text : (root.failureMessage.length > 0 ? Color.lock.textError : Color.lock.placeholder)
font.family: Style.font.family
font.pixelSize: root.fieldFontSize
font.italic: !root.authenticatingPassword && root.failureMessage.length > 0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Fingerprint hint pinned inside the field's right edge when a sensor is
// enrolled, so the user knows they can touch to unlock instead of typing.
// Matches hyprlock, which draws its fingerprint icon in the same spot.
Text {
id: fingerprintIcon
objectName: "fingerprintIndicator"
anchors.right: parent.right
anchors.rightMargin: inputField.borderRight + 18
anchors.verticalCenter: parent.verticalCenter
visible: root.fingerprintConfigured
text: "󰈷"
color: Color.lock.placeholder
font.family: Style.font.family
font.pixelSize: Math.round(root.fieldFontSize * 1.1)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
+559
View File
@@ -0,0 +1,559 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
import Quickshell.Wayland
import qs.Commons
Item {
id: root
property var shell: null
property string blobPath: ""
readonly property string home: Quickshell.env("HOME")
readonly property string stateHome: home + "/.local/state"
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
readonly property string currentBackgroundLink: stateHome + "/blob/current/background"
property bool lockRequested: false
property bool pendingSessionLock: false
property bool authenticatingPassword: false
property bool fingerprintAuthenticating: false
property bool passwordPamConfigured: false
property bool fingerprintConfigured: false
property bool previewVisible: false
property string enteredPassword: ""
property string pendingPassword: ""
property string failureMessage: ""
property int failedAttempts: 0
property string backgroundPath: ""
property int backgroundVersion: 0
property string lastEvent: "init"
property string lastEventAt: ""
property bool strandedLock: false
property bool strandedLockResolved: false
readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure
readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating
function realScreenCount() {
var screens = Quickshell.screens || []
var count = 0
for (var i = 0; i < screens.length; i++) {
var screen = screens[i]
if (screen && screen.name && screen.width > 0 && screen.height > 0) count += 1
}
return count
}
function hasRealScreen() {
return realScreenCount() > 0
}
function queueSessionLock() {
pendingSessionLock = true
if (!sessionLockStabilizeTimer.running) logEvent("lock-pending: screen-stabilizing")
sessionLockStabilizeTimer.restart()
if (!pendingSessionLockTimer.running) pendingSessionLockTimer.start()
}
function requestSessionLock() {
if (!lockRequested || sessionLock.locked || sessionLock.secure) return
if (sessionLockStabilizeTimer.running) return
if (!hasRealScreen()) {
if (!pendingSessionLock || lastEvent !== "lock-pending: no-real-screen") logEvent("lock-pending: no-real-screen")
pendingSessionLock = true
if (!pendingSessionLockTimer.running) pendingSessionLockTimer.start()
return
}
pendingSessionLock = false
pendingSessionLockTimer.stop()
sessionLock.locked = true
}
// ext-session-lock outlives its client, and a restart carries no lock over, so
// a session locked this early is an orphan behind Hyprland's failsafe. Outputs
// are often still absent here, so ask until the answer means something.
function checkStrandedLock() {
if (strandedLockResolved || strandedLockCheckProc.running) return
// A lock this shell took is nobody's orphan.
if (locked || lockRequested) {
strandedLockResolved = true
return
}
strandedLockCheckProc.running = true
}
function recoverStrandedLock() {
if (!strandedLock || locked || !passwordPamConfigured) return
strandedLock = false
logEvent("lock-stranded: recovering")
beginLock()
}
function refreshBackground() {
if (!readlinkProc.running) readlinkProc.running = true
}
function refreshFingerprintStatus() {
if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true
}
function logEvent(event) {
lastEvent = event
lastEventAt = new Date().toISOString()
console.log("blob lock " + lastEventAt + " " + event)
}
function resetAuthenticationState() {
enteredPassword = ""
pendingPassword = ""
failureMessage = ""
failedAttempts = 0
authenticatingPassword = false
fingerprintAuthenticating = false
fingerprintRetryTimer.stop()
if (passwordPam.active) passwordPam.abort()
if (fingerprintPam.active) fingerprintPam.abort()
}
function beginLock() {
if (!passwordPamConfigured) {
logEvent("lock-denied: missing-pam")
return false
}
resetAuthenticationState()
lockRequested = true
armBlankTimer()
logEvent("lock-requested")
queueSessionLock()
Qt.callLater(function() {
root.refreshBackground()
root.refreshFingerprintStatus()
})
return true
}
function finishUnlock() {
if (!root.locked && !lockRequested) return
lockRequested = false
pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
resetAuthenticationState()
idleBlankTimer.stop()
sessionLock.locked = false
logEvent("unlocked")
runWake()
}
function armBlankTimer() {
idleBlankTimer.armedAt = Date.now()
idleBlankTimer.restart()
}
function runWake() {
if (!wakeProcess.running) wakeProcess.running = true
if (lockRequested) armBlankTimer()
}
function runBlank() {
if (!blankProcess.running) blankProcess.running = true
}
function submitPassword(value) {
var password = String(value || "")
if (!lockRequested || authenticatingPassword || password.length === 0) return
runWake()
pendingPassword = password
failureMessage = ""
authenticatingPassword = true
if (!passwordPam.start()) {
handlePasswordFailure()
return
}
Qt.callLater(respondToPasswordPrompt)
}
function respondToPasswordPrompt() {
if (!authenticatingPassword || !passwordPam.active || !passwordPam.responseRequired) return
passwordPam.respond(pendingPassword)
}
function handlePasswordFailure() {
if (!lockRequested) return
authenticatingPassword = false
enteredPassword = ""
pendingPassword = ""
failedAttempts += 1
failureMessage = "Authentication failed (" + failedAttempts + ")"
runWake()
}
function startFingerprint() {
if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return
if (fingerprintPam.active || fingerprintAuthenticating) return
fingerprintAuthenticating = true
if (!fingerprintPam.start()) {
fingerprintAuthenticating = false
}
}
function handleFingerprintFinished(result) {
fingerprintAuthenticating = false
if (!lockRequested) return
if (result === PamResult.Success) {
finishUnlock()
} else if (fingerprintConfigured) {
fingerprintRetryTimer.restart()
}
}
WlSessionLock {
id: sessionLock
locked: false
onSecureStateChanged: {
root.logEvent("secure=" + secure)
if (secure) {
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
root.startFingerprint()
}
}
onLockStateChanged: {
root.logEvent("session-locked=" + locked)
if (locked) {
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
}
if (!locked && root.lockRequested) {
root.lockRequested = false
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
root.resetAuthenticationState()
root.runWake()
}
}
WlSessionLockSurface {
id: lockSurface
color: Color.background
LockView {
id: lockView
anchors.fill: parent
backgroundPath: root.backgroundPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured
authenticatingPassword: root.authenticatingPassword
failureMessage: root.failureMessage
failedAttempts: root.failedAttempts
inputEnabled: root.lockRequested
loadBackground: root.locked
brandingText: brandingSource.brandingText
paletteColor4: brandingSource.paletteColor4
passwordText: root.enteredPassword
onPasswordTextEdited: function(password) { root.enteredPassword = password }
onSubmitPassword: function(password) { root.submitPassword(password) }
onClearFailureRequested: root.failureMessage = ""
onWakeRequested: root.runWake()
}
}
}
PanelWindow {
id: previewWindow
visible: root.previewVisible
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-lock-preview"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
LockView {
anchors.fill: parent
backgroundPath: root.backgroundPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured
authenticatingPassword: false
failureMessage: ""
failedAttempts: 0
inputEnabled: false
loadBackground: root.previewVisible
passwordText: ""
brandingText: brandingSource.brandingText
paletteColor4: brandingSource.paletteColor4
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: root.previewVisible = false
}
}
PamContext {
id: passwordPam
config: "blob-lock-password"
user: root.userName
onResponseRequiredChanged: root.respondToPasswordPrompt()
onPamMessage: root.respondToPasswordPrompt()
onCompleted: function(result) {
root.authenticatingPassword = false
root.pendingPassword = ""
if (!root.lockRequested) return
if (result === PamResult.Success) root.finishUnlock()
else root.handlePasswordFailure()
}
onError: function(error) {
root.handlePasswordFailure()
}
}
PamContext {
id: fingerprintPam
config: "blob-lock-fingerprint"
user: root.userName
onCompleted: function(result) {
root.handleFingerprintFinished(result)
}
onError: function(error) {
root.fingerprintAuthenticating = false
if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart()
}
}
Timer {
id: fingerprintRetryTimer
interval: 250
repeat: false
onTriggered: root.startFingerprint()
}
Process {
id: readlinkProc
command: ["readlink", "-f", root.currentBackgroundLink]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var next = String(text || "").trim()
if (next !== root.backgroundPath) {
root.backgroundPath = next
root.backgroundVersion += 1
}
}
}
}
Process {
id: fingerprintCheckProc
command: ["bash", "-c", "if [[ -f /etc/pam.d/blob-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1 && fprintd-list \"$USER\" 2>/dev/null | grep -qi finger; then echo yes; else echo no; fi"]
stdout: StdioCollector { id: fingerprintCheckStdout; waitForEnd: true }
onExited: {
root.fingerprintConfigured = String(fingerprintCheckStdout.text || "").trim() === "yes"
if (root.lockRequested && root.fingerprintConfigured) root.startFingerprint()
else if (!root.fingerprintConfigured && fingerprintPam.active) fingerprintPam.abort()
}
}
Process {
id: strandedLockCheckProc
command: ["bash", "-c", "blob-hypr-session-locked"]
onExited: function(exitCode) {
// No output to read the lock off yet.
if (exitCode === 2) return
root.strandedLockResolved = true
// A lock taken while this was in flight is this shell's own.
root.strandedLock = exitCode === 0 && !root.locked && !root.lockRequested
root.recoverStrandedLock()
}
}
Process {
id: wakeProcess
command: ["bash", "-c", "blob-system-wake"]
}
Process {
id: blankProcess
command: ["bash", "-c", "blob-brightness-keyboard off; blob-brightness-display off"]
}
Timer {
id: idleBlankTimer
interval: 5000
repeat: false
property double armedAt: 0
onTriggered: {
// A countdown frozen by suspend fires right after resume, which would
// blank the freshly woken unlock screen under the user. Wall-clock time
// exposes the gap: take a fresh run-up instead of blanking.
if (Date.now() - armedAt > interval + 2000) {
root.armBlankTimer()
return
}
// Only a password check in flight should hold the display up. The
// fingerprint PAM stays armed for the whole lock, so gating on
// `authenticating` here would keep the panel lit until unlock.
if (root.lockRequested && !root.authenticatingPassword) root.runBlank()
}
}
Timer {
id: sessionLockStabilizeTimer
interval: 500
repeat: false
onTriggered: root.requestSessionLock()
}
Timer {
id: pendingSessionLockTimer
interval: 100
repeat: true
onTriggered: root.requestSessionLock()
}
Timer {
id: strandedLockRetryTimer
interval: 500
repeat: true
// Covers the compositor settling; screens coming back re-arm it.
readonly property int budget: 20
property int remaining: 20
running: !root.strandedLockResolved && remaining > 0
function rearm() {
if (!root.strandedLockResolved) remaining = budget
}
onTriggered: {
remaining -= 1
root.checkStrandedLock()
}
}
Connections {
target: Quickshell
function onScreensChanged() {
root.requestSessionLock()
// A monitor still coming up has no workspace, so cannot answer yet.
strandedLockRetryTimer.rearm()
root.checkStrandedLock()
}
}
onAuthenticatingPasswordChanged: {
if (!lockRequested) return
if (authenticatingPassword) idleBlankTimer.stop()
else armBlankTimer()
}
BrandingSource {
id: brandingSource
}
FileView {
path: "/etc/pam.d/blob-lock-password"
watchChanges: true
printErrors: false
onLoaded: root.passwordPamConfigured = true
onLoadFailed: root.passwordPamConfigured = false
onFileChanged: reload()
}
// No lock before PAM is known good. An answer from before then may be stale --
// the failsafe can be cleared from a TTY -- so re-ask rather than act on it.
onPasswordPamConfiguredChanged: {
if (!passwordPamConfigured) return
strandedLock = false
strandedLockResolved = false
strandedLockRetryTimer.rearm()
checkStrandedLock()
}
Component.onCompleted: {
refreshBackground()
refreshFingerprintStatus()
checkStrandedLock()
}
IpcHandler {
target: "lock"
function lock(): string {
if (!root.passwordPamConfigured) return "missing-pam"
if (!root.locked && !root.beginLock()) return "failed"
return "ok"
}
function isLocked(): string {
return root.locked ? "true" : "false"
}
function status(): string {
return JSON.stringify({
locked: root.locked,
requested: root.lockRequested,
pending: root.pendingSessionLock,
sessionLocked: sessionLock.locked,
secure: sessionLock.secure,
realScreens: root.realScreenCount(),
passwordPam: root.passwordPamConfigured,
fingerprint: root.fingerprintConfigured,
authenticating: root.authenticating,
lastEvent: root.lastEvent,
lastEventAt: root.lastEventAt
})
}
function preview(): string {
root.refreshBackground()
root.refreshFingerprintStatus()
root.previewVisible = true
return "ok"
}
function hidePreview(): string {
root.previewVisible = false
return "ok"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.lock",
"name": "Lock Screen",
"version": "1.0.0",
"author": "Blob",
"description": "Quickshell session lock with separate password and fingerprint PAM flows.",
"blob": {
"capabilities": [
"authentication"
]
},
"kinds": [
"service"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}
+67
View File
@@ -0,0 +1,67 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.menu"
readonly property string iconPath: Quickshell.env("HOME") + "/.config/blob/branding/blob_icon.svg"
readonly property int iconSize: Math.round(Style.font.body * 1.35)
property string iconSvg: ""
readonly property string iconColor: hexColor(button.foreground)
readonly property string tintedSvg: iconSvg.replace(/fill="#000000"/g, 'fill="' + iconColor + '"')
readonly property string iconUrl: iconSvg.length > 0 ? "data:image/svg+xml;base64," + Qt.btoa(tintedSvg) : ""
readonly property bool iconReady: icon.status === Image.Ready
function hexColor(value) {
function channel(fraction) {
return ("0" + Math.round(fraction * 255).toString(16)).slice(-2)
}
return "#" + channel(value.r) + channel(value.g) + channel(value.b)
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
FileView {
path: root.iconPath
watchChanges: true
printErrors: false
onLoaded: root.iconSvg = text()
onLoadFailed: root.iconSvg = ""
onFileChanged: reload()
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: ""
fontFamily: "omarchy"
labelVisible: !root.iconReady
fixedWidth: root.iconReady ? root.iconSize + Style.spaceReal(15) : -1
horizontalMargin: 7.5
onPressed: function(button) {
if (!root.bar) return
if (button === Qt.RightButton) root.bar.run("xdg-terminal-exec")
else root.bar.run("blob-shell shell toggle blob.menu '{\"menu\":\"root\"}'")
}
Image {
id: icon
anchors.centerIn: parent
width: root.iconSize
height: root.iconSize
source: root.iconUrl
sourceSize.width: root.iconSize * 2
sourceSize.height: root.iconSize * 2
smooth: true
visible: root.iconReady
}
}
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
function stripJsonc(raw) {
return String(raw || "")
.replace(/^\s*\/\/[^\n]*(\n|$)/gm, "")
.replace(/,(\s*[}\]])/g, "$1")
}
function normalizeAliases(value) {
if (Array.isArray(value)) return value.filter(function(v) { return v })
if (typeof value === "string" && value) return [value]
return []
}
function normalizeItem(id, raw) {
var value = raw || {}
var aliases = normalizeAliases(value.aliases)
var parent = value.parent
if (parent === undefined)
parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root"
if (id === "root") parent = ""
var kind = value.action ? "action" : (value.target ? "link" : "menu")
return {
id: id,
parent: parent,
kind: kind,
icon: value.icon || "",
iconFont: value.iconFont || "",
label: value.label || id,
title: value.title || "",
target: value.target || "",
description: value.description || "",
action: value.action || "",
provider: value.provider || "",
aliases: aliases,
when: value.when || "",
checked: value.checked || ""
}
}
function parseMenuJsonc(raw) {
var stripped = stripJsonc(raw)
if (!stripped.trim()) return []
var parsed
try {
parsed = JSON.parse(stripped)
} catch (e) {
return []
}
if (typeof parsed !== "object" || parsed === null) return []
var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items))
? parsed.items
: parsed
var out = []
for (var id in source) {
var entry = source[id]
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
out.push(normalizeItem(id, entry))
}
return out
}
function mergeMenuSources(defaultItems, userItems) {
var nextItems = ({})
var nextOrder = []
var sources = [defaultItems || [], userItems || []]
for (var s = 0; s < sources.length; s++) {
var src = sources[s]
for (var i = 0; i < src.length; i++) {
var entry = src[i]
if (!entry || !entry.id) continue
if (!nextItems[entry.id]) nextOrder.push(entry.id)
var prior = nextItems[entry.id] || {}
var merged = {}
for (var k in prior) merged[k] = prior[k]
for (var k2 in entry) merged[k2] = entry[k2]
merged.id = entry.id
nextItems[entry.id] = merged
}
}
if (!nextItems.root) {
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
nextOrder.unshift("root")
}
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
return {
items: nextItems,
itemOrder: nextOrder
}
}
// Both merges below return fresh items/itemOrder objects for the caller to
// assign in one go. They must never write into the maps they are handed: those
// live in QML `var` properties, and an in-place write into such an object is
// occasionally dropped by the engine — the key lands with an undefined value.
// A lost write used to leave an id in itemOrder with no item behind it, and
// the next merge then kept that orphan and appended a second row for the same
// app, so the launcher listed it twice (and again on every later rescan).
// Swaps every app row for the current set. Rows keep the order they arrive in;
// ids already claimed (including duplicate desktop ids) are listed once.
function mergeAppRows(items, itemOrder, appRows) {
var source = items || ({})
var order = Array.isArray(itemOrder) ? itemOrder : []
var rows = Array.isArray(appRows) ? appRows : []
var nextItems = ({})
var nextOrder = []
for (var i = 0; i < order.length; i++) {
var id = order[i]
var existing = source[id]
// Orphans (an id with no item) are dropped rather than carried forward,
// so a single lost write cannot compound into a duplicate row.
if (!existing || existing.kind === "app") continue
nextItems[id] = existing
nextOrder.push(id)
}
for (var j = 0; j < rows.length; j++) {
var row = rows[j]
if (!row || !row.id || nextItems[row.id]) continue
row.order = nextOrder.length
nextItems[row.id] = row
nextOrder.push(row.id)
}
return { items: nextItems, itemOrder: nextOrder }
}
// Swaps the rows one provider contributed, leaving every other item untouched.
// Rows carry the id of the submenu that produced them, so a provider that runs
// again drops its previous batch — a plugin that was just enabled disappears
// from the Enable list — without disturbing static children declared in JSONC.
function swapProviderRows(items, itemOrder, menuId, rows) {
var source = items || ({})
var order = Array.isArray(itemOrder) ? itemOrder : []
var incoming = Array.isArray(rows) ? rows : []
var nextItems = ({})
var nextOrder = []
for (var i = 0; i < order.length; i++) {
var id = order[i]
var existing = source[id]
if (!existing || existing.providerMenu === menuId) continue
nextItems[id] = existing
nextOrder.push(id)
}
for (var j = 0; j < incoming.length; j++) {
var row = incoming[j]
if (!row || !row.id || nextItems[row.id]) continue
row.providerMenu = menuId
row.order = nextOrder.length
nextItems[row.id] = row
nextOrder.push(row.id)
}
return { items: nextItems, itemOrder: nextOrder }
}
function item(items, id) {
return items && items[id] ? items[id] : null
}
// Routes may name a real id (`system`, `setup.power`) or an alias declared in
// JSONC (`power-menu`, `settings`). An exact id beats any alias, and app rows
// are never routable: their aliases carry .desktop Keywords and GenericName
// for search, so an installed application could otherwise shadow a menu route
// (htop ships `Keywords=system;...`). Unknown strings fall through as the
// literal input so misspellings still attempt to open that id.
function resolveRoute(items, itemOrder, input) {
var raw = String(input || "").toLowerCase().replace(/_/g, "-")
if (!raw || raw === "go" || raw === "menu") return "root"
if (item(items, raw)) return raw
var order = Array.isArray(itemOrder) ? itemOrder : []
for (var i = 0; i < order.length; i++) {
var entry = item(items, order[i])
if (!entry || entry.kind === "app" || !entry.aliases) continue
for (var j = 0; j < entry.aliases.length; j++) {
var alias = String(entry.aliases[j] || "").toLowerCase().replace(/_/g, "-")
if (alias === raw) return entry.id
}
}
return raw
}
function slugify(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
}
function depthFor(items, id) {
var depth = 0
var current = item(items, id)
var guard = 0
while (current && current.parent && current.parent !== "root" && guard < 32) {
depth += 1
current = item(items, current.parent)
guard += 1
}
return depth
}
function pathFor(items, id) {
var labels = []
var current = item(items, id)
var guard = 0
while (current && current.id !== "root" && guard < 32) {
labels.unshift(current.label)
current = item(items, current.parent)
guard += 1
}
return labels.join(" ")
}
function parentPathFor(items, id) {
var entry = item(items, id)
if (!entry || !entry.parent || entry.parent === "root") return ""
return pathFor(items, entry.parent)
}
function isDescendantOf(items, id, ancestorId) {
if (ancestorId === "root") return id !== "root"
var current = item(items, id)
var guard = 0
while (current && current.parent && guard < 32) {
if (current.parent === ancestorId) return true
current = item(items, current.parent)
guard += 1
}
return false
}
function childCount(items, itemOrder, id) {
var count = 0
var order = Array.isArray(itemOrder) ? itemOrder : []
for (var i = 0; i < order.length; i++) {
var entry = item(items, order[i])
if (entry && entry.parent === id) count += 1
}
return count
}
function isVisible(items, itemOrder, whenResults, entry, depth) {
if (!entry) return false
if (entry.when && whenResults && whenResults[entry.id] === false) return false
if (entry.kind !== "menu" && entry.kind !== "link") return true
if (entry.provider) return true
var guard = depth || 0
if (guard >= 32) return false
var target = entry.kind === "link" ? entry.target : entry.id
var order = Array.isArray(itemOrder) ? itemOrder : []
for (var i = 0; i < order.length; i++) {
var child = item(items, order[i])
if (child && child.parent === target && isVisible(items, itemOrder, whenResults, child, guard + 1)) return true
}
return false
}
function labelFor(entry, checkedResults) {
if (!entry) return ""
if (entry.checked && checkedResults && checkedResults[entry.id]) return entry.label + " ✓"
return entry.label
}
function searchableToken(value) {
return String(value || "").replace(/[._-]+/g, " ")
}
function leafIdFor(id) {
var parts = String(id || "").split(".")
return parts.length > 0 ? parts[parts.length - 1] : id
}
function nameSearchText(entry) {
if (!entry) return ""
var aliases = []
var values = Array.isArray(entry.aliases) ? entry.aliases : []
for (var i = 0; i < values.length; i++) aliases.push(searchableToken(values[i]))
return [entry.label, searchableToken(leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase()
}
function termInSearchWords(term, text) {
var words = String(text || "").toLowerCase().split(/\s+/)
for (var i = 0; i < words.length; i++) {
if (words[i] === term) return true
}
return false
}
function descriptionTextMatches(query, text) {
var terms = String(query || "").toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (terms[i] && !termInSearchWords(terms[i], text)) return false
}
return true
}
function matchesQuery(entry, query, visible) {
if (!entry || entry.id === "root") return false
if (!visible) return false
var nameText = nameSearchText(entry)
var descriptionText = String(entry.description || "").toLowerCase()
var terms = String(query || "").toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (!terms[i]) continue
if (nameText.indexOf(terms[i]) >= 0) continue
if (termInSearchWords(terms[i], descriptionText)) continue
return false
}
return true
}
function searchScore(items, entry, query) {
var needle = String(query || "").toLowerCase().trim()
var label = entry.label.toLowerCase()
var nameText = nameSearchText(entry)
var descriptionText = String(entry.description || "").toLowerCase()
var score = 80
if (label === needle) score = entry.parent === "root" ? 2 : 0
// An installed app whose name contains the query as a whole word ("zen"
// for Zen Browser) beats exact-labeled menu entries like Install > Zen.
else if (entry.kind === "app" && label.split(/\s+/).indexOf(needle) >= 0) score = 0
else if (label.indexOf(needle) === 0) score = 10
else if (label.indexOf(needle) >= 0) score = 30
else if (nameText.indexOf(needle) >= 0) score = 40
else if (descriptionTextMatches(needle, descriptionText)) score = 60
if (entry.kind === "menu" || entry.kind === "link") score -= 2
// App rows sort after all menu items, so they lose the tiebreak below to an
// equal match. Outrank those, but stay inside the tier so better ones win.
if (entry.kind === "app") score -= 5
return score * 1000 + depthFor(items, entry.id) * 25 + entry.order
}
function displayRow(items, itemOrder, checkedResults, entry, detail, score, section) {
var target = entry.kind === "link" ? entry.target : entry.id
return {
itemId: entry.id,
kind: entry.kind,
icon: entry.icon,
iconFont: entry.iconFont || "",
appIcon: entry.appIcon || "",
appId: entry.appId || "",
label: labelFor(entry, checkedResults),
target: target,
detail: detail || "",
path: pathFor(items, entry.id),
childCount: (entry.kind === "menu" || entry.kind === "link") ? childCount(items, itemOrder, target) : 0,
action: entry.action || "",
provider: entry.provider || "",
score: score || 0,
section: section || ""
}
}
// Commands a `checked:` expression reads a value out of. Every sibling row
// asks the same one -- Defaults > Browser has seven rows all comparing
// against `blob-default-browser` -- so the batch runs it once and the rows
// read the captured answer.
//
// The capture has to be eager. These are read inside `$(...)`, and a value
// cached while one expression runs lives in that subshell only, so a lazy
// memo never survives to the expression after it.
var GUARD_READERS = [
"blob-default-browser",
"blob-default-editor",
"blob-default-terminal",
"blob-network-dns"
]
// Package and command presence account for most of what the guards ask, and
// asked one at a time they are almost all fork: the shipped menu spends over
// a second on them. Answer them inside the guard process instead. These
// shadow the real commands for the batch only, so they have to agree with
// them everywhere, including for no arguments at all (present is true of
// nothing, missing is not).
//
// `pacman -Q` resolves a name through what installed packages provide, not
// just what they are called -- with gvim installed it reports `vim` as
// present -- so the set has to carry provides too, or `install.editor.vim`
// comes back and offers to install what is already there. A version
// constraint (`bash>=1`) is not a name any set can answer, so it goes to
// pacman itself; no shipped guard writes one.
//
// `pacman -Qi` wraps a long list across continuation lines whenever COLUMNS
// is set in the environment, which a login shell may well have done, so the
// parser follows the indented lines rather than reading the first one and
// dropping half of what is installed.
function guardHelpers() {
return 'declare -A __blob_pkgs=()\n'
+ 'mapfile -t __blob_pkg_names < <({ pacman -Qq; LC_ALL=C pacman -Qi'
+ " | awk '/^[A-Za-z]/ { provides = ($0 ~ /^Provides/); sub(/^[^:]*: /, \"\") }"
+ ' provides && $0 != "None" { n = split($0, p, " ");'
+ ' for (i = 1; i <= n; i++) { sub(/[<>=].*/, "", p[i]); print p[i] } }\'; } 2>/dev/null)\n'
+ 'for __blob_pkg in "${__blob_pkg_names[@]}"; do __blob_pkgs[$__blob_pkg]=1; done\n'
+ '__blob_pkg_has() { [[ -n ${__blob_pkgs[$1]-} ]] && return 0; '
+ '[[ $1 == *[\\<\\>=]* ]] && { pacman -Q "$1" &>/dev/null; return; }; return 1; }\n'
+ 'blob-pkg-present() { local p; for p in "$@"; do __blob_pkg_has "$p" || return 1; done; return 0; }\n'
+ 'blob-pkg-missing() { local p; for p in "$@"; do __blob_pkg_has "$p" || return 0; done; return 1; }\n'
+ 'blob-cmd-present() { local c; for c in "$@"; do command -v "$c" &>/dev/null || return 1; done; return 0; }\n'
+ 'blob-cmd-missing() { local c; for c in "$@"; do command -v "$c" &>/dev/null || return 0; done; return 1; }\n'
}
// Substitute the captured answer into the expression rather than shadowing
// the reader with a function. `$(reader)` and the variable holding what it
// printed are interchangeable -- both strip trailing newlines, both split the
// same way unquoted -- while a function would also catch `command -v reader`,
// `VAR=x reader`, and every other form, and answer those wrong. Anything but
// the plain substitution is left alone to run the real command.
function guardPrelude(guards) {
var prelude = guardHelpers()
for (var i = 0; i < GUARD_READERS.length; i++) {
// The guards arrive already substituted, so what marks a reader as wanted
// is the slot standing in for it, not the call it replaced.
if (guards.indexOf(guardReaderSlot(i)) < 0) continue
// `|| :` so a reader that exits nonzero cannot take the batch down with
// it under a login shell that turned on errexit.
prelude += "__blob_read_" + i + "=$(" + GUARD_READERS[i] + " 2>/dev/null) || :\n"
}
return prelude
}
function guardReaderSlot(index) {
return "${__blob_read_" + index + "}"
}
function substituteGuardReaders(expression) {
for (var i = 0; i < GUARD_READERS.length; i++)
expression = expression.split("$(" + GUARD_READERS[i] + ")").join(guardReaderSlot(i))
return expression
}
function guardLine(id, tag, expression) {
return "if { " + substituteGuardReaders(expression) + "; } >/dev/null 2>&1; then echo "
+ id + ":" + tag + ":1; else echo " + id + ":" + tag + ":0; fi\n"
}
// One bash script for every `when:` and `checked:` in the menu, reporting
// `<id>:<w|c>:<0|1>` per line. Speed is the whole point: the menu opens on
// the last evaluation's answers, so however long this takes is how long a row
// can contradict the state it describes.
function guardScript(items) {
var guards = ""
var ids = Object.keys(items || {})
for (var i = 0; i < ids.length; i++) {
var entry = items[ids[i]]
if (!entry) continue
if (entry.when) guards += guardLine(ids[i], "w", entry.when)
if (entry.checked) guards += guardLine(ids[i], "c", entry.checked)
}
return guards ? guardPrelude(guards) + guards : ""
}
if (typeof module !== "undefined") {
module.exports = {
guardReaders: GUARD_READERS,
guardScript: guardScript,
stripJsonc: stripJsonc,
normalizeAliases: normalizeAliases,
normalizeItem: normalizeItem,
parseMenuJsonc: parseMenuJsonc,
mergeMenuSources: mergeMenuSources,
mergeAppRows: mergeAppRows,
swapProviderRows: swapProviderRows,
item: item,
resolveRoute: resolveRoute,
slugify: slugify,
depthFor: depthFor,
pathFor: pathFor,
parentPathFor: parentPathFor,
isDescendantOf: isDescendantOf,
childCount: childCount,
isVisible: isVisible,
labelFor: labelFor,
searchableToken: searchableToken,
leafIdFor: leafIdFor,
nameSearchText: nameSearchText,
termInSearchWords: termInSearchWords,
descriptionTextMatches: descriptionTextMatches,
matchesQuery: matchesQuery,
searchScore: searchScore,
displayRow: displayRow
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"id": "blob.menu",
"name": "Blob menu",
"version": "1.0.0",
"author": "Blob",
"description": "Quickshell-powered Blob command menu",
"kinds": [
"menu",
"bar-widget"
],
"keepLoaded": true,
"entryPoints": {
"menu": "Menu.qml",
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Blob menu",
"description": "Launches the Blob menu",
"category": "Compositor",
"allowMultiple": false
}
}
@@ -0,0 +1,479 @@
function isChromiumDerived(app, appIcon) {
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
// True when a `<...>` run is an image tag, so the name is read the way Qt's
// parser reads it: after the `<`, the leading run of letters and digits.
//
// Skip everything up to that run rather than matching the separator, because
// there is no JavaScript expression for what Qt skips. QQuickStyledText calls
// skipSpace(), which is QChar::isSpace(), and that set is not `\s`: Qt counts
// U+0085 NEL and `\s` does not, while `\s` counts U+FEFF and Qt does not. A
// name read with `\s` therefore misses a tag written as `<`, U+0085, `img`:
// Qt skips the NEL, reads `img` and issues the GET, while the regex finds no
// name at all and the tag is kept. Measured against Qt 6.11.2.
//
// Over-skipping is the safe direction. It can only classify more runs as
// images, and dropping a run never manufactures a tag: a dropped run joins two
// stretches of text that each contain no `<`.
function isImageTag(tag) {
var name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag)
return !!name && name[1].toLowerCase() === "img"
}
// The body renders as StyledText so notifications can use the markup the
// body-markup capability advertises (see Service.qml). StyledText honours
// <img src>, and a remote src makes the shell issue an unauthenticated GET
// with no user action, so image tags go before the renderer sees them.
//
// Work in whole tags, never in substrings of one. A `<` opens a tag that runs
// to the next `>`, nested `<` and all, and only a tag whose own name is `img`
// is dropped.
//
// That is the conservative bound, not Qt's exact one: Qt lets a `>` inside a
// quoted attribute value pass without closing the tag, so a Qt tag can be
// longer than the run taken here. Do not "correct" this to match Qt. Taking
// the shorter run only ever splits one Qt tag into several, and a split can
// only expose an `<img` to be dropped, never hide one — whereas honouring
// quotes would let `<b title="a>b"><img src="http://host/x.png">` through.
//
// Deleting a substring is what makes a naive `/<img[^>]*>/g` unsafe. Given
//
// <im<img src="http://a/decoy.png">g src="http://a/beacon.png">
//
// Qt reads ONE malformed tag named `im` and renders nothing, but removing the
// inner match closes the surviving halves up into `<img src=".../beacon.png">`
// — a live tag the input never contained. The stripper would be manufacturing
// the very thing it exists to remove.
//
// Because every `<` opens a tag, the text between tags never contains one, so
// dropping a tag cannot splice its neighbours into a new one. That makes a
// single pass sufficient, with no re-scanning and no input bound to police.
function stripImageTags(text) {
var out = ""
var i = 0
while (i < text.length) {
var open = text.indexOf("<", i)
if (open === -1) {
out += text.slice(i)
break
}
out += text.slice(i, open)
// An unterminated tag at the end of the string still reaches the renderer,
// which closes it itself, so treat the remainder as one tag.
var close = text.indexOf(">", open)
var tag = close === -1 ? text.slice(open) : text.slice(open, close + 1)
if (!isImageTag(tag)) out += tag
i = close === -1 ? text.length : close + 1
}
return out
}
// What the card renders, and the last thing to touch the string before Qt parses
// it. The newline rewrite belongs here rather than in the card because it inserts
// `<br/>` into text stripImageTags chose to KEEP, and a kept tag may hold a `<` of
// its own: `<x`, newline, `<img src="http://…">` is one tag named `x` to both the
// stripper and Qt, until the rewrite splits it into `<x<br/>` and a live image tag
// the input never contained. Measured against Qt 6.11.2 — the rewritten form
// fetches, the original does not. So strip again after, and what Qt parses is what
// was checked last.
function styledBody(body, app, appIcon) {
return stripImageTags(sanitizeBody(body, app, appIcon).replace(/\r\n|\r|\n/g, "<br/>"))
}
function sanitizeBody(body, app, appIcon) {
var text = stripImageTags(String(body || ""))
if (!isChromiumDerived(app, appIcon)) return text
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
}
function summaryStartsWithGlyph(summary) {
var text = String(summary || "").replace(/^\s+/, "")
if (!text) return false
var offset = 1
var first = text.charCodeAt(0)
if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2
var spaces = 0
while (offset < text.length && text.charAt(offset) === " ") {
spaces++
offset++
}
return spaces >= 2
}
function shouldBypassDnd(notification, criticalUrgency) {
var appName = String((notification && notification.appName) || "")
if (appName === "blob-action") return true
return appName === "notify-send" && notification && notification.urgency === criticalUrgency
}
function isEphemeralApp(appName) {
var name = String(appName || "")
return name === "notify-send" || name === "blob-action"
}
function stringHint(hints, name) {
try {
if (hints) {
var value = hints[name]
if (value !== undefined && value !== null) return String(value)
}
} catch (e) {
}
return ""
}
function glyphFromHints(hints) {
return stringHint(hints, "blob-glyph")
}
// The click action: a JSON argv string from blob-notify-send
// --exec. Carried as data so a toast restored after a shell restart stays
// clickable (a libnotify action can't — its sender is gone). Run via
// Util.execArgv as bash positional parameters, never a shell string, so
// attacker-controlled values (a title, a filename) can't become commands.
function execArgvFromHints(hints) {
return stringHint(hints, "blob-exec")
}
// Validate a persisted blob-exec into a runnable argv, or null. This is
// a STRUCTURAL check only: it fails closed on a malformed hint (non-array, a
// non-string or empty program, or a leading-dash program that argv would read as
// an option). It does not judge intent — a well-formed ["bash","-c",…] is
// accepted. WHICH senders may set this hint is a separate boundary: any
// session-bus process can, by the freedesktop protocol's design (see
// docs/notifications.md), which is equivalent to same-uid code execution.
function parseExecArgv(value) {
var text = String(value || "")
if (!text) return null
var parsed
try {
parsed = JSON.parse(text)
} catch (e) {
return null
}
if (!Array.isArray(parsed) || parsed.length === 0) return null
for (var i = 0; i < parsed.length; i++) {
if (typeof parsed[i] !== "string") return null
}
if (!parsed[0] || parsed[0].charAt(0) === "-") return null
return parsed
}
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast
}
function snapshotOf(notification, timestamp) {
var n = notification || {}
var id = n.id || 0
var expireTimeout = Number(n.expireTimeout || 0)
if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0
return {
id: id,
originalId: id,
app: n.appName || "",
appIcon: n.appIcon || "",
summary: String(n.summary || ""),
body: n.body || "",
image: n.image || "",
glyph: glyphFromHints(n.hints),
execArgv: execArgvFromHints(n.hints),
urgency: n.urgency,
expireTimeout: expireTimeout,
timestamp: timestamp === undefined ? Date.now() : timestamp
}
}
// Everything the popup card draws, and therefore everything an in-place
// update has to write through to the row and its file.
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"]
function popupRoles() {
return POPUP_ROLES
}
// Whether a refresh has anything to write. Each property a client updates
// emits its own signal, and the catch-up refresh after a row is inserted
// usually finds the object exactly as it was snapshotted — without this,
// one update would rewrite the file several times over.
function popupRowChanged(row, updated) {
var current = row || {}
var next = updated || {}
for (var i = 0; i < POPUP_ROLES.length; i++) {
var role = POPUP_ROLES[i]
if (current[role] !== next[role]) return true
}
return false
}
// A client updating a notification through replaces_id keeps the identity of
// the popup it took over: the file name is the timestamp and id the popup was
// first persisted under, and the restore, replace and archive paths all key
// off that name. Only what the card draws comes from the updated object.
function replacementSnapshot(notification, originalId, timestamp) {
var updated = snapshotOf(notification, timestamp)
updated.id = originalId
updated.originalId = originalId
return updated
}
function historyEntry(value, normalUrgency) {
var e = value || {}
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
execArgv: e.execArgv || "",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
expireTimeout: 0,
timestamp: e.timestamp || 0
}
}
// notifications.json holds nothing but the last-set DND preference now that
// history is a directory of files. Older versions kept `pending`/`past`
// (and, older still, `entries`) arrays in there; their presence is reported
// so the service can rewrite the file without the dead payload.
function parseSettings(raw) {
var text = String(raw || "").trim()
if (!text) return { error: false, dnd: null, legacy: false }
try {
var parsed = JSON.parse(text)
return {
error: false,
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
legacy: !!(parsed && (parsed.pending || parsed.past || parsed.entries))
}
} catch (e) {
return { error: true, errorMessage: String(e), dnd: null, legacy: false }
}
}
// ---------------------------------------------------- popup persistence
//
// Each on-screen popup is mirrored to its own file under
// ~/.local/state/blob/notifications/ so toasts survive shell restarts
// (e.g. the restart `blob-update` performs). The file exists exactly as
// long as the popup is on screen: it is written when the toast appears and
// moved into the history/ subdirectory when the toast expires, is dismissed,
// or its action is invoked. History is those moved files, newest last-10.
function popupEntry(value, normalUrgency) {
var entry = historyEntry(value, normalUrgency)
var expire = Number((value || {}).expireTimeout || 0)
if (!isFinite(expire) || expire < 0) expire = 0
entry.expireTimeout = expire
// Absolute expiry deadline, set only when a restore resets a surviving
// popup's display lifetime. Kept out of the entry entirely when unset so
// restored rows match the roles of freshly received ones.
var deadline = Number((value || {}).deadline || 0)
if (isFinite(deadline) && deadline > 0) entry.deadline = deadline
return entry
}
function popupFileName(entry) {
return imageStem(entry) + ".json"
}
// ---------------------------------------------------- persisted images
//
// A notification's images only exist while it is live: Chromium-family
// senders (all Blob web apps) delete their scoped /tmp files on close,
// and image-data hints surface as in-process image:// URLs that die with
// the server object. Persisted entries therefore reference their own
// copies, named by the entry's file stem so cleanup can find them from
// the JSON file name alone.
var PERSISTED_IMAGE_ROLES = ["appIcon", "image"]
function imageStem(entry) {
var e = entry || {}
return String(e.timestamp || 0) + "-" + String(e.originalId || 0)
}
// The filesystem path behind a file-backed image value, or "" for anything
// a copy can't capture: themed icon names, in-process image:// URLs, empty.
function localImageFile(value) {
var s = String(value || "")
if (s.indexOf("file://") === 0) {
s = s.slice(7)
try { s = decodeURIComponent(s) } catch (e) {}
}
return s.charAt(0) === "/" ? s : ""
}
// The entry as it should hit the disk, plus the copies that make it true.
// File-backed images redirect to their copy under imagesDir; dead image://
// URLs drop to "" (the card falls back to the app icon). Already-redirected
// values map onto themselves and produce no copy, keeping restores no-ops.
function persistablePopup(entry, imagesDir) {
var e = entry || {}
var out = {}
for (var key in e) out[key] = e[key]
var copies = []
for (var i = 0; i < PERSISTED_IMAGE_ROLES.length; i++) {
var role = PERSISTED_IMAGE_ROLES[i]
var value = String(out[role] || "")
if (!value) continue
var source = localImageFile(value)
if (source) {
var copy = String(imagesDir || "") + imageStem(e) + "-" + role
if (source !== copy) copies.push({ from: source, to: copy })
out[role] = "file://" + copy
} else if (value.indexOf("image://") === 0) {
out[role] = ""
}
}
return { entry: out, copies: copies }
}
function serializePopup(entry, normalUrgency) {
// Compact (single-line) on purpose: restore cats every file together and
// parses line by line, which only works when each file is one line.
return JSON.stringify(popupEntry(entry, normalUrgency))
}
// Parse the concatenation of every persisted popup file into entries,
// newest-first. Deliberately NO dedupe by originalId: ids restart from 1
// with every server process, so two files sharing an id are usually
// different generations — dropping the older one would silently discard a
// restored critical alert the moment a fresh notification reuses its id.
// The one case that leaves a genuine duplicate (a crash between a
// replacement's write and the replaced file's delete) merely re-shows a
// superseded toast, which expires or is dismissed and cleans itself up.
function parsePopupFiles(raw, normalUrgency) {
var lines = String(raw || "").split("\n")
var entries = []
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
try {
var value = JSON.parse(line)
if (value && typeof value === "object") entries.push(popupEntry(value, normalUrgency))
} catch (e) {
// A torn write from a crash mid-save — skip the line, keep the rest.
}
}
entries.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return entries
}
// A persisted popup whose lifetime already ran out would have expired on
// screen had the shell kept running, so it is not restored. duration 0 means
// the popup never expires (critical urgency) and always survives restarts.
// A restore-reset deadline outranks the original timestamp: without it, a
// second restart would judge a re-shown toast by a clock that no longer
// governs its display and drop it while it is still on screen.
function popupExpired(entry, duration, now) {
var deadline = Number((entry || {}).deadline || 0)
if (isFinite(deadline) && deadline > 0) return Number(now) >= deadline
var lifetime = Number(duration || 0)
if (!isFinite(lifetime) || lifetime <= 0) return false
return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime
}
function popupPlacement(barPosition, barClearance, gapsOut) {
var position = String(barPosition || "top")
var clearance = Number(barClearance)
var gap = Number(gapsOut)
if (!isFinite(clearance)) clearance = 0
if (!isFinite(gap)) gap = 0
return {
anchors: { top: true, bottom: false, left: false, right: true },
margins: {
top: position === "top" ? clearance : gap,
bottom: gap,
left: gap,
right: position === "right" ? clearance : gap
}
}
}
// The archived files are the history. They are read back exactly like the
// live popup files, then normalized into history rows: replaying a toast
// must not inherit the original's expire timeout or restore deadline, so it
// gets the standard on-screen lifetime for its urgency instead.
//
// liveRows are the toasts still on screen when the replay was asked for.
// They belong in it — they're the newest notifications there are — but the
// directory read races their archival, so they're carried across by hand and
// keyed by file name (timestamp + id) to drop the copy the read already saw.
function historyRows(raw, liveRows, normalUrgency, limit) {
var max = limit === undefined || limit === null ? 10 : Number(limit)
if (isNaN(max)) max = 10
max = Math.max(0, max)
var out = []
var seen = {}
function collect(rows) {
for (var i = 0; i < rows.length; i++) {
var entry = rows[i]
if (!entry) continue
var key = popupFileName(entry)
if (seen[key]) continue
seen[key] = true
out.push(historyEntry(entry, normalUrgency))
}
}
collect(Array.isArray(liveRows) ? liveRows : [])
collect(parsePopupFiles(raw, normalUrgency))
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out.slice(0, max)
}
if (typeof module !== "undefined") {
module.exports = {
isChromiumDerived: isChromiumDerived,
sanitizeBody: sanitizeBody,
styledBody: styledBody,
summaryStartsWithGlyph: summaryStartsWithGlyph,
shouldBypassDnd: shouldBypassDnd,
isEphemeralApp: isEphemeralApp,
stringHint: stringHint,
glyphFromHints: glyphFromHints,
execArgvFromHints: execArgvFromHints,
parseExecArgv: parseExecArgv,
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
snapshotOf: snapshotOf,
popupRoles: popupRoles,
popupRowChanged: popupRowChanged,
replacementSnapshot: replacementSnapshot,
historyEntry: historyEntry,
parseSettings: parseSettings,
historyRows: historyRows,
popupEntry: popupEntry,
popupFileName: popupFileName,
imageStem: imageStem,
localImageFile: localImageFile,
persistablePopup: persistablePopup,
serializePopup: serializePopup,
parsePopupFiles: parsePopupFiles,
popupExpired: popupExpired,
popupPlacement: popupPlacement
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,196 @@
// Notification card. Pure presentational — no service, Notification, or
// ListModel references. The popup container drives lifetime; the history
// panel drives static rendering. Both use the same component.
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Ui
import "../NotificationLogic.js" as NotificationLogic
BorderSurface {
id: root
property string app: ""
property string appIcon: ""
property string summary: ""
property string body: ""
property string image: ""
// Nerd Font glyph rendered in the icon slot when no real icon is set.
// Used by blob-notify-send so user-action toasts (`Silenced
// notifications` etc.) show their bell/lock/etc. glyph without leaking
// into the summary text.
property string glyph: ""
// NotificationUrgency: Low=0, Normal=1, Critical=2 (upstream).
property int urgency: 1
property double timestamp: 0
property int cornerRadius: 0
// System monospace font injected by the container.
property string fontFamily: ""
readonly property bool hovered: hoverTracker.hovered
signal closeRequested()
signal cardClicked()
// Prefer per-notification media/avatar data, then fall back to the app icon.
// The `check` flag avoids Qt's missing-texture placeholder for unknown names.
readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon)
readonly property bool hasGlyph: glyph.length > 0
readonly property bool compactGlyph: NotificationLogic.shouldRenderCompactGlyph(glyph, smallIconSource, singleLineToast)
readonly property bool hasSmallIcon: smallIconSource.length > 0
readonly property bool summaryStartsWithGlyph: NotificationLogic.summaryStartsWithGlyph(summary)
readonly property bool singleLineToast: sanitizedBody.length === 0
readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph
readonly property string sanitizedBody: sanitizeBody(body)
readonly property string styledBody: NotificationLogic.styledBody(body, app, appIcon)
readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4)
readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15)
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
readonly property var cardBorderSpec: Border.surfaceSpec("notifications", "border", Color.notifications.border, Math.max(1, Style.space(2)))
function sanitizeBody(s) {
return NotificationLogic.sanitizeBody(s, app, appIcon)
}
function iconSource(icon) {
var value = String(icon || "")
if (value.length === 0) return ""
if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value
if (value.charAt(0) === "/") return Util.fileUrl(value)
return Quickshell.iconPath(value, true)
}
implicitWidth: Style.space(380)
// Add vertical border insets so mainColumn (inset by border on top/left/right)
// doesn't push content under the bottom edge.
implicitHeight: mainColumn.implicitHeight + borderTop + borderBottom
radius: cornerRadius
color: Color.notifications.background
borderSpec: cardBorderSpec
clip: true
HoverHandler { id: hoverTracker }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
root.closeRequested()
} else {
root.cardClicked()
}
}
}
ColumnLayout {
id: mainColumn
// Inset by the card border so the content doesn't paint over the card's
// outer border.
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: root.borderTop
anchors.leftMargin: root.borderLeft
anchors.rightMargin: root.borderRight
spacing: 0
// Text content.
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.space(12)
Layout.rightMargin: Style.space(12)
Layout.topMargin: root.singleLineToast ? Style.space(7) : Style.space(10)
Layout.bottomMargin: root.singleLineToast ? Style.space(7) : Style.space(10)
spacing: root.collapseRedundantIcon ? 0 : (root.compactGlyph ? Style.space(8) : Style.space(12))
Item {
id: smallIconSlot
Layout.preferredWidth: visible ? Style.space(40) : 0
Layout.preferredHeight: visible ? Style.space(40) : 0
Layout.alignment: Qt.AlignVCenter
// Hide the slot when the icon failed to resolve (themed-icon name
// not in the user's icon theme) AND we don't have a glyph fallback
// — prevents rendering Qt's pink broken-image placeholder.
visible: !root.collapseRedundantIcon && !root.compactGlyph && (root.hasSmallIcon || root.hasGlyph) && (root.hasGlyph || smallIconImage.status !== Image.Error)
Image {
id: smallIconImage
anchors.fill: parent
source: root.smallIconSource
sourceSize.width: smallIconSlot.width * Screen.devicePixelRatio
sourceSize.height: smallIconSlot.height * Screen.devicePixelRatio
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
visible: !root.hasGlyph || smallIconImage.status === Image.Ready
}
// Glyph fallback (Nerd Font character) when no image icon is
// available. Used by blob-notify-send's `-g` flag.
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
text: root.glyph
color: Color.notifications.text
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
}
}
Text {
textFormat: Text.PlainText
Layout.alignment: Qt.AlignVCenter
visible: root.compactGlyph
text: root.glyph
color: Color.notifications.text
font.family: root.fontFamily
font.pixelSize: Style.font.icon
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: Style.space(2)
Text {
// The spec defines the summary as a single line of plain text, so
// AutoText could only ever promote a hostile string to rich text.
// The body below is StyledText on purpose — see Service.qml's
// bodyMarkupSupported — and is stripped in NotificationLogic.
textFormat: Text.PlainText
Layout.fillWidth: true
visible: root.summary.length > 0
text: root.summary
font.family: "Liberation Sans"
color: Color.notifications.text
font.pixelSize: Style.font.title
font.bold: true
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 2
}
Text {
Layout.fillWidth: true
Layout.topMargin: Style.space(2)
visible: root.sanitizedBody.length > 0
text: root.styledBody
textFormat: Text.StyledText
font.family: "Liberation Sans"
color: root.bodyColor
font.pixelSize: Style.font.title
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 3
}
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.notifications",
"name": "Notifications",
"version": "1.0.0",
"author": "Blob",
"description": "Notification daemon, popups, DND, and history",
"kinds": [
"service"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}
+206
View File
@@ -0,0 +1,206 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "OsdModel.js" as OsdModel
Item {
id: root
property bool opened: false
property string icon: ""
property string message: ""
property string iconKey: ""
property int value: 0
property int maxValue: 100
property bool hasProgress: true
property int duration: 1200
readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0
// The card is built out of measured columns instead of fixed widths, so it
// keeps exactly `pad` between border and content on every side whatever
// glyph or message it carries. Messages grow with their text up to
// `maxMessageWidth` and elide beyond it.
readonly property int pad: Style.space(16)
readonly property int gap: Style.space(16)
// A glyph next to a message reads airier than it measures: the icon outline
// and the letterforms both fall away from their ink extremes, so the space
// between them opens up well past the nominal gap. Text takes two thirds of
// it; the progress bar's hard edge keeps the full gap.
readonly property int messageGap: Math.round(root.gap * 2 / 3)
readonly property int barWidth: Style.space(142)
readonly property int maxMessageWidth: root.mediaOsd ? Style.space(325) : Style.space(190)
// Nerd Font glyphs draw well outside their monospace cell, so the icon
// column is measured by ink rather than by advance width. Progress OSDs pin
// it to the widest glyph the model can return, so the bar doesn't shift when
// volume crosses an icon threshold.
readonly property int iconInkWidth: Math.ceil(iconMetrics.tightBoundingRect.width)
readonly property int iconWidth: root.hasProgress
? Math.max(root.iconInkWidth, Math.ceil(widestIconMetrics.tightBoundingRect.width))
: root.iconInkWidth
// Same idea for the readout: it is as wide as the longest percentage so the
// digits don't jitter between 9% and 100%.
readonly property int valueWidth: Math.ceil(Math.max(valueMetrics.advanceWidth, messageMetrics.advanceWidth))
readonly property int messageWidth: Math.min(Math.ceil(messageMetrics.advanceWidth), root.maxMessageWidth)
readonly property int contentWidth: root.hasProgress
? root.iconWidth + root.gap + root.barWidth + root.gap + root.valueWidth
: (root.message === "" ? root.iconWidth : root.iconWidth + root.messageGap + root.messageWidth)
function iconFor(name, percent) {
return OsdModel.iconFor(name, percent)
}
function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) {
var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration)
// Update before opening so a fresh OSD starts at its new value; only
// subsequent updates while it remains open animate the progress bar.
iconKey = next.iconKey
maxValue = next.maxValue
hasProgress = next.hasProgress
value = next.value
message = next.message
icon = next.icon
duration = next.duration
opened = true
if (duration > 0) hideTimer.restart()
else hideTimer.stop()
}
function open(payloadJson) {
try {
var p = JSON.parse(payloadJson || "{}")
show(p.icon || "", p.message || "", p.value === undefined ? "" : String(p.value), p.max === undefined ? "100" : String(p.max), p.progressText || "", p.duration === undefined ? "1200" : String(p.duration))
} catch (e) {}
}
function close() { opened = false }
Timer {
id: hideTimer
interval: root.duration
onTriggered: root.opened = false
}
TextMetrics {
id: messageMetrics
font.family: Style.font.family
font.bold: true
font.pixelSize: Style.font.title
text: root.message
}
TextMetrics {
id: valueMetrics
font: messageMetrics.font
text: "100%"
}
TextMetrics {
id: iconMetrics
font.family: Style.font.family
font.pixelSize: Style.font.displayLarge
text: root.icon
}
TextMetrics {
id: widestIconMetrics
font: iconMetrics.font
text: OsdModel.widestIcon
}
IpcHandler {
target: "osd"
function show(payloadJson: string): string {
root.open(payloadJson)
return "ok"
}
function close(): string { root.close(); return "ok" }
function state(): string { return root.opened ? "open" : "closed" }
function ping(): string { return "ok" }
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-osd"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
// Visual-only surface: keep the layer-shell input region empty so the OSD
// never blocks clicks to the desktop below it.
mask: Region {}
BorderSurface {
id: card
width: card.borderLeft + root.pad + root.contentWidth + root.pad + card.borderRight
height: card.borderTop + root.pad + Style.font.displayLarge + root.pad + card.borderBottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Style.space(67)
color: Util.alpha(Color.background, 0.97)
borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2)))
radius: Style.cornerRadius
opacity: root.opened ? 1 : 0
Row {
anchors.fill: parent
anchors.topMargin: card.borderTop + root.pad
anchors.rightMargin: card.borderRight + root.pad
anchors.bottomMargin: card.borderBottom + root.pad
anchors.leftMargin: card.borderLeft + root.pad
spacing: root.hasProgress ? root.gap : root.messageGap
Item {
width: root.iconWidth
height: parent.height
Text {
textFormat: Text.PlainText
// Sit the glyph's ink flush in the column, centered when the
// column is wider than this particular glyph.
x: Math.round((root.iconWidth - root.iconInkWidth) / 2 - iconMetrics.tightBoundingRect.x)
anchors.verticalCenter: parent.verticalCenter
text: root.icon
font: iconMetrics.font
color: Color.popups.text
}
}
Rectangle {
visible: root.hasProgress
width: root.barWidth
height: Math.max(Style.space(6), Style.spacing.sm)
anchors.verticalCenter: parent.verticalCenter
color: Util.alpha(Color.popups.text, 0.45)
Rectangle {
height: parent.height
width: parent.width * (root.hasProgress ? root.value / root.maxValue : 0)
color: Color.accent
Behavior on width {
enabled: root.opened
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
}
}
Text {
textFormat: Text.PlainText
visible: root.message !== ""
width: root.hasProgress ? root.valueWidth : root.messageWidth
// The readout hugs the card edge so a short percentage doesn't leave
// a hole in the padding; the slack lands in the gap after the bar.
horizontalAlignment: root.hasProgress ? Text.AlignRight : Text.AlignLeft
anchors.verticalCenter: parent.verticalCenter
text: root.message
font: messageMetrics.font
color: Color.popups.text
elide: Text.ElideRight
maximumLineCount: 1
}
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value))
}
// The widest glyph `iconFor` can return. The progress OSD sizes its icon
// column to it so the bar keeps its place as the icon changes.
var widestIcon = ""
function iconFor(name, percent) {
var n = String(name || "").toLowerCase()
if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return ""
if (n === "volume-low") return ""
if (n === "volume-medium") return ""
if (n === "volume-high" || n === "volume") return ""
if (n === "microphone-muted" || n === "microphone-off" || n === "mic-muted" || n === "mic-off") return "󰍭"
if (n === "microphone" || n === "mic") return "󰍬"
if (n === "keyboard") return "󰌌"
if (n === "brightness" || n === "display") return "󰍹"
if (n === "touchpad") return "󰟸"
if (n === "touch" || n === "touchscreen") return "󰝁"
if (n === "reboot" || n === "restart") return "󰜉"
if (n === "shutdown" || n === "power" || n === "poweroff") return "󰐥"
if (n === "logout" || n === "sign-out" || n === "leave") return "󰍃"
if (n === "media" || n === "player") return "󰝚"
if (n === "media-source" || n === "player-source") return "󰝚"
if (n === "media-play" || n === "player-play") return "󰐊"
if (n === "media-pause" || n === "player-pause") return "󰏤"
if (n === "media-next" || n === "player-next") return "󰒭"
if (n === "media-previous" || n === "player-previous") return "󰒮"
if (n.length > 0) return name
if (percent <= 0) return ""
if (percent <= 33) return ""
if (percent <= 66) return ""
return ""
}
function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) {
var maxValue = Math.max(1, parseInt(rawMax || "100", 10))
var parsedValue = parseInt(rawValue || "0", 10)
var hasProgress = rawValue !== "" && !isNaN(parsedValue) && rawMessage === ""
var value = hasProgress ? clamp(parsedValue, 0, maxValue) : 0
var percent = hasProgress ? Math.round(value * 100 / maxValue) : -1
var parsedDuration = parseInt(rawDuration || "1200", 10)
return {
iconKey: String(iconName || "").toLowerCase(),
maxValue: maxValue,
hasProgress: hasProgress,
value: value,
message: String(rawMessage || (hasProgress ? (rawProgressText || percent + "%") : "")),
icon: iconFor(iconName, percent),
duration: isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration)
}
}
if (typeof module !== "undefined") {
module.exports = {
widestIcon: widestIcon,
iconFor: iconFor,
stateForShow: stateForShow
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.osd",
"name": "On-screen display",
"version": "1.0.0",
"description": "Quickshell volume, brightness, and status overlays.",
"kinds": [
"panel"
],
"keepLoaded": true,
"entryPoints": {
"panel": "Osd.qml"
}
}
+262
View File
@@ -0,0 +1,262 @@
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
}
function isAudioSource(node) {
if (!node) return false
if (node.audio) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Audio/Source") !== -1
|| mediaClass.indexOf("AudioSource") !== -1
|| mediaClass.indexOf("Source") !== -1
}
function listSnapshot(list) {
return list && list.slice ? list.slice() : []
}
function outputVolumeName(volume, muted) {
if (muted) return "Muted"
var p = Math.round(volume * 100)
if (p === 0) return "Silenced"
if (p >= 100) return "Concert hall"
if (p >= 85) return "Party mode"
if (p >= 70) return "Cranked up"
if (p >= 50) return "Steady groove"
if (p >= 30) return "Easy listening"
if (p >= 15) return "Murmur"
return "Whisper"
}
function parseSinkAvailability(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
if (parts.length >= 2) next[parts[0]] = parts[1] !== "0"
}
return next
}
function friendlyDeviceLabel(text) {
var label = String(text || "").trim()
label = label.replace(/^sof-soundwire\s+/i, "")
label = label.replace(/^built-?in audio\s+/i, "")
label = label.replace(/\s+Output$/i, "")
label = label.replace(/\s+Input$/i, "")
label = label.replace(/\bMicrophones\b/g, "Microphone")
return label
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function nodeLabel(node) {
if (!node) return "Unknown"
var p = nodeProps(node)
var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "")
if (nickname) return nickname
return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown")
}
function isHeadphones(node) {
if (!node) return false
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || "",
p["node.description"] || "",
p["node.nick"] || ""
].join(" ")).toLowerCase()
return blob.indexOf("headphone") !== -1
|| blob.indexOf("headset") !== -1
|| blob.indexOf("earbud") !== -1
|| blob.indexOf("earphone") !== -1
|| blob.indexOf("airpod") !== -1
}
function sinkGlyph(node) {
if (!node) return "󰓃"
if (isHeadphones(node)) return "󰋋"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹"
return "󰓃"
}
function sourceGlyph(node) {
if (!node) return "󰍬"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("headset") !== -1) return "󰋋"
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "󰄀"
return "󰍬"
}
function friendlyStreamLabel(label) {
label = String(label || "").trim()
if (!label) return ""
var known = {
"spotify": "Spotify"
}
var normalized = label.toLowerCase()
return known[normalized] || label
}
function streamLabelKey(label) {
return String(label || "").trim().toLowerCase()
}
function streamLabelIsGeneric(label) {
return streamLabelKey(label) === "audio-src"
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
}
function mprisPlayerLabel(player) {
if (!player) return ""
return friendlyStreamLabel(player.identity || player.desktopEntry || "")
}
function mprisPlayerIsProxy(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel))
var playerKey = streamLabelKey(playerLabel)
if (!streamKey || !playerKey) return false
return streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1
}
function mprisLabelsFor(players, predicate) {
var values = Array.isArray(players) ? players : []
var playingCandidates = []
var candidates = []
var playingProxyCandidates = []
var proxyCandidates = []
for (var i = 0; i < values.length; i++) {
var player = values[i]
if (!player) continue
if (!player.isPlaying && !player.canPlay) continue
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel || !predicate(playerLabel)) continue
if (mprisPlayerIsProxy(player)) {
if (player.isPlaying) playingProxyCandidates.push(playerLabel)
proxyCandidates.push(playerLabel)
} else {
if (player.isPlaying) playingCandidates.push(playerLabel)
candidates.push(playerLabel)
}
}
if (playingCandidates.length === 1) return playingCandidates[0]
if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0]
if (candidates.length === 1) return candidates[0]
if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0]
return ""
}
function matchingMprisStreamLabel(label, players) {
if (streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
return streamRepresentsMprisPlayer(label, playerLabel)
})
}
function unmatchedMprisStreamLabel(label, players, streams) {
if (!streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
var values = Array.isArray(streams) ? streams : []
for (var i = 0; i < values.length; i++) {
var stream = values[i]
var streamLabel = rawStreamLabel(stream)
if (!streamLabelIsGeneric(streamLabel) && streamRepresentsMprisPlayer(streamLabel, playerLabel))
return false
}
return true
})
}
function streamLabel(node, players, streams) {
if (!node) return "Stream"
var label = rawStreamLabel(node)
return friendlyStreamLabel(matchingMprisStreamLabel(label, players)
|| unmatchedMprisStreamLabel(label, players, streams)
|| label) || "Stream"
}
function streamRepresentsPlayer(node, player, players, streams) {
if (!node || !player) return false
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel) return false
var label = rawStreamLabel(node)
if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel)
return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel)
}
if (typeof module !== "undefined") {
module.exports = {
isPlaybackStream: isPlaybackStream,
isAudioSource: isAudioSource,
listSnapshot: listSnapshot,
outputVolumeName: outputVolumeName,
parseSinkAvailability: parseSinkAvailability,
friendlyDeviceLabel: friendlyDeviceLabel,
nodeProps: nodeProps,
nodeLabel: nodeLabel,
isHeadphones: isHeadphones,
sinkGlyph: sinkGlyph,
sourceGlyph: sourceGlyph,
friendlyStreamLabel: friendlyStreamLabel,
streamLabelKey: streamLabelKey,
streamLabelIsGeneric: streamLabelIsGeneric,
rawStreamLabel: rawStreamLabel,
mprisPlayerLabel: mprisPlayerLabel,
mprisPlayerIsProxy: mprisPlayerIsProxy,
streamRepresentsMprisPlayer: streamRepresentsMprisPlayer,
mprisLabelsFor: mprisLabelsFor,
matchingMprisStreamLabel: matchingMprisStreamLabel,
unmatchedMprisStreamLabel: unmatchedMprisStreamLabel,
streamLabel: streamLabel,
streamRepresentsPlayer: streamRepresentsPlayer
}
}

Some files were not shown because too many files have changed in this diff Show More