Fork the desktop off Omarchy as a self-contained system

This commit is contained in:
2026-09-19 23:50:39 -04:00
parent 16a56f49a1
commit 9501f2bb4d
559 changed files with 43273 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
}
function isAudioSource(node) {
if (!node) return false
if (node.audio) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Audio/Source") !== -1
|| mediaClass.indexOf("AudioSource") !== -1
|| mediaClass.indexOf("Source") !== -1
}
function listSnapshot(list) {
return list && list.slice ? list.slice() : []
}
function outputVolumeName(volume, muted) {
if (muted) return "Muted"
var p = Math.round(volume * 100)
if (p === 0) return "Silenced"
if (p >= 100) return "Concert hall"
if (p >= 85) return "Party mode"
if (p >= 70) return "Cranked up"
if (p >= 50) return "Steady groove"
if (p >= 30) return "Easy listening"
if (p >= 15) return "Murmur"
return "Whisper"
}
function parseSinkAvailability(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
if (parts.length >= 2) next[parts[0]] = parts[1] !== "0"
}
return next
}
function friendlyDeviceLabel(text) {
var label = String(text || "").trim()
label = label.replace(/^sof-soundwire\s+/i, "")
label = label.replace(/^built-?in audio\s+/i, "")
label = label.replace(/\s+Output$/i, "")
label = label.replace(/\s+Input$/i, "")
label = label.replace(/\bMicrophones\b/g, "Microphone")
return label
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function nodeLabel(node) {
if (!node) return "Unknown"
var p = nodeProps(node)
var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "")
if (nickname) return nickname
return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown")
}
function isHeadphones(node) {
if (!node) return false
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || "",
p["node.description"] || "",
p["node.nick"] || ""
].join(" ")).toLowerCase()
return blob.indexOf("headphone") !== -1
|| blob.indexOf("headset") !== -1
|| blob.indexOf("earbud") !== -1
|| blob.indexOf("earphone") !== -1
|| blob.indexOf("airpod") !== -1
}
function sinkGlyph(node) {
if (!node) return "󰓃"
if (isHeadphones(node)) return "󰋋"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹"
return "󰓃"
}
function sourceGlyph(node) {
if (!node) return "󰍬"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("headset") !== -1) return "󰋋"
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "󰄀"
return "󰍬"
}
function friendlyStreamLabel(label) {
label = String(label || "").trim()
if (!label) return ""
var known = {
"spotify": "Spotify"
}
var normalized = label.toLowerCase()
return known[normalized] || label
}
function streamLabelKey(label) {
return String(label || "").trim().toLowerCase()
}
function streamLabelIsGeneric(label) {
return streamLabelKey(label) === "audio-src"
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
}
function mprisPlayerLabel(player) {
if (!player) return ""
return friendlyStreamLabel(player.identity || player.desktopEntry || "")
}
function mprisPlayerIsProxy(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel))
var playerKey = streamLabelKey(playerLabel)
if (!streamKey || !playerKey) return false
return streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1
}
function mprisLabelsFor(players, predicate) {
var values = Array.isArray(players) ? players : []
var playingCandidates = []
var candidates = []
var playingProxyCandidates = []
var proxyCandidates = []
for (var i = 0; i < values.length; i++) {
var player = values[i]
if (!player) continue
if (!player.isPlaying && !player.canPlay) continue
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel || !predicate(playerLabel)) continue
if (mprisPlayerIsProxy(player)) {
if (player.isPlaying) playingProxyCandidates.push(playerLabel)
proxyCandidates.push(playerLabel)
} else {
if (player.isPlaying) playingCandidates.push(playerLabel)
candidates.push(playerLabel)
}
}
if (playingCandidates.length === 1) return playingCandidates[0]
if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0]
if (candidates.length === 1) return candidates[0]
if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0]
return ""
}
function matchingMprisStreamLabel(label, players) {
if (streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
return streamRepresentsMprisPlayer(label, playerLabel)
})
}
function unmatchedMprisStreamLabel(label, players, streams) {
if (!streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
var values = Array.isArray(streams) ? streams : []
for (var i = 0; i < values.length; i++) {
var stream = values[i]
var streamLabel = rawStreamLabel(stream)
if (!streamLabelIsGeneric(streamLabel) && streamRepresentsMprisPlayer(streamLabel, playerLabel))
return false
}
return true
})
}
function streamLabel(node, players, streams) {
if (!node) return "Stream"
var label = rawStreamLabel(node)
return friendlyStreamLabel(matchingMprisStreamLabel(label, players)
|| unmatchedMprisStreamLabel(label, players, streams)
|| label) || "Stream"
}
function streamRepresentsPlayer(node, player, players, streams) {
if (!node || !player) return false
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel) return false
var label = rawStreamLabel(node)
if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel)
return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel)
}
if (typeof module !== "undefined") {
module.exports = {
isPlaybackStream: isPlaybackStream,
isAudioSource: isAudioSource,
listSnapshot: listSnapshot,
outputVolumeName: outputVolumeName,
parseSinkAvailability: parseSinkAvailability,
friendlyDeviceLabel: friendlyDeviceLabel,
nodeProps: nodeProps,
nodeLabel: nodeLabel,
isHeadphones: isHeadphones,
sinkGlyph: sinkGlyph,
sourceGlyph: sourceGlyph,
friendlyStreamLabel: friendlyStreamLabel,
streamLabelKey: streamLabelKey,
streamLabelIsGeneric: streamLabelIsGeneric,
rawStreamLabel: rawStreamLabel,
mprisPlayerLabel: mprisPlayerLabel,
mprisPlayerIsProxy: mprisPlayerIsProxy,
streamRepresentsMprisPlayer: streamRepresentsMprisPlayer,
mprisLabelsFor: mprisLabelsFor,
matchingMprisStreamLabel: matchingMprisStreamLabel,
unmatchedMprisStreamLabel: unmatchedMprisStreamLabel,
streamLabel: streamLabel,
streamRepresentsPlayer: streamRepresentsPlayer
}
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.audio",
"name": "Audio",
"version": "1.0.0",
"author": "Blob",
"description": "Volume slider, output picker, per-app mixer",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Audio",
"description": "Volume slider, output picker, per-app mixer",
"category": "Audio",
"allowMultiple": false
}
}
+177
View File
@@ -0,0 +1,177 @@
function deviceLabel(device) {
if (!device) return ""
return String(device.deviceName || device.name || "").trim()
}
function toArray(values) {
if (!values) return []
if (Array.isArray(values)) return values.slice()
var length = Number(values.length || 0)
if (!isFinite(length) || length <= 0) return []
var list = []
for (var i = 0; i < length; i++) list.push(values[i])
return list
}
function isUuidLike(value) {
var text = String(value || "").trim()
if (text === "") return false
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)
|| /^[0-9a-f]{32}$/i.test(text)
|| /^0x[0-9a-f]{4,32}$/i.test(text)
|| /^0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb$/i.test(text)
}
function isAddressLike(value) {
var text = String(value || "").trim()
return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text)
}
function normalizedAddress(value) {
return String(value || "").trim().toLowerCase().replace(/[^0-9a-f]/g, "")
}
function hasHumanName(device) {
var label = deviceLabel(device)
return label !== "" && !isUuidLike(label) && !isAddressLike(label)
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function nodeText(node) {
var props = nodeProps(node)
return [
node ? node.name : "",
node ? node.description : "",
node ? node.nickname : "",
node ? node.nick : "",
props["node.name"],
props["node.description"],
props["node.nick"],
props["device.name"],
props["device.description"],
props["device.product.name"],
props["device.alias"],
props["device.string"],
props["api.bluez5.address"],
props["bluez5.address"],
props["media.name"]
].join(" ").toLowerCase()
}
function bluetoothSinkMatchesDevice(node, device) {
if (!node || !node.isSink || node.isStream || !device) return false
var address = normalizedAddress(device.address)
var text = nodeText(node)
if (address !== "" && normalizedAddress(text).indexOf(address) !== -1) return true
var label = deviceLabel(device).toLowerCase()
return label !== "" && text.indexOf(label) !== -1
}
function sortedByLabel(devices) {
var list = toArray(devices)
list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) })
return list
}
// Primitives-only projection of a BlueZ device for list-model rows. Holding
// the Device QObject in model data puts a live wrapper into every delegate's
// var property, and BlueZ churn (discovery timeouts, unpair) can destroy the
// object while a delegate is still incubating, which segfaults quickshell.
// Actions resolve the backend object via Panel.deviceFor().
function deviceRow(d) {
if (!d) return null
return {
address: d.address || "",
name: d.name || "",
deviceName: d.deviceName || "",
connected: !!d.connected,
state: d.state !== undefined ? d.state : -1,
batteryAvailable: !!d.batteryAvailable,
battery: d.battery !== undefined ? d.battery : 0,
pairing: !!d.pairing
}
}
function deviceLists(devices) {
var values = toArray(devices)
var connected = []
var known = []
var discovered = []
for (var i = 0; i < values.length; i++) {
var d = values[i]
if (!d || !hasHumanName(d)) continue
if (d.connected) connected.push(d)
else if (d.paired || d.bonded || d.trusted) known.push(d)
else discovered.push(d)
}
return {
connected: sortedByLabel(connected),
known: sortedByLabel(known),
discovered: sortedByLabel(discovered)
}
}
function cloneMap(map) {
var next = ({})
for (var key in map || {}) next[key] = map[key]
return next
}
function pendingAction(actions, address) {
return address && actions && actions[address] ? actions[address] : ""
}
function withPendingAction(actions, address, action) {
var next = cloneMap(actions)
if (!address) return next
if (action) next[address] = action
else delete next[address]
return next
}
function visibleSections(lists, discovering) {
var sections = []
if (lists && lists.connected && lists.connected.length > 0) sections.push("connected")
if (lists && lists.known && lists.known.length > 0) sections.push("known")
if (discovering && lists && lists.discovered && lists.discovered.length > 0) sections.push("discovered")
return sections
}
function sectionDevices(lists, section) {
if (!lists) return []
if (section === "connected") return lists.connected || []
if (section === "known") return lists.known || []
if (section === "discovered") return lists.discovered || []
return []
}
if (typeof module !== "undefined") {
module.exports = {
deviceLabel: deviceLabel,
toArray: toArray,
isUuidLike: isUuidLike,
isAddressLike: isAddressLike,
normalizedAddress: normalizedAddress,
hasHumanName: hasHumanName,
nodeProps: nodeProps,
nodeText: nodeText,
bluetoothSinkMatchesDevice: bluetoothSinkMatchesDevice,
sortedByLabel: sortedByLabel,
deviceRow: deviceRow,
deviceLists: deviceLists,
cloneMap: cloneMap,
pendingAction: pendingAction,
withPendingAction: withPendingAction,
visibleSections: visibleSections,
sectionDevices: sectionDevices
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.bluetooth",
"name": "Bluetooth",
"version": "1.0.0",
"author": "Blob",
"description": "Bluetooth device list with connect/disconnect",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Bluetooth",
"description": "Bluetooth device list with connect/disconnect",
"category": "Network",
"allowMultiple": false
}
}
+180
View File
@@ -0,0 +1,180 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Date/time label for the bar, and the host for the calendar popup.
//
// Left click reveals the calendar — asking "what is the date?" is what a
// click on a clock means — right click walks the common label formats, and
// middle click opens the timezone picker.
BarWidget {
id: root
moduleName: "blob.clock"
property date displayDate: clock.date
readonly property string configuredFormat: vertical
? setting("verticalFormat", "HH\n—\nmm")
: setting("format", "dddd HH:mm")
readonly property string configuredAltFormat: vertical
? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy")
: setting("formatAlt", "d MMMM 'W'ww yyyy")
readonly property var formatRing: Model.clockFormatRing(configuredFormat, configuredAltFormat, Model.clockFormats(vertical))
// What the bar shows is what shell.json stores, so a cycled format is the
// format from then on rather than something that reverts on restart.
readonly property string activeFormat: configuredFormat
readonly property string displayText: formatted(displayDate)
readonly property var verticalLines: displayText.split("\n")
function refresh() {
displayDate = new Date()
if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh()
}
function cycleFormat() {
var current = String(configuredFormat)
var next = Model.nextClockFormat(formatRing, current)
if (next === "" || next === current) return
var entry = { id: root.moduleName }
for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key]
entry[vertical ? "verticalFormat" : "format"] = next
// Applied locally first so the label changes on the click itself; the
// shell.json write comes back through the bar as the same value.
root.settings = entry
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
root.bar.shell.updateEntryInline(root.moduleName, entry)
}
function formatted(date) {
return Qt.formatDateTime(date, activeFormat.replace(/ww/g, Model.isoWeekLiteral(date.getFullYear(), date.getMonth(), date.getDate())))
}
// ---- Calendar popup. Shape contract for shell.summon/hide/toggle
// routing: Bar.findPanelWidget requires open/close/opened on the
// bar-widget root.
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
function open() {
if (panelLoader.item) panelLoader.item.open()
}
function close() {
if (panelLoader.item) panelLoader.item.close()
}
function togglePanel() {
if (panelLoader.item) panelLoader.item.toggle()
}
function toggleWeekStart() {
if (panelLoader.item) panelLoader.item.toggleWeekStart()
}
// The clock fills more slot than it paints a mark for, at both
// orientations: horizontally it is a text label in a padded slot, so the
// dot takes the label width; vertically it is a stack of icon-sized lines,
// so the dot takes one line — the same mark every icon widget gets, rather
// than a rule running the height of the whole stack.
readonly property real openPanelIndicatorWidth: button.labelWidth
readonly property real openPanelIndicatorHeight: Math.max(Style.space(10), Math.round(Style.bar.iconSlot * 0.55))
// Forwarded so this widget can stand in for the panel as the bar's popout
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
// KeyboardPanel reads popoutSwitchClosing back off its owner.
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
function closeForPopoutSwitch() {
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
}
function injectPanel() {
var target = panelLoader.item
if (!target) return
if ("bar" in target) target.bar = root.bar
if ("settings" in target) target.settings = root.settings
if ("anchorItem" in target) target.anchorItem = button
if ("hostWidget" in target) target.hostWidget = root
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onBarChanged: injectPanel()
onSettingsChanged: injectPanel()
SystemClock {
id: clock
precision: SystemClock.Minutes
onDateChanged: root.displayDate = date
}
Loader {
id: panelLoader
active: true
source: Qt.resolvedUrl("Panel.qml")
visible: false
onLoaded: {
root.injectPanel()
Qt.callLater(root.injectPanel)
}
}
IpcHandler {
target: "blob.clock"
function refresh(): void { root.broadcast("refresh") }
function cycleFormat(): void { root.cycleFormat() }
function toggleWeekStart(): void { root.toggleWeekStart() }
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.togglePanel() }
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.vertical ? "" : root.displayText
labelVisible: !root.vertical
hasVisualContent: root.vertical ? root.verticalLines.length > 0 : text !== ""
fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1
horizontalMargin: 8.75
verticalPadding: 8.75
onPressed: function(b) {
if (b === Qt.RightButton) root.cycleFormat()
else if (b === Qt.MiddleButton) { if (root.bar) root.bar.run("blob-menu-timezone") }
else root.togglePanel()
}
Column {
visible: root.vertical
anchors.fill: parent
Repeater {
model: root.verticalLines
OpticalGlyph {
required property string modelData
width: button.width
height: Style.bar.iconSlot
text: modelData
fontFamily: button.fontFamily
fontSize: modelData.length > 3
? button.fontSize * 0.9
: button.fontSize
color: button.foreground
}
}
}
}
}
+296
View File
@@ -0,0 +1,296 @@
// Pure date and format math for the clock widget and its calendar panel.
// Everything here is locale- and Qt-free so it can be unit tested under node
// (test/shell.d/clock-test.sh); the QML owns month/weekday naming through
// Qt.locale().
var MS_PER_DAY = 86400000
// Weekday indices match both JS Date.getDay() and QML's Locale.Sunday…
// Locale.Saturday, so a locale's firstDayOfWeek can be passed straight in.
var WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]
// ---- Bar label formats. Right-clicking the clock walks these in order and
// writes the result back to shell.json, so the label the bar shows and
// the format the config stores are always the same thing.
//
// The locale-shaped time presets are each followed by their 12-hour twin, so
// the walk from a 24-hour label to the same label in AM/PM is a single right
// click rather than a lap of the ring. The ISO preset is deliberately left
// without one: ISO 8601 writes time on a 24-hour clock, so an AM/PM variant
// would contradict the only thing that format is for.
var CLOCK_FORMATS = [
"dddd HH:mm",
"dddd h:mm AP",
"HH:mm",
"h:mm AP",
"ddd d MMM HH:mm",
"ddd d MMM h:mm AP",
"d MMMM 'W'ww yyyy",
"yyyy-MM-dd HH:mm"
]
// Vertical bars have room for a few stacked lines and nothing else, so the
// ring stays short. AM/PM costs a fourth line, which is why only the plain
// time carries it here.
var VERTICAL_CLOCK_FORMATS = [
"HH\n—\nmm",
"h\n—\nmm\nAP",
"dd\nMMM\n'W'ww\n''yy",
"HH\nmm"
]
function clockFormats(vertical) {
return vertical ? VERTICAL_CLOCK_FORMATS.slice() : CLOCK_FORMATS.slice()
}
// The presets in a fixed order, plus the configured alternate and current
// format when they are something else. The order must not depend on which
// entry is current: cycling writes the result back to shell.json, and a ring
// that reshuffled itself around the current value would bounce between two
// entries instead of walking.
function clockFormatRing(configured, configuredAlt, presets) {
var ring = []
var candidates = (presets || []).concat([configuredAlt, configured])
for (var i = 0; i < candidates.length; i++) {
var format = String(candidates[i] === undefined || candidates[i] === null ? "" : candidates[i])
if (format === "" || ring.indexOf(format) !== -1) continue
ring.push(format)
}
return ring.length > 0 ? ring : ["HH:mm"]
}
// Next entry after `current`. An unknown current format (a hand-written one
// that is not in the ring) starts the walk at the top.
function nextClockFormat(ring, current) {
if (!ring || ring.length === 0) return ""
var index = ring.indexOf(String(current === undefined || current === null ? "" : current))
return ring[(index + 1) % ring.length]
}
// Two-digit ISO week, substituted into a format's 'ww' token before Qt
// formats it -- Qt has no ISO week specifier of its own.
function isoWeekLiteral(year, month, day) {
return pad2(isoWeek(year, month, day))
}
function pad2(value) {
var n = Number(value)
return (n < 10 ? "0" : "") + n
}
// Stable "yyyy-MM-dd" identity for a day, so a grid cell can be compared
// against today without dragging Date objects through bindings.
function dateKey(year, month, day) {
return year + "-" + pad2(Number(month) + 1) + "-" + pad2(day)
}
function keyForDate(date) {
return dateKey(date.getFullYear(), date.getMonth(), date.getDate())
}
function coerceWeekStart(value) {
if (value === undefined || value === null) return null
if (typeof value === "number")
return isFinite(value) ? ((Math.round(value) % 7) + 7) % 7 : null
var text = String(value).replace(/^\s+|\s+$/g, "").toLowerCase()
if (text === "") return null
for (var i = 0; i < WEEKDAY_NAMES.length; i++)
if (WEEKDAY_NAMES[i] === text || WEEKDAY_NAMES[i].substr(0, 3) === text) return i
var parsed = parseInt(text, 10)
return isFinite(parsed) ? ((parsed % 7) + 7) % 7 : null
}
// Configured week start, falling back to the locale's own first day when
// the setting is missing or nonsense.
function normalizedWeekStart(value, fallback) {
var configured = coerceWeekStart(value)
if (configured !== null) return configured
var fallbackStart = coerceWeekStart(fallback)
return fallbackStart === null ? 1 : fallbackStart
}
function weekStartSettingName(index) {
return WEEKDAY_NAMES[normalizedWeekStart(index, 1)]
}
// The toggle flips between the two conventions people actually switch
// between. A calendar configured to any other start (Saturday, say) is
// shown as-is and lands on Monday the first time it is toggled.
function toggledWeekStart(index) {
return normalizedWeekStart(index, 1) === 1 ? 0 : 1
}
function weekdayOrder(weekStart) {
var start = normalizedWeekStart(weekStart, 1)
var out = []
for (var i = 0; i < 7; i++) out.push((start + i) % 7)
return out
}
// ISO-8601 week number: the week owning the Thursday of that date's
// Monday-based week. Mirrors the clock widget's 'ww' format token.
function isoWeek(year, month, day) {
var date = new Date(Date.UTC(year, month, day))
var weekday = date.getUTCDay() || 7
date.setUTCDate(date.getUTCDate() + 4 - weekday)
var yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
return Math.ceil(((date.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7)
}
function dayOfYear(year, month, day) {
return Math.round((Date.UTC(year, month, day) - Date.UTC(year, 0, 1)) / MS_PER_DAY) + 1
}
function daysInYear(year) {
return dayOfYear(year, 11, 31)
}
// Share of the year already behind you: whole days completed over days in
// the year, so January 1 reads 0% and December 31 reads 100%.
function yearProgress(year, month, day) {
var total = daysInYear(year)
if (total <= 0) return 0
return Math.max(0, Math.min(1, (dayOfYear(year, month, day) - 1) / total))
}
function yearProgressPercent(year, month, day) {
return Math.round(yearProgress(year, month, day) * 100)
}
// Memento mori. The default span is a round number rather than anything from
// an actuarial table: the point of the bar is the reminder, not the
// arithmetic, and whoever wants a different number can say so.
var DEFAULT_LIFE_EXPECTANCY = 90
// A birth year rather than an age, so the bar keeps counting on its own
// instead of going stale the moment it is entered. 0 means "not set", which
// is also what a blank, malformed, future, or implausibly distant year means.
function parseBirthYear(value, currentYear) {
var now = Math.round(Number(currentYear))
if (!isFinite(now)) return 0
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
if (!/^\d{4}$/.test(text)) return 0
var year = parseInt(text, 10)
if (!isFinite(year) || year > now || year < now - 120) return 0
return year
}
// Whole years, the way people say their age: born in 1979 makes you 47 for
// all of 2026, whichever side of your birthday today falls.
function ageFromBirthYear(birthYear, currentYear) {
var born = parseBirthYear(birthYear, currentYear)
if (born <= 0) return 0
return Math.round(Number(currentYear)) - born
}
// 0 means "not set", which is also what a blank, negative, fractional, or
// absurd entry means — the life bar simply stays hidden.
function parseAge(value) {
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
if (!/^\d+$/.test(text)) return 0
var years = parseInt(text, 10)
if (!isFinite(years) || years <= 0 || years > 120) return 0
return years
}
// Unset or nonsense falls back to the default rather than to zero, so the
// bar always has something to measure against.
function parseLifeExpectancy(value) {
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
if (!/^\d+$/.test(text)) return DEFAULT_LIFE_EXPECTANCY
var years = parseInt(text, 10)
if (!isFinite(years) || years <= 0 || years > 150) return DEFAULT_LIFE_EXPECTANCY
return years
}
function lifeProgress(age, expectancy) {
var years = parseAge(age)
var span = parseLifeExpectancy(expectancy)
if (years <= 0 || span <= 0) return 0
return Math.max(0, Math.min(1, years / span))
}
function lifeProgressPercent(age, expectancy) {
return Math.round(lifeProgress(age, expectancy) * 100)
}
// Always six rows of seven days. A fixed grid keeps the popup exactly the
// same height in every month, so stepping through the year never makes the
// panel jump under the pointer.
function monthGrid(year, month, weekStart, todayKey) {
var start = normalizedWeekStart(weekStart, 1)
var leading = (new Date(year, month, 1).getDay() - start + 7) % 7
var cursor = new Date(year, month, 1 - leading)
var today = String(todayKey || "")
var weeks = []
for (var w = 0; w < 6; w++) {
var days = []
var thursday = null
for (var d = 0; d < 7; d++) {
var cellYear = cursor.getFullYear()
var cellMonth = cursor.getMonth()
var cellDay = cursor.getDate()
var weekday = cursor.getDay()
var key = dateKey(cellYear, cellMonth, cellDay)
if (weekday === 4) thursday = { year: cellYear, month: cellMonth, day: cellDay }
days.push({
key: key,
year: cellYear,
month: cellMonth,
day: cellDay,
weekday: weekday,
inMonth: cellMonth === month && cellYear === year,
weekend: weekday === 0 || weekday === 6,
today: key === today
})
cursor.setDate(cursor.getDate() + 1)
}
// Number every row by the ISO week owning its Thursday. That is the
// definition itself for Monday-start weeks, and the only answer that
// stays stable for the other starts, where a row straddles two ISO
// weeks but shares all of Monday through Thursday with one of them.
var anchor = thursday || days[0]
weeks.push({
week: isoWeek(anchor.year, anchor.month, anchor.day),
days: days
})
}
return weeks
}
function stepMonth(year, month, delta) {
var target = new Date(year, Number(month) + Number(delta), 1)
return { year: target.getFullYear(), month: target.getMonth() }
}
if (typeof module !== "undefined") {
module.exports = {
dateKey: dateKey,
keyForDate: keyForDate,
normalizedWeekStart: normalizedWeekStart,
weekStartSettingName: weekStartSettingName,
toggledWeekStart: toggledWeekStart,
weekdayOrder: weekdayOrder,
isoWeek: isoWeek,
dayOfYear: dayOfYear,
daysInYear: daysInYear,
yearProgress: yearProgress,
yearProgressPercent: yearProgressPercent,
parseAge: parseAge,
parseBirthYear: parseBirthYear,
ageFromBirthYear: ageFromBirthYear,
parseLifeExpectancy: parseLifeExpectancy,
lifeProgress: lifeProgress,
lifeProgressPercent: lifeProgressPercent,
monthGrid: monthGrid,
stepMonth: stepMonth,
clockFormats: clockFormats,
clockFormatRing: clockFormatRing,
nextClockFormat: nextClockFormat,
isoWeekLiteral: isoWeekLiteral
}
}
+762
View File
@@ -0,0 +1,762 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Ui
import "Model.js" as Model
// The clock's calendar popup: a month grid with ISO week numbers, built to
// sit beside the weather panel — same hero-over-detail composition, same
// spacing scale, same small-caps labels.
//
// The grid is a read-out rather than a picker: today is the only marked
// day, and the only thing that moves is which month is on screen —
// chevrons, the scroll wheel, and the arrow keys all step it.
//
// BarWidget.qml owns the bar label and hands this panel the button to
// anchor against.
Panel {
id: root
moduleName: "blob.clock"
ipcTarget: "blob.clock"
manageIpc: false
property var anchorItem: null
// The bar tracks the widget mounted in its slot — BarWidget.qml — not this
// nested panel. Everything the bar identifies a panel by has to be that
// widget: the popout coordinator (and with it the open-panel dot under the
// pill) compares against `slot.activeItem`, and switchPanelFrom looks the
// slot up the same way.
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
// ---- Today. SystemClock keeps this honest across midnight so the
// highlight rolls over without the panel being reopened.
property date today: new Date()
readonly property string todayKey: Model.keyForDate(today)
// The month on screen. Stepping moves this and nothing else: the grid is
// a read-out, not a picker, so there is no per-day cursor to keep in sync.
property int viewYear: today.getFullYear()
property int viewMonth: today.getMonth()
readonly property date viewDate: new Date(viewYear, viewMonth, 1)
readonly property bool viewingCurrentMonth: viewYear === today.getFullYear() && viewMonth === today.getMonth()
// Pinned to today, not to the month being browsed — stepping through the
// calendar does not change how much of the year is gone.
readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate())
readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate())
// Memento mori, for anyone who goes looking: double-tapping the year bar
// asks for a birth year and a life expectancy, and a second bar tracks one
// against the other. A birth year rather than an age, so it keeps counting
// on its own. Without one the bar stays hidden.
readonly property int birthYear: Model.parseBirthYear(setting("birthYear", 0), today.getFullYear())
readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear())
readonly property int lifeExpectancy: Model.parseLifeExpectancy(setting("lifeExpectancy", 0))
readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy)
readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy)
property bool editingLife: false
// Unset falls through to the locale's own first day, so a fresh install
// starts out matching the rest of the desktop rather than a hardcoded
// convention. Clicking the grid's "W" heading writes the choice back to
// shell.json.
readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek)
// The interface is English throughout, so day names are not taken from the
// system locale. Where the week starts still is: that is a regional
// convention rather than a translation, and it stays overridable above.
readonly property var labelLocale: Qt.locale("en_US")
readonly property string nextWeekStartLabel: labelLocale.dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat)
readonly property var weekdays: Model.weekdayOrder(weekStart)
readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey)
// Guarded so the widget renders before the bar is injected (the bar-widget
// contract instantiates it bare).
readonly property color contentForeground: bar ? bar.foreground : Color.foreground
readonly property string contentFontFamily: bar ? bar.fontFamily : Style.font.family
readonly property int cellWidth: Style.space(52)
readonly property int cellHeight: Style.space(34)
readonly property int cellSpacing: Style.space(2)
readonly property int weekColumnWidth: Style.space(32)
readonly property int gutterWidth: Style.space(14)
function open() {
refresh()
root.controller.show()
// Set after showing, not before: showing hands the popout coordinator
// over, which closes whichever panel was open, and that close clears the
// shared flag. Deferring means the panel taking over always wins, while
// a handoff to a panel that does not manage the flag still leaves it
// cleared rather than stuck on.
Qt.callLater(function() {
if (root.opened) setCenterHoverRevealSuppressed(true)
})
}
function close() {
setCenterHoverRevealSuppressed(false)
// Dismissing the panel mid-edit would otherwise leave the inputs up,
// waiting behind a closed popup for the next time it opens.
if (root.editingLife) root.cancelEditingLife()
root.controller.hide()
}
function toggle() {
if (root.opened) root.close()
else root.open()
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.barIdentity, direction)
return false
}
// Summoning by hotkey moves no pointer, so a hover the bar was still
// holding must not keep the center indicators revealed behind the panel.
function setCenterHoverRevealSuppressed(value) {
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
root.bar.setCenterHoverRevealSuppressed(value)
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
root.bar.centerHoverRevealSuppressed = value
}
function refresh() {
root.today = new Date()
root.goToToday()
}
function goToToday() {
root.viewYear = today.getFullYear()
root.viewMonth = today.getMonth()
}
function moveMonth(delta) {
var next = Model.stepMonth(viewYear, viewMonth, delta)
root.viewYear = next.year
root.viewMonth = next.month
}
function moveYear(delta) {
moveMonth(delta * 12)
}
// Applied locally first so the panel redraws on the click itself; the
// shell.json write comes back through the bar as the same value. With no
// writable entry (the widget is not in the layout) it stays a session-only
// preference rather than doing nothing. The host widget builds its own
// entry when the label format is cycled, so it has to be kept in step or
// it would write this key straight back out from a stale copy.
function persistSettings(values) {
var entry = { id: root.moduleName }
for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]
for (var key in values) entry[key] = values[key]
root.settings = entry
if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
root.bar.shell.updateEntryInline(root.moduleName, entry)
}
function setWeekStart(day) {
var next = Model.normalizedWeekStart(day, root.weekStart)
if (next === root.weekStart) return
persistSettings({ weekStartDay: Model.weekStartSettingName(next) })
}
function startEditingLife() {
root.editingLife = true
Qt.callLater(function() {
bornField.text = root.birthYear > 0 ? String(root.birthYear) : ""
expectancyField.text = String(root.lifeExpectancy)
bornField.selectAll()
bornField.forceActiveFocus()
})
}
function cancelEditingLife() {
root.editingLife = false
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
// Shared by both fields: Tab hops to the other one, Enter commits the pair,
// Escape drops the lot.
function handleLifeKey(event, other) {
if (event.key === Qt.Key_Escape) {
root.cancelEditingLife()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.commitLife()
event.accepted = true
} else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
other.selectAll()
other.forceActiveFocus()
event.accepted = true
}
}
// Double-tapping the life bar puts it away again. The expectancy stays in
// the config so setting a birth year again brings your own number back
// rather than the default.
function clearLife() {
if (root.birthYear <= 0) return
persistSettings({ birthYear: 0 })
}
function commitLife() {
var born = Model.parseBirthYear(bornField.text, today.getFullYear())
var span = Model.parseLifeExpectancy(expectancyField.text)
if (born !== root.birthYear || span !== root.lifeExpectancy)
persistSettings({ birthYear: born, lifeExpectancy: span })
cancelEditingLife()
}
function toggleWeekStart() {
setWeekStart(Model.toggledWeekStart(root.weekStart))
}
// English short day names, matching the rest of the interface.
function weekdayLabel(weekday) {
return String(labelLocale.dayName(weekday, Locale.ShortFormat)).toUpperCase()
}
SystemClock {
id: clock
precision: SystemClock.Minutes
onDateChanged: {
if (Model.keyForDate(clock.date) === String(root.todayKey)) return
var followToday = root.viewingCurrentMonth
root.today = clock.date
if (followToday) root.goToToday()
}
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.barIdentity
bar: root.bar
open: root.opened
centerOnBar: true
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(560))
contentHeight: panel.fittedContentHeight(calendarColumn.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: root.editingLife
onMoveRequested: function(dx, dy) {
if (dx !== 0) root.moveMonth(dx)
if (dy !== 0) root.moveYear(dy)
}
onActivateRequested: root.goToToday()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
if (t === "[") root.moveMonth(-1)
else if (t === "]") root.moveMonth(1)
else if (t === "{") root.moveYear(-1)
else if (t === "}") root.moveYear(1)
else if (t === "t" || t === "T") root.goToToday()
else if (t === "w" || t === "W") root.toggleWeekStart()
}
Flickable {
id: calendarScroll
anchors.fill: parent
contentWidth: calendarColumn.width
contentHeight: calendarColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: contentHeight > height || contentWidth > width
Column {
id: calendarColumn
// Never narrower than the grid. The popup width is capped to what
// the screen allows, and a fixed seven-column grid would otherwise
// lose its last days off the edge instead of scrolling.
width: Math.max(calendarScroll.width, gridColumn.width)
spacing: Style.space(8)
// ---- Hero: today, centered. Once the view has stepped back
// it is also the way home — clicking the date you are
// looking for beats hunting for a reset button.
Item {
width: parent.width
height: heroRow.height
Row {
id: heroRow
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(22)
Text {
// Baseline-aligned, not center-aligned: "July 26" carries a
// descender, so centering the two boxes leaves the icon
// sitting visibly low against the digits.
anchors.baseline: heroDate.baseline
text: "󰃭"
color: heroMouse.containsMouse
? Style.hoverStateColor(root.contentForeground, Color.accent)
: root.contentForeground
font.family: root.contentFontFamily
// Decorative, and deliberately outside the Style.font.*
// scale. Sized so the glyph reads at the cap height of the
// date beside it rather than towering over it.
font.pixelSize: 48
}
Text {
id: heroDate
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDate(root.today, "MMMM d")
color: heroMouse.containsMouse
? Style.hoverStateColor(root.contentForeground, Color.accent)
: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: 52
font.bold: true
}
}
MouseArea {
id: heroMouse
x: heroRow.x
y: heroRow.y
width: heroRow.width
height: heroRow.height
enabled: !root.viewingCurrentMonth
hoverEnabled: enabled
cursorShape: Qt.PointingHandCursor
onClicked: root.goToToday()
PanelToolTip {
visible: heroMouse.containsMouse
text: "Back to today"
fontFamily: root.contentFontFamily
}
}
}
// ---- Year progress, doubling as the rule under the hero:
// a plain hairline said nothing, and whole days done
// over days in the year says the same thing louder.
Item {
width: parent.width
height: yearBlock.y + yearBlock.height
Item {
id: yearBlock
y: Style.space(6)
anchors.horizontalCenter: parent.horizontalCenter
width: gridColumn.width
height: Math.max(yearLabel.implicitHeight, Style.space(10))
TapHandler {
enabled: !root.editingLife
onDoubleTapped: root.startEditingLife()
}
Row {
visible: root.editingLife
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(10)
Text {
anchors.verticalCenter: parent.verticalCenter
text: "BORN"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
TextField {
id: bornField
width: Style.space(70)
anchors.verticalCenter: parent.verticalCenter
placeholderText: "year"
foreground: root.contentForeground
font.family: root.contentFontFamily
inputMethodHints: Qt.ImhDigitsOnly
Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) }
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 0
leftPadding: Style.space(6)
text: "LIVE TO"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
TextField {
id: expectancyField
width: Style.space(60)
anchors.verticalCenter: parent.verticalCenter
placeholderText: "90"
foreground: root.contentForeground
font.family: root.contentFontFamily
inputMethodHints: Qt.ImhDigitsOnly
Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) }
}
}
Text {
id: yearLabel
textFormat: Text.PlainText
visible: !root.editingLife
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.today.getFullYear()
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
id: yearPercent
textFormat: Text.PlainText
visible: !root.editingLife
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.yearDonePercent + "%"
color: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Rectangle {
id: yearTrack
visible: !root.editingLife
anchors.left: yearLabel.right
anchors.right: yearPercent.left
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.verticalCenter: parent.verticalCenter
height: Style.space(6)
radius: Style.cornerRadius > 0 ? height / 2 : 0
color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12)
Rectangle {
width: Math.round(parent.width * root.yearDone)
height: parent.height
radius: parent.radius
color: Style.selectedStateColor(root.contentForeground, Color.accent)
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
}
}
}
}
// ---- Memento mori. Only here once someone has gone looking and
// given an age; the same rail as the year above it, measured
// against a nominal lifetime.
Item {
visible: root.birthYear > 0
width: parent.width
height: visible ? lifeBlock.height : 0
Item {
id: lifeBlock
anchors.horizontalCenter: parent.horizontalCenter
width: gridColumn.width
height: Math.max(lifeLabel.implicitHeight, Style.space(10))
Text {
id: lifeLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "LIFE"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
id: lifePercent
textFormat: Text.PlainText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.lifeDonePercent + "%"
color: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Rectangle {
anchors.left: lifeLabel.right
anchors.right: lifePercent.left
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.verticalCenter: parent.verticalCenter
height: Style.space(6)
radius: Style.cornerRadius > 0 ? height / 2 : 0
color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12)
Rectangle {
width: Math.round(parent.width * root.lifeDone)
height: parent.height
radius: parent.radius
color: Style.selectedStateColor(root.contentForeground, Color.accent)
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
}
}
TapHandler {
onDoubleTapped: root.clearLife()
}
MouseArea {
id: lifeMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
PanelToolTip {
visible: lifeMouse.containsMouse
text: "Memento Mori"
fontFamily: root.contentFontFamily
}
}
}
}
// ---- Month grid: week numbers down a gutter on the left, then
// the seven day columns. Always six rows, so the popup is
// exactly as tall in February as it is in August.
Item {
width: parent.width
height: gridColumn.y + gridColumn.height
WheelHandler {
onWheel: function(event) {
// Horizontal wheels and touchpad side-scrolls report y === 0;
// without this they would every one read as "next month".
if (event.angleDelta.y === 0) return
root.moveMonth(event.angleDelta.y > 0 ? -1 : 1)
}
}
Column {
id: gridColumn
// The meter above is a solid rule; the grid needs room to
// read as its own block rather than hanging off it.
y: Style.space(18)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(3)
Row {
id: headerRow
spacing: root.cellSpacing
// The week-number heading doubles as the week-start toggle.
// It is the one control in the panel whose meaning is not
// self-evident, so it carries a tooltip naming the day the
// click will switch to.
Rectangle {
width: root.weekColumnWidth
height: Style.space(16)
radius: Style.cornerRadius
color: weekStartMouse.containsMouse
? Style.hoverFillFor(root.contentForeground, Color.accent)
: "transparent"
Text {
anchors.centerIn: parent
text: "W"
color: weekStartMouse.containsMouse
? Style.hoverStateColor(root.contentForeground, Color.accent)
: Qt.darker(root.contentForeground, 1.9)
font.family: root.contentFontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
font.bold: true
}
MouseArea {
id: weekStartMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleWeekStart()
}
PanelToolTip {
visible: weekStartMouse.containsMouse
text: "Start weeks on " + root.nextWeekStartLabel
fontFamily: root.contentFontFamily
}
}
Item {
width: root.gutterWidth
height: Style.space(16)
}
Repeater {
model: root.weekdays
Text {
textFormat: Text.PlainText
required property var modelData
width: root.cellWidth
height: Style.space(16)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: root.weekdayLabel(modelData)
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
font.bold: true
}
}
}
Repeater {
model: root.weeks
Row {
required property var modelData
spacing: root.cellSpacing
Text {
textFormat: Text.PlainText
width: root.weekColumnWidth
height: root.cellHeight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: modelData.week
color: Qt.darker(root.contentForeground, 1.9)
font.family: root.contentFontFamily
font.pixelSize: Style.font.caption
}
Item {
width: root.gutterWidth
height: root.cellHeight
}
Repeater {
model: modelData.days
Rectangle {
required property var modelData
width: root.cellWidth
height: root.cellHeight
radius: Style.cornerRadius
// Today is outlined, not filled: a lit-up block shouts
// over a grid this quiet.
color: "transparent"
border.width: modelData.today ? Style.spacing.hairline : 0
border.color: Style.normalBorderFor(root.contentForeground, Color.accent)
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: modelData.day
color: modelData.inMonth
? (modelData.weekend ? Qt.darker(root.contentForeground, 1.45) : root.contentForeground)
: Qt.darker(root.contentForeground, 2.2)
font.family: root.contentFontFamily
font.pixelSize: Style.font.body
font.bold: modelData.today
}
}
}
}
}
}
// Hairline down the week-number gutter, drawn only beside the
// day rows so it does not cut through the header band.
Rectangle {
x: gridColumn.x + root.weekColumnWidth + root.cellSpacing + Math.round((root.gutterWidth - width) / 2)
y: gridColumn.y + headerRow.height + gridColumn.spacing
width: Style.spacing.hairline
height: gridColumn.height - headerRow.height - gridColumn.spacing
color: root.contentForeground
opacity: 0.1
}
}
// ---- Month stepping, spanning the grid it drives. The chevrons
// sit on the grid's outer bounds, the same edges the year
// rail above uses, so the row reads as the panel's other
// full-width rail instead of a cluster floating in space.
// The label is centered and fixed-width, so it holds still
// from "MAY" to "SEPTEMBER".
Item {
width: parent.width
height: monthNav.height
Item {
id: monthNav
anchors.horizontalCenter: parent.horizontalCenter
width: gridColumn.width
height: monthLabel.implicitHeight + Style.space(10)
Text {
id: monthLabel
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
// Fixed width so the chevrons hold still between a
// "MAY 2026" and a "SEPTEMBER 2026".
width: Style.space(130)
horizontalAlignment: Text.AlignHCenter
text: Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase()
color: Qt.darker(root.contentForeground, 1.4)
font.family: root.contentFontFamily
font.pixelSize: Style.font.body
font.letterSpacing: 1
}
PanelActionButton {
// Pulled out by the button's own padding so the glyph, not
// its hit box, lines up with the "2026" on the year rail.
anchors.left: parent.left
anchors.leftMargin: -Style.space(8)
anchors.verticalCenter: parent.verticalCenter
iconText: "󰅁"
tooltipText: "Previous month"
foreground: root.contentForeground
fontFamily: root.contentFontFamily
onClicked: root.moveMonth(-1)
}
PanelActionButton {
anchors.right: parent.right
anchors.rightMargin: -Style.space(8)
anchors.verticalCenter: parent.verticalCenter
iconText: "󰅂"
tooltipText: "Next month"
foreground: root.contentForeground
fontFamily: root.contentFontFamily
onClicked: root.moveMonth(1)
}
}
}
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.clock",
"name": "Clock",
"version": "1.0.0",
"author": "Blob",
"description": "Date/time label with a calendar popup",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Clock",
"description": "Date/time label with a calendar popup",
"category": "Time",
"allowMultiple": false
}
}
@@ -0,0 +1,151 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
// The shared gauge-cluster overlay dressed for the disk speed test: read and
// write dials in MB/s, titled with the model of the disk under test. One
// blob-disk-speedtest run streams both phases and cleans up after itself,
// so dismissal only has to stop the process.
Item {
id: root
property var shell: null
property var manifest: null
property bool opened: false
property bool running: false
property bool expectedStop: false
property bool pendingRun: false
property string phase: "" // "read" | "write" | ""
property string diskName: ""
property string writeMBps: ""
property string readMBps: ""
property string error: ""
property string stderrText: ""
function open(payloadJson) {
opened = true
runTest()
}
// Host-initiated close (`shell hide`). The user-initiated paths (Esc, the
// scrim) route through shell.hide so the host's open-panel state stays
// consistent, and land back here.
function close() {
opened = false
pendingRun = false
// Clear the phase before killing the process, so onExited reads the stop
// as a dismissal rather than a failed run.
phase = ""
running = false
if (proc.running) {
expectedStop = true
proc.running = false
}
}
function dismiss() {
if (shell && typeof shell.hide === "function")
shell.hide((manifest && manifest.id) || "blob.disk-speedtest")
else close()
}
function runTest() {
if (proc.running) {
// A dismissal's SIGTERM is still in flight; Process.running stays true
// until the child exits, so queue the fresh run for onExited.
if (expectedStop) pendingRun = true
return
}
error = ""
diskName = ""
writeMBps = ""
readMBps = ""
stderrText = ""
phase = "read"
running = true
proc.running = true
}
function toRate(raw) {
var value = parseFloat(raw)
return isFinite(value) && value > 0 ? value : 0
}
// Lines are "disk <model>", then "read <MB/s>" once a second, then
// "write <MB/s>". The phase follows whichever figure is streaming, and each
// phase's final line is its steady-state average, which the dial settles on.
function updateLine(line) {
var parts = String(line).trim().split(/\s+/)
if (parts.length < 2) return
if (parts[0] === "disk") {
diskName = parts.slice(1).join(" ")
return
}
var value = parseFloat(parts[1])
if (!isFinite(value) || value < 0) return
if (parts[0] === "write") {
phase = "write"
writeMBps = String(value)
} else if (parts[0] === "read") {
phase = "read"
readMBps = String(value)
}
}
Process {
id: proc
command: ["blob-disk-speedtest"]
stdout: SplitParser { onRead: function(line) { root.updateLine(line) } }
// Exit and stream-finished have no guaranteed order: when a failed exit
// beat the collector and published the generic message, replace it with
// the specific one once it lands.
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.stderrText = String(text || "").trim()
if (root.error !== "" && root.stderrText !== "") root.error = root.stderrText
}
}
onExited: function(exitCode) {
if (root.pendingRun) {
root.pendingRun = false
root.expectedStop = false
if (root.opened) Qt.callLater(root.runTest)
return
}
if (!root.expectedStop && exitCode !== 0) {
root.error = root.stderrText || "Disk speed test failed"
root.phase = ""
root.running = false
return
}
root.expectedStop = false
root.phase = ""
root.running = false
}
}
SpeedTestOverlay {
fontFamily: Style.font.family
layerNamespace: "blob-disk-speedtest"
title: root.diskName
leftLabel: "READ"
rightLabel: "WRITE"
unit: "MB/s"
runAgainTooltip: "Measure again"
running: root.running
leftValue: root.toRate(root.readMBps)
rightValue: root.toRate(root.writeMBps)
leftLive: root.running && root.phase === "read"
rightLive: root.running && root.phase === "write"
error: root.error
open: root.opened
scaleStops: [500, 1000, 2500, 5000, 10000, 15000]
onCloseRequested: root.dismiss()
onRunAgainRequested: root.runTest()
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.disk-speedtest",
"name": "Disk speed test",
"version": "1.0.0",
"author": "Blob",
"description": "Live disk write and read speed dials. Summon with: blob-shell shell summon blob.disk-speedtest",
"kinds": [
"panel"
],
"entryPoints": {
"panel": "Panel.qml"
}
}
+124
View File
@@ -0,0 +1,124 @@
function clampBrightness(value) {
var n = Number(value)
if (!isFinite(n)) return 1
return Math.max(1, Math.min(100, Math.round(n)))
}
function normalizeScale(scale) {
var n = parseFloat(String(scale || ""))
if (!isFinite(n)) return ""
return String(Math.round(n * 100) / 100)
}
function gcd(a, b) {
while (b) {
var remainder = a % b
a = b
b = remainder
}
return a
}
function cleanScale(scale, width, height) {
var requested = Number(scale)
var modeWidth = Number(width)
var modeHeight = Number(height)
if (!isFinite(requested) || !isFinite(modeWidth) || !isFinite(modeHeight)
|| requested <= 0 || modeWidth <= 0 || modeHeight <= 0) return ""
var divisor = gcd(Math.round(modeWidth * 120), Math.round(modeHeight * 120))
var scaleUnits = Math.round(requested * 120)
if (scaleUnits > divisor) scaleUnits = divisor
while (divisor % scaleUnits !== 0) scaleUnits++
return normalizeScale(scaleUnits / 120)
}
function matchingScaleIndex(scales, currentScale, width, height) {
var current = Number(currentScale)
if (!Array.isArray(scales) || !isFinite(current)) return -1
var bestIndex = -1
var bestDistance = Infinity
var normalizedCurrent = normalizeScale(current)
for (var i = 0; i < scales.length; i++) {
if (cleanScale(scales[i], width, height) !== normalizedCurrent) continue
var distance = Math.abs(Number(scales[i]) - current)
if (distance < bestDistance) {
bestIndex = i
bestDistance = distance
}
}
return bestIndex
}
function availableScales(scales, width, height) {
if (!Array.isArray(scales) || Number(width) <= 0 || Number(height) <= 0) return scales || []
var byEffectiveScale = {}
for (var i = 0; i < scales.length; i++) {
var requested = Number(scales[i])
var effective = Number(cleanScale(requested, width, height))
if (!isFinite(requested) || !isFinite(effective)) continue
var key = normalizeScale(effective)
var existing = byEffectiveScale[key]
if (!existing || Math.abs(requested - effective) < existing.distance) {
byEffectiveScale[key] = {
value: String(scales[i]),
index: i,
distance: Math.abs(requested - effective)
}
}
}
return Object.keys(byEffectiveScale)
.map(function(key) { return byEffectiveScale[key] })
.sort(function(a, b) { return a.index - b.index })
.map(function(candidate) { return candidate.value })
}
function brightnessName(percent) {
var p = Math.round(percent)
if (p >= 95) return "Sun blast"
if (p >= 80) return "Solar flare"
if (p >= 65) return "Golden hour"
if (p >= 45) return "Even day"
if (p >= 30) return "Soft glow"
if (p >= 20) return "Lamp light"
if (p >= 10) return "Candlelit"
return "Night owl"
}
function parseDisplays(raw) {
var displays = []
try {
displays = raw ? JSON.parse(String(raw)) : []
} catch (e) {
displays = []
}
if (!Array.isArray(displays)) displays = []
var count = 0
for (var i = 0; i < displays.length; i++) {
if (displays[i] && displays[i].enabled) count++
}
return {
displays: displays,
enabledDisplayCount: count
}
}
if (typeof module !== "undefined") {
module.exports = {
clampBrightness: clampBrightness,
normalizeScale: normalizeScale,
cleanScale: cleanScale,
matchingScaleIndex: matchingScaleIndex,
availableScales: availableScales,
brightnessName: brightnessName,
parseDisplays: parseDisplays
}
}
+929
View File
@@ -0,0 +1,929 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Ui
import qs.Commons
import "Model.js" as Model
Panel {
id: root
moduleName: "blob.monitor"
ipcTarget: "blob.monitor"
manageIpc: false
// manageIpc: false so this panel can own the single IpcHandler the target
// permits — needed for the brightness + state methods below.
property int brightnessPercent: 0
property int pendingBrightnessPercent: 0
property bool brightnessSetQueued: false
property bool brightnessAvailable: false
property string internalMonitor: ""
property string externalMonitor: ""
property string focusedMonitor: ""
property bool internalEnabled: false
property bool mirrorEnabled: false
property string monitorScale: ""
property var displays: []
property int enabledDisplayCount: 0
// Carry sub-notch touchpad deltas between wheel events.
property real wheelAccumulator: 0
// Cursor model shared by keyboard and mouse. Sections:
// "brightness" - single slider row, selectedIndex = -1 sentinel
// (mirrors Audio's slider rows). Only present if a
// controllable backlight was detected.
// "scale" - 6 Button scale presets; treated as a single
// horizontal row from j/k's perspective. h/l moves
// between presets, identical to bluetooth's header.
// "monitors" - vertical display row list for enabling/disabling displays;
// j/k walks each row.
// Mouse hover on a target updates root state via the components' `hovered`
// signal so keyboard cursor and pointer share one highlight.
readonly property var scalePresets: ["1", "1.25", "1.6", "2", "3", "4"]
readonly property var scaleValues: {
for (var i = 0; i < displays.length; i++) {
var display = displays[i]
if (display && display.focused)
return Model.availableScales(scalePresets, display.width, display.height)
}
return scalePresets
}
property string focusSection: "scale"
property int selectedIndex: 0
property bool cursorActive: false
// Text size slider — curated macOS-style notches (px). The panel snaps to
// these stops; the CLI (blob-display-size) accepts any integer in range.
readonly property var textSizeStops: [9, 10, 11, 12, 14, 16, 20]
// While a change is in flight, the chosen stop index overrides the live
// base-size so the knob doesn't snap back during the file round-trip. -1 =
// no pending change; follow Style.font.baseSize.
property int textSizePreviewIndex: -1
// A text-size change reflows the whole panel (both font and spacing scale),
// which slides rows under a stationary pointer and fires synthetic hover.
// While true, hover is not allowed to hijack the keyboard focus section —
// otherwise h/l on the text-size slider can jump focus to another row.
property bool reflowingText: false
function markReflowing() {
root.reflowingText = true
reflowSettle.restart()
}
readonly property var visibleSections: {
var list = []
if (brightnessAvailable) list.push("brightness")
list.push("textsize")
list.push("scale")
if (displays.length > 1) list.push("monitors")
return list
}
function sectionCount(section) {
if (section === "brightness") return 0 // only the slider sentinel at -1
if (section === "textsize") return 0 // slider sentinel at -1, like brightness
if (section === "scale") return scaleValues.length
if (section === "monitors") return displays.length
return 0
}
function sectionIsSingleRow(section) {
// brightness and text size are lone sliders; scale presets sit horizontally.
return section === "brightness" || section === "textsize" || section === "scale"
}
function sectionFirstIndex(section) {
if (section === "brightness" || section === "textsize") return -1
return 0
}
function moveCursor(delta) {
var sections = visibleSections
if (!sections || sections.length === 0) return
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) {
focusSection = sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
var inSingleRow = sectionIsSingleRow(focusSection)
var max = inSingleRow ? 0 : sectionCount(focusSection) - 1
if (delta > 0) {
if (!inSingleRow && selectedIndex < max) { selectedIndex = selectedIndex + 1; return }
if (sIdx < sections.length - 1) {
focusSection = sections[sIdx + 1]
selectedIndex = sectionFirstIndex(focusSection)
}
} else {
if (!inSingleRow && selectedIndex > 0) { selectedIndex = selectedIndex - 1; return }
if (sIdx > 0) {
var prev = sections[sIdx - 1]
focusSection = prev
// Coming up from below — land on the last navigable row of the prev
// section, or its sentinel for single-row sections.
selectedIndex = sectionIsSingleRow(prev) ? sectionFirstIndex(prev) : sectionCount(prev) - 1
}
}
}
// h/l: in scale section, walks the preset row; everywhere else, no-op
// because adjustBrightness handles horizontal motion on the brightness
// slider.
function moveCursorH(delta) {
if (focusSection !== "scale") return
var next = selectedIndex + delta
if (next < 0) next = 0
if (next > scaleValues.length - 1) next = scaleValues.length - 1
selectedIndex = next
}
function adjustBrightness(delta) {
if (focusSection !== "brightness") return
if (!brightnessAvailable) return
setBrightness(root.brightnessPercent + delta)
}
function activateCursor() {
if (focusSection === "scale" && selectedIndex >= 0 && selectedIndex < scaleValues.length) {
setScale(scaleValues[selectedIndex])
return
}
if (focusSection === "monitors" && selectedIndex >= 0 && selectedIndex < displays.length) {
var d = displays[selectedIndex]
if (d) toggleDisplay(d.name, d.enabled)
}
// brightness: no separate action; the slider value is the action.
}
function clampCursor() {
var sections = visibleSections
if (!sections || !sections.length) return
if (sections.indexOf(focusSection) < 0) {
focusSection = sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
var count = sectionCount(focusSection)
if (sectionIsSingleRow(focusSection)) {
// brightness/text size use the -1 sentinel; scale clamps into the presets.
if (focusSection === "brightness" || focusSection === "textsize") selectedIndex = -1
else if (selectedIndex < 0 || selectedIndex >= count) selectedIndex = 0
return
}
if (count === 0) {
var sIdx = sections.indexOf(focusSection)
focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
if (selectedIndex > count - 1) selectedIndex = count - 1
if (selectedIndex < 0) selectedIndex = 0
}
// Keep the keyboard-focused row inside the viewport when the panel grows
// taller than its allotted height (lots of displays). Mirrors audio's
// ensureCursorVisible helper.
function ensureCursorVisible(item) {
if (!item || !scrollArea) return
var flick = scrollArea.contentItem
if (!flick || flick.contentY === undefined) return
var pt = item.mapToItem(flick.contentItem || flick, 0, 0)
var top = pt.y
var bottom = top + (item.height || 0)
var viewTop = flick.contentY
var viewBottom = viewTop + flick.height
var margin = 6
if (top < viewTop + margin) flick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin)
flick.contentY = bottom + margin - flick.height
}
function brightnessIpc(percent) {
var value = Number(percent)
root.setBrightness(value)
return "got " + root.pendingBrightnessPercent
}
function stateIpc() {
return JSON.stringify({
brightness: root.brightnessPercent,
brightnessAvailable: root.brightnessAvailable,
focusedMonitor: root.focusedMonitor,
scale: root.monitorScale,
displays: root.displays
})
}
IpcHandler {
target: "blob.monitor"
function brightness(percent: string): string { return root.brightnessIpc(percent) }
function state(): string { return root.stateIpc() }
function open() { root.open() }
function close() { root.close() }
function toggle() { root.toggle() }
function show() { root.open() }
function hide() { root.close() }
}
function refresh() {
if (!stateProc.running) stateProc.running = true
}
function setBrightness(value) {
var percent = Model.clampBrightness(value)
root.brightnessPercent = percent
root.pendingBrightnessPercent = percent
if (setBrightnessProc.running) {
root.brightnessSetQueued = true
return
}
root.brightnessSetQueued = false
setBrightnessProc.command = ["blob-brightness-display", "--no-osd", "--monitor", root.focusedMonitor, percent + "%"]
setBrightnessProc.running = true
}
function previewBrightness(value) {
root.brightnessPercent = Model.clampBrightness(value)
brightnessDebounce.restart()
}
function showBrightnessOsd(percent) {
if (!bar || !bar.shell) return
bar.shell.summon("blob.osd", JSON.stringify({
icon: "brightness",
value: percent
}))
}
function normalizeScale(scale) {
return Model.normalizeScale(scale)
}
function activeScaleIndex() {
for (var i = 0; i < displays.length; i++) {
var display = displays[i]
if (display && display.focused)
return Model.matchingScaleIndex(scaleValues, monitorScale, display.width, display.height)
}
return -1
}
function effectiveScale(scale) {
for (var i = 0; i < displays.length; i++) {
var display = displays[i]
if (display && display.focused)
return Model.cleanScale(scale, display.width, display.height)
}
return normalizeScale(scale)
}
// Playful mood-name for a given brightness percent. Bands intentionally
// span ~1020 points so casual tweaks change the label, while small
// nudges within one band don't.
function brightnessName(percent) {
return Model.brightnessName(percent)
}
function updateDisplays(displaysJson) {
var parsed = Model.parseDisplays(displaysJson)
root.displays = parsed.displays
root.enabledDisplayCount = parsed.enabledDisplayCount
}
function toggleDisplay(name, enabled) {
if (!name) return
if (enabled && root.enabledDisplayCount <= 1) return
actionProc.command = ["hyprctl", "keyword", "monitor", name + (enabled ? ",disable" : ",preferred,auto,auto")]
if (!actionProc.running) actionProc.running = true
}
function setScale(scale) {
actionProc.command = ["bash", "-c", "blob-hypr-monitor-scaling " + scale]
if (!actionProc.running) actionProc.running = true
}
// ---- Text size (shell base font + GTK text-scaling, via one CLI) ----
function nearestTextStop(px) {
var best = 0
var bestDist = 1e9
for (var i = 0; i < textSizeStops.length; i++) {
var d = Math.abs(textSizeStops[i] - px)
if (d < bestDist) { bestDist = d; best = i }
}
return best
}
// Effective stop index: the pending choice while a change is in flight,
// otherwise whatever Style's live base-size rounds to.
function currentTextIndex() {
return textSizePreviewIndex >= 0 ? textSizePreviewIndex : nearestTextStop(Style.font.baseSize)
}
// px shown in the header: the pending stop if any, else the true base-size
// (which may be an off-notch value set from the CLI).
function displayedTextPx() {
return textSizePreviewIndex >= 0 ? textSizeStops[textSizePreviewIndex] : Style.font.baseSize
}
function setTextSize(px) {
textScaleProc.command = ["blob-display-size", String(px)]
if (!textScaleProc.running) textScaleProc.running = true
}
function adjustTextSize(deltaSteps) {
var idx = currentTextIndex() + deltaSteps
if (idx < 0) idx = 0
if (idx > textSizeStops.length - 1) idx = textSizeStops.length - 1
markReflowing()
textSizePreviewIndex = idx
setTextSize(textSizeStops[idx])
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
Component.onCompleted: refresh()
// KeyboardPanel primes focus at open-time, so SUPER-bound IPC summons land
// with j/k ready to navigate. Keep a default landing point, but don't paint
// the cursor until hover or the first navigation key.
onOpenedChanged: {
if (opened) {
refresh()
if (brightnessAvailable) {
focusSection = "brightness"
selectedIndex = -1
} else {
focusSection = "scale"
selectedIndex = 0
}
cursorActive = false
}
}
onBrightnessAvailableChanged: clampCursor()
onDisplaysChanged: clampCursor()
onScaleValuesChanged: clampCursor()
onVisibleSectionsChanged: clampCursor()
// Only poll while the panel is open; the bar glyph tracks monitor count via
// Quickshell.screens, and open-time refresh + Component.onCompleted cover the
// rest. External brightness changes are reflected whenever the panel is open.
Timer {
interval: 5000
running: root.opened
repeat: true
onTriggered: root.refresh()
}
Process {
id: stateProc
command: ["blob-display-state"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var lines = String(text || "").split("\n")
var brightness = String(lines[0] || "").trim()
root.brightnessAvailable = brightness !== "unavailable" && brightness !== ""
root.brightnessPercent = root.brightnessAvailable ? Math.max(0, Math.min(100, parseInt(brightness, 10))) : 0
root.internalMonitor = String(lines[1] || "").trim()
root.externalMonitor = String(lines[2] || "").trim()
root.internalEnabled = String(lines[3] || "").trim() !== ""
root.mirrorEnabled = String(lines[4] || "").trim() === root.externalMonitor && root.externalMonitor !== ""
root.focusedMonitor = String(lines[5] || "").trim()
root.monitorScale = root.normalizeScale(String(lines[6] || "").trim())
root.updateDisplays(String(lines[7] || "[]").trim())
}
}
}
Timer {
id: brightnessDebounce
interval: 180
repeat: false
onTriggered: root.setBrightness(root.brightnessPercent)
}
Process {
id: setBrightnessProc
stdout: StdioCollector { waitForEnd: true }
// Do NOT call refresh() after a brightness set completes. The local
// brightnessPercent we just wrote is authoritative; re-reading via
// `blob-brightness-display` races the hardware/driver and can
// return an empty string, which the parser then coerces to 0 —
// visible as a "bounce to zero" after h/l keypresses. External
// brightness changes are still picked up by the 5s periodic refresh,
// the open-time refresh, and Component.onCompleted.
onRunningChanged: {
if (running) return
if (root.brightnessSetQueued) {
root.setBrightness(root.pendingBrightnessPercent)
}
}
}
Process {
id: actionProc
stdout: StdioCollector { waitForEnd: true }
onRunningChanged: if (!running) root.refresh()
}
// Applies text size via the CLI, which rewrites the shell override file;
// Style picks the new base-size up through its own file watch, so there's
// nothing to refresh here.
Process {
id: textScaleProc
stdout: StdioCollector { waitForEnd: true }
}
// Clears the hover-suppression flag once the reflow triggered by a text-size
// change has settled.
Timer {
id: reflowSettle
interval: 300
repeat: false
onTriggered: root.reflowingText = false
}
// Once Style's base-size catches up to the pending choice, drop the preview
// so the slider tracks the live value again. The change itself reflows the
// panel, so suppress hover for a beat while it lands.
Connections {
target: Style
function onFontBaseSizeChanged() {
root.markReflowing()
if (root.textSizePreviewIndex >= 0
&& root.nearestTextStop(Style.font.baseSize) === root.textSizePreviewIndex)
root.textSizePreviewIndex = -1
}
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: Quickshell.screens.length > 1 ? "󰍺" : "󰍹"
onPressed: function(b) { root.toggle() }
onWheelMoved: function(delta) {
if (!root.brightnessAvailable) return
var wheel = Util.wheelSteps(root.wheelAccumulator, delta)
root.wheelAccumulator = wheel.remainder
if (wheel.steps === 0) return
root.setBrightness(root.brightnessPercent + wheel.steps * 5)
root.showBrightnessOsd(root.brightnessPercent)
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(380))
contentHeight: panel.fittedContentHeight(panelColumn.implicitHeight, Style.space(560))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) {
if (root.focusSection === "brightness") root.adjustBrightness(dx * 5)
else if (root.focusSection === "textsize") root.adjustTextSize(dx)
else if (root.focusSection === "scale") root.moveCursorH(dx)
}
}
onActivateRequested: if (root.cursorActive) root.activateCursor()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
ScrollView {
id: scrollArea
anchors.fill: parent
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: panelColumn.implicitHeight > height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
Binding {
target: scrollArea.contentItem
property: "interactive"
value: panelColumn.implicitHeight > scrollArea.height
}
Column {
id: panelColumn
width: scrollArea.availableWidth
spacing: Style.space(14)
// ---------- Hero: display icon · title/status ----------
Item {
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
Text {
id: heroIcon
textFormat: Text.PlainText
text: root.displays.length > 1 ? "󰍺" : "󰍹"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Column {
id: heroLabels
anchors.left: heroIcon.right
anchors.leftMargin: Style.space(14)
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
text: "Display"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
id: heroLabel
textFormat: Text.PlainText
text: {
if (root.brightnessAvailable) {
return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase()
}
return "FIXED BRIGHTNESS"
}
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
width: parent.width
}
}
}
// ---------- Brightness ----------
PanelSeparator {
visible: root.brightnessAvailable
foreground: root.bar.foreground
}
Column {
visible: root.brightnessAvailable
width: parent.width
spacing: Style.space(6)
Item {
width: parent.width
implicitHeight: Math.max(brightnessHeader.implicitHeight, brightnessPercent.implicitHeight)
PanelSectionHeader {
id: brightnessHeader
text: "BRIGHTNESS"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: brightnessPercent
textFormat: Text.PlainText
text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
anchors.right: parent.right
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
}
}
CursorSurface {
id: brightnessRow
width: parent.width
height: brightnessSlider.implicitHeight + Style.spacing.controlGap
hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow)
foreground: root.bar.foreground
outline: true
PanelSlider {
id: brightnessSlider
bar: root.bar
anchors.fill: parent
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
minimum: 1
maximum: 100
step: 1
value: root.brightnessPercent
integer: true
onMoved: function(v) { root.previewBrightness(v) }
onReleased: function(v) {
brightnessDebounce.stop()
root.setBrightness(v)
}
}
HoverHandler {
onHoveredChanged: if (hovered && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "brightness"
root.selectedIndex = -1
}
}
}
}
// ---------- Text size ----------
PanelSeparator {
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(6)
Item {
width: parent.width
implicitHeight: Math.max(textSizeHeader.implicitHeight, textSizePx.implicitHeight)
PanelSectionHeader {
id: textSizeHeader
text: "TEXT SIZE"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: textSizePx
textFormat: Text.PlainText
text: (textSizeSlider.dragging
? root.textSizeStops[Math.round(textSizeSlider.liveValue)]
: root.displayedTextPx()) + "px"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
anchors.right: parent.right
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
}
}
CursorSurface {
id: textSizeRow
width: parent.width
height: textSizeSlider.implicitHeight + Style.spacing.controlGap
hasCursor: root.cursorActive && root.focusSection === "textsize" && root.selectedIndex === -1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(textSizeRow)
foreground: root.bar.foreground
outline: true
PanelSlider {
id: textSizeSlider
bar: root.bar
anchors.fill: parent
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
minimum: 0
maximum: root.textSizeStops.length - 1
step: 1
integer: true
tickCount: root.textSizeStops.length
value: root.currentTextIndex()
onReleased: function(v) { root.setTextSize(root.textSizeStops[Math.round(v)]) }
}
HoverHandler {
onHoveredChanged: if (hovered && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "textsize"
root.selectedIndex = -1
}
}
}
}
// ---------- Scale ----------
PanelSeparator {
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(10)
Item {
width: parent.width
implicitHeight: Math.max(scaleHeader.implicitHeight, scaleMonitor.implicitHeight)
PanelSectionHeader {
id: scaleHeader
text: "SCALE"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
// Name the monitor SCALE targets, since it only applies to the
// focused one.
Text {
id: scaleMonitor
textFormat: Text.PlainText
text: root.focusedMonitor
// Only worth naming when more than one display is in play.
visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
anchors.right: parent.right
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
}
}
Grid {
id: scaleRow
width: parent.width
columns: root.scaleValues.length
spacing: Style.spacing.xs
readonly property real cellWidth: root.scaleValues.length > 0
? (width - spacing * (columns - 1)) / columns
: 0
Repeater {
model: root.scaleValues
ScalePill {
required property string modelData
required property int index
scaleValue: modelData
scaleIndex: index
width: scaleRow.cellWidth
}
}
}
}
// ---------- Monitors ----------
PanelSeparator {
visible: root.displays.length > 1
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(10)
visible: root.displays.length > 1
PanelSectionHeader {
text: "DISPLAYS"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
Repeater {
model: root.displays
MonitorRow {
required property var modelData
required property int index
width: panelColumn.width
display: modelData
rowIndex: index
}
}
}
Item {
width: parent.width
height: Style.space(4)
}
}
}
}
}
component ScalePill: Button {
id: pill
required property string scaleValue
required property int scaleIndex
text: root.effectiveScale(scaleValue) + "x"
fontSize: Style.font.caption
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
horizontalPadding: Style.spacing.sm
verticalPadding: Style.spacing.controlPaddingY
bordered: true
active: root.activeScaleIndex() === scaleIndex
hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === scaleIndex
onClicked: root.setScale(scaleValue)
onHovered: function(isHovered) {
if (!isHovered || root.reflowingText) return
root.cursorActive = true
root.focusSection = "scale"
root.selectedIndex = pill.scaleIndex
}
}
component MonitorRow: CursorSurface {
id: monitorRow
required property var display
required property int rowIndex
readonly property bool isFocused: display && display.focused
readonly property bool canToggle: display && (!display.enabled || root.enabledDisplayCount > 1)
hasCursor: root.cursorActive && root.focusSection === "monitors" && root.selectedIndex === rowIndex
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(monitorRow)
current: isFocused
foreground: root.bar.foreground
fill: Style.hoverFillFor(root.bar.foreground, Color.accent)
currentFill: Style.selectedFillFor(root.bar.foreground, Color.accent)
implicitHeight: monitorInner.implicitHeight + Style.spacing.xl
opacity: canToggle ? 1.0 : 0.45
Row {
id: monitorInner
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
spacing: Style.space(8)
Text {
text: "󰍹"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
width: Style.space(22)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
}
Text {
textFormat: Text.PlainText
text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
width: parent.width - Style.space(22) - Style.space(14) - Style.space(16)
anchors.verticalCenter: parent.verticalCenter
}
Text {
textFormat: Text.PlainText
text: monitorRow.display.enabled ? "󰄬" : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.subtitle
width: Style.space(14)
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: monitorRow.canToggle ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "monitors"
root.selectedIndex = monitorRow.rowIndex
}
onClicked: if (monitorRow.canToggle) root.toggleDisplay(monitorRow.display.name, monitorRow.display.enabled)
}
}
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.monitor",
"name": "Display",
"version": "1.0.0",
"author": "Blob",
"description": "Brightness slider and laptop display controls",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Display",
"description": "Brightness slider and laptop display controls",
"category": "System",
"allowMultiple": false
}
}
+380
View File
@@ -0,0 +1,380 @@
function parseNetworkStatus(raw) {
var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t")
return {
kind: parts[0] || "disconnected",
label: parts[1] || "",
signalStrength: parts[2] ? parseInt(parts[2], 10) : -1,
frequency: parts[3] || ""
}
}
function wifiIconFor(strength) {
var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"]
var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1))
return icons[index]
}
function connectionIcon(kind, signalStrength) {
if (kind === "wifi") return wifiIconFor(signalStrength)
if (kind === "ethernet") return "󰈀"
return "󰤮"
}
function formatHeaderSpeed(mbps) {
var v = parseInt(mbps, 10)
if (!v || v < 0) return ""
if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit"
return v + "mbit"
}
function formatHeaderFreq(mhz) {
var v = parseFloat(mhz)
if (!v) return ""
if (v >= 2400 && v < 2500) return "2.4ghz"
if (v >= 4900 && v < 5925) return "5ghz"
if (v >= 5925 && v < 7125) return "6ghz"
if (v >= 57000 && v < 71000) return "60ghz"
var ghz = v / 1000
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
}
// Wi-Fi band state belongs in the selector section, not beside the hero name.
// Ethernet has no equivalent selector, so keep its negotiated link speed here.
function headerDetail(info) {
var value = info || {}
if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "")
return ""
}
function bandLabel(band) {
if (band === "auto") return "Auto"
if (!band) return ""
return band + "ghz"
}
// Under Automatic the pills are hidden, so the header carries the live band
// instead -- "WI-FI BAND: 2.4GHZ". Once a band is pinned the pills are on
// screen and say it themselves, so the header drops back to a plain label.
function bandSectionTitle(selected, current) {
if (selected !== "auto") return "WI-FI BAND"
var label = bandLabel(current)
if (label === "") return "WI-FI BAND"
return "WI-FI BAND: " + label.toUpperCase()
}
function bandTooltip(band) {
if (band === "auto") return "Let Wi-Fi pick the band"
if (!band) return ""
return "Stay on " + bandLabel(band)
}
function parseBandStatus(raw) {
var next = parseKeyValue(raw)
var tokens = String(next.available || "").split(" ")
var available = []
for (var i = 0; i < tokens.length; i++) {
if (tokens[i] !== "") available.push(tokens[i])
}
return {
band: next.band || "",
selected: next.selected || "auto",
available: available
}
}
function decodeIwSsid(value) {
var raw = String(value || "")
try {
var encoded = ""
for (var i = 0; i < raw.length; i++) {
if (raw[i] === "\\" && raw[i + 1] === "x" && /^[0-9a-f]{2}$/i.test(raw.substring(i + 2, i + 4))) {
var hex = raw.substring(i + 2, i + 4)
var byte = parseInt(hex, 16)
encoded += byte < 32 || byte === 127 ? encodeURIComponent(raw.substring(i, i + 4)) : "%" + hex
i += 3
} else {
encoded += encodeURIComponent(raw[i])
}
}
return decodeURIComponent(encoded)
} catch (error) {
return raw
}
}
function parseKeyValue(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
if (!line) continue
var idx = line.indexOf("\t")
if (idx === -1) continue
var key = line.substring(0, idx)
var value = line.substring(idx + 1)
next[key] = key === "ssid" ? decodeIwSsid(value) : value.trim()
}
return next
}
function throughputState(previous, next, now) {
var prev = previous || {}
var sample = next || {}
var iface = sample.iface || ""
var rx = parseFloat(sample.rx_bytes || "0")
var tx = parseFloat(sample.tx_bytes || "0")
var previousTime = Number(prev.prevSampleTime || 0)
if (iface !== (prev.prevIface || "") || previousTime === 0) {
return {
prevIface: iface,
prevRxBytes: rx,
prevTxBytes: tx,
prevSampleTime: now,
downloadRate: 0,
uploadRate: 0
}
}
var downloadRate = Number(prev.downloadRate || 0)
var uploadRate = Number(prev.uploadRate || 0)
var dt = now - previousTime
if (dt > 0) {
downloadRate = Math.max(0, (rx - Number(prev.prevRxBytes || 0)) / dt)
uploadRate = Math.max(0, (tx - Number(prev.prevTxBytes || 0)) / dt)
}
return {
prevIface: iface,
prevRxBytes: rx,
prevTxBytes: tx,
prevSampleTime: now,
downloadRate: downloadRate,
uploadRate: uploadRate
}
}
function pingSampleValue(raw) {
var value = parseFloat(raw)
if (!isFinite(value) || value < 0) return null
return value
}
function appendPingSample(samples, raw, limit) {
var values = Array.isArray(samples) ? samples.slice() : []
values.push(pingSampleValue(raw))
while (values.length > limit) values.shift()
return values
}
function averagePingLatency(samples, limit) {
var values = Array.isArray(samples) ? samples : []
var sampleLimit = Math.max(1, parseInt(limit, 10) || values.length || 1)
var total = 0
var count = 0
for (var i = Math.max(0, values.length - sampleLimit); i < values.length; i++) {
var value = values[i]
if (typeof value !== "number" || !isFinite(value) || value < 0) continue
total += value
count++
}
return count > 0 ? total / count : -1
}
function pingPacketLossPercent(samples) {
var values = Array.isArray(samples) ? samples : []
if (values.length === 0) return 0
var lost = 0
for (var i = 0; i < values.length; i++) {
if (values[i] === null) lost++
}
return Math.round((lost / values.length) * 100)
}
function formatPacketLoss(percent, hasSamples) {
if (hasSamples === false) return "--"
var value = parseInt(percent, 10)
if (!value || value < 0) return "0%"
return value + "%"
}
function pingLatencyState(previous, next, limit, averageLimit) {
var prev = previous || {}
var sample = next || {}
var iface = sample.iface || ""
var window = Math.max(1, parseInt(limit, 10) || 5)
var averageWindow = Math.max(1, parseInt(averageLimit, 10) || window)
var reset = iface === "" || iface !== (prev.pingIface || "")
var routerSamples = reset ? [] : prev.routerPingSamples
var internetSamples = reset ? [] : prev.internetPingSamples
routerSamples = sample.router_ping_ms === undefined ? [] : appendPingSample(routerSamples, sample.router_ping_ms, window)
internetSamples = sample.internet_ping_ms === undefined ? [] : appendPingSample(internetSamples, sample.internet_ping_ms, window)
return {
pingIface: iface,
routerPingSamples: routerSamples,
internetPingSamples: internetSamples,
routerPingLatency: averagePingLatency(routerSamples, averageWindow),
internetPingLatency: averagePingLatency(internetSamples, averageWindow),
internetPingPacketLoss: pingPacketLossPercent(internetSamples)
}
}
function formatBytes(bytes) {
var n = Number(bytes)
if (!isFinite(n) || n < 0) n = 0
if (n < 1024) return Math.round(n) + " B"
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB"
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB"
}
function formatRate(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s"
}
// `hasSamples` false means no probe has come back yet, which is different from
// a probe that timed out. The rows stay mounted through that gap and read "--"
// so the grid doesn't reflow a second after the panel opens.
function formatPingLatency(ms, hasSamples) {
if (hasSamples === false) return "--"
var value = parseFloat(ms)
if (!isFinite(value) || value < 0) return "Timeout"
return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " ms"
}
function wifiRow(network) {
if (!network) return null
// Primitives only: rows become list-model data, so a WifiNetwork here puts a
// live QObject wrapper in every delegate's var property. NetworkManager churn
// (scans, AP removals) can destroy the object while a delegate is still
// incubating, which segfaults quickshell in wrap_slowPath on the dangling
// wrapper. Callers that need the object resolve it via networkForSsid().
return {
connected: !!network.connected,
known: !!network.known,
ssid: network.name || "",
signal: Math.round((network.signalStrength || 0) * 100),
security: network.security
}
}
function sortWifiRows(rows) {
var nets = Array.isArray(rows) ? rows.slice() : []
nets.sort(function(a, b) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
if (a.known !== b.known) return a.known ? -1 : 1
return b.signal - a.signal
})
return nets
}
function wifiSectionTitle(wifiNetworks, index) {
var networks = Array.isArray(wifiNetworks) ? wifiNetworks : []
if (index < 0 || index >= networks.length) return ""
var net = networks[index]
if (!net) return ""
if (net.known && index === 0) return "KNOWN NETWORKS"
if (!net.known && (index === 0 || (networks[index - 1] && networks[index - 1].known))) return "OTHER NETWORKS"
return ""
}
// OWE (Enhanced Open) encrypts traffic without authenticating the user, so it
// has no credentials to collect. The panel's lock is a credentials-required
// affordance, so OWE should neither show it nor open its attached prompt.
function requiresCredentials(security, openSecurity, oweSecurity) {
// Only explicit passwordless types bypass the prompt. Unknown security
// stays credentialed as the conservative fallback.
return security !== openSecurity && security !== oweSecurity
}
function canForgetNetwork(network) {
return !!(network && network.known && !network.connected)
}
// The password arrives on stdin and reaches nmcli through the scriptable
// `connection edit` editor -- argv is world-readable in /proc, so the secret
// must never be an argument (printf is a bash builtin, so no process spawns
// with it either).
var enterpriseConnectScript =
"u=$(uuidgen); IFS= read -r pw;" +
" nmcli connection add type wifi con-name \"$1\" ssid \"$1\" connection.uuid \"$u\"" +
" wifi-sec.key-mgmt wpa-eap 802-1x.eap peap 802-1x.phase2-auth mschapv2" +
" 802-1x.identity \"$2\" 802-1x.auth-timeout 8 >/dev/null" +
" && printf 'set 802-1x.password %s\\nsave\\nquit\\n' \"$pw\" | nmcli connection edit uuid \"$u\" >/dev/null" +
" && nmcli connection up uuid \"$u\"" +
" || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }"
function networkFailureReason(reason, needsCredentials, reasons) {
var r = reasons || {}
if (needsCredentials && reason === r.NoSecrets) return "Passphrase required"
if (needsCredentials && reason === r.WifiAuthTimeout) return "Wrong password"
if (reason === r.WifiNetworkLost) return "Network lost"
if (reason === r.WifiClientDisconnected) return "Disconnected"
if (reason === r.WifiClientFailed) return "Connection failed"
return "Failed to connect"
}
// Whether a failed connect should reopen the passphrase prompt. NoSecrets
// means credentials are missing only for a network that actually uses them.
// An auth timeout on such a network means the saved passphrase is wrong (the
// same profile a first failed attempt leaves behind as "known"), so the user
// needs a chance to re-enter it -- connectWithPsk overwrites the stored PSK on
// submit.
function shouldRepromptPassphrase(reason, needsCredentials, reasons) {
var r = reasons || {}
if (!needsCredentials) return false
return reason === r.NoSecrets || reason === r.WifiAuthTimeout
}
if (typeof module !== "undefined") {
module.exports = {
parseNetworkStatus: parseNetworkStatus,
wifiIconFor: wifiIconFor,
connectionIcon: connectionIcon,
formatHeaderSpeed: formatHeaderSpeed,
formatHeaderFreq: formatHeaderFreq,
headerDetail: headerDetail,
bandLabel: bandLabel,
bandSectionTitle: bandSectionTitle,
bandTooltip: bandTooltip,
parseBandStatus: parseBandStatus,
decodeIwSsid: decodeIwSsid,
parseKeyValue: parseKeyValue,
throughputState: throughputState,
pingLatencyState: pingLatencyState,
pingPacketLossPercent: pingPacketLossPercent,
formatPacketLoss: formatPacketLoss,
formatBytes: formatBytes,
formatRate: formatRate,
formatPingLatency: formatPingLatency,
wifiRow: wifiRow,
sortWifiRows: sortWifiRows,
wifiSectionTitle: wifiSectionTitle,
requiresCredentials: requiresCredentials,
canForgetNetwork: canForgetNetwork,
enterpriseConnectScript: enterpriseConnectScript,
networkFailureReason: networkFailureReason,
shouldRepromptPassphrase: shouldRepromptPassphrase
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.network",
"name": "Network",
"version": "1.0.0",
"author": "Blob",
"description": "Wi-Fi list and connection state",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Network",
"description": "Wi-Fi list and connection state",
"category": "Network",
"allowMultiple": false
}
}
+104
View File
@@ -0,0 +1,104 @@
function clampIndex(index, length) {
if (length <= 0) return 0
return Math.max(0, Math.min(length - 1, index))
}
function selectProfileIndex(index, delta, profiles) {
var values = Array.isArray(profiles) ? profiles : []
if (values.length === 0) return 0
return clampIndex(index + delta, values.length)
}
function parseKeyValue(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var idx = lines[i].indexOf("\t")
if (idx <= 0) continue
next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim()
}
return next
}
function parseProfiles(raw, previousIndex) {
var lines = String(raw || "").split("\n")
var list = []
var active = ""
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
list.push(parts[0])
if (parts[1] === "1") active = parts[0]
}
return {
profiles: list,
activeProfile: active,
profileIndex: clampIndex(previousIndex || 0, list.length)
}
}
function profileIcon(name) {
if (name === "power-saver") return "󰌪"
if (name === "balanced") return "󰊚"
if (name === "performance") return "󰓅"
return "󰂄"
}
function batteryFraction(device) {
return device && device.isPresent ? Math.max(0, Math.min(1, device.percentage)) : 0
}
function chargeThresholdActive(device, onBattery, states) {
var d = device || {}
var s = states || {}
if (!(d && d.isPresent && !onBattery)) return false
var fraction = batteryFraction(d)
if (d.state === s.Discharging) return false
if (d.state === s.PendingCharge) return true
if (d.state === s.FullyCharged && fraction < 0.99) return true
if (d.state !== s.Charging || fraction >= 0.99) return false
return Number(d.changeRate || 0) <= 0.2 || Number(d.timeToFull || 0) >= 8 * 60 * 60
}
function batteryIcon(device, onBattery, states) {
var d = device || {}
if (!d.isPresent) return ""
var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
var index = Math.max(0, Math.min(9, Math.floor(d.percentage * 10)))
var threshold = chargeThresholdActive(d, onBattery, states)
if (threshold) return defaultIcons[index]
if (d.state === states.FullyCharged) return "󰂅"
if (!onBattery) return chargingIcons[index]
return defaultIcons[index]
}
function modeLabel(device, onBattery, states) {
var d = device || {}
if (!d.isPresent) return ""
var percentage = d.isPresent ? d.percentage : 0
if (chargeThresholdActive(d, onBattery, states)) return "Threshold"
if (onBattery) return "On battery"
if (!onBattery && percentage >= 1) return "Fully charged"
return "Charging"
}
if (typeof module !== "undefined") {
module.exports = {
clampIndex: clampIndex,
selectProfileIndex: selectProfileIndex,
parseKeyValue: parseKeyValue,
parseProfiles: parseProfiles,
profileIcon: profileIcon,
batteryFraction: batteryFraction,
chargeThresholdActive: chargeThresholdActive,
batteryIcon: batteryIcon,
modeLabel: modeLabel
}
}
+536
View File
@@ -0,0 +1,536 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import qs.Commons
import qs.Ui
import "Model.js" as Model
Panel {
id: root
moduleName: "blob.power"
ipcTarget: "blob.power"
// manageIpc: false so this panel can own the single IpcHandler the target
// permits — needed for the togglePercentage method below.
manageIpc: false
property var batteryInfo: ({})
property var systemInfo: ({})
property var profiles: []
property string activeProfile: ""
property int profileIndex: 0
property bool cursorActive: false
readonly property bool showPercentage: setting("showPercentage", false) === true
// With the percentage shown the button paints a text block wider than an
// icon, so the open-panel mark takes the painted width instead of the
// icon-sized fraction of the slot the fallback assumes.
readonly property real openPanelIndicatorWidth: showPercentage && !button.vertical ? button.glyphPaintedWidth : 0
readonly property bool batteryPresent: {
var device = UPower.displayDevice
return !!(device && device.isPresent)
}
function upowerStates() {
return {
Charging: UPowerDeviceState.Charging,
Discharging: UPowerDeviceState.Discharging,
FullyCharged: UPowerDeviceState.FullyCharged,
PendingCharge: UPowerDeviceState.PendingCharge
}
}
function selectProfileByDelta(delta) {
profileIndex = Model.selectProfileIndex(profileIndex, delta, profiles)
}
function activateSelectedProfile() {
if (profileIndex < 0 || profileIndex >= profiles.length) return
setProfile(profiles[profileIndex])
}
function batteryIcon() {
var device = UPower.displayDevice
return Model.batteryIcon(device, root.discharging, upowerStates())
}
function modeLabel() {
var device = UPower.displayDevice
return Model.modeLabel(device, root.discharging, upowerStates())
}
function profileIcon(name) {
return Model.profileIcon(name)
}
readonly property bool fullyCharged: {
var device = UPower.displayDevice
return device && device.isPresent && device.state === UPowerDeviceState.FullyCharged && !root.chargeThresholdActive
}
readonly property bool discharging: {
var device = UPower.displayDevice
return !!(device && device.isPresent && UPower.onBattery)
}
readonly property bool chargeThresholdActive: {
var device = UPower.displayDevice
return Model.chargeThresholdActive(device, root.discharging, upowerStates())
}
readonly property bool batteryFull: fullyCharged || (!root.discharging && batteryFraction >= 1)
readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive
// 0..1 charge level, used by the visual progress bar.
readonly property real batteryFraction: {
var d = UPower.displayDevice
return Model.batteryFraction(d)
}
readonly property bool charging: {
var d = UPower.displayDevice
return d && d.isPresent && !UPower.onBattery && !root.batteryFlowIdle
}
readonly property color batteryFillColor: {
return root.bar ? root.bar.foreground : Color.foreground
}
// Cute agent-flavored phrases shown in the hero status line, rotated on a
// timer so the panel feels alive when current is flowing (either direction).
readonly property var chargingPhrases: [
"Pumping power",
"Injecting electrons",
"Pouring juice",
"Amassing watts",
"Hoarding joules",
"Sucking volts",
"Topping reserves",
"Soaking amps",
"Inhaling kilowatts"
]
readonly property var onBatteryPhrases: [
"Slurping power",
"Spending joules",
"Draining watts",
"Burning electrons",
"Sipping juice",
"Spending coulombs",
"Bleeding amps",
"Guzzling volts",
"Munching reserves"
]
property int phraseIndex: 0
// Whichever list is "active" given the current power state.
readonly property var activePhrases: {
if (fullyCharged) return []
if (charging) return chargingPhrases
if (discharging) return onBatteryPhrases
return []
}
readonly property bool rotatingPhrases: activePhrases.length > 0
readonly property string heroStatusText: {
if (fullyCharged) return "Fully charged"
if (rotatingPhrases) return activePhrases[phraseIndex % activePhrases.length]
return modeLabel()
}
function refresh() {
if (!batteryPresent) return
if (!batteryProc.running) batteryProc.running = true
if (!profilesProc.running) profilesProc.running = true
if (!systemProc.running) systemProc.running = true
}
function updateKeyValue(raw, targetName) {
var next = Model.parseKeyValue(raw)
// Keep last known good data if a refresh briefly returns nothing — happens
// around AC plug/unplug events. Avoids the section collapsing mid-transition.
if (Object.keys(next).length === 0) return
if (targetName === "battery") batteryInfo = next
else systemInfo = next
}
function updateProfiles(raw) {
var parsed = Model.parseProfiles(raw, profileIndex)
// Same guard as battery: preserve the last known profile list across
// transient empty payloads so the buttons don't blink out.
if (parsed.profiles.length === 0) return
profiles = parsed.profiles
activeProfile = parsed.activeProfile
profileIndex = parsed.profileIndex
if (opened && !cursorActive) {
var idx = profiles.indexOf(activeProfile)
if (idx >= 0) profileIndex = idx
}
}
function setProfile(profile) {
if (!profile || actionProc.running) return
actionProc.command = ["blob-power-set", root.discharging ? "battery" : "ac", profile]
actionProc.running = true
}
function togglePercentage() {
root.settings = Object.assign({}, root.settings, { showPercentage: !root.showPercentage })
if (root.bar && root.bar.shell) root.bar.shell.updateEntryInline(root.moduleName, root.settings)
}
IpcHandler {
target: "blob.power"
function open() { root.open() }
function close() { root.close() }
function show() { root.open() }
function hide() { root.close() }
function toggle() { root.toggle() }
function togglePercentage() { root.togglePercentage() }
}
onOpenedChanged: {
if (opened) {
if (!batteryPresent) {
close()
return
}
refresh()
var idx = profiles.indexOf(activeProfile)
profileIndex = idx >= 0 ? idx : 0
cursorActive = false
}
}
onBatteryPresentChanged: if (!batteryPresent) close()
visible: batteryPresent
implicitWidth: batteryPresent ? button.implicitWidth : 0
implicitHeight: batteryPresent ? button.implicitHeight : 0
Process {
id: batteryProc
command: ["blob-battery-status", "--shell"]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "battery") }
}
Process {
id: profilesProc
command: ["blob-power-list", "--active-state"]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateProfiles(text) }
}
Process {
id: systemProc
command: ["blob-system-stats"]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "system") }
}
Process {
id: actionProc
onExited: root.refresh()
}
Timer { interval: 5000; running: root.opened; repeat: true; onTriggered: root.refresh() }
// Rotate the status phrase while the panel is open and we're in a
// rotating state (charging or on battery). The text swap is wrapped in a
// fade so the changeover reads as one organism rather than a hard cut.
Timer {
id: phraseTimer
interval: 2800
running: root.opened && root.rotatingPhrases
repeat: true
triggeredOnStart: false
onTriggered: phraseSwap.restart()
}
SequentialAnimation {
id: phraseSwap
PropertyAnimation {
target: heroStatus; property: "opacity"
to: 0.0; duration: 180; easing.type: Easing.OutQuad
}
ScriptAction {
script: {
var n = root.activePhrases.length
if (n > 0) root.phraseIndex = (root.phraseIndex + 1) % n
}
}
PropertyAnimation {
target: heroStatus; property: "opacity"
to: 1.0; duration: 260; easing.type: Easing.InQuad
}
}
// If we leave a rotating state mid-swap, halt the animation and snap back
// to full opacity so "FULLY CHARGED" is legible immediately rather than
// appearing dimmed.
Connections {
target: root
function onRotatingPhrasesChanged() {
if (!root.rotatingPhrases) {
phraseSwap.stop()
heroStatus.opacity = 1.0
}
}
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.showPercentage && !vertical
? Math.round(root.batteryFraction * 100) + "% " + root.batteryIcon()
: root.batteryIcon()
slotSize: Style.bar.iconSlot * (root.showPercentage && !vertical ? 2 : 1)
tooltipText: ""
onPressed: function(b) {
if (!root.batteryPresent) return
if (b === Qt.RightButton) root.togglePercentage()
else root.toggle()
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened && root.batteryPresent
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(380))
contentHeight: panel.fittedContentHeight(column.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
if (dx !== 0) root.selectProfileByDelta(dx)
else if (dy !== 0) root.selectProfileByDelta(dy)
}
onActivateRequested: if (root.cursorActive) root.activateSelectedProfile()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Column {
id: column
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Style.space(14)
// ---------- Hero: battery icon · title/status · percentage ----------
Item {
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, heroPercent.implicitHeight)
Text {
id: heroIcon
textFormat: Text.PlainText
text: root.batteryIcon()
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
Behavior on color { ColorAnimation { duration: 200 } }
}
Column {
id: heroLabels
anchors.left: heroIcon.right
anchors.leftMargin: Style.space(14)
anchors.right: heroPercent.left
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
text: "Battery"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
id: heroStatus
textFormat: Text.PlainText
text: root.heroStatusText.toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
width: parent.width
}
}
Text {
id: heroPercent
textFormat: Text.PlainText
text: root.batteryInfo.percentage || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.displayLarge
font.bold: true
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
Behavior on color { ColorAnimation { duration: 200 } }
}
}
// ---------- Battery progress bar ----------
Item {
width: parent.width
implicitHeight: Style.space(8)
Rectangle {
id: barTrack
anchors.fill: parent
radius: height / 2
color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12)
}
Rectangle {
id: barFill
anchors.left: barTrack.left
anchors.verticalCenter: barTrack.verticalCenter
height: barTrack.height
radius: barTrack.radius
color: root.batteryFillColor
width: Math.max(barTrack.height, barTrack.width * root.batteryFraction)
Behavior on width { NumberAnimation { duration: 320; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 220 } }
// Subtle pulse while charging — visible signal that energy is flowing in.
SequentialAnimation on opacity {
running: root.charging && !root.fullyCharged && root.opened
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { from: 1.0; to: 0.55; duration: 950; easing.type: Easing.InOutSine }
NumberAnimation { from: 0.55; to: 1.0; duration: 950; easing.type: Easing.InOutSine }
}
}
}
// ---------- Stats ----------
// Visibility is intentionally only gated by "we've ever loaded data" so
// the section never collapses mid-transition. fullyCharged is *not* part
// of the condition: UPower briefly reports FullyCharged on plug-in when
// the battery sits above the charge-control start threshold, and we
// refuse to flicker the whole panel for that ~1s window.
Row {
visible: root.batteryInfo.percentage !== undefined
width: parent.width
spacing: Style.space(20)
Column {
width: (parent.width - parent.spacing) / 2
spacing: Style.spacing.labelGap
InfoPair { label: "Battery size"; value: root.batteryInfo.size || "" }
InfoPair { label: "Charge cycles"; value: root.batteryInfo.cycles || "—" }
}
Column {
width: (parent.width - parent.spacing) / 2
spacing: Style.spacing.labelGap
InfoPair {
label: root.chargeThresholdActive ? "Charge limit" : (root.discharging ? "Time left" : "Time to full")
value: root.chargeThresholdActive ? (root.batteryInfo.threshold || "-") : (root.batteryFlowIdle ? "-" : (root.batteryInfo.time || "—"))
}
InfoPair {
label: root.chargeThresholdActive ? "Battery state" : (root.discharging ? "Discharging" : "Charging")
value: root.chargeThresholdActive ? "Holding" : (root.batteryFull ? "-" : (root.batteryInfo.rate || ""))
}
}
}
// ---------- Power profile picker ----------
PanelSeparator {
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "POWER PROFILE"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
Row {
id: profileRow
width: parent.width
spacing: Style.space(6)
readonly property real cellWidth: root.profiles.length > 0
? (width - spacing * (root.profiles.length - 1)) / root.profiles.length
: 0
Repeater {
model: root.profiles
Button {
required property var modelData
required property int index
width: profileRow.cellWidth
iconText: root.profileIcon(String(modelData))
iconSize: Style.font.title
text: String(modelData).charAt(0).toUpperCase() + String(modelData).slice(1)
fontSize: Style.font.bodySmall
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
horizontalPadding: Style.spacing.controlPaddingX
verticalPadding: Style.spacing.controlPaddingY + Style.space(2)
bordered: true
active: root.activeProfile === modelData
hasCursor: root.cursorActive && root.profileIndex === index
onClicked: root.setProfile(modelData)
onHovered: function(h) {
if (h) {
root.cursorActive = true
root.profileIndex = index
}
}
}
}
}
}
}
}
}
component InfoPair: Row {
property string label: ""
property string value: ""
width: parent.width
spacing: Style.space(8)
InfoLabel { text: label }
Item { width: Math.max(0, parent.width - parent.children[0].implicitWidth - parent.children[2].implicitWidth - parent.spacing * 2); height: 1 }
InfoValue { text: value }
}
component InfoLabel: Text {
textFormat: Text.PlainText
color: root.bar.foreground
opacity: 0.6
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
component InfoValue: Text {
textFormat: Text.PlainText
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.power",
"name": "Power",
"version": "1.0.0",
"author": "Blob",
"description": "Battery, power profile, and system stats",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Power",
"description": "Battery, power profile, and system stats",
"category": "System",
"allowMultiple": false
}
}
+202
View File
@@ -0,0 +1,202 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
// The shared gauge-cluster overlay (SpeedTestOverlay) dressed for the
// internet speed test: download and upload dials in Mbps, titled with the
// connection under test.
//
// Standalone panel plugin: summoning it starts a fresh run, dismissing it
// stops the traffic, so the download workers never keep saturating the link
// behind a closed overlay. The payload may carry the connection's display
// name -- {"connection": "MyWifi"} -- and the panel looks it up itself via
// blob-network-status when the caller doesn't know it.
Item {
id: root
property var shell: null
property var manifest: null
property bool opened: false
property string connectionName: ""
property bool running: false
property bool expectedStop: false
property bool pendingRun: false
property string phase: "" // "down" | "up" | ""
property string stderrText: ""
property string downloadMbps: ""
property string uploadMbps: ""
property string error: ""
readonly property real downloadValue: toMbps(downloadMbps)
readonly property real uploadValue: toMbps(uploadMbps)
function toMbps(raw) {
var value = parseFloat(raw)
return isFinite(value) && value > 0 ? value : 0
}
function open(payloadJson) {
var payload = {}
try { payload = JSON.parse(payloadJson || "{}") || {} } catch (e) {}
if (payload.connection !== undefined) root.connectionName = String(payload.connection)
else refreshConnectionName()
root.opened = true
runSpeedTest()
}
function close() {
root.opened = false
root.pendingRun = false
phaseTimer.stop()
// Clear the phase before killing the process: onExited advances to the
// upload phase when it still reads "down".
root.phase = ""
root.running = false
if (speedTestProc.running) {
root.expectedStop = true
speedTestProc.running = false
}
}
function dismiss() {
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "blob.speedtest")
else close()
}
function refreshConnectionName() {
root.connectionName = ""
statusProc.running = false
statusProc.running = true
}
function updateSpeedTestLine(line) {
var value = parseFloat(line)
if (!isFinite(value) || value < 0) return
if (phase === "down") downloadMbps = String(value)
else if (phase === "up") uploadMbps = String(value)
}
function runSpeedTest() {
if (speedTestProc.running) {
// A dismissal's SIGTERM is still in flight; Process.running stays true
// until the child exits, so queue the fresh run for onExited.
if (expectedStop) pendingRun = true
return
}
error = ""
downloadMbps = ""
uploadMbps = ""
running = true
startPhase("down")
}
function startPhase(nextPhase) {
expectedStop = false
phase = nextPhase
stderrText = ""
speedTestProc.command = ["blob-network-speedtest", nextPhase]
speedTestProc.running = true
phaseTimer.restart()
}
function stopPhase() {
phaseTimer.stop()
if (speedTestProc.running) {
expectedStop = true
speedTestProc.running = false
return
}
finishPhase()
}
function finishPhase() {
if (phase === "down") {
startPhase("up")
return
}
phase = ""
running = false
expectedStop = false
}
Process {
id: speedTestProc
stdout: SplitParser { onRead: function(line) { root.updateSpeedTestLine(line) } }
// Exit and stream-finished have no guaranteed order: when a failed exit
// beat the collector and published the generic message, replace it with
// the specific one once it lands.
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.stderrText = String(text || "").trim()
if (root.error !== "" && root.stderrText !== "") root.error = root.stderrText
}
}
onExited: function(exitCode) {
phaseTimer.stop()
if (root.pendingRun) {
root.pendingRun = false
root.expectedStop = false
if (root.opened) Qt.callLater(root.runSpeedTest)
return
}
if (!root.expectedStop && exitCode !== 0) {
root.error = root.stderrText || "Speed test failed"
root.phase = ""
root.running = false
return
}
root.expectedStop = false
root.finishPhase()
}
}
Timer {
id: phaseTimer
interval: 5000
repeat: false
onTriggered: root.stopPhase()
}
// Names the connection under test when the summoner didn't. First tab
// field is the kind, second the SSID (wifi) or device (ethernet).
Process {
id: statusProc
command: ["blob-network-status"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var fields = String(text || "").trim().split("\t")
if (fields[0] === "wifi") root.connectionName = fields[1] || "Wi-Fi"
else if (fields[0] === "ethernet") root.connectionName = "Ethernet"
}
}
}
SpeedTestOverlay {
fontFamily: Style.font.family
layerNamespace: "blob-network-speedtest"
title: root.connectionName
leftLabel: "DOWNLOAD"
rightLabel: "UPLOAD"
runAgainTooltip: "Measure again via fast.com"
running: root.running
leftValue: root.downloadValue
rightValue: root.uploadValue
leftLive: root.running && root.phase === "down"
rightLive: root.running && root.phase === "up"
error: root.error
open: root.opened
onCloseRequested: root.dismiss()
onRunAgainRequested: root.runSpeedTest()
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.speedtest",
"name": "Speed Test",
"version": "1.0.0",
"author": "Blob",
"description": "Internet speed test with download and upload dials",
"kinds": [
"panel"
],
"entryPoints": {
"panel": "Panel.qml"
}
}
@@ -0,0 +1,83 @@
import QtQuick
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.weather"
function injectPanel() {
var target = panelLoader.item
if (!target) return
if ("bar" in target) target.bar = root.bar
if ("settings" in target) target.settings = root.settings
if ("anchorItem" in target) target.anchorItem = button
if ("hostWidget" in target) target.hostWidget = root
}
function refresh() {
if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh()
}
function togglePanel() {
if (panelLoader.item && panelLoader.item.toggle) panelLoader.item.toggle()
}
// Shape contract for shell.summon/hide/toggle routing (Bar.findPanelWidget
// requires open/close/opened on the bar-widget root). Open maps to the
// panel's hotkey path so summoning suppresses the center hover reveal,
// matching what the old per-plugin IpcHandler did.
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
function open() {
if (panelLoader.item && panelLoader.item.openFromHotkey) panelLoader.item.openFromHotkey()
}
function close() {
if (panelLoader.item && panelLoader.item.close) panelLoader.item.close()
}
// Forwarded so this widget can stand in for the panel as the bar's popout
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
// KeyboardPanel reads popoutSwitchClosing back off its owner.
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
function closeForPopoutSwitch() {
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
}
visible: panelLoader.item && panelLoader.item.label !== ""
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onBarChanged: injectPanel()
onSettingsChanged: injectPanel()
Loader {
id: panelLoader
active: true
source: Qt.resolvedUrl("Panel.qml")
visible: false
onLoaded: {
root.injectPanel()
Qt.callLater(root.injectPanel)
}
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: panelLoader.item ? panelLoader.item.label : ""
slotSize: Style.bar.statusSlot
// Tooltip suppressed because the panel is the detail view.
tooltipText: ""
onPressed: function(b) {
if (!root.bar) return
if (b === Qt.RightButton) root.bar.run("blob-notify-send \"$(blob-weather-status)\"")
else if (b === Qt.MiddleButton) root.refresh()
else root.togglePanel()
}
}
}
+295
View File
@@ -0,0 +1,295 @@
// weather.json holds {"name": ..., "latitude": ..., "longitude": ...} (see
// blob-weather-location, which owns the format). Missing, blank, or
// unparseable means the location is auto-detected from the IP address.
function parseLocationFile(raw) {
var unset = { name: "", latitude: null, longitude: null }
try {
var data = JSON.parse(String(raw || ""))
if (!data || typeof data !== "object") return unset
var latitude = parseFloat(data.latitude)
var longitude = parseFloat(data.longitude)
var hasCoordinates = !isNaN(latitude) && !isNaN(longitude)
return {
name: typeof data.name === "string" ? data.name.replace(/^\s+|\s+$/g, "") : "",
latitude: hasCoordinates ? latitude : null,
longitude: hasCoordinates ? longitude : null
}
} catch (e) {
return unset
}
}
// wttr.in path segment for a configured location: exact coordinates when
// both are present, the URL-encoded name as a fallback (hand-edited
// weather.loc files may only carry a name), empty for IP auto-detect.
function wttrLocationQuery(location, latitude, longitude) {
var lat = parseFloat(String(latitude))
var lon = parseFloat(String(longitude))
if (!isNaN(lat) && !isNaN(lon)) return lat + "," + lon
var name = String(location || "").replace(/^\s+|\s+$/g, "")
return name === "" ? "" : encodeURIComponent(name)
}
// Open-Meteo geocoding response → suggestion rows for the location picker.
function parseGeocodingResults(raw) {
try {
var data = JSON.parse(String(raw || "{}"))
var results = data.results
if (!results || !results.length) return []
var out = []
for (var i = 0; i < results.length; i++) {
var r = results[i]
if (!r || !r.name || r.latitude === undefined || r.longitude === undefined) continue
var region = [r.admin1, r.country].filter(function(part) { return !!part }).join(", ")
out.push({
name: String(r.name),
description: region,
latitude: r.latitude,
longitude: r.longitude
})
}
return out
} catch (e) {
return []
}
}
function locationCommit(text, suggestions, selectedIndex) {
var name = String(text || "").replace(/^\s+|\s+$/g, "")
if (name === "") return { name: "", latitude: null, longitude: null }
var choices = suggestions || []
var index = Math.max(0, Math.min(parseInt(selectedIndex, 10) || 0, choices.length - 1))
var suggestion = choices[index]
if (suggestion) return suggestion
return { name: name, latitude: null, longitude: null }
}
function isFutureForecastDate(dateString, todayString) {
if (!dateString) return false
return String(dateString).slice(0, 10) > String(todayString || "")
}
function roundedTemp(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : String(Math.round(n))
}
function celsiusToFahrenheit(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : (n * 9 / 5) + 32
}
function formatTemp(value, useImperial) {
if (value === undefined || value === null || value === "") return ""
return value + "°" + (useImperial ? "F" : "C")
}
function normalizedUnit(value) {
return String(value || "").replace(/^\s+|\s+$/g, "").toLowerCase()
}
function localeUsesImperial(localeName) {
var name = String(localeName || "").replace(".", "_")
return /^en[_-]US($|[_.-])/.test(name) || /^en[_-]LR($|[_.-])/.test(name) || /^my($|[_.-])/.test(name)
}
function countryUsesImperial(countryName) {
var country = String(countryName || "")
.replace(/^\s+|\s+$/g, "")
.replace(/[._-]+/g, " ")
.toLowerCase()
if (!country) return null
if (country === "us" || country === "usa" || country === "united states" || country === "united states of america") return true
if (country === "liberia" || country === "myanmar" || country === "burma") return true
return false
}
function shouldUseImperial(unitOverride, localeName, countryName) {
var unit = normalizedUnit(unitOverride)
if (unit === "imperial") return true
if (unit === "metric") return false
var countryPreference = countryUsesImperial(countryName)
if (countryPreference !== null) return countryPreference
return localeUsesImperial(localeName)
}
function dayName(dateString, formatter) {
if (!dateString) return ""
var d = new Date(dateString + "T12:00:00")
if (isNaN(d.getTime())) return ""
if (formatter) return formatter(d)
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()]
}
function openMeteoForecastDays(dailyForecastReport, todayString) {
var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null
if (!daily || !daily.time) return []
var result = []
for (var i = 0; i < daily.time.length && result.length < 3; ++i) {
var date = daily.time[i]
if (!isFutureForecastDate(date, todayString)) continue
var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : ""
var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : ""
result.push({
date: date,
maxtempC: roundedTemp(maxC),
mintempC: roundedTemp(minC),
maxtempF: roundedTemp(celsiusToFahrenheit(maxC)),
mintempF: roundedTemp(celsiusToFahrenheit(minC)),
openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null
})
}
return result
}
// Open-Meteo bundles current conditions with the daily forecast request and
// answers far faster than wttr.in. Normalize them to wttr's
// current_condition shape so the panel can use either source
// interchangeably. Open-Meteo reports metric (°C, km/h).
function openMeteoCurrentCondition(dailyForecastReport) {
var current = dailyForecastReport && dailyForecastReport.current ? dailyForecastReport.current : null
if (!current || current.temperature_2m === undefined || current.temperature_2m === null) return null
return {
temp_C: roundedTemp(current.temperature_2m),
temp_F: roundedTemp(celsiusToFahrenheit(current.temperature_2m)),
FeelsLikeC: roundedTemp(current.apparent_temperature),
FeelsLikeF: roundedTemp(celsiusToFahrenheit(current.apparent_temperature)),
windspeedKmph: roundedTemp(current.wind_speed_10m),
windspeedMiles: roundedTemp(current.wind_speed_10m * 0.621371),
humidity: roundedTemp(current.relative_humidity_2m),
openMeteoWeatherCode: current.weather_code,
isDay: current.is_day
}
}
function currentIcon(current, fallback) {
if (!current) return fallback || ""
if (current.openMeteoWeatherCode !== undefined && current.openMeteoWeatherCode !== null)
return iconForOpenMeteoCode(current.openMeteoWeatherCode, Number(current.isDay) === 0)
if (current.weatherCode !== undefined && current.weatherCode !== null)
return iconForCode(current.weatherCode, false)
return fallback || ""
}
// wttr.in has no day/night flag. Use its icon only to fill an empty initial
// state, never to replace a day/night-aware icon resolved by Open-Meteo.
function provisionalCurrentIcon(current, resolvedIcon) {
return resolvedIcon || currentIcon(current, "")
}
function weatherResponseCompletesSave(hasConfiguredCoordinates, source) {
return hasConfiguredCoordinates ? source === "open-meteo" : source === "wttr"
}
function wttrNextForecastDays(report, todayString) {
var days = report && report.weather ? report.weather : []
var result = []
for (var i = 0; i < days.length && result.length < 3; ++i) {
if (isFutureForecastDate(days[i].date, todayString)) result.push(days[i])
}
return result
}
function buildForecastDays(report, dailyForecastReport, todayString) {
var days = openMeteoForecastDays(dailyForecastReport, todayString)
return days.length > 0 ? days : wttrNextForecastDays(report, todayString)
}
function bareTempForDay(day, kind, useImperial) {
if (!day) return ""
var v = useImperial
? (kind === "max" ? day.maxtempF : day.mintempF)
: (kind === "max" ? day.maxtempC : day.mintempC)
if (v === undefined || v === null || v === "") return ""
return v + "°"
}
function dayIcon(day) {
if (!day) return ""
if (day.openMeteoWeatherCode !== undefined && day.openMeteoWeatherCode !== null)
return iconForOpenMeteoCode(day.openMeteoWeatherCode)
if (!day.hourly || day.hourly.length === 0) return ""
var best = day.hourly[0]
var bestDist = 9999
for (var i = 0; i < day.hourly.length; ++i) {
var t = parseInt(String(day.hourly[i].time || "0"), 10)
var dist = Math.abs(t - 1200)
if (dist < bestDist) {
bestDist = dist
best = day.hourly[i]
}
}
return iconForCode(best.weatherCode, false)
}
function iconForOpenMeteoCode(code, night) {
var c = parseInt(String(code || "0"), 10)
if (c === 0) return iconForCode(113, night)
if (c === 1 || c === 2) return iconForCode(116, night)
if (c === 3) return iconForCode(119, night)
if (c === 45 || c === 48) return iconForCode(143, night)
if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, night)
if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, night)
if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, night)
if (c === 95 || c === 96 || c === 99) return iconForCode(389, night)
return iconForCode(119, night)
}
function iconForCode(code, night) {
var c = parseInt(String(code || "0"), 10)
switch (c) {
case 113: return night ? "" : ""
case 116: return night ? "" : ""
case 119: case 122: return ""
case 143: case 248: case 260: return night ? "\ue346" : "\ue313"
case 176: case 263: case 353: return night ? "" : ""
case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : ""
case 182: case 185: case 281: case 284: case 311: case 314:
case 317: case 320: case 350: case 362: case 365: case 374: case 377: return ""
case 200: case 386: case 389: case 392: case 395: return ""
case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return ""
case 329: case 332: case 335: case 338: case 371: return ""
default: return ""
}
}
if (typeof module !== "undefined") {
module.exports = {
parseLocationFile: parseLocationFile,
wttrLocationQuery: wttrLocationQuery,
parseGeocodingResults: parseGeocodingResults,
locationCommit: locationCommit,
isFutureForecastDate: isFutureForecastDate,
roundedTemp: roundedTemp,
celsiusToFahrenheit: celsiusToFahrenheit,
formatTemp: formatTemp,
normalizedUnit: normalizedUnit,
localeUsesImperial: localeUsesImperial,
countryUsesImperial: countryUsesImperial,
shouldUseImperial: shouldUseImperial,
dayName: dayName,
openMeteoForecastDays: openMeteoForecastDays,
openMeteoCurrentCondition: openMeteoCurrentCondition,
currentIcon: currentIcon,
provisionalCurrentIcon: provisionalCurrentIcon,
weatherResponseCompletesSave: weatherResponseCompletesSave,
wttrNextForecastDays: wttrNextForecastDays,
buildForecastDays: buildForecastDays,
bareTempForDay: bareTempForDay,
dayIcon: dayIcon,
iconForOpenMeteoCode: iconForOpenMeteoCode,
iconForCode: iconForCode
}
}
+881
View File
@@ -0,0 +1,881 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
Panel {
id: root
moduleName: "blob.weather"
ipcTarget: "blob.weather"
manageIpc: false
property var anchorItem: null
property bool openedFromHotkey: false
// The bar tracks the widget mounted in its slot — BarWidget.qml — not this
// nested panel. Everything the bar identifies a panel by has to be that
// widget: the popout coordinator (and with it the open-panel dot under the
// pill) compares against `slot.activeItem`, and switchPanelFrom looks the
// slot up the same way.
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
function open() {
openedFromHotkey = false
setCenterHoverRevealSuppressed(false)
root.controller.show()
locationFile.reload()
root.refresh()
}
function openFromHotkey() {
openedFromHotkey = true
root.controller.show()
locationFile.reload()
root.refresh()
// Set after showing, not before: showing hands the popout coordinator
// over, which closes whichever panel was open, and that close clears the
// shared flag. Deferring means the panel taking over always wins, while
// a handoff to a panel that does not manage the flag still leaves it
// cleared rather than stuck on.
Qt.callLater(function() {
if (root.opened) setCenterHoverRevealSuppressed(true)
})
}
function close() {
setCenterHoverRevealSuppressed(false)
if (root.editingLocation) root.cancelEditingLocation()
root.controller.hide()
}
function toggle() {
if (root.opened) root.close()
else root.openFromHotkey()
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.barIdentity, direction)
return false
}
function setCenterHoverRevealSuppressed(value) {
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
root.bar.setCenterHoverRevealSuppressed(value)
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
root.bar.centerHoverRevealSuppressed = value
}
// Parsed wttr.in j1 response. Kept on failure so stale data stays visible.
property var report: null
property var dailyForecastReport: null
property string wttrLocation: ""
// Configured location, read from the weather.json state file (owned by
// blob-weather-location). The query is the wttr.in path segment
// (coordinates when stored, else the encoded name); empty means IP
// auto-detect. The watch makes hand edits take effect live.
property var configuredLocationState: ({ name: "", latitude: null, longitude: null })
readonly property string configuredLocation: configuredLocationState.name
readonly property string locationQuery: Model.wttrLocationQuery(configuredLocationState.name, configuredLocationState.latitude, configuredLocationState.longitude)
// Keep the previous report visible while the new location loads. The
// editor remains open with a spinner, so stale data is never presented
// under the newly configured location label.
onLocationQueryChanged: {
if (savingLocation) savingLocationQueryStarted = true
forecastRetries = 0
dailyForecastRetries = 0
forecastProc.running = false
dailyForecastProc.running = false
Qt.callLater(refresh)
}
property FileView locationFile: FileView {
path: Quickshell.env("HOME") + "/.local/state/blob/settings/weather.json"
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: root.configuredLocationState = Model.parseLocationFile(text())
onLoadFailed: root.configuredLocationState = Model.parseLocationFile("")
}
// The first read can race shell startup (observed sporadically), leaving a
// stored location unhonored until the next file write. One delayed reload
// self-corrects; if the first read was fine it's a no-op, since identical
// state doesn't change locationQuery and so triggers no refetch.
Timer {
interval: 1500
running: true
onTriggered: locationFile.reload()
}
property int forecastRetries: 0
property int dailyForecastRetries: 0
// Click-to-edit state for the location label.
property bool editingLocation: false
property bool savingLocation: false
property bool savingLocationQueryStarted: false
property var locationSuggestions: []
property int suggestionIndex: 0
property string geocodePendingQuery: ""
property string geocodeActiveQuery: ""
// Shared hero/bar icon state, updated with each successful weather response.
property string label: ""
// wttr's current conditions when available; open-meteo's (bundled with the
// much faster daily forecast fetch) fill the hero while wttr is in flight.
readonly property bool hasConfiguredCoordinates: !isNaN(parseFloat(String(configuredLocationState.latitude))) && !isNaN(parseFloat(String(configuredLocationState.longitude)))
readonly property var openMeteoCurrent: Model.openMeteoCurrentCondition(dailyForecastReport)
readonly property var current: (hasConfiguredCoordinates && openMeteoCurrent) ? openMeteoCurrent : ((report && report.current_condition && report.current_condition[0]) ? report.current_condition[0] : openMeteoCurrent)
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
readonly property var forecastDays: buildForecastDays()
readonly property string reportCountry: areaInfo && areaInfo.country && areaInfo.country[0] ? areaInfo.country[0].value : ""
readonly property bool useImperial: Model.shouldUseImperial(setting("unit", ""), Qt.locale().name, reportCountry)
// Auto-refresh interval in minutes; clamped to a sane minimum.
readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 15), 10) || 15)
readonly property string reportLocation: configuredLocation || wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "")
readonly property string reportTempNum: current ? String(useImperial ? current.temp_F : current.temp_C) : ""
readonly property string tempUnit: "°" + (useImperial ? "F" : "C")
readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : ""
readonly property string reportWind: current ? (useImperial ? (current.windspeedMiles + " mph") : (current.windspeedKmph + " km/h")) : ""
readonly property string reportHumidity: current ? (current.humidity + "%") : ""
function refresh() {
// Each full refresh cycle gets a fresh retry budget, so an earlier
// exhausted round (e.g. waking with the network still down) doesn't
// starve retries for the rest of the session.
forecastRetries = 0
dailyForecastRetries = 0
if (!forecastProc.running) forecastProc.running = true
if (root.locationQuery === "" && !locationProc.running) locationProc.running = true
// With stored coordinates this fetches open-meteo right away — no need
// to wait for the slow wttr response. Without them it's a no-op until
// wttr reports the detected area.
refreshDailyForecast(null)
}
function refreshDailyForecast(sourceReport) {
if (dailyForecastProc.running) return
var lat = parseFloat(String(root.configuredLocationState.latitude))
var lon = parseFloat(String(root.configuredLocationState.longitude))
if (isNaN(lat) || isNaN(lon)) {
var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo
if (!area) return
lat = parseFloat(String(area.latitude || ""))
lon = parseFloat(String(area.longitude || ""))
}
if (isNaN(lat) || isNaN(lon)) return
var url = "https://api.open-meteo.com/v1/forecast"
+ "?latitude=" + encodeURIComponent(String(lat))
+ "&longitude=" + encodeURIComponent(String(lon))
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
+ "&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day"
+ "&forecast_days=4"
+ "&timezone=auto"
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
dailyForecastProc.running = true
}
// ---- Location editing. Clicking the location label swaps it for a search
// field; picking a geocoded suggestion persists name + coordinates to
// the module's shell.json entry. An empty commit returns to auto.
function startEditingLocation() {
editingLocation = true
savingLocation = false
savingLocationQueryStarted = false
locationSuggestions = []
suggestionIndex = 0
Qt.callLater(function() {
locationField.text = root.configuredLocation
locationField.selectAll()
locationField.forceActiveFocus()
})
}
function cancelEditingLocation() {
editingLocation = false
savingLocation = false
savingLocationQueryStarted = false
locationSuggestions = []
geocodeDebounce.stop()
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
function commitLocation() {
var location = Model.locationCommit(locationField.text, locationSuggestions, suggestionIndex)
if (location.name === "") {
clearLocation()
return
}
savingLocation = true
savingLocationQueryStarted = false
configuredLocationState = {
name: location.name,
latitude: location.latitude,
longitude: location.longitude
}
persistLocation(location.name, location.latitude, location.longitude)
}
function clearLocation() {
persistLocation("", null, null)
wttrLocation = ""
cancelEditingLocation()
}
function pickSuggestion(suggestion) {
if (!suggestion) return
savingLocation = true
savingLocationQueryStarted = false
configuredLocationState = {
name: suggestion.name,
latitude: suggestion.latitude,
longitude: suggestion.longitude
}
persistLocation(suggestion.name, suggestion.latitude, suggestion.longitude)
}
function finishSavingLocation() {
if (savingLocation && savingLocationQueryStarted) cancelEditingLocation()
}
function persistLocation(name, latitude, longitude) {
if (name && latitude !== null && longitude !== null)
locationSaveProc.command = ["blob-weather-location", "--set", name, latitude + "," + longitude]
else if (name)
locationSaveProc.command = ["blob-weather-location", "--set", name]
else
locationSaveProc.command = ["blob-weather-location", "--clear"]
locationSaveProc.running = true
}
// Debounced geocoding. Only one curl runs at a time; if the query moved on
// while a fetch was in flight, the latest query is fetched right after.
function requestGeocode() {
var query = locationField.text.trim()
if (query.length < 2) {
locationSuggestions = []
return
}
geocodePendingQuery = query
if (!geocodeProc.running) startGeocode()
}
function startGeocode() {
geocodeActiveQuery = geocodePendingQuery
geocodeProc.command = ["curl", "-fsS", "--max-time", "5",
"https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(geocodeActiveQuery) + "&count=5&language=en&format=json"]
geocodeProc.running = true
}
function buildForecastDays() {
return Model.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function openMeteoForecastDays() {
return Model.openMeteoForecastDays(dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function wttrNextForecastDays() {
return Model.wttrNextForecastDays(report, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function isFutureForecastDate(dateString) {
return Model.isFutureForecastDate(dateString, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function roundedTemp(value) {
return Model.roundedTemp(value)
}
function celsiusToFahrenheit(value) {
return Model.celsiusToFahrenheit(value)
}
function formatTemp(value) {
return Model.formatTemp(value, useImperial)
}
function dayName(dateString) {
return Model.dayName(dateString, function(date) { return Qt.formatDate(date, "dddd") })
}
// Bare degree value (no unit letter), used in the forecast row.
function bareTempForDay(day, kind) {
return Model.bareTempForDay(day, kind, useImperial)
}
// Representative icon for a forecast day: the hourly entry nearest noon.
function dayIcon(day) {
return Model.dayIcon(day)
}
function iconForOpenMeteoCode(code) {
return Model.iconForOpenMeteoCode(code)
}
// Mirrors blob-weather-icon's wttr.in code → nerd-font glyph mapping.
function iconForCode(code, night) {
return Model.iconForCode(code, night)
}
Process {
id: forecastProc
command: ["curl", "-fsS", "--max-time", "10", "https://wttr.in/" + root.locationQuery + "?format=j1"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) {
root.scheduleForecastRetry()
return
}
try {
var parsed = JSON.parse(raw)
root.report = parsed
if (!root.hasConfiguredCoordinates)
root.label = Model.provisionalCurrentIcon(parsed.current_condition && parsed.current_condition[0], root.label)
root.forecastRetries = 0
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "wttr"))
root.finishSavingLocation()
// Stored coordinates already drove the fast open-meteo fetch from
// refresh(); only auto-detect needs the area wttr reported.
if (isNaN(parseFloat(String(root.configuredLocationState.latitude))))
root.refreshDailyForecast(parsed)
} catch (e) {
// Keep last-good report visible, but try again shortly.
root.scheduleForecastRetry()
}
}
}
}
// wttr.in can be slow or flaky, especially for a location it hasn't
// cached yet. Retry a few times before leaving it to the refresh timer.
function scheduleForecastRetry() {
if (forecastRetries >= 3) return
forecastRetries++
forecastRetryTimer.restart()
}
Timer {
id: forecastRetryTimer
interval: 2500
onTriggered: if (!forecastProc.running) forecastProc.running = true
}
// With configured coordinates this fetch is the only thing that updates the
// bar icon, so a dropped response (e.g. waking before the network is back)
// must retry rather than wait out the refresh timer with a stale icon.
function scheduleDailyForecastRetry() {
if (dailyForecastRetries >= 3) return
dailyForecastRetries++
dailyForecastRetryTimer.restart()
}
Timer {
id: dailyForecastRetryTimer
interval: 2500
onTriggered: root.refreshDailyForecast(null)
}
Process {
id: dailyForecastProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) {
root.scheduleDailyForecastRetry()
return
}
try {
var parsed = JSON.parse(raw)
var parsedCurrent = Model.openMeteoCurrentCondition(parsed)
root.dailyForecastReport = parsed
root.label = Model.currentIcon(parsedCurrent, root.label)
root.dailyForecastRetries = 0
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "open-meteo"))
root.finishSavingLocation()
} catch (e) {
// Keep last-good daily forecast visible, but try again shortly.
root.scheduleDailyForecastRetry()
}
}
}
}
Process {
id: geocodeProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.locationSuggestions = root.editingLocation ? Model.parseGeocodingResults(text) : []
root.suggestionIndex = 0
if (root.geocodePendingQuery !== root.geocodeActiveQuery) Qt.callLater(root.startGeocode)
}
}
}
Timer {
id: geocodeDebounce
interval: 300
onTriggered: root.requestGeocode()
}
Process {
id: locationSaveProc
onExited: function(exitCode) {
if (exitCode !== 0 || !root.savingLocation) return
// FileView handles changed locations. Explicitly refresh here too so
// saving the already-active location cannot strand the spinner.
locationFile.reload()
if (!root.savingLocationQueryStarted) {
root.savingLocationQueryStarted = true
root.forecastRetries = 0
root.dailyForecastRetries = 0
forecastProc.running = false
dailyForecastProc.running = false
Qt.callLater(root.refresh)
}
}
}
Process {
id: locationProc
command: ["curl", "-fsS", "--max-time", "4", "https://wttr.in/?format=%l"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) return
root.wttrLocation = raw.split(",")[0]
}
}
}
Timer {
id: refreshTimer
interval: root.refreshMinutes * 60 * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
IpcHandler {
target: root.ipcTarget
function open(): void { root.openFromHotkey() }
function close(): void { root.close() }
function show(): void { root.openFromHotkey() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function edit(): void { root.openFromHotkey(); root.startEditingLocation() }
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.barIdentity
bar: root.bar
open: root.opened
centerOnBar: true
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(480))
contentHeight: panel.fittedContentHeight(weatherColumn.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: root.editingLocation
onReturnRequested: root.startEditingLocation()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Flickable {
id: weatherScroll
anchors.fill: parent
contentWidth: width
contentHeight: weatherColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: contentHeight > height
Column {
id: weatherColumn
width: weatherScroll.width
spacing: Style.space(14)
// ---- Hero row: big icon + temp on the left; location and stats stacked on the right.
Item {
width: parent.width
height: Math.max(heroLeft.height, heroRight.height)
Row {
id: heroLeft
anchors.left: parent.left
anchors.leftMargin: Style.space(16)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(16)
Text {
id: heroIcon
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 5
text: root.label || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
// Decorative condition emoji; intentionally larger than the
// Style.font.* scale's displayLarge (28).
font.pixelSize: 64
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
id: tempBig
textFormat: Text.PlainText
text: root.reportTempNum || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
// Hero temperature read-out; deliberately oversized, outside
// the Style.font.* scale.
font.pixelSize: 56
font.bold: true
}
Text {
textFormat: Text.PlainText
text: root.current ? root.tempUnit : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
anchors.top: tempBig.top
anchors.topMargin: Style.space(10)
}
}
}
Column {
id: heroRight
width: weatherStats.implicitWidth
anchors.right: parent.right
anchors.rightMargin: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(12)
Row {
visible: !root.editingLocation && root.reportLocation !== ""
spacing: Style.space(6)
TapHandler {
onTapped: root.startEditingLocation()
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
Text {
text: "" // nf-fa-map_marker
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
anchors.verticalCenter: parent.verticalCenter
}
Text {
textFormat: Text.PlainText
text: (root.reportLocation || "").toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
font.letterSpacing: 1
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
visible: root.editingLocation
spacing: Style.space(6)
TextField {
id: locationField
width: Style.space(190)
enabled: !root.savingLocation
placeholderText: "Search city"
foreground: root.bar.foreground
font.family: root.bar.fontFamily
onTextChanged: if (root.editingLocation && !root.savingLocation) geocodeDebounce.restart()
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
root.cancelEditingLocation()
event.accepted = true
} else if (event.key === Qt.Key_Down) {
if (root.suggestionIndex < root.locationSuggestions.length - 1) root.suggestionIndex++
event.accepted = true
} else if (event.key === Qt.Key_Up) {
if (root.suggestionIndex > 0) root.suggestionIndex--
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.commitLocation()
event.accepted = true
}
}
}
// Clear back to IP auto-detect. While a committed location is
// loading, this same compact affordance becomes a spinner.
Rectangle {
width: Style.space(18)
height: Style.space(18)
anchors.verticalCenter: parent.verticalCenter
radius: Math.min(4, Style.cornerRadius)
color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.savingLocation ? "󰦖" : "✕"
font.family: root.bar.fontFamily
color: Qt.darker(root.bar.foreground, 1.4)
font.pixelSize: Style.font.bodySmall
RotationAnimator on rotation {
running: root.savingLocation
from: 0; to: 360
duration: 800
loops: Animation.Infinite
}
}
MouseArea {
id: clearLocationArea
anchors.fill: parent
enabled: !root.savingLocation
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.clearLocation()
}
}
}
Row {
id: weatherStats
visible: !!root.current
spacing: Style.space(36)
Column {
spacing: Style.space(5)
Text {
text: "FEELS"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportFeels
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
Column {
spacing: Style.space(5)
Text {
text: "WIND"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportWind
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
Column {
spacing: Style.space(5)
Text {
text: "HUMID"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportHumidity
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
}
}
}
// ---- Geocoding suggestions while the location is being edited.
Column {
visible: root.editingLocation && !root.savingLocation && root.locationSuggestions.length > 0
width: parent.width
spacing: 0
Repeater {
model: root.locationSuggestions
Rectangle {
required property var modelData
required property int index
width: parent.width
height: suggestionRow.implicitHeight + Style.space(12)
radius: Style.cornerRadius
color: index === root.suggestionIndex ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
Row {
id: suggestionRow
anchors.left: parent.left
anchors.leftMargin: Style.space(16)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
text: modelData.name
color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
visible: text !== ""
text: modelData.description
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: root.suggestionIndex = index
onClicked: root.pickSuggestion(modelData)
}
}
}
}
Text {
visible: !root.current
text: "Fetching forecast…"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.italic: true
}
// ---- Divider between current conditions and forecast.
Rectangle {
visible: root.forecastDays.length > 0
width: parent.width
height: Style.spacing.hairline
color: root.bar.foreground
opacity: 0.12
}
// ---- Forecast row: each cell has the day icon left of a day-name + hi/lo column.
// Wrapped in an Item so the block of cells can be centered within the popup.
Item {
visible: root.forecastDays.length > 0
width: parent.width
height: forecastRow.height
Row {
id: forecastRow
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(44)
Repeater {
model: root.forecastDays
Row {
required property var modelData
required property int index
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.dayIcon(modelData)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
textFormat: Text.PlainText
text: root.dayName(modelData.date).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
}
Row {
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: root.bareTempForDay(modelData, "max")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
text: root.bareTempForDay(modelData, "min")
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
}
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "blob.weather",
"name": "Weather",
"version": "1.0.0",
"author": "Blob",
"description": "Weather pill with detail popup",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Weather",
"description": "Weather pill with detail popup",
"category": "Info",
"allowMultiple": false,
"settingsForm": "weatherSettings"
}
}
+37
View File
@@ -0,0 +1,37 @@
// Parses blob-network-qr output: a "meta\t<iface>\t<security>\t<ssid>"
// header, then a square 0/1 module matrix. The SSID sits last so it may
// contain tabs. A malformed matrix returns empty rather than rendering a
// code that cannot scan.
function parseQrOutput(raw) {
var lines = String(raw || "").trim().split(/\r?\n/).filter(function(line) { return line !== "" })
var meta = { iface: "", security: "", ssid: "" }
if (lines.length > 0 && lines[0].indexOf("meta\t") === 0) {
var fields = lines.shift().split("\t")
meta.iface = fields[1] || ""
meta.security = fields[2] || ""
meta.ssid = fields.slice(3).join("\t")
}
return { meta: meta, matrix: parseQrMatrix(lines) }
}
function parseQrMatrix(lines) {
if (lines.length === 0) return { rows: [], size: 0 }
var size = lines[0].length
if (size !== lines.length) return { rows: [], size: 0 }
for (var i = 0; i < lines.length; i++) {
if (lines[i].length !== size || !/^[01]+$/.test(lines[i])) return { rows: [], size: 0 }
}
return { rows: lines, size: size }
}
if (typeof module !== "undefined") {
module.exports = {
parseQrOutput: parseQrOutput,
parseQrMatrix: parseQrMatrix
}
}
+369
View File
@@ -0,0 +1,369 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Centered Wi-Fi share overlay: no card, just the QR code floating on a
// heavy scrim. Esc or the scrim dismiss it.
//
// Standalone panel plugin: each summon regenerates the code via
// blob-network-qr, which emits the interface, security, and SSID it
// shared ahead of the module matrix — so a bare summon self-detects the
// connection. The payload may pin the interface and pre-title the card:
// {"iface": "wlan0", "ssid": "MyWifi"}.
Item {
id: root
property string blobPath: Quickshell.env("BLOB_PATH")
property var shell: null
property var manifest: null
property bool opened: false
property string iface: ""
property string ssid: ""
property bool secured: false
property var qrRows: []
property int qrSize: 0
property string error: ""
property bool loading: false
property bool expectedStop: false
property bool pendingShow: false
property string pendingIface: ""
property string password: ""
property bool passwordVisible: false
property string passwordError: ""
property bool pwExpectedStop: false
readonly property bool showingQr: qrSize > 0 && !loading && error === ""
// The scrim below is a fixed near-black regardless of theme, so text on
// it needs a fixed light palette, not the themed foreground.
readonly property color onScrim: "white"
readonly property color onScrimDim: Qt.rgba(1, 1, 1, 0.55)
readonly property color onScrimUrgent: "#ff6b6b"
readonly property string fontFamily: Style.font.family
function open(payloadJson) {
var payload = {}
try { payload = JSON.parse(payloadJson || "{}") || {} } catch (e) {}
// The payload SSID titles the card during generation; the meta line the
// generator emits is authoritative and overwrites it. A payload without
// one clears the title: a re-summon may be sharing a different
// connection, so the previous card's name must not label this one.
root.ssid = payload.ssid !== undefined ? String(payload.ssid) : ""
generate(String(payload.iface || ""))
root.opened = true
// The window is instantiated hidden, so the content's `focus: true` is
// evaluated before the surface is mapped and Escape would land nowhere.
// Re-acquire after mapping.
Qt.callLater(function() {
if (root.opened) keyCatcher.forceActiveFocus()
})
}
function close() {
root.opened = false
root.pendingShow = false
if (qrProc.running) {
root.expectedStop = true
qrProc.running = false
}
if (pwProc.running) pwProc.running = false
root.qrSize = 0
root.qrRows = []
root.error = ""
root.loading = false
root.iface = ""
root.ssid = ""
root.secured = false
// The Wi-Fi password only enters shell memory while the card is up.
root.password = ""
root.passwordVisible = false
root.passwordError = ""
}
function dismiss() {
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "blob.wifiqr")
else close()
}
function generate(requestedIface) {
if (qrProc.running) {
// Whether the run in flight is a dismissal's SIGTERM still landing or
// a live generation for an earlier summon, the latest request wins:
// queue it for onExited and stop the old process.
pendingShow = true
pendingIface = requestedIface
if (!expectedStop) {
expectedStop = true
qrProc.running = false
}
return
}
qrSize = 0
qrRows = []
error = ""
loading = true
expectedStop = false
// A re-summon while the card is still loaded reaches here without a
// close() in between, and may be sharing a different connection now:
// neither the previous reveal's password nor a reveal still in flight
// may survive onto the new card.
iface = ""
secured = false
password = ""
passwordVisible = false
passwordError = ""
if (pwProc.running) {
pwExpectedStop = true
pwProc.running = false
}
qrProc.command = requestedIface
? ["blob-network-qr", "--meta", requestedIface]
: ["blob-network-qr", "--meta"]
qrProc.running = true
}
function updateQr(raw) {
var parsed = Model.parseQrOutput(raw)
qrRows = parsed.matrix.rows
qrSize = parsed.matrix.size
if (parsed.meta.ssid !== "") ssid = parsed.meta.ssid
if (parsed.meta.iface !== "") iface = parsed.meta.iface
secured = parsed.meta.security !== "" && parsed.meta.security !== "nopass"
// Good output settles the run: a canceled predecessor's stderr may have
// landed after this generation started, and must not shadow its result.
if (qrSize > 0) error = ""
}
function togglePassword() {
if (passwordVisible) { passwordVisible = false; return }
if (password !== "") { passwordVisible = true; return }
if (pwProc.running || !iface) return
passwordError = ""
// Only a deliberate new lookup lowers the canceled-fetch guard, right as
// it launches -- see the pwProc comment.
pwExpectedStop = false
pwProc.command = ["blob-network-password", iface]
pwProc.running = true
}
Process {
id: qrProc
// Both collectors check expectedStop: a dismissal mid-generation kills
// the process, but buffered output still arrives afterwards and would
// repopulate qrSize -- reopening the card the user just closed. The flag
// stays set through onExited (generate resets it) because the exit and
// stream-finished signals have no guaranteed order.
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: if (!root.expectedStop) root.updateQr(text)
}
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (!root.expectedStop) root.error = String(text || "").trim()
}
onExited: function(exitCode) {
root.loading = false
if (root.pendingShow) {
root.pendingShow = false
// expectedStop stays set until generate() launches the replacement:
// the canceled run's collectors may fire between here and then, and
// must keep being dropped.
Qt.callLater(function() { root.generate(root.pendingIface) })
return
}
if (root.expectedStop) return
if (exitCode !== 0 || root.qrSize === 0) {
root.qrSize = 0
root.qrRows = []
if (root.error === "") root.error = "Could not generate the Wi-Fi QR code"
}
}
}
// The Wi-Fi password only enters shell memory when the user clicks to
// reveal it, and close() drops it again. Both handlers bail when the card
// is gone so a fetch that was in flight during dismissal can't stash the
// secret into a closed panel's state, and check pwExpectedStop so a fetch
// that a regeneration killed can't reveal the previous network's password
// under the new card. The exit and stream-finished signals have no
// guaranteed order, so the flag survives onExited; only togglePassword
// lowers it, as it launches the next deliberate lookup.
Process {
id: pwProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: if (root.opened && !root.pwExpectedStop) root.password = String(text || "").trim()
}
onExited: function(exitCode) {
if (root.pwExpectedStop) return
if (!root.opened) return
if (exitCode === 0 && root.password !== "") root.passwordVisible = true
else root.passwordError = "Could not read the Wi-Fi password"
}
}
PanelWindow {
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "blob-network-qr"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
// Deep scrim: the floating code needs the backdrop to carry the contrast
// on any wallpaper.
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.78)
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
}
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.dismiss()
Item {
anchors.centerIn: parent
width: content.implicitWidth
height: content.implicitHeight
// Narrow or heavily scaled outputs: shrink the whole card 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 content dismisses.
MouseArea { anchors.fill: parent; onClicked: {} }
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.space(16)
Text {
textFormat: Text.PlainText
text: (root.ssid || "Wi-Fi").toUpperCase()
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 2
elide: Text.ElideRight
Layout.maximumWidth: Style.space(320)
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
}
// Render every QR module as an integer-sized native rectangle. This
// stays crisp and avoids temporary images and file-cache races. Only
// the dark modules paint, so the white canvas can keep its rounded
// corners; the spec quiet zone baked into the matrix keeps the code
// itself clear of them.
Rectangle {
id: qrCanvas
readonly property int moduleSize: root.qrSize > 0
? Math.max(4, Math.floor(Style.space(240) / root.qrSize))
: 0
visible: root.showingQr
width: root.qrSize * moduleSize
height: width
color: "white"
radius: Style.cornerRadius
Layout.alignment: Qt.AlignHCenter
Grid {
anchors.fill: parent
columns: root.qrSize
Repeater {
model: root.qrSize * root.qrSize
Rectangle {
required property int index
readonly property int matrixRow: Math.floor(index / root.qrSize)
readonly property int matrixColumn: index % root.qrSize
width: qrCanvas.moduleSize
height: qrCanvas.moduleSize
color: root.qrRows[matrixRow].charAt(matrixColumn) === "1" ? "#111111" : "transparent"
}
}
}
}
Text {
visible: root.loading
text: "Generating QR code…"
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
Text {
textFormat: Text.PlainText
visible: root.error !== ""
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(320)
horizontalAlignment: Text.AlignHCenter
}
Text {
visible: root.showingQr
text: "Scan to join this network"
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
Text {
textFormat: Text.PlainText
visible: root.showingQr && root.secured
text: root.passwordError !== "" ? root.passwordError
: root.passwordVisible ? root.password
: "Show password"
color: root.passwordError !== "" ? root.onScrimUrgent : root.onScrim
opacity: root.passwordVisible || root.passwordError !== "" ? 1 : 0.6
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WrapAnywhere
Layout.fillWidth: true
Layout.maximumWidth: Style.space(320)
horizontalAlignment: Text.AlignHCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.togglePassword()
}
}
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.wifiqr",
"name": "Wi-Fi QR",
"version": "1.0.0",
"author": "Blob",
"description": "Share the connected Wi-Fi network as a scannable QR code",
"kinds": [
"panel"
],
"entryPoints": {
"panel": "Panel.qml"
}
}