Move panel plugins under panels

This commit is contained in:
David Heinemeier Hansson
2026-05-25 14:47:08 +02:00
parent e6d990e31d
commit 10898aea16
31 changed files with 45 additions and 27 deletions
+262
View File
@@ -0,0 +1,262 @@
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
}
function isAudioSource(node) {
if (!node) return false
if (node.audio) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Audio/Source") !== -1
|| mediaClass.indexOf("AudioSource") !== -1
|| mediaClass.indexOf("Source") !== -1
}
function listSnapshot(list) {
return list && list.slice ? list.slice() : []
}
function outputVolumeName(volume, muted) {
if (muted) return "Muted"
var p = Math.round(volume * 100)
if (p === 0) return "Silenced"
if (p >= 100) return "Concert hall"
if (p >= 85) return "Party mode"
if (p >= 70) return "Cranked up"
if (p >= 50) return "Steady groove"
if (p >= 30) return "Easy listening"
if (p >= 15) return "Murmur"
return "Whisper"
}
function parseSinkAvailability(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
if (parts.length >= 2) next[parts[0]] = parts[1] !== "0"
}
return next
}
function friendlyDeviceLabel(text) {
var label = String(text || "").trim()
label = label.replace(/^sof-soundwire\s+/i, "")
label = label.replace(/^built-?in audio\s+/i, "")
label = label.replace(/\s+Output$/i, "")
label = label.replace(/\s+Input$/i, "")
label = label.replace(/\bMicrophones\b/g, "Microphone")
return label
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function nodeLabel(node) {
if (!node) return "Unknown"
var p = nodeProps(node)
var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "")
if (nickname) return nickname
return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown")
}
function isHeadphones(node) {
if (!node) return false
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || "",
p["node.description"] || "",
p["node.nick"] || ""
].join(" ")).toLowerCase()
return blob.indexOf("headphone") !== -1
|| blob.indexOf("headset") !== -1
|| blob.indexOf("earbud") !== -1
|| blob.indexOf("earphone") !== -1
|| blob.indexOf("airpod") !== -1
}
function sinkGlyph(node) {
if (!node) return "󰓃"
if (isHeadphones(node)) return "󰋋"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹"
return "󰓃"
}
function sourceGlyph(node) {
if (!node) return "󰍬"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("headset") !== -1) return "󰋋"
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "󰄀"
return "󰍬"
}
function friendlyStreamLabel(label) {
label = String(label || "").trim()
if (!label) return ""
var known = {
"spotify": "Spotify"
}
var normalized = label.toLowerCase()
return known[normalized] || label
}
function streamLabelKey(label) {
return String(label || "").trim().toLowerCase()
}
function streamLabelIsGeneric(label) {
return streamLabelKey(label) === "audio-src"
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
}
function mprisPlayerLabel(player) {
if (!player) return ""
return friendlyStreamLabel(player.identity || player.desktopEntry || "")
}
function mprisPlayerIsProxy(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel))
var playerKey = streamLabelKey(playerLabel)
if (!streamKey || !playerKey) return false
return streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1
}
function mprisLabelsFor(players, predicate) {
var values = Array.isArray(players) ? players : []
var playingCandidates = []
var candidates = []
var playingProxyCandidates = []
var proxyCandidates = []
for (var i = 0; i < values.length; i++) {
var player = values[i]
if (!player) continue
if (!player.isPlaying && !player.canPlay) continue
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel || !predicate(playerLabel)) continue
if (mprisPlayerIsProxy(player)) {
if (player.isPlaying) playingProxyCandidates.push(playerLabel)
proxyCandidates.push(playerLabel)
} else {
if (player.isPlaying) playingCandidates.push(playerLabel)
candidates.push(playerLabel)
}
}
if (playingCandidates.length === 1) return playingCandidates[0]
if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0]
if (candidates.length === 1) return candidates[0]
if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0]
return ""
}
function matchingMprisStreamLabel(label, players) {
if (streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
return streamRepresentsMprisPlayer(label, playerLabel)
})
}
function unmatchedMprisStreamLabel(label, players, streams) {
if (!streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
var values = Array.isArray(streams) ? streams : []
for (var i = 0; i < values.length; i++) {
var stream = values[i]
var streamLabel = rawStreamLabel(stream)
if (!streamLabelIsGeneric(streamLabel) && streamRepresentsMprisPlayer(streamLabel, playerLabel))
return false
}
return true
})
}
function streamLabel(node, players, streams) {
if (!node) return "Stream"
var label = rawStreamLabel(node)
return friendlyStreamLabel(matchingMprisStreamLabel(label, players)
|| unmatchedMprisStreamLabel(label, players, streams)
|| label) || "Stream"
}
function streamRepresentsPlayer(node, player, players, streams) {
if (!node || !player) return false
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel) return false
var label = rawStreamLabel(node)
if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel)
return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel)
}
if (typeof module !== "undefined") {
module.exports = {
isPlaybackStream: isPlaybackStream,
isAudioSource: isAudioSource,
listSnapshot: listSnapshot,
outputVolumeName: outputVolumeName,
parseSinkAvailability: parseSinkAvailability,
friendlyDeviceLabel: friendlyDeviceLabel,
nodeProps: nodeProps,
nodeLabel: nodeLabel,
isHeadphones: isHeadphones,
sinkGlyph: sinkGlyph,
sourceGlyph: sourceGlyph,
friendlyStreamLabel: friendlyStreamLabel,
streamLabelKey: streamLabelKey,
streamLabelIsGeneric: streamLabelIsGeneric,
rawStreamLabel: rawStreamLabel,
mprisPlayerLabel: mprisPlayerLabel,
mprisPlayerIsProxy: mprisPlayerIsProxy,
streamRepresentsMprisPlayer: streamRepresentsMprisPlayer,
mprisLabelsFor: mprisLabelsFor,
matchingMprisStreamLabel: matchingMprisStreamLabel,
unmatchedMprisStreamLabel: unmatchedMprisStreamLabel,
streamLabel: streamLabel,
streamRepresentsPlayer: streamRepresentsPlayer
}
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "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
}
}
+100
View File
@@ -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
}
}
+766
View File
@@ -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
}
}
+52
View File
@@ -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
}
}
+672
View File
@@ -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 ~1020 points so casual tweaks change the label, while small
// nudges within one band don't.
function brightnessName(percent) {
return Model.brightnessName(percent)
}
function updateDisplays(displaysJson) {
var parsed = Model.parseDisplays(displaysJson)
root.displays = parsed.displays
root.enabledDisplayCount = parsed.enabledDisplayCount
}
function toggleDisplay(name, enabled) {
if (!name) return
if (enabled && root.enabledDisplayCount <= 1) return
actionProc.command = ["hyprctl", "keyword", "monitor", name + (enabled ? ",disable" : ",preferred,auto,auto")]
if (!actionProc.running) actionProc.running = true
}
function setScale(scale) {
actionProc.command = ["bash", "-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
}
}
+173
View File
@@ -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
}
}
+104
View File
@@ -0,0 +1,104 @@
function clampIndex(index, length) {
if (length <= 0) return 0
return Math.max(0, Math.min(length - 1, index))
}
function selectProfileIndex(index, delta, profiles) {
var values = Array.isArray(profiles) ? profiles : []
if (values.length === 0) return 0
return clampIndex(index + delta, values.length)
}
function parseKeyValue(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var idx = lines[i].indexOf("\t")
if (idx <= 0) continue
next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim()
}
return next
}
function parseProfiles(raw, previousIndex) {
var lines = String(raw || "").split("\n")
var list = []
var active = ""
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
list.push(parts[0])
if (parts[1] === "1") active = parts[0]
}
return {
profiles: list,
activeProfile: active,
profileIndex: clampIndex(previousIndex || 0, list.length)
}
}
function profileIcon(name) {
if (name === "power-saver") return "󰌪"
if (name === "balanced") return "󰊚"
if (name === "performance") return "󰓅"
return "󰂄"
}
function batteryFraction(device) {
return device && device.isPresent ? Math.max(0, Math.min(1, device.percentage)) : 0
}
function chargeThresholdActive(device, onBattery, states) {
var d = device || {}
var s = states || {}
if (!(d && d.isPresent && !onBattery)) return false
var fraction = batteryFraction(d)
if (d.state === s.Discharging || 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
}
}
+496
View File
@@ -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
}
}
+20
View File
@@ -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()
}
}
}
+155
View File
@@ -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
}
}
+452
View File
@@ -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"
}
}