Merge remote-tracking branch 'origin/omarchy-shell' into omarchy-shell

# Conflicts:
#	shell/Ui/PillButton.qml
#	shell/plugins/bar/widgets/monitorPanel.qml
#	shell/plugins/bar/widgets/networkPanel.qml
This commit is contained in:
Ryan Hughes
2026-05-18 21:11:37 -04:00
12 changed files with 691 additions and 188 deletions
+23 -1
View File
@@ -69,6 +69,19 @@ Item {
property string tooltipText: ""
property bool tooltipShown: false
property var activePopout: null
property var clickTargets: []
function registerClickTarget(target) {
if (!target || clickTargets.indexOf(target) !== -1) return
var next = clickTargets.slice()
next.push(target)
clickTargets = next
}
function unregisterClickTarget(target) {
var next = clickTargets.filter(function(item) { return item !== target })
clickTargets = next
}
function requestPopout(owner) {
if (activePopout === owner) return
@@ -267,6 +280,7 @@ Item {
"audioPanel": { displayName: "Audio", description: "Volume slider, output picker, per-app mixer", category: "Audio", allowMultiple: false },
"monitorPanel": { displayName: "Display", description: "Brightness slider and laptop display controls", category: "System", allowMultiple: false },
"networkPanel": { displayName: "Network", description: "Wi-Fi list and connection state", category: "Network", allowMultiple: false },
"powerPanel": { displayName: "Power", description: "Battery, power profile, and system stats", category: "System", allowMultiple: false },
"bluetoothPanel": { displayName: "Bluetooth", description: "Bluetooth device list with connect/disconnect", category: "Network", allowMultiple: false },
"calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false, settingsForm: "calendarSettings" },
"notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND", category: "Status", allowMultiple: false },
@@ -1086,6 +1100,14 @@ Item {
signal pressed(int button)
signal wheelMoved(int delta)
function triggerPress(button) {
root.hideTooltip(buttonRoot)
buttonRoot.pressed(button)
}
Component.onCompleted: root.registerClickTarget(buttonRoot)
Component.onDestruction: root.unregisterClickTarget(buttonRoot)
visible: text !== "" || keepSpace
opacity: text === "" ? 0 : 1
implicitWidth: fixedWidth > 0 ? fixedWidth : (root.vertical ? root.barSize : Math.max(12, label.implicitWidth + horizontalMargin * 2 + rightExtraMargin))
@@ -1113,7 +1135,7 @@ Item {
cursorShape: Qt.PointingHandCursor
onEntered: root.showTooltip(buttonRoot, buttonRoot.tooltipText)
onExited: root.hideTooltip(buttonRoot)
onClicked: function(mouse) { buttonRoot.pressed(mouse.button) }
onClicked: function(mouse) { buttonRoot.triggerPress(mouse.button) }
onWheel: function(wheel) { buttonRoot.wheelMoved(wheel.angleDelta.y) }
}
}
+2 -1
View File
@@ -39,7 +39,7 @@ Example `shell.json` (bar subtree only shown):
],
"right": [
{ "id": "audioPanel" },
{ "id": "battery" }
{ "id": "powerPanel" }
]
}
}
@@ -57,6 +57,7 @@ Example `shell.json` (bar subtree only shown):
| `media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup |
| `audioPanel` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = audio TUI · scroll = volume |
| `networkPanel` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui |
| `powerPanel` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup |
| `bluetoothPanel` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI |
| `calendar` | Clock + popup with month-grid calendar | left = popup · right = tz selector |
| `notificationCenter` | Bell with badge + popup with recent notifications, DND toggle | left = popup · right = toggle DND |
+3 -12
View File
@@ -416,21 +416,12 @@ Item {
HeaderPill {
pillIndex: 0
property real scanRotation: 0
iconText: "󰑐"
iconRotation: root.adapter && root.adapter.discovering ? scanRotation : 0
iconSpinning: root.adapter && root.adapter.discovering
tooltipText: !root.adapter ? "" : !root.adapter.enabled ? "Bluetooth is off"
: root.adapter.discovering ? "Stop scanning" : "Scan for devices"
pillEnabled: root.adapter !== null && root.adapter.enabled
onActivated: if (root.adapter) root.adapter.discovering = !root.adapter.discovering
NumberAnimation on scanRotation {
from: 0
to: 360
duration: 900
loops: Animation.Infinite
running: root.adapter && root.adapter.discovering
}
}
HeaderPill {
@@ -611,8 +602,8 @@ Item {
if (dev.batteryAvailable) return "Connected · " + Math.round(dev.battery * 100) + "%"
return "Connected"
}
if (isDiscovered) return "Available · click to pair"
return "Paired"
if (isDiscovered) return ""
return ""
}
readonly property color statusColor: {
+2 -2
View File
@@ -45,7 +45,7 @@ Item {
var list = []
if (brightnessAvailable) list.push("brightness")
list.push("scale")
if (displays.length > 0) list.push("monitors")
if (displays.length > 1) list.push("monitors")
return list
}
@@ -531,7 +531,7 @@ Item {
Column {
width: parent.width
spacing: Style.space(6)
visible: root.displays.length > 0
visible: root.displays.length > 1
PanelSectionHeader {
text: "Monitors"
+256 -153
View File
@@ -78,9 +78,9 @@ Item {
// — OnDemand only grants focus on click/hover.
onPopupOpenChanged: {
if (popupOpen) {
refresh()
refresh(true)
selectedIndex = wifiNetworks.length > 0 ? 0 : -1
focusSection = "dns"
focusSection = wifiNetworks.length > 0 ? "wifi" : "dns"
var idx = dnsProviders.indexOf(dnsProvider)
dnsIndex = idx >= 0 ? idx : 0
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
@@ -112,7 +112,7 @@ Item {
function selectByDelta(delta) {
if (wifiNetworks.length === 0) { selectedIndex = -1; return }
if (selectedIndex < 0) selectedIndex = delta > 0 ? 0 : wifiNetworks.length - 1
else selectedIndex = (selectedIndex + delta + wifiNetworks.length) % wifiNetworks.length
else selectedIndex = Math.max(0, Math.min(wifiNetworks.length - 1, selectedIndex + delta))
}
// Enter/Space on the highlighted row. Mirrors row-click semantics:
@@ -165,6 +165,11 @@ iwctl known-networks list 2>/dev/null \\
frequency = parts[3] || ""
}
function copyToClipboard(value) {
if (!value || !root.bar) return
Quickshell.execDetached(["bash", "-lc", "printf %s " + root.bar.shellQuote(value) + " | wl-copy"])
}
function networkTooltip() {
if (kind === "wifi") {
var f = parseFloat(frequency)
@@ -216,7 +221,8 @@ iwctl known-networks list 2>/dev/null \\
return "󰤮"
}
function refresh() {
function refresh(scanWifi) {
if (scanWifi === undefined) scanWifi = false
if (!detailsProc.running) detailsProc.running = true
if (!dnsProc.running) {
dnsProc.command = ["bash", "-lc", root.dnsCommand("")]
@@ -224,10 +230,38 @@ iwctl known-networks list 2>/dev/null \\
}
if (!wifiProc.running) {
scanning = true
wifiProc.command = ["bash", "-c", root.wifiScanScript(scanWifi)]
wifiProc.running = true
}
}
function wifiScanScript(scanWifi) {
return `
station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }')
[[ -z $station ]] && exit 0
echo STATION_AVAILABLE
${scanWifi ? 'iwctl station "$station" scan >/dev/null 2>&1 || true' : ''}
iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
| sed -e 's/\\x1b\\[[0-9;]*m//g' \\
| awk '
NR <= 4 { next }
/^[[:space:]]*$/ { next }
{
connected = (substr($0, 1, 4) ~ />/) ? 1 : 0
line = $0
sub(/^[[:space:]]*>?[[:space:]]+/, "", line)
sub(/[[:space:]]+$/, "", line)
n = split(line, parts, /[[:space:]]{2,}/)
if (n < 3) next
ssid = parts[1]
security = parts[n-1]
dbm = parts[n] / 100
signal = (dbm >= -50) ? 100 : (dbm <= -100) ? 0 : int(2 * (dbm + 100))
printf "%d\\t%s\\t%d\\t%s\\n", connected, ssid, signal, security
}'
`
}
function formatSpeed(mbps) {
var v = parseInt(mbps, 10)
if (!v || v < 0) return ""
@@ -265,15 +299,17 @@ iwctl known-networks list 2>/dev/null \\
// Format: connected<TAB>ssid<TAB>signal<TAB>security
var parts = line.split("\t")
if (parts.length < 3) continue
var isConnected = parts[0] === "1"
if (isConnected && parts[1] !== root.actionSsid) continue // Skip the connected network so it doesn't appear in the list, unless we are currently trying to connect to it
nets.push({
connected: parts[0] === "1",
connected: false,
ssid: parts[1],
signal: parseInt(parts[2], 10) || 0,
security: parts[3] || ""
})
}
nets.sort(function(a, b) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
return b.signal - a.signal
})
wifiNetworks = nets
@@ -347,8 +383,13 @@ iwctl --dont-ask station "$station" connect ${quotedSsid}
var quotedPass = bar.shellQuote(passphrase)
runAction("connect", ssid, `
station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }')
[[ -z $station ]] && { echo "no Wi-Fi station available" >&2; exit 1; }
iwctl --passphrase ${quotedPass} station "$station" connect ${quotedSsid}
[[ -z $station ]] && { echo "No Wi-Fi station available" >&2; exit 1; }
output=$(iwctl --passphrase ${quotedPass} station "$station" connect ${quotedSsid} 2>&1)
exit_code=$?
if [[ $exit_code -ne 0 ]]; then
echo "$output" | sed -e 's/\\x1b\\[[0-9;]*m//g' >&2
exit $exit_code
fi
`)
}
@@ -455,29 +496,6 @@ fi
// iwctl's table layout starts each row with one, which breaks naive parsing.
Process {
id: wifiProc
command: ["bash", "-c", `
station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }')
[[ -z $station ]] && exit 0
echo STATION_AVAILABLE
iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
| sed -e 's/\\x1b\\[[0-9;]*m//g' \\
| awk '
NR <= 4 { next }
/^[[:space:]]*$/ { next }
{
connected = (substr($0, 1, 4) ~ />/) ? 1 : 0
line = $0
sub(/^[[:space:]]*>?[[:space:]]+/, "", line)
sub(/[[:space:]]+$/, "", line)
n = split(line, parts, /[[:space:]]{2,}/)
if (n < 3) next
ssid = parts[1]
security = parts[n-1]
dbm = parts[n] / 100
signal = (dbm >= -50) ? 100 : (dbm <= -100) ? 0 : int(2 * (dbm + 100))
printf "%d\\t%s\\t%d\\t%s\\n", connected, ssid, signal, security
}'
`]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.updateWifi(text)
@@ -529,7 +547,14 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
else reason = "Failed to forget"
}
// Squash multi-line iwctl errors into a single readable line.
root.failureReason = reason.split("\n").pop()
// Also strip bash script error prefixes like "bash: line 4: " and "Operation failed"
var finalReason = reason.split("\n").pop().replace(/^bash: line \d+: /, "").replace(/^Operation failed/, "").trim()
if (!finalReason) {
if (kind === "connect") finalReason = "Failed to connect"
else if (kind === "disconnect") finalReason = "Failed to disconnect"
else finalReason = "Failed to forget"
}
root.failureReason = finalReason
}
root.actionSsid = ""
root.actionKind = ""
@@ -652,159 +677,153 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
// Header — interface name + type, refresh on the right.
Item {
width: parent.width
height: Math.max(headerInfo.implicitHeight, refreshBtn.implicitHeight)
height: Math.max(headerInfo.implicitHeight, headerActions.implicitHeight)
Row {
Item {
id: headerInfo
anchors.left: parent.left
anchors.right: headerActions.left
anchors.rightMargin: Style.spacing.controlPaddingX
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(10)
implicitHeight: Math.max(wifiToggleBtn.implicitHeight, wifiMainText.implicitHeight)
Text {
text: root.icon
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
PanelActionButton {
id: wifiToggleBtn
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
iconText: root.icon
fontSize: Style.font.iconLarge
size: Style.space(28)
tooltipText: "Toggle Wi-Fi"
foreground: root.bar.foreground
hoverColor: root.bar.foreground // Override the dimming behavior
panelBackground: root.bar.background
fontFamily: root.bar.fontFamily
enabled: true
onClicked: {
root.bar.run("rfkill toggle wlan")
Qt.callLater(function() { root.refresh(true) })
}
}
Column {
spacing: Style.space(2)
Row {
anchors.left: wifiToggleBtn.right
anchors.leftMargin: Style.spacing.controlPaddingX
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
Text {
text: root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection")
id: wifiMainText
text: {
if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi"
if (root.info.type === "ethernet") return "Ethernet"
return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection")
}
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.subtitle
font.bold: true
elide: Text.ElideRight
width: Math.min(implicitWidth, parent.width - (wifiFreqText.visible ? wifiFreqText.implicitWidth : 0))
}
Text {
text: {
if (root.info.type === "wifi") {
var s = root.info.ssid || "Wi-Fi"
if (root.info.freq) s += " · " + root.formatFreq(root.info.freq)
return s
}
if (root.info.type === "ethernet") return "Ethernet"
return ""
}
visible: text !== ""
id: wifiFreqText
visible: root.info.type === "wifi" && !!root.info.freq
text: " • " + root.formatFreq(root.info.freq)
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.pixelSize: Style.font.subtitle
font.bold: true
}
}
}
Button {
id: refreshBtn
Row {
id: headerActions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
iconText: "󰑐"
tooltipText: "Refresh"
tooltipBackground: root.bar.background
tooltipForeground: root.bar.foreground
foreground: root.bar.foreground
horizontalPadding: Style.spacing.controlGap
verticalPadding: Style.spacing.labelGap
iconSize: Style.font.icon
active: root.scanning
onClicked: root.refresh()
}
}
spacing: Style.spacing.controlGap
PanelSeparator {
visible: !!root.info.iface
foreground: root.bar.foreground
PanelActionButton {
id: disconnectBtn
visible: root.info.type === "wifi" && !!root.info.ssid
enabled: !root.busy
iconText: "󰅙"
tooltipText: "Disconnect"
foreground: root.bar.foreground
hoverColor: root.bar.urgent
panelBackground: root.bar.background
fontFamily: root.bar.fontFamily
anchors.verticalCenter: parent.verticalCenter
onClicked: root.disconnect(root.info.ssid)
}
Button {
id: refreshBtn
anchors.verticalCenter: parent.verticalCenter
iconText: "󰑐"
iconSpinning: root.scanning
tooltipText: "Refresh"
tooltipBackground: root.bar.background
tooltipForeground: root.bar.foreground
foreground: root.bar.foreground
horizontalPadding: Style.spacing.controlGap
verticalPadding: Style.spacing.labelGap
iconSize: Style.font.icon
active: root.scanning
onClicked: root.refresh(true)
}
}
}
// Connection details: IP, gateway, link speed, etc.
Grid {
Row {
visible: !!root.info.iface
width: parent.width
columns: 2
columnSpacing: Style.space(14)
rowSpacing: Style.space(4)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(24)
// IP address.
Text {
visible: !!root.info.ip
text: "IP address"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
visible: !!root.info.ip
text: (root.info.ip || "") + (root.info.prefix ? "/" + root.info.prefix : "")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
Column {
width: Style.space(140)
spacing: Style.spacing.labelGap
InfoPair {
visible: !!root.info.ip
label: "IP"
value: root.info.ip || ""
copyable: true
tooltipText: "Copy IP"
}
InfoPair {
visible: !!root.info.gateway
label: "Gateway"
value: root.info.gateway || ""
copyable: true
tooltipText: "Copy gateway"
}
}
// Gateway.
Text {
visible: !!root.info.gateway
text: "Gateway"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
visible: !!root.info.gateway
text: root.info.gateway || ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
Column {
width: Style.space(140)
spacing: Style.spacing.labelGap
// Ethernet link speed / duplex.
Text {
visible: root.info.type === "ethernet" && !!root.info.speed
text: "Link"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
visible: root.info.type === "ethernet" && !!root.info.speed
text: root.formatSpeed(root.info.speed || "") + (root.info.duplex ? " · " + root.info.duplex + " duplex" : "")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
// Ethernet details
InfoPair {
visible: root.info.type === "ethernet" && !!root.info.speed
label: "Link"
value: root.formatSpeed(root.info.speed || "") + (root.info.duplex ? " • " + root.info.duplex + " dup" : "")
}
// Wi-Fi signal.
Text {
visible: root.info.type === "wifi" && !!root.info.signal_dbm
text: "Signal"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
visible: root.info.type === "wifi" && !!root.info.signal_dbm
text: (root.info.signal_dbm || "") + " dBm"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
// Wi-Fi tx bitrate.
Text {
visible: root.info.type === "wifi" && !!root.info.bitrate
text: "Link rate"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
visible: root.info.type === "wifi" && !!root.info.bitrate
text: root.info.bitrate || ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
// Wi-Fi details
InfoPair {
visible: root.info.type === "wifi" && !!root.info.signal_dbm
label: "Signal"
value: (root.info.signal_dbm || "") + " dBm"
}
InfoPair {
visible: root.info.type === "wifi" && !!root.info.bitrate
label: "Link"
value: root.info.bitrate || ""
}
}
}
@@ -907,10 +926,9 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
}
}
}
}
}
}
}
// One DNS provider pill. The cursor + current visuals come entirely from
// CursorSurface; this component just binds them to the panel's cursor
@@ -967,6 +985,7 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
readonly property string statusText: {
if (!net) return ""
if (isPasswordOpen) return ""
if (isBusy && root.actionKind === "connect") return "Connecting…"
if (isBusy && root.actionKind === "disconnect") return "Disconnecting…"
if (isBusy && root.actionKind === "forget") return "Forgetting…"
@@ -1124,6 +1143,17 @@ iwctl known-networks list 2>/dev/null \\
}
}
Timer {
id: failureTimer
interval: 2000
running: row.isFailed && row.isPasswordOpen
onTriggered: {
root.failureSsid = ""
root.failureReason = ""
pwField.forceActiveFocus()
}
}
// Inline passphrase prompt — only shown when we hit a protected network
// we don't have saved credentials for. Submitting (Enter or the check
// button) fires connect; Esc cancels back to the row.
@@ -1137,9 +1167,11 @@ iwctl known-networks list 2>/dev/null \\
anchors.rightMargin: Style.space(10)
anchors.topMargin: Style.space(4)
implicitHeight: pwField.implicitHeight + Style.spacing.rowGap
height: implicitHeight
TextField {
id: pwField
visible: !row.isBusy && !row.isFailed
anchors.left: parent.left
anchors.right: connectPwBtn.left
anchors.verticalCenter: parent.verticalCenter
@@ -1162,14 +1194,38 @@ iwctl known-networks list 2>/dev/null \\
Component.onCompleted: if (visible) Qt.callLater(forceActiveFocus)
}
Rectangle {
id: statusMsgWrapper
visible: row.isBusy || row.isFailed
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: Style.spacing.controlHeight
color: Style.normalFillFor(root.bar.foreground)
border.color: Style.normalBorderFor(root.bar.foreground)
border.width: Style.normalBorderWidth
radius: Style.cornerRadius
Text {
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: row.isFailed ? "Wrong password" : "Connecting..."
color: row.isFailed ? root.bar.urgent : root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
// 22×22 right-anchored to line up with forgetBtn and lockIndicator
// above. Esc closes the prompt (handled by pwField.Keys.onEscapePressed)
// so there's no separate cancel button.
PanelActionButton {
id: connectPwBtn
visible: !row.isBusy && !row.isFailed
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
enabled: !root.busy && row.net && pwField.text.length > 0
enabled: row.net && pwField.text.length > 0
iconText: "󰄬"
tooltipText: "Connect"
foreground: root.bar.foreground
@@ -1219,4 +1275,51 @@ iwctl known-networks list 2>/dev/null \\
triggeredOnStart: true
onTriggered: if (!networkProc.running) networkProc.running = true
}
component InfoPair: Row {
property string label: ""
property string value: ""
property bool copyable: false
property string tooltipText: "Copy to clipboard"
width: parent.width
spacing: Style.space(8)
InfoLabel { text: label }
Item { width: Math.max(0, parent.width - parent.children[0].implicitWidth - valueText.implicitWidth - parent.spacing * 2); height: 1 }
InfoValue {
id: valueText
text: value
MouseArea {
id: valueMouse
anchors.fill: parent
enabled: copyable && valueText.text !== ""
hoverEnabled: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.copyToClipboard(valueText.text)
}
PanelToolTip {
visible: valueMouse.enabled && valueMouse.containsMouse
text: tooltipText
panelForeground: root.bar.foreground
panelBackground: root.bar.background
fontFamily: root.bar.fontFamily
}
}
}
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
}
}
+323
View File
@@ -0,0 +1,323 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import qs.Commons
import qs.Ui
Item {
id: root
property QtObject bar: null
property string moduleName: "powerPanel"
property var settings: ({})
property bool popupOpen: false
property var batteryInfo: ({})
property var systemInfo: ({})
property var profiles: []
property string activeProfile: ""
property int profileIndex: 0
function closePopout() { popupOpen = false }
function selectProfileByDelta(delta) {
if (profiles.length === 0) { profileIndex = 0; return }
profileIndex = Math.max(0, Math.min(profiles.length - 1, profileIndex + delta))
}
function activateSelectedProfile() {
if (profileIndex < 0 || profileIndex >= profiles.length) return
setProfile(profiles[profileIndex])
}
function batteryIcon() {
var device = UPower.displayDevice
if (!device || !device.isPresent) return ""
var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
var index = Math.max(0, Math.min(9, Math.floor(device.percentage * 10)))
if (device.state === UPowerDeviceState.FullyCharged) return "󰂅"
if (!UPower.onBattery && device.state !== UPowerDeviceState.Charging) return ""
if (device.state === UPowerDeviceState.Charging) return chargingIcons[index]
return defaultIcons[index]
}
function modeLabel() {
var device = UPower.displayDevice
var percentage = device && device.isPresent ? device.percentage : 0
if (!UPower.onBattery && percentage >= 1) {
return "Fully charged"
} else if (UPower.onBattery) {
return "Battery"
} else {
return "Charging"
}
}
readonly property bool fullyCharged: {
var device = UPower.displayDevice
return device && device.isPresent && device.state === UPowerDeviceState.FullyCharged
}
function refresh() {
if (!batteryProc.running) batteryProc.running = true
if (!profilesProc.running) profilesProc.running = true
if (!systemProc.running) systemProc.running = true
}
function updateKeyValue(raw, targetName) {
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()
}
if (targetName === "battery") batteryInfo = next
else systemInfo = next
}
function updateProfiles(raw) {
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]
}
profiles = list
activeProfile = active
if (profileIndex >= profiles.length) profileIndex = Math.max(0, profiles.length - 1)
if (popupOpen && 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
}
onPopupOpenChanged: {
if (popupOpen) {
refresh()
var idx = profiles.indexOf(activeProfile)
profileIndex = idx >= 0 ? idx : 0
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
}
Component.onCompleted: refresh()
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
IpcHandler {
target: "powerPanel"
function toggle(): void { root.popupOpen = !root.popupOpen }
function show(): void { root.popupOpen = true }
function hide(): void { root.closePopout() }
}
Process {
id: batteryProc
command: ["bash", "-lc", `
bat=$(upower -e 2>/dev/null | grep BAT | head -n1)
[[ -z $bat ]] && exit 0
info=$(upower -i "$bat")
printf 'percentage\t%s\n' "$(awk '/percentage/ { print int($2) "%"; exit }' <<<"$info")"
printf 'state\t%s\n' "$(awk '/state/ { print $2; exit }' <<<"$info")"
printf 'rate\t%s\n' "$(awk '/energy-rate/ { v=sprintf("%.1f", $2); sub(/\.0$/, "", v); print v "W"; exit }' <<<"$info")"
printf 'size\t%s\n' "$(awk '/energy-full:/ { printf "%dWh", $2; exit }' <<<"$info")"
printf 'time\t%s\n' "$($OMARCHY_PATH/bin/omarchy-battery-remaining-time 2>/dev/null)"
`]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "battery") }
}
Process {
id: profilesProc
command: ["bash", "-lc", "powerprofilesctl list 2>/dev/null | awk '/^\\s*[* ]\\s*[a-zA-Z0-9-]+:$/ { active=($1==\"*\"); gsub(/^[*[:space:]]+|:$/,\"\"); print $0 \"\\t\" (active ? 1 : 0) }'"]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateProfiles(text) }
}
Process {
id: systemProc
command: ["bash", "-lc", "cpu=$(top -bn1 | awk '/^%?Cpu/ { gsub(/,/, \"\"); for (i=1; i<=NF; i++) if ($(i+1) == \"id\") { printf \"%.0f%%\", 100 - $i; exit } }'); awk -v cpu=\"$cpu\" '/^MemTotal:/ { total=$2 } /^MemAvailable:/ { avail=$2 } END { used=total-avail; printf \"cpu\\t%s\\n\", cpu; printf \"memory\\t%.1fGB / %.0fGB\\n\", used/1024/1024, total/1024/1024 }' /proc/meminfo"]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "system") }
}
Process {
id: actionProc
onExited: root.refresh()
}
Timer { interval: 5000; running: true; repeat: true; triggeredOnStart: true; onTriggered: root.refresh() }
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.batteryIcon()
horizontalMargin: 8.5
rightExtraMargin: 2
active: UPower.displayDevice && UPower.displayDevice.percentage <= 0.2 && UPower.onBattery
tooltipText: ""
onPressed: function(b) { root.popupOpen = !root.popupOpen }
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
contentWidth: panel.fittedContentWidth(Style.space(340))
contentHeight: panel.fittedContentHeight(column.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (dx !== 0) root.selectProfileByDelta(dx)
else if (dy !== 0) root.selectProfileByDelta(dy)
}
onActivateRequested: root.activateSelectedProfile()
onCloseRequested: root.closePopout()
Column {
id: column
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Style.space(16)
Item {
width: parent.width
implicitHeight: Style.space(28)
Item {
id: iconWrapper
width: Style.space(28)
height: Style.space(28)
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
Text {
id: acIcon
text: root.batteryIcon()
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.iconLarge
anchors.centerIn: parent
}
}
Text {
text: root.modeLabel()
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.subtitle
font.bold: true
anchors.left: iconWrapper.right
anchors.leftMargin: Style.spacing.controlPaddingX
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
visible: root.batteryInfo.percentage !== undefined && !root.fullyCharged
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(24)
Column {
width: Style.space(140)
spacing: Style.spacing.labelGap
InfoPair { label: "Percentage"; value: root.batteryInfo.percentage || "" }
InfoPair { label: "Battery size"; value: root.batteryInfo.size || "" }
}
Column {
width: Style.space(140)
spacing: Style.spacing.labelGap
InfoPair { label: UPower.onBattery ? "Time left" : "Time to full"; value: root.batteryInfo.time || "—" }
InfoPair { label: UPower.onBattery ? "Draw" : "Charge rate"; value: root.batteryInfo.rate || "" }
}
}
PanelSeparator {
visible: !root.fullyCharged
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(12)
PanelSectionHeader {
visible: !root.fullyCharged
text: "POWER PROFILE"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
Row {
width: parent.width
spacing: Style.space(6)
Repeater {
model: root.profiles
Button {
required property var modelData
required property int index
text: String(modelData).charAt(0).toUpperCase() + String(modelData).slice(1)
foreground: root.bar.foreground
tooltipBackground: root.bar.background
tooltipForeground: root.bar.foreground
fontFamily: root.bar.fontFamily
horizontalPadding: Style.spacing.controlPaddingX
verticalPadding: Style.spacing.controlPaddingY
active: root.activeProfile === modelData
hasCursor: root.profileIndex === index
onClicked: root.setProfile(modelData)
onHovered: function(h) {
if (h) 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
}
}