Move panel plugins under panels
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": "omarchy.audio",
|
||||
"name": "Audio",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"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,100 @@
|
||||
function deviceLabel(device) {
|
||||
if (!device) return ""
|
||||
return String(device.deviceName || device.name || "").trim()
|
||||
}
|
||||
|
||||
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 hasHumanName(device) {
|
||||
var label = deviceLabel(device)
|
||||
return label !== "" && !isUuidLike(label) && !isAddressLike(label)
|
||||
}
|
||||
|
||||
function sortedByLabel(devices) {
|
||||
var list = Array.isArray(devices) ? devices.slice() : []
|
||||
list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) })
|
||||
return list
|
||||
}
|
||||
|
||||
function deviceLists(devices) {
|
||||
var values = Array.isArray(devices) ? 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,
|
||||
isUuidLike: isUuidLike,
|
||||
isAddressLike: isAddressLike,
|
||||
hasHumanName: hasHumanName,
|
||||
sortedByLabel: sortedByLabel,
|
||||
deviceLists: deviceLists,
|
||||
cloneMap: cloneMap,
|
||||
pendingAction: pendingAction,
|
||||
withPendingAction: withPendingAction,
|
||||
visibleSections: visibleSections,
|
||||
sectionDevices: sectionDevices
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Bluetooth
|
||||
import qs.Ui
|
||||
import qs.Commons
|
||||
import "Model.js" as Model
|
||||
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "omarchy.bluetooth"
|
||||
ipcTarget: "omarchy.bluetooth"
|
||||
|
||||
// Address -> "connecting" | "disconnecting" | "forgetting".
|
||||
// The actual Bluetooth sequencing lives in bin/omarchy-bluetooth-device;
|
||||
// this map only keeps the panel responsive while BlueZ catches up.
|
||||
property var pendingActions: ({})
|
||||
|
||||
readonly property var adapter: Bluetooth.defaultAdapter
|
||||
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : []
|
||||
|
||||
function deviceLabel(device) {
|
||||
return Model.deviceLabel(device)
|
||||
}
|
||||
|
||||
function isUuidLike(value) {
|
||||
return Model.isUuidLike(value)
|
||||
}
|
||||
|
||||
function isAddressLike(value) {
|
||||
return Model.isAddressLike(value)
|
||||
}
|
||||
|
||||
function hasHumanName(device) {
|
||||
return Model.hasHumanName(device)
|
||||
}
|
||||
|
||||
readonly property var deviceGroups: Model.deviceLists(devices)
|
||||
readonly property var connectedDevices: deviceGroups.connected || []
|
||||
readonly property var knownDevices: deviceGroups.known || []
|
||||
readonly property var discoveredDevices: deviceGroups.discovered || []
|
||||
|
||||
readonly property string icon: {
|
||||
if (!adapter) return ""
|
||||
if (!adapter.enabled) return ""
|
||||
if (connectedDevices.length > 0) return ""
|
||||
return ""
|
||||
}
|
||||
|
||||
property int phraseIndex: 0
|
||||
readonly property var activePhrases: [
|
||||
"Untangling wires",
|
||||
"Streaming vikings",
|
||||
"Pairing mysteries",
|
||||
"Herding headsets",
|
||||
"Taming radios",
|
||||
"Summoning speakers",
|
||||
"Wrangling codecs",
|
||||
"Polishing packets"
|
||||
]
|
||||
readonly property bool rotatingPhrases: adapter && adapter.enabled
|
||||
readonly property string heroStatusText: {
|
||||
if (!adapter) return "No adapter"
|
||||
if (!adapter.enabled) return "Bluetooth off"
|
||||
return activePhrases[phraseIndex % activePhrases.length]
|
||||
}
|
||||
|
||||
// Single cursor model shared by keyboard and mouse. Sections:
|
||||
// "connected" — currently connected devices; Enter disconnects.
|
||||
// "known" — remembered devices; Enter connects.
|
||||
// "discovered" — unremembered devices visible while scanning; Enter connects.
|
||||
// Visuals always come from CursorSurface (hasCursor / current),
|
||||
// never from containsMouse. Mouse hover updates root cursor state too,
|
||||
// guaranteeing one highlight on screen.
|
||||
property string focusSection: "connected"
|
||||
property int selectedIndex: 0
|
||||
property bool actionFocused: false
|
||||
property bool cursorActive: false
|
||||
|
||||
// Stable identity for the focused device. Devices move between sections as
|
||||
// they connect, disconnect, pair, or get forgotten, so follow the BlueZ
|
||||
// address across section changes instead of preserving a stale row index.
|
||||
property string focusedDeviceAddress: ""
|
||||
|
||||
readonly property color hoverFill: bar
|
||||
? Style.hoverFillFor(bar.foreground, Color.accent)
|
||||
: "transparent"
|
||||
readonly property color selectedFill: bar
|
||||
? Style.selectedFillFor(bar.foreground, Color.accent)
|
||||
: "transparent"
|
||||
|
||||
function sectionCount(section) {
|
||||
if (section === "connected") return connectedDevices.length
|
||||
if (section === "known") return knownDevices.length
|
||||
if (section === "discovered") return discoveredDevices.length
|
||||
return 0
|
||||
}
|
||||
|
||||
function sectionVisible(section) {
|
||||
if (section === "connected") return connectedDevices.length > 0
|
||||
if (section === "known") return knownDevices.length > 0
|
||||
if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
readonly property var visibleSections: {
|
||||
return Model.visibleSections(deviceGroups, adapter && adapter.discovering)
|
||||
}
|
||||
|
||||
function devicesForSection(section) {
|
||||
return Model.sectionDevices(deviceGroups, section)
|
||||
}
|
||||
|
||||
function deviceAt(section, index) {
|
||||
var list = devicesForSection(section)
|
||||
return index >= 0 && index < list.length ? list[index] : null
|
||||
}
|
||||
|
||||
function cloneMap(map) {
|
||||
return Model.cloneMap(map)
|
||||
}
|
||||
|
||||
function pendingAction(address) {
|
||||
return Model.pendingAction(pendingActions, address)
|
||||
}
|
||||
|
||||
function setPendingAction(address, action) {
|
||||
if (!address) return
|
||||
pendingActions = Model.withPendingAction(pendingActions, address, action)
|
||||
if (action) pendingTimeout.restart()
|
||||
}
|
||||
|
||||
function deviceCommand(action, address) {
|
||||
var command = root.bar && root.bar.omarchyPath
|
||||
? root.bar.omarchyPath + "/bin/omarchy-bluetooth-device"
|
||||
: "omarchy-bluetooth-device"
|
||||
return [command, action, address]
|
||||
}
|
||||
|
||||
function runDeviceAction(device, action, pending) {
|
||||
if (!device || !device.address) return
|
||||
setPendingAction(device.address, pending)
|
||||
Quickshell.execDetached(deviceCommand(action, device.address))
|
||||
}
|
||||
|
||||
function connectDevice(device) {
|
||||
if (!device || device.connected) return
|
||||
if (device.paired || device.bonded || device.trusted) runDeviceAction(device, "connect", "connecting")
|
||||
else runDeviceAction(device, "pair", "connecting")
|
||||
}
|
||||
|
||||
function disconnectDevice(device) {
|
||||
if (!device || !device.address) return
|
||||
if (!device.connected) return
|
||||
setPendingAction(device.address, "disconnecting")
|
||||
if (device.disconnect) device.disconnect()
|
||||
Quickshell.execDetached(deviceCommand("disconnect", device.address))
|
||||
}
|
||||
|
||||
function forgetDevice(device) {
|
||||
if (!device || !device.address) return
|
||||
runDeviceAction(device, "forget", "forgetting")
|
||||
}
|
||||
|
||||
function syncPendingActions() {
|
||||
var next = cloneMap(pendingActions)
|
||||
var changed = false
|
||||
|
||||
for (var address in next) {
|
||||
var action = next[address]
|
||||
var found = null
|
||||
|
||||
for (var i = 0; i < devices.length; i++) {
|
||||
var d = devices[i]
|
||||
if (d && d.address === address) {
|
||||
found = d
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ((action === "connecting" && found && found.connected)
|
||||
|| (action === "disconnecting" && found && !found.connected)
|
||||
|| (action === "forgetting" && (!found || (!found.paired && !found.bonded && !found.trusted)))) {
|
||||
delete next[address]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) pendingActions = next
|
||||
}
|
||||
|
||||
// j/k navigates between device sections row-by-row.
|
||||
function moveCursor(delta) {
|
||||
var sections = visibleSections
|
||||
if (!sections || sections.length === 0) return
|
||||
var sIdx = sections.indexOf(focusSection)
|
||||
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = 0; actionFocused = false; return }
|
||||
|
||||
var idx = selectedIndex
|
||||
var max = sectionCount(focusSection) - 1
|
||||
|
||||
if (delta > 0) {
|
||||
if (idx < max) { selectedIndex = idx + 1; actionFocused = false; return }
|
||||
if (sIdx < sections.length - 1) {
|
||||
focusSection = sections[sIdx + 1]
|
||||
selectedIndex = 0
|
||||
actionFocused = false
|
||||
}
|
||||
} else {
|
||||
if (idx > 0) { selectedIndex = idx - 1; actionFocused = false; return }
|
||||
if (sIdx > 0) {
|
||||
focusSection = sections[sIdx - 1]
|
||||
selectedIndex = sectionCount(focusSection) - 1
|
||||
actionFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveCursorH(delta) {
|
||||
if (!cursorActive) { cursorActive = true; return }
|
||||
if (focusSection !== "known") return
|
||||
var dev = deviceAt(focusSection, selectedIndex)
|
||||
if (!dev || dev.connected) return
|
||||
if (delta > 0) actionFocused = true
|
||||
else if (delta < 0) actionFocused = false
|
||||
}
|
||||
|
||||
function activateCursor() {
|
||||
if (actionFocused) {
|
||||
deleteSelected()
|
||||
return
|
||||
}
|
||||
|
||||
if (focusSection === "connected" || focusSection === "known") {
|
||||
var dev = deviceAt(focusSection, selectedIndex)
|
||||
if (!dev) return
|
||||
if (dev.connected) disconnectDevice(dev)
|
||||
else connectDevice(dev)
|
||||
return
|
||||
}
|
||||
if (focusSection === "discovered") {
|
||||
var d = discoveredDevices[selectedIndex]
|
||||
if (!d) return
|
||||
connectDevice(d)
|
||||
}
|
||||
}
|
||||
|
||||
// 'x' forgets remembered devices. Connected rows toggle connection via
|
||||
// Enter/click, so the destructive forget action is intentionally unavailable
|
||||
// while connected.
|
||||
function deleteSelected() {
|
||||
if (focusSection !== "known") return
|
||||
var dev = deviceAt(focusSection, selectedIndex)
|
||||
if (!dev) return
|
||||
forgetDevice(dev)
|
||||
}
|
||||
|
||||
onOpenedChanged: {
|
||||
if (opened) {
|
||||
if (adapter && adapter.enabled && !adapter.discovering) adapter.discovering = true
|
||||
if (connectedDevices.length > 0) { focusSection = "connected"; selectedIndex = 0 }
|
||||
else if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 }
|
||||
else if (discoveredDevices.length > 0) { focusSection = "discovered"; selectedIndex = 0 }
|
||||
actionFocused = false
|
||||
cursorActive = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateFocusedAddress() {
|
||||
var d = deviceAt(focusSection, selectedIndex)
|
||||
focusedDeviceAddress = d ? (d.address || "") : ""
|
||||
}
|
||||
|
||||
function reselectFocusedDevice() {
|
||||
if (focusedDeviceAddress === "") {
|
||||
clampCursor()
|
||||
return
|
||||
}
|
||||
|
||||
var sections = ["connected", "known", "discovered"]
|
||||
for (var s = 0; s < sections.length; s++) {
|
||||
var section = sections[s]
|
||||
if (!sectionVisible(section)) continue
|
||||
var list = devicesForSection(section)
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i] && list[i].address === focusedDeviceAddress) {
|
||||
focusSection = section
|
||||
selectedIndex = i
|
||||
clampCursor()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clampCursor()
|
||||
}
|
||||
|
||||
onSelectedIndexChanged: updateFocusedAddress()
|
||||
onFocusSectionChanged: updateFocusedAddress()
|
||||
onConnectedDevicesChanged: { reselectFocusedDevice(); syncPendingActions() }
|
||||
onKnownDevicesChanged: { reselectFocusedDevice(); syncPendingActions() }
|
||||
onDiscoveredDevicesChanged: { reselectFocusedDevice(); syncPendingActions() }
|
||||
onVisibleSectionsChanged: clampCursor()
|
||||
|
||||
// Keep the keyboard-focused row inside the visible viewport of the device
|
||||
// Flickable. Each DeviceRow calls this when it gains hasCursor. Without
|
||||
// it, j/k can walk the selection off-screen in a long device list.
|
||||
function ensureCursorVisible(item) {
|
||||
if (!item || !deviceFlick) return
|
||||
var pt = item.mapToItem(deviceFlick.contentItem, 0, 0)
|
||||
var top = pt.y
|
||||
var bottom = top + (item.height || 0)
|
||||
var viewTop = deviceFlick.contentY
|
||||
var viewBottom = viewTop + deviceFlick.height
|
||||
var margin = 6
|
||||
if (top < viewTop + margin) deviceFlick.contentY = Math.max(0, top - margin)
|
||||
else if (bottom > viewBottom - margin)
|
||||
deviceFlick.contentY = bottom + margin - deviceFlick.height
|
||||
}
|
||||
|
||||
function clampCursor() {
|
||||
var sections = visibleSections
|
||||
if (!sections || !sections.length) {
|
||||
selectedIndex = 0
|
||||
return
|
||||
}
|
||||
if (sections.indexOf(focusSection) < 0) {
|
||||
focusSection = sections[0]
|
||||
selectedIndex = 0
|
||||
return
|
||||
}
|
||||
var count = sectionCount(focusSection)
|
||||
if (count === 0) {
|
||||
// Section emptied out — bounce to the previous visible one.
|
||||
var sIdx = sections.indexOf(focusSection)
|
||||
focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0]
|
||||
selectedIndex = Math.max(0, sectionCount(focusSection) - 1)
|
||||
return
|
||||
}
|
||||
if (selectedIndex > count - 1) selectedIndex = count - 1
|
||||
if (selectedIndex < 0) selectedIndex = 0
|
||||
}
|
||||
|
||||
visible: adapter !== null
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
Connections {
|
||||
target: root.adapter || null
|
||||
function onEnabledChanged() {
|
||||
if (root.opened && root.adapter && root.adapter.enabled && !root.adapter.discovering)
|
||||
root.adapter.discovering = true
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: pendingTimeout
|
||||
interval: 20000
|
||||
repeat: false
|
||||
onTriggered: root.pendingActions = ({})
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: phraseTimer
|
||||
interval: 2800
|
||||
running: root.opened && root.rotatingPhrases
|
||||
repeat: true
|
||||
onTriggered: phraseSwap.restart()
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: phraseSwap
|
||||
PropertyAnimation {
|
||||
target: heroStatus; property: "opacity"
|
||||
to: 0.0; duration: 180; easing.type: Easing.OutQuad
|
||||
}
|
||||
ScriptAction {
|
||||
script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length
|
||||
}
|
||||
PropertyAnimation {
|
||||
target: heroStatus; property: "opacity"
|
||||
to: 1.0; duration: 260; easing.type: Easing.InQuad
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onRotatingPhrasesChanged() {
|
||||
if (!root.rotatingPhrases) {
|
||||
phraseSwap.stop()
|
||||
heroStatus.opacity = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBluetooth() {
|
||||
if (!adapter) return
|
||||
adapter.enabled = !adapter.enabled
|
||||
if (adapter.enabled) Qt.callLater(function() {
|
||||
if (root.adapter) root.adapter.discovering = true
|
||||
})
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.icon
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.RightButton) root.toggleBluetooth()
|
||||
else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-bluetooth")
|
||||
else root.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
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 (dy !== 0) root.moveCursor(dy)
|
||||
else if (dx !== 0) root.moveCursorH(dx)
|
||||
}
|
||||
onActivateRequested: if (root.cursorActive) root.activateCursor()
|
||||
onCloseRequested: root.close()
|
||||
onDeleteRequested: if (root.cursorActive) root.deleteSelected()
|
||||
|
||||
Column {
|
||||
id: column
|
||||
anchors.fill: parent
|
||||
spacing: Style.space(10)
|
||||
|
||||
// ---------- Hero: Bluetooth icon · status ----------
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: heroIcon
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.icon
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.display
|
||||
opacity: root.adapter && root.adapter.enabled ? 1.0 : 0.5
|
||||
|
||||
MouseArea {
|
||||
id: heroIconMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.adapter ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
enabled: !!root.adapter
|
||||
onClicked: root.toggleBluetooth()
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: heroIconMouse.containsMouse
|
||||
text: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on"
|
||||
fontFamily: root.bar.fontFamily
|
||||
}
|
||||
}
|
||||
|
||||
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: "Bluetooth"
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable device list — capped so a noisy neighborhood doesn't
|
||||
// grow the popup past the screen.
|
||||
Flickable {
|
||||
id: deviceFlick
|
||||
width: parent.width
|
||||
height: Math.min(deviceList.implicitHeight, Style.space(400))
|
||||
contentWidth: width
|
||||
contentHeight: deviceList.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
|
||||
Column {
|
||||
id: deviceList
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
// Connected devices.
|
||||
PanelSectionHeader {
|
||||
visible: root.connectedDevices.length > 0
|
||||
text: "CONNECTED"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.connectedDevices
|
||||
DeviceRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: deviceList.width
|
||||
dev: modelData
|
||||
rowIndex: index
|
||||
sectionName: "connected"
|
||||
isDiscovered: false
|
||||
}
|
||||
}
|
||||
|
||||
// Remembered devices.
|
||||
PanelSectionHeader {
|
||||
visible: root.knownDevices.length > 0
|
||||
text: "PAIRED"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.knownDevices
|
||||
DeviceRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: deviceList.width
|
||||
dev: modelData
|
||||
rowIndex: index
|
||||
sectionName: "known"
|
||||
isDiscovered: false
|
||||
}
|
||||
}
|
||||
|
||||
// Discovered (unpaired) devices, only shown while scanning.
|
||||
PanelSectionHeader {
|
||||
visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0
|
||||
text: "AVAILABLE"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.adapter && root.adapter.discovering ? root.discoveredDevices : []
|
||||
DeviceRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: deviceList.width
|
||||
dev: modelData
|
||||
rowIndex: index
|
||||
sectionName: "discovered"
|
||||
isDiscovered: true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.connectedDevices.length === 0
|
||||
&& root.knownDevices.length === 0
|
||||
&& (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0)
|
||||
text: !root.adapter ? "No Bluetooth adapter"
|
||||
: !root.adapter.enabled ? "Turn Bluetooth on to scan"
|
||||
: root.adapter.discovering ? "Scanning for devices…"
|
||||
: "No paired devices. Reopen this panel to scan again."
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
wrapMode: Text.WordWrap
|
||||
width: deviceList.width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two-line device row showing name + live status. Pending state is owned
|
||||
// by the panel so it survives rows moving between sections.
|
||||
component DeviceRow: CursorSurface {
|
||||
id: row
|
||||
required property var dev
|
||||
required property int rowIndex
|
||||
required property string sectionName
|
||||
required property bool isDiscovered
|
||||
|
||||
readonly property bool isConnected: dev && dev.connected
|
||||
readonly property int devState: dev && dev.state !== undefined ? dev.state : -1
|
||||
readonly property string action: root.pendingAction(dev ? dev.address : "")
|
||||
readonly property string actionTooltip: {
|
||||
if (!dev) return ""
|
||||
if (isConnected) return "Disconnect"
|
||||
if (isDiscovered) return "Pair"
|
||||
return "Connect"
|
||||
}
|
||||
|
||||
readonly property bool rowSelected: root.cursorActive && root.focusSection === sectionName && root.selectedIndex === rowIndex
|
||||
readonly property bool forgetAvailable: sectionName === "known" && !isConnected && !isDiscovered
|
||||
readonly property bool showForgetButton: forgetAvailable && (rowMouse.containsMouse || rowSelected)
|
||||
|
||||
hasCursor: rowSelected && !root.actionFocused
|
||||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row)
|
||||
current: isConnected
|
||||
foreground: root.bar.foreground
|
||||
fill: root.hoverFill
|
||||
currentFill: root.selectedFill
|
||||
|
||||
readonly property string statusText: {
|
||||
if (!dev) return ""
|
||||
if (action === "forgetting") return "Forgetting…"
|
||||
if (action === "disconnecting" || devState === 2) return "Disconnecting…"
|
||||
if (isConnected) {
|
||||
if (dev.batteryAvailable) return Math.round(dev.battery * 100) + "%"
|
||||
return sectionName === "connected" ? "" : "Connected"
|
||||
}
|
||||
if (action === "connecting" || devState === 3 || dev.pairing === true) return "Connecting…"
|
||||
if (isDiscovered) return ""
|
||||
return ""
|
||||
}
|
||||
|
||||
readonly property color statusColor: {
|
||||
if (isConnected) return root.bar.foreground
|
||||
if (action !== "" || devState === 3 || dev.pairing === true) return root.bar.foreground
|
||||
return Qt.darker(root.bar.foreground, 1.5)
|
||||
}
|
||||
|
||||
implicitHeight: rowContent.implicitHeight + Style.spacing.rowPaddingX
|
||||
|
||||
MouseArea {
|
||||
id: rowMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: row.dev ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
|
||||
onContainsMouseChanged: if (containsMouse) {
|
||||
root.cursorActive = true
|
||||
root.focusSection = row.sectionName
|
||||
root.selectedIndex = row.rowIndex
|
||||
root.actionFocused = false
|
||||
}
|
||||
|
||||
onClicked: function(mouse) {
|
||||
if (!row.dev) return
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
if (row.isConnected) root.disconnectDevice(row.dev)
|
||||
else if (!row.isDiscovered) root.forgetDevice(row.dev)
|
||||
return
|
||||
}
|
||||
if (row.isConnected) root.disconnectDevice(row.dev)
|
||||
else root.connectDevice(row.dev)
|
||||
}
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: row.actionTooltip !== "" && rowMouse.containsMouse && !root.actionFocused
|
||||
text: row.actionTooltip
|
||||
fontFamily: root.bar.fontFamily
|
||||
}
|
||||
|
||||
Item {
|
||||
id: rowContent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.rightMargin: Style.space(10)
|
||||
implicitHeight: Math.max(deviceIcon.implicitHeight, info.implicitHeight, forgetBtn.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: deviceIcon
|
||||
text: row.isConnected ? "" : ""
|
||||
color: row.statusColor
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
id: info
|
||||
spacing: Style.space(1)
|
||||
anchors.left: deviceIcon.right
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.right: forgetBtn.visible ? forgetBtn.left : parent.right
|
||||
anchors.rightMargin: forgetBtn.visible ? Style.space(8) : 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
text: root.deviceLabel(row.dev) || "Device"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
Text {
|
||||
visible: row.statusText !== ""
|
||||
text: row.statusText
|
||||
color: row.statusColor
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
PanelActionButton {
|
||||
id: forgetBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: row.showForgetButton
|
||||
iconText: ""
|
||||
tooltipText: "Forget"
|
||||
foreground: root.bar.foreground
|
||||
hoverColor: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
hasCursor: row.rowSelected && root.actionFocused
|
||||
onHovered: function(isHovered) {
|
||||
if (!isHovered) {
|
||||
if (rowMouse.containsMouse) root.actionFocused = false
|
||||
return
|
||||
}
|
||||
root.cursorActive = true
|
||||
root.focusSection = row.sectionName
|
||||
root.selectedIndex = row.rowIndex
|
||||
root.actionFocused = true
|
||||
}
|
||||
onClicked: {
|
||||
if (!row.dev) return
|
||||
root.forgetDevice(row.dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.bluetooth",
|
||||
"name": "Bluetooth",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"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,52 @@
|
||||
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 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,
|
||||
brightnessName: brightnessName,
|
||||
parseDisplays: parseDisplays
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
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: "omarchy.monitor"
|
||||
ipcTarget: "omarchy.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
|
||||
|
||||
// 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 scaleValues: ["1", "1.25", "1.6", "2", "3", "4"]
|
||||
property string focusSection: "scale"
|
||||
property int selectedIndex: 0
|
||||
property bool cursorActive: false
|
||||
|
||||
readonly property var visibleSections: {
|
||||
var list = []
|
||||
if (brightnessAvailable) list.push("brightness")
|
||||
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 === "scale") return scaleValues.length
|
||||
if (section === "monitors") return displays.length
|
||||
return 0
|
||||
}
|
||||
|
||||
function sectionIsSingleRow(section) {
|
||||
// brightness has only the slider; scale presets sit horizontally.
|
||||
return section === "brightness" || section === "scale"
|
||||
}
|
||||
|
||||
function sectionFirstIndex(section) {
|
||||
if (section === "brightness") 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 uses -1 sentinel; scale clamps into the preset range.
|
||||
if (focusSection === "brightness") 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: "omarchy.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 = ["bash", "-lc", "omarchy-brightness-display --no-osd " + percent + "%"]
|
||||
setBrightnessProc.running = true
|
||||
}
|
||||
|
||||
function previewBrightness(value) {
|
||||
root.brightnessPercent = Model.clampBrightness(value)
|
||||
brightnessDebounce.restart()
|
||||
}
|
||||
|
||||
function normalizeScale(scale) {
|
||||
return Model.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", "-lc", "omarchy-hyprland-monitor-scaling " + scale]
|
||||
if (!actionProc.running) actionProc.running = true
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
// KeyboardPanel takes Exclusive focus at map-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()
|
||||
onVisibleSectionsChanged: clampCursor()
|
||||
|
||||
Timer {
|
||||
interval: 5000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: stateProc
|
||||
command: [root.bar ? root.bar.omarchyPath + "/bin/omarchy-monitor-state" : "omarchy-monitor-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
|
||||
// `omarchy-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()
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.displays.length > 1 ? "" : ""
|
||||
fontSize: Style.font.subtitle
|
||||
onPressed: function(b) { root.toggle() }
|
||||
onWheelMoved: function(delta) {
|
||||
if (root.brightnessAvailable) root.setBrightness(root.brightnessPercent + (delta > 0 ? 5 : -5))
|
||||
}
|
||||
}
|
||||
|
||||
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 === "scale") root.moveCursorH(dx)
|
||||
}
|
||||
}
|
||||
onActivateRequested: if (root.cursorActive) root.activateCursor()
|
||||
onCloseRequested: root.close()
|
||||
|
||||
ScrollView {
|
||||
id: scrollArea
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
|
||||
Column {
|
||||
id: panelColumn
|
||||
width: scrollArea.availableWidth
|
||||
spacing: Style.space(14)
|
||||
|
||||
// ---------- Hero: display icon · title/status ----------
|
||||
Item {
|
||||
width: parent.width
|
||||
visible: root.brightnessAvailable
|
||||
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: heroIcon
|
||||
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
|
||||
text: root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Brightness ----------
|
||||
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
|
||||
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.cursorActive = true
|
||||
root.focusSection = "brightness"
|
||||
root.selectedIndex = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !root.brightnessAvailable
|
||||
text: "No controllable backlight found"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
// ---------- Scale ----------
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "SCALE"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
}
|
||||
|
||||
Row {
|
||||
id: scaleRow
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
readonly property real cellWidth: root.scaleValues.length > 0
|
||||
? (width - spacing * (root.scaleValues.length - 1)) / root.scaleValues.length
|
||||
: 0
|
||||
|
||||
Repeater {
|
||||
model: root.scaleValues
|
||||
|
||||
ScalePill {
|
||||
required property string modelData
|
||||
required property int index
|
||||
|
||||
scaleValue: modelData
|
||||
scaleIndex: index
|
||||
width: scaleRow.cellWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Monitors ----------
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component ScalePill: Button {
|
||||
id: pill
|
||||
required property string scaleValue
|
||||
required property int scaleIndex
|
||||
|
||||
text: scaleValue + "x"
|
||||
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.normalizeScale(root.monitorScale) === root.normalizeScale(scaleValue)
|
||||
hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === scaleIndex
|
||||
|
||||
onClicked: root.setScale(scaleValue)
|
||||
onHovered: function(isHovered) {
|
||||
if (!isHovered) 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 {
|
||||
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 {
|
||||
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.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": "omarchy.monitor",
|
||||
"name": "Display",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"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,173 @@
|
||||
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 ""
|
||||
var ghz = v / 1000
|
||||
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
|
||||
}
|
||||
|
||||
function headerDetail(info) {
|
||||
var value = info || {}
|
||||
if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "")
|
||||
if (value.type === "wifi") return formatHeaderFreq(value.freq || "")
|
||||
return ""
|
||||
}
|
||||
|
||||
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
|
||||
next[line.substring(0, idx)] = line.substring(idx + 1).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 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"
|
||||
}
|
||||
|
||||
function wifiRow(network) {
|
||||
if (!network) return null
|
||||
return {
|
||||
network: network,
|
||||
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 ""
|
||||
}
|
||||
|
||||
function isProtected(security, openSecurity) {
|
||||
return security !== openSecurity
|
||||
}
|
||||
|
||||
function networkFailureReason(reason, reasons) {
|
||||
var r = reasons || {}
|
||||
if (reason === r.NoSecrets) return "Passphrase required"
|
||||
if (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"
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
parseNetworkStatus: parseNetworkStatus,
|
||||
wifiIconFor: wifiIconFor,
|
||||
connectionIcon: connectionIcon,
|
||||
formatHeaderSpeed: formatHeaderSpeed,
|
||||
formatHeaderFreq: formatHeaderFreq,
|
||||
headerDetail: headerDetail,
|
||||
parseKeyValue: parseKeyValue,
|
||||
throughputState: throughputState,
|
||||
formatBytes: formatBytes,
|
||||
formatRate: formatRate,
|
||||
wifiRow: wifiRow,
|
||||
sortWifiRows: sortWifiRows,
|
||||
wifiSectionTitle: wifiSectionTitle,
|
||||
isProtected: isProtected,
|
||||
networkFailureReason: networkFailureReason
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.network",
|
||||
"name": "Network",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"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 || 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 (d.state === states.Charging) return chargingIcons[index]
|
||||
if (!onBattery) return ""
|
||||
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 && percentage >= 1) return "Fully charged"
|
||||
if (onBattery) return "On battery"
|
||||
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,496 @@
|
||||
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: "omarchy.power"
|
||||
ipcTarget: "omarchy.power"
|
||||
property var batteryInfo: ({})
|
||||
property var systemInfo: ({})
|
||||
property var profiles: []
|
||||
property string activeProfile: ""
|
||||
property int profileIndex: 0
|
||||
property bool cursorActive: false
|
||||
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, UPower.onBattery, upowerStates())
|
||||
}
|
||||
|
||||
function modeLabel() {
|
||||
var device = UPower.displayDevice
|
||||
return Model.modeLabel(device, UPower.onBattery, 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 chargeThresholdActive: {
|
||||
var device = UPower.displayDevice
|
||||
return Model.chargeThresholdActive(device, UPower.onBattery, upowerStates())
|
||||
}
|
||||
readonly property bool batteryFull: fullyCharged || (!UPower.onBattery && 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 batteryLow: UPower.onBattery && batteryFraction > 0 && batteryFraction <= 0.2
|
||||
readonly property bool charging: {
|
||||
var d = UPower.displayDevice
|
||||
return d && d.isPresent && d.state === UPowerDeviceState.Charging && !root.chargeThresholdActive
|
||||
}
|
||||
|
||||
readonly property color batteryFillColor: {
|
||||
if (batteryLow) return Color.urgent
|
||||
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 (UPower.onBattery || chargeThresholdActive) 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 && activeProfile !== "") {
|
||||
var idx = profiles.indexOf(activeProfile)
|
||||
if (idx >= 0) profileIndex = idx
|
||||
}
|
||||
}
|
||||
|
||||
function setProfile(profile) {
|
||||
if (!profile || actionProc.running) return
|
||||
actionProc.command = ["powerprofilesctl", "set", profile]
|
||||
actionProc.running = true
|
||||
}
|
||||
|
||||
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: [root.bar ? root.bar.omarchyPath + "/bin/omarchy-battery-status" : "omarchy-battery-status", "--shell"]
|
||||
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "battery") }
|
||||
}
|
||||
|
||||
Process {
|
||||
id: profilesProc
|
||||
command: [root.bar ? root.bar.omarchyPath + "/bin/omarchy-powerprofiles-list" : "omarchy-powerprofiles-list", "--active-state"]
|
||||
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateProfiles(text) }
|
||||
}
|
||||
|
||||
Process {
|
||||
id: systemProc
|
||||
command: [root.bar ? root.bar.omarchyPath + "/bin/omarchy-system-stats" : "omarchy-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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.batteryIcon()
|
||||
horizontalMargin: 8.5
|
||||
rightExtraMargin: 2
|
||||
active: root.batteryPresent && UPower.displayDevice.percentage <= 0.2 && UPower.onBattery
|
||||
tooltipText: ""
|
||||
onPressed: function(b) { if (root.batteryPresent) 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()
|
||||
|
||||
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
|
||||
text: root.batteryIcon()
|
||||
color: root.batteryLow ? Color.urgent : 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
|
||||
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
|
||||
text: root.batteryInfo.percentage || "—"
|
||||
color: root.batteryLow ? Color.urgent : 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" : (UPower.onBattery ? "Time left" : "Time to full")
|
||||
value: root.chargeThresholdActive ? (root.batteryInfo.threshold || "-") : (root.batteryFlowIdle ? "-" : (root.batteryInfo.time || "—"))
|
||||
}
|
||||
InfoPair {
|
||||
label: root.chargeThresholdActive ? "Battery state" : (UPower.onBattery ? "Discharging" : "Charging")
|
||||
value: root.chargeThresholdActive ? "Holding" : (root.batteryFull ? "-" : (root.batteryInfo.rate || ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Power profile picker ----------
|
||||
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 {
|
||||
color: root.bar.foreground
|
||||
opacity: 0.6
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
component InfoValue: Text {
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.power",
|
||||
"name": "Power",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"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,63 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.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
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh()
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panelLoader.item && panelLoader.item.toggle) panelLoader.item.toggle()
|
||||
}
|
||||
|
||||
visible: panelLoader.item && panelLoader.item.label !== ""
|
||||
implicitWidth: bar && bar.vertical ? button.implicitWidth : button.implicitWidth + Style.spacing.controlGap
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: bar && bar.vertical ? Math.round((parent.width - width) / 2) : 0
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
bar: root.bar
|
||||
text: panelLoader.item ? panelLoader.item.label : ""
|
||||
active: panelLoader.item && panelLoader.item.klass === "active"
|
||||
horizontalMargin: 1
|
||||
// Tooltip suppressed because the panel is the detail view.
|
||||
tooltipText: ""
|
||||
|
||||
onPressed: function(b) {
|
||||
if (!root.bar) return
|
||||
if (b === Qt.RightButton) root.bar.run("omarchy-notification-send \"$(omarchy-weather-status)\"")
|
||||
else if (b === Qt.MiddleButton) root.refresh()
|
||||
else root.togglePanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
function parseWeatherStatus(raw) {
|
||||
try {
|
||||
var data = JSON.parse(String(raw || "{}"))
|
||||
return {
|
||||
label: data.text || "",
|
||||
klass: data.class || ""
|
||||
}
|
||||
} catch (e) {
|
||||
return { label: "", klass: "" }
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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) {
|
||||
var c = parseInt(String(code || "0"), 10)
|
||||
if (c === 0) return iconForCode(113, false)
|
||||
if (c === 1 || c === 2) return iconForCode(116, false)
|
||||
if (c === 3) return iconForCode(119, false)
|
||||
if (c === 45 || c === 48) return iconForCode(143, false)
|
||||
if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, false)
|
||||
if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, false)
|
||||
if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, false)
|
||||
if (c === 95 || c === 96 || c === 99) return iconForCode(389, false)
|
||||
return iconForCode(119, false)
|
||||
}
|
||||
|
||||
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 ""
|
||||
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 = {
|
||||
parseWeatherStatus: parseWeatherStatus,
|
||||
isFutureForecastDate: isFutureForecastDate,
|
||||
roundedTemp: roundedTemp,
|
||||
celsiusToFahrenheit: celsiusToFahrenheit,
|
||||
formatTemp: formatTemp,
|
||||
dayName: dayName,
|
||||
openMeteoForecastDays: openMeteoForecastDays,
|
||||
wttrNextForecastDays: wttrNextForecastDays,
|
||||
buildForecastDays: buildForecastDays,
|
||||
bareTempForDay: bareTempForDay,
|
||||
dayIcon: dayIcon,
|
||||
iconForOpenMeteoCode: iconForOpenMeteoCode,
|
||||
iconForCode: iconForCode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "omarchy.weather"
|
||||
ipcTarget: "omarchy.weather"
|
||||
|
||||
property var anchorItem: null
|
||||
|
||||
function open() {
|
||||
root.controller.show()
|
||||
root.refresh()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.opened) root.close()
|
||||
else root.open()
|
||||
}
|
||||
|
||||
// Parsed wttr.in j1 response. Kept on failure so stale data stays visible.
|
||||
property var report: null
|
||||
property var dailyForecastReport: null
|
||||
property string wttrLocation: ""
|
||||
|
||||
// Bar pill state. Polled locally; populated by weatherProc below.
|
||||
property string label: ""
|
||||
property string klass: ""
|
||||
|
||||
function updateWeather(raw) {
|
||||
var data = Model.parseWeatherStatus(raw)
|
||||
label = data.label
|
||||
klass = data.klass
|
||||
}
|
||||
|
||||
readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null
|
||||
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
|
||||
readonly property var forecastDays: buildForecastDays()
|
||||
|
||||
readonly property bool useImperial: {
|
||||
var override = setting("unit", "")
|
||||
if (override === "imperial") return true
|
||||
if (override === "metric") return false
|
||||
var name = String(Qt.locale().name || "")
|
||||
return /^en_US/.test(name) || /^en_LR/.test(name) || /^my/.test(name)
|
||||
}
|
||||
|
||||
// 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: 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() {
|
||||
if (!forecastProc.running) forecastProc.running = true
|
||||
if (!locationProc.running) locationProc.running = true
|
||||
}
|
||||
|
||||
function refreshDailyForecast(sourceReport) {
|
||||
var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo
|
||||
if (!area || dailyForecastProc.running) return
|
||||
|
||||
var lat = parseFloat(String(area.latitude || ""))
|
||||
var 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"
|
||||
+ "&forecast_days=4"
|
||||
+ "&timezone=auto"
|
||||
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
|
||||
dailyForecastProc.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 omarchy-weather-icon's wttr.in code → nerd-font glyph mapping.
|
||||
function iconForCode(code, night) {
|
||||
return Model.iconForCode(code, night)
|
||||
}
|
||||
|
||||
Process {
|
||||
id: forecastProc
|
||||
command: ["bash", "-lc", "curl -fsS --max-time 5 'https://wttr.in/?format=j1' 2>/dev/null"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var raw = String(text || "").trim()
|
||||
if (!raw) return
|
||||
try {
|
||||
var parsed = JSON.parse(raw)
|
||||
root.report = parsed
|
||||
root.refreshDailyForecast(parsed)
|
||||
} catch (e) {
|
||||
// Keep last-good report on parse failure so the popup isn't blanked.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: dailyForecastProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var raw = String(text || "").trim()
|
||||
if (!raw) return
|
||||
try {
|
||||
root.dailyForecastReport = JSON.parse(raw)
|
||||
} catch (e) {
|
||||
// Keep last-good daily forecast on parse failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: locationProc
|
||||
command: ["bash", "-lc", "curl -fsS --max-time 4 'https://wttr.in?format=%l' 2>/dev/null"]
|
||||
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()
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: popup
|
||||
anchorItem: root.anchorItem
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
centerOnBar: true
|
||||
triggerMode: "click"
|
||||
contentWidth: popup.fittedContentWidth(Style.space(480))
|
||||
contentHeight: popup.fittedContentHeight(weatherColumn.implicitHeight)
|
||||
|
||||
Flickable {
|
||||
id: weatherScroll
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: weatherColumn.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
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
|
||||
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
|
||||
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 {
|
||||
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
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(20)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(12)
|
||||
|
||||
Row {
|
||||
visible: root.reportLocation !== ""
|
||||
spacing: Style.space(6)
|
||||
|
||||
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 {
|
||||
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.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 {
|
||||
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 {
|
||||
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 {
|
||||
text: root.reportHumidity
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
text: root.bareTempForDay(modelData, "max")
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
Text {
|
||||
text: root.bareTempForDay(modelData, "min")
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the weather pill text/class every minute. Local to this widget.
|
||||
Process {
|
||||
id: weatherProc
|
||||
command: ["bash", "-lc", root.bar ? Util.shellQuote(root.bar.omarchyPath + "/shell/scripts/weather.sh") : ""]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.updateWeather(text)
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 60000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: if (!weatherProc.running) weatherProc.running = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.weather",
|
||||
"name": "Weather",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user