Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ~10–20 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
+ "¤t=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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user