diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 46477064..24adf821 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -57,6 +57,7 @@ hl.bind("SUPER + CTRL + A", hl.dsp.exec_cmd("omarchy-shell audioPanel toggle"), hl.bind("SUPER + CTRL + B", hl.dsp.exec_cmd("omarchy-shell bluetoothPanel toggle"), { description = "Bluetooth panel" }) hl.bind("SUPER + CTRL + D", hl.dsp.exec_cmd("omarchy-shell monitorPanel toggle"), { description = "Display panel" }) hl.bind("SUPER + CTRL + W", hl.dsp.exec_cmd("omarchy-shell networkPanel toggle"), { description = "Network panel" }) +hl.bind("SUPER + CTRL + P", hl.dsp.exec_cmd("omarchy-shell powerPanel toggle"), { description = "Power panel" }) hl.bind("SUPER + CTRL + T", hl.dsp.exec_cmd("omarchy-launch-tui btop"), { description = "Activity" }) hl.bind("SUPER + CTRL + X", hl.dsp.exec_cmd("voxtype record toggle"), { description = "Toggle dictation" }) diff --git a/shell/Ui/Button.qml b/shell/Ui/Button.qml index 84ae0d72..29f2ee5f 100644 --- a/shell/Ui/Button.qml +++ b/shell/Ui/Button.qml @@ -41,6 +41,7 @@ Rectangle { property real fontSize: Style.font.body property real iconSize: Style.font.icon property real iconRotation: 0 + property bool iconSpinning: false property real horizontalPadding: Style.spacing.controlPaddingX property real verticalPadding: Style.spacing.controlPaddingY property bool leftAlign: false @@ -132,9 +133,17 @@ Rectangle { color: root.selected ? root._selectedColor : root.foreground font.family: root.fontFamily font.pixelSize: root.iconSize - rotation: root.iconRotation + rotation: root.iconSpinning ? 0 : root.iconRotation transformOrigin: Item.Center anchors.verticalCenter: parent.verticalCenter + + RotationAnimation on rotation { + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + running: root.iconSpinning + } } Text { diff --git a/shell/Ui/KeyboardPanel.qml b/shell/Ui/KeyboardPanel.qml index b0b20645..7a52c1da 100644 --- a/shell/Ui/KeyboardPanel.qml +++ b/shell/Ui/KeyboardPanel.qml @@ -83,20 +83,17 @@ PanelWindow { right: true } - // Clickable region = whole screen MINUS the bar's strip. Clicks on the - // bar pass through to the bar layer; clicks anywhere else are caught - // by us and either land on the card (no-op) or trigger dismissal. - readonly property real _barStripSize: bar ? bar.barSize : 0 + // Clickable region is the whole screen. Clicks in the bar strip are + // forwarded to registered bar buttons so switching between panel icons + // works in one click even when the overlay surface is above the bar. + readonly property real _barStripSize: { + if (!bar) return 0 + var actual = (root.barPos === "top" || root.barPos === "bottom") ? root.barH : root.barW + return Math.max(bar.barSize, actual) + root.gap + } mask: Region { width: root.screenW height: root.screenH - Region { - x: root.barPos === "right" ? root.screenW - root._barStripSize : 0 - y: root.barPos === "bottom" ? root.screenH - root._barStripSize : 0 - width: (root.barPos === "top" || root.barPos === "bottom") ? root.screenW : root._barStripSize - height: (root.barPos === "top" || root.barPos === "bottom") ? root._barStripSize : root.screenH - intersection: Intersection.Subtract - } } // Track every layout change between the bar's contentItem and the @@ -203,9 +200,52 @@ PanelWindow { // during the fade-out so the dying overlay doesn't swallow clicks that // were meant for the apps behind it. MouseArea { + id: dismissArea anchors.fill: parent enabled: root.open - onClicked: root.closePopout() + hoverEnabled: true + property bool hoveringBar: false + cursorShape: hoveringBar ? Qt.PointingHandCursor : Qt.ArrowCursor + + function inBarRegion(px, py) { + if (root.barPos === "bottom") return py >= root.screenH - root._barStripSize + if (root.barPos === "left") return px <= root._barStripSize + if (root.barPos === "right") return px >= root.screenW - root._barStripSize + return py <= root._barStripSize + } + + function barPoint(px, py) { + if (root.barPos === "bottom") return Qt.point(px, py - (root.screenH - root.barH)) + if (root.barPos === "right") return Qt.point(px - (root.screenW - root.barW), py) + return Qt.point(px, py) + } + + function pressTargetAt(px, py) { + if (!root.anchorWindow || !root.anchorWindow.contentItem || !root.bar || !root.bar.clickTargets) return null + var p = barPoint(px, py) + var targets = root.bar.clickTargets + for (var i = targets.length - 1; i >= 0; i--) { + var target = targets[i] + if (!target || !target.triggerPress || target.visible === false || target.opacity === 0 || !target.mapToItem) continue + var pos = root.anchorWindow.itemPosition(target) + if (p.x >= pos.x && p.x <= pos.x + target.width && p.y >= pos.y && p.y <= pos.y + target.height) return target + } + return null + } + + function forwardBarClick(px, py, button) { + var target = pressTargetAt(px, py) + if (!target) return false + target.triggerPress(button) + return true + } + + onPositionChanged: function(mouse) { hoveringBar = inBarRegion(mouse.x, mouse.y) } + onExited: hoveringBar = false + onClicked: function(mouse) { + if (inBarRegion(mouse.x, mouse.y) && forwardBarClick(mouse.x, mouse.y, mouse.button)) return + root.closePopout() + } } // --- card ---------------------------------------------------------------- diff --git a/shell/Ui/PanelActionButton.qml b/shell/Ui/PanelActionButton.qml index 7004fb80..c4fb8410 100644 --- a/shell/Ui/PanelActionButton.qml +++ b/shell/Ui/PanelActionButton.qml @@ -77,7 +77,7 @@ Rectangle { anchors.centerIn: parent text: root.iconText color: root.enabled - ? (root._hot ? root.hoverColor : Qt.darker(root.foreground, 1.3)) + ? (root._hot ? root.hoverColor : root.foreground) : Qt.darker(root.foreground, 2.0) font.family: root.fontFamily font.pixelSize: root.fontSize diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 0b960421..c0fec766 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -19,10 +19,26 @@ Item { property real textRotation: 0 property bool keepSpace: false property string tooltipText: "" + property var registeredBar: null signal pressed(int button) signal wheelMoved(int delta) + function triggerPress(button) { + if (root.bar) root.bar.hideTooltip(root) + root.pressed(button) + } + + function syncClickRegistration() { + if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(root) + registeredBar = root.bar + if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(root) + } + + onBarChanged: syncClickRegistration() + Component.onCompleted: syncClickRegistration() + Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(root) + readonly property bool vertical: bar ? bar.vertical : false readonly property int barSize: bar ? bar.barSize : Style.bar.sizeHorizontal readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin) @@ -64,10 +80,7 @@ Item { cursorShape: Qt.PointingHandCursor onEntered: if (root.bar) root.bar.showTooltip(root, root.tooltipText) onExited: if (root.bar) root.bar.hideTooltip(root) - onClicked: function(mouse) { - if (root.bar) root.bar.hideTooltip(root) - root.pressed(mouse.button) - } + onClicked: function(mouse) { root.triggerPress(mouse.button) } onWheel: function(wheel) { root.wheelMoved(wheel.angleDelta.y) } } } diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 8e8761f9..b2e9e11e 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -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) } } } diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 3bc72c1d..74906b6c 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -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 | diff --git a/shell/plugins/bar/widgets/bluetoothPanel.qml b/shell/plugins/bar/widgets/bluetoothPanel.qml index 137fd90c..fcedf3a9 100644 --- a/shell/plugins/bar/widgets/bluetoothPanel.qml +++ b/shell/plugins/bar/widgets/bluetoothPanel.qml @@ -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: { diff --git a/shell/plugins/bar/widgets/monitorPanel.qml b/shell/plugins/bar/widgets/monitorPanel.qml index 4548cdea..82506226 100644 --- a/shell/plugins/bar/widgets/monitorPanel.qml +++ b/shell/plugins/bar/widgets/monitorPanel.qml @@ -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" diff --git a/shell/plugins/bar/widgets/networkPanel.qml b/shell/plugins/bar/widgets/networkPanel.qml index 6e48e43d..bcb74a16 100644 --- a/shell/plugins/bar/widgets/networkPanel.qml +++ b/shell/plugins/bar/widgets/networkPanel.qml @@ -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: connectedssidsignalsecurity 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 + } } diff --git a/shell/plugins/bar/widgets/powerPanel.qml b/shell/plugins/bar/widgets/powerPanel.qml new file mode 100644 index 00000000..9573622a --- /dev/null +++ b/shell/plugins/bar/widgets/powerPanel.qml @@ -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 + } +} diff --git a/shell/shell-defaults.json b/shell/shell-defaults.json index f3280488..c64f6f34 100644 --- a/shell/shell-defaults.json +++ b/shell/shell-defaults.json @@ -56,7 +56,7 @@ "id": "monitorPanel" }, { - "id": "battery" + "id": "powerPanel" } ] }