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