From 540aaf956d6f57de71c8535be6236a2af00ab122 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 17 May 2026 12:37:47 +0200 Subject: [PATCH] Add new display widget for the bar --- bin/omarchy-brightness-display | 12 +- bin/omarchy-brightness-display-apple | 3 +- bin/omarchy-brightness-display-apple-get | 20 + bin/omarchy-brightness-display-get | 10 + bin/omarchy-hw-display | 19 + bin/omarchy-hyprland-monitor-scaling-get | 6 + .../omarchy-shell/plugins/bar/Bar.qml | 1 + .../plugins/bar/widgets/monitorPanel.qml | 408 ++++++++++++++++++ .../plugins/settings/SettingsPanel.qml | 2 +- .../omarchy-shell/shell-defaults.json | 3 + 10 files changed, 471 insertions(+), 13 deletions(-) create mode 100755 bin/omarchy-brightness-display-apple-get create mode 100755 bin/omarchy-brightness-display-get create mode 100755 bin/omarchy-hw-display create mode 100755 bin/omarchy-hyprland-monitor-scaling-get create mode 100644 default/quickshell/omarchy-shell/plugins/bar/widgets/monitorPanel.qml diff --git a/bin/omarchy-brightness-display b/bin/omarchy-brightness-display index fc6a8a4f..0fa89d9d 100755 --- a/bin/omarchy-brightness-display +++ b/bin/omarchy-brightness-display @@ -21,21 +21,13 @@ runtime_dir="${XDG_RUNTIME_DIR:-/tmp}" exec 9>"$runtime_dir/omarchy-brightness-display.lock" flock -n 9 || exit 0 -# Start with the first possible output, then refine to the most likely given an order heuristic. -device="$(ls -1 /sys/class/backlight 2>/dev/null | head -n1)" -for candidate in amdgpu_bl* intel_backlight acpi_video*; do - if [[ -e /sys/class/backlight/$candidate ]]; then - device="$candidate" - break - fi -done - - if omarchy-hyprland-monitor-focused-apple; then omarchy-brightness-display-apple "$step" exit fi +device="$(omarchy-hw-display)" || exit 1 + # Current brightness percentage current=$(brightnessctl -d "$device" -m | cut -d',' -f4 | tr -d '%') diff --git a/bin/omarchy-brightness-display-apple b/bin/omarchy-brightness-display-apple index 912538b6..d1912fee 100755 --- a/bin/omarchy-brightness-display-apple +++ b/bin/omarchy-brightness-display-apple @@ -29,6 +29,5 @@ else fi sudo asdcontrol "$device" -- "$step" >/dev/null - value="$(sudo asdcontrol "$device" | awk -F= '/BRIGHTNESS=/{print $2+0}')" - omarchy-osd -i brightness -p "$(( value * 100 / 60000 ))" + omarchy-osd -i brightness -p "$(omarchy-brightness-display-apple-get)" fi diff --git a/bin/omarchy-brightness-display-apple-get b/bin/omarchy-brightness-display-apple-get new file mode 100755 index 00000000..1ab5b2a7 --- /dev/null +++ b/bin/omarchy-brightness-display-apple-get @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Get Apple Studio Display or XDR Display brightness using asdcontrol. +# omarchy:examples=omarchy brightness display apple get | omarchy-brightness-display-apple-get + +device="$(sudo asdcontrol --detect /dev/usb/hiddev* /dev/hiddev* 2>/dev/null | awk -F: '/^\/dev\/(usb\/)?hiddev/{ print $1; exit }')" + +if [[ -z $device ]]; then + echo "No Apple Display HID device found" >&2 + exit 1 +fi + +sudo asdcontrol "$device" 2>/dev/null | awk -F= ' + /BRIGHTNESS=/ { + print int($2 * 100 / 60000) + found = 1 + } + + END { exit !found } +' diff --git a/bin/omarchy-brightness-display-get b/bin/omarchy-brightness-display-get new file mode 100755 index 00000000..15475026 --- /dev/null +++ b/bin/omarchy-brightness-display-get @@ -0,0 +1,10 @@ +#!/bin/bash + +# omarchy:summary=Get brightness for the most likely display device. +# omarchy:examples=omarchy-brightness-display-get + +if omarchy-hyprland-monitor-focused-apple; then + omarchy-brightness-display-apple-get +else + brightnessctl -d "$(omarchy-hw-display)" -m 2>/dev/null | awk -F, '{ gsub("%", "", $4); print $4; found=1 } END{ exit !found }' +fi diff --git a/bin/omarchy-hw-display b/bin/omarchy-hw-display new file mode 100755 index 00000000..dcab14ac --- /dev/null +++ b/bin/omarchy-hw-display @@ -0,0 +1,19 @@ +#!/bin/bash + +# omarchy:summary=Print the most likely display backlight device. +# omarchy:examples=omarchy-hw-display + +# Start with the first possible output, then refine to the most likely given an order heuristic. +device="$(ls -1 /sys/class/backlight 2>/dev/null | head -n1)" +for candidate in amdgpu_bl* intel_backlight acpi_video*; do + if [[ -e /sys/class/backlight/$candidate ]]; then + device="$candidate" + break + fi +done + +if [[ -n $device ]]; then + printf '%s\n' "$device" +else + exit 1 +fi diff --git a/bin/omarchy-hyprland-monitor-scaling-get b/bin/omarchy-hyprland-monitor-scaling-get new file mode 100755 index 00000000..f3ccb655 --- /dev/null +++ b/bin/omarchy-hyprland-monitor-scaling-get @@ -0,0 +1,6 @@ +#!/bin/bash + +# omarchy:summary=Get focused Hyprland monitor scaling +# omarchy:examples=omarchy-hyprland-monitor-scaling-get + +hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .scale' diff --git a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml index d53ba0ab..664c9ce1 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml @@ -313,6 +313,7 @@ Item { readonly property var firstPartyWidgetMetadata: ({ "media": { displayName: "Media", description: "MPRIS now-playing with playback controls", category: "Media", allowMultiple: false }, "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 }, "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" }, diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/monitorPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/monitorPanel.qml new file mode 100644 index 00000000..c7b5ec41 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/monitorPanel.qml @@ -0,0 +1,408 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import "../common" as Common + +Item { + id: root + + property QtObject bar: null + property string moduleName: "monitorPanel" + property var settings: ({}) + + property bool popupOpen: false + 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 + + function closePopout() { popupOpen = false } + + IpcHandler { + target: "monitorPanel" + + function brightness(percent: string): string { + var value = Number(percent) + root.setBrightness(value) + return "got " + root.pendingBrightnessPercent + } + + function state(): string { + return JSON.stringify({ + brightness: root.brightnessPercent, + brightnessAvailable: root.brightnessAvailable, + focusedMonitor: root.focusedMonitor, + scale: root.monitorScale, + displays: root.displays + }) + } + } + + function refresh() { + if (!stateProc.running) stateProc.running = true + } + + function setBrightness(value) { + var percent = Math.max(1, Math.min(100, Math.round(value))) + root.brightnessPercent = percent + root.pendingBrightnessPercent = percent + + if (setBrightnessProc.running) { + root.brightnessSetQueued = true + return + } + + root.brightnessSetQueued = false + setBrightnessProc.command = ["bash", "-lc", "omarchy-brightness-display " + percent + "%"] + setBrightnessProc.running = true + } + + function previewBrightness(value) { + root.brightnessPercent = Math.max(1, Math.min(100, Math.round(value))) + brightnessDebounce.restart() + } + + function toggleMirror() { + if (!internalMonitor || !externalMonitor) return + actionProc.command = ["bash", "-lc", "if hyprctl monitors -j | jq -e --arg i '" + internalMonitor + "' --arg e '" + externalMonitor + "' '.[] | select(.name == $i and .mirrorOf == $e)' >/dev/null; then hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto'; else hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto,mirror," + externalMonitor + "'; fi"] + if (!actionProc.running) actionProc.running = true + } + + function toggleInternal() { + if (!internalMonitor || !externalMonitor) return + actionProc.command = ["bash", "-lc", "if hyprctl monitors -j | jq -e --arg i '" + internalMonitor + "' '.[] | select(.name == $i)' >/dev/null; then hyprctl keyword monitor '" + internalMonitor + ",disable'; else hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto'; fi"] + if (!actionProc.running) actionProc.running = true + } + + function normalizeScale(scale) { + var n = parseFloat(String(scale || "")) + if (!isFinite(n)) return "" + return String(Math.round(n * 100) / 100) + } + + function updateDisplays(displaysJson) { + try { + root.displays = displaysJson ? JSON.parse(displaysJson) : [] + } catch(e) { + root.displays = [] + } + + var count = 0 + for (var i = 0; i < root.displays.length; i++) + if (root.displays[i] && root.displays[i].enabled) count++ + root.enabledDisplayCount = count + } + + 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-set " + scale] + if (!actionProc.running) actionProc.running = true + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + Component.onCompleted: refresh() + + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: root.refresh() + } + + Process { + id: stateProc + command: ["bash", "-lc", "omarchy-brightness-display-get 2>/dev/null || true; monitors_json=$(hyprctl monitors all -j); printf '%s\\n' \"$monitors_json\" | jq -r 'def internal: test(\"^(eDP|LVDS|DSI)-\"); ([.[] | select(.name | internal)][0].name // \"\"), ([.[] | select((.name | internal) | not)][0].name // \"\"), ([.[] | select((.name | internal) and .disabled != true)][0].name // \"\"), ([.[] | select((.name | internal) and .mirrorOf != \"none\")][0].mirrorOf // \"\")'; omarchy-hyprland-monitor-focused 2>/dev/null || echo; omarchy-hyprland-monitor-scaling-get 2>/dev/null || echo; printf '%s\\n' \"$monitors_json\" | jq -c '[.[] | {name, enabled:(.disabled != true), focused:(.focused == true)}]'"] + 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 } + onRunningChanged: { + if (running) return + if (root.brightnessSetQueued) { + root.setBrightness(root.pendingBrightnessPercent) + } else { + root.refresh() + } + } + } + + Process { + id: actionProc + stdout: StdioCollector { waitForEnd: true } + onRunningChanged: if (!running) root.refresh() + } + + Common.WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: "󰍹" + fontSize: 13 + onPressed: function(b) { root.popupOpen = !root.popupOpen } + onWheelMoved: function(delta) { + if (root.brightnessAvailable) root.setBrightness(root.brightnessPercent + (delta > 0 ? 5 : -5)) + } + } + + Common.PopupCard { + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 320 + contentHeight: panelColumn.implicitHeight + 28 + + Column { + id: panelColumn + anchors.fill: parent + spacing: 14 + + Row { + width: parent.width + spacing: 8 + + Text { + text: "Display" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: root.focusedMonitor ? "· " + root.focusedMonitor : "" + color: Qt.darker(root.bar.foreground, 1.8) + font.family: root.bar.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width - 70 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + width: parent.width + spacing: 8 + visible: root.brightnessAvailable + + Text { + text: "󰃠" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 16 + width: 22 + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + } + + Common.Slider { + id: brightnessSlider + bar: root.bar + width: parent.width - 22 - brightnessPercent.width - 16 + anchors.verticalCenter: parent.verticalCenter + 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) + } + } + + Text { + id: brightnessPercent + text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + width: 36 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + Text { + visible: !root.brightnessAvailable + text: "No controllable backlight found" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + } + + Column { + width: parent.width + spacing: 8 + + Text { + text: "Scale" + (root.monitorScale ? " · " + root.monitorScale + "x" : "") + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: true + } + + Row { + width: parent.width + spacing: 6 + + ScaleButton { scaleValue: "1" } + ScaleButton { scaleValue: "1.25" } + ScaleButton { scaleValue: "1.6" } + ScaleButton { scaleValue: "2" } + ScaleButton { scaleValue: "3" } + ScaleButton { scaleValue: "4" } + } + } + + Column { + width: parent.width + spacing: 8 + visible: root.displays.length > 0 + + Text { + text: "Monitors" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: true + } + + Repeater { + model: root.displays + + ToggleRow { + required property var modelData + + label: modelData.name + (modelData.focused ? " · focused" : "") + checked: modelData.enabled + enabled: !modelData.enabled || root.enabledDisplayCount > 1 + opacity: enabled ? 1.0 : 0.45 + onClicked: root.toggleDisplay(modelData.name, modelData.enabled) + } + } + } + } + } + + component ScaleButton: Rectangle { + property string scaleValue: "" + readonly property bool active: root.normalizeScale(root.monitorScale) === root.normalizeScale(scaleValue) + + width: (panelColumn.width - 30) / 6 + height: 28 + radius: 0 + color: scaleArea.pressed ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.24) + : scaleArea.containsMouse ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.14) + : active ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.20) + : "transparent" + + Text { + anchors.centerIn: parent + text: scaleValue + "x" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + font.bold: active + } + + MouseArea { + id: scaleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.setScale(scaleValue) + } + } + + component ToggleRow: Rectangle { + property string label: "" + property bool checked: false + signal clicked() + + width: panelColumn.width + height: 30 + color: area.pressed ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.22) + : area.containsMouse ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + : "transparent" + + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Text { + text: checked ? "󰄬" : "󰄱" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 14 + width: 18 + horizontalAlignment: Text.AlignHCenter + } + + Text { + text: label + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width - 26 + } + } + + MouseArea { + id: area + anchors.fill: parent + enabled: parent.enabled + hoverEnabled: true + cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: parent.clicked() + } + } +} diff --git a/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml b/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml index 19f1c504..32f8a127 100644 --- a/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml @@ -156,7 +156,7 @@ Item { right: [ { id: "tray" }, { id: "systemStats" }, { id: "microphone" }, { id: "bluetoothPanel" }, { id: "networkPanel" }, { id: "audioPanel" }, - { id: "battery" } + { id: "monitorPanel" }, { id: "battery" } ] } }, diff --git a/default/quickshell/omarchy-shell/shell-defaults.json b/default/quickshell/omarchy-shell/shell-defaults.json index 3268168d..774610a2 100644 --- a/default/quickshell/omarchy-shell/shell-defaults.json +++ b/default/quickshell/omarchy-shell/shell-defaults.json @@ -52,6 +52,9 @@ { "id": "audioPanel" }, + { + "id": "monitorPanel" + }, { "id": "battery" }