diff --git a/default/hypr/bindings/media.lua b/default/hypr/bindings/media.lua index a9961466d7..6435c060a0 100644 --- a/default/hypr/bindings/media.lua +++ b/default/hypr/bindings/media.lua @@ -3,8 +3,8 @@ o.bind("XF86AudioRaiseVolume", "Volume up", { audio = "raise" }, { locked = true o.bind("XF86AudioLowerVolume", "Volume down", { audio = "lower" }, { locked = true, repeating = true }) o.bind("XF86AudioMute", "Mute", { audio = "mute-toggle" }, { locked = true }) o.bind("XF86AudioMicMute", "Mute microphone", "omarchy-audio-input-mute", { locked = true }) -o.bind("XF86MonBrightnessUp", "Brightness up", "omarchy-brightness-display +5%", { locked = true, repeating = true }) -o.bind("XF86MonBrightnessDown", "Brightness down", "omarchy-brightness-display 5%-", { locked = true, repeating = true }) +o.bind("XF86MonBrightnessUp", "Brightness up", { brightness = "raise" }, { locked = true, repeating = true }) +o.bind("XF86MonBrightnessDown", "Brightness down", { brightness = "lower" }, { locked = true, repeating = true }) o.bind("SHIFT + XF86MonBrightnessUp", "Brightness maximum", "omarchy-brightness-display 100%", { locked = true, repeating = true }) o.bind("SHIFT + XF86MonBrightnessDown", "Brightness minimum", "omarchy-brightness-display 1%", { locked = true, repeating = true }) o.bind("XF86KbdBrightnessUp", "Keyboard brightness up", "omarchy-brightness-keyboard up", { locked = true, repeating = true }) diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index eaccf957cb..f75e909104 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -100,6 +100,9 @@ local function command_from(value, description) return shell_dispatcher("panel", value.panel, "omarchy-shell shell toggle " .. shell_quote(value.panel)) elseif value.audio then return shell_dispatcher("audio", value.audio, "omarchy-audio-output-volume " .. shell_quote(value.audio)) + elseif value.brightness then + local step = value.brightness == "raise" and "+5%" or "5%-" + return shell_dispatcher("brightness", value.brightness, "omarchy-brightness-display " .. step) elseif value.ipc then local target, method = value.ipc:match("^([^.]+)%.(.+)$") return shell_dispatcher("ipc", value.ipc, "omarchy-shell " .. shell_quote(target) .. " " .. shell_quote(method)) diff --git a/default/omarchy/shortcuts b/default/omarchy/shortcuts index 27cd044830..02aa5bbef4 100644 --- a/default/omarchy/shortcuts +++ b/default/omarchy/shortcuts @@ -1,11 +1,12 @@ # Hyprland global shortcuts the Omarchy shell registers, one per line: a menu -# route to toggle, a panel plugin to toggle, a volume key, or an argument-free -# IPC call as target.method. A binding written as o.bind(keys, description, -# { menu = "theme" }), { panel = "omarchy.emojis" }, { audio = "raise" } or -# { ipc = "media.next" } dispatches the matching shortcut straight to the shell, -# with no process to spawn per keypress. Anything missing here still binds, -# through the omarchy-menu, omarchy-shell or omarchy-audio-output-volume -# command instead. +# route to toggle, a panel plugin to toggle, a volume or brightness key, or an +# argument-free IPC call as target.method. A binding written as o.bind(keys, +# description, { menu = "theme" }), { panel = "omarchy.emojis" }, +# { audio = "raise" }, { brightness = "raise" } or { ipc = "media.next" } +# dispatches the matching shortcut straight to the shell, with no process to +# spawn per keypress. Anything missing here still binds, through the +# omarchy-menu, omarchy-shell, omarchy-audio-output-volume or +# omarchy-brightness-display command instead. menu root menu apps menu capture @@ -27,6 +28,8 @@ panel omarchy.power audio raise audio lower audio mute-toggle +brightness raise +brightness lower ipc media.playPause ipc media.next ipc media.previous diff --git a/shell/services/BrightnessKeys.qml b/shell/services/BrightnessKeys.qml new file mode 100644 index 0000000000..0f14b141af --- /dev/null +++ b/shell/services/BrightnessKeys.qml @@ -0,0 +1,101 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import qs.Commons +import "BrightnessModel.js" as BrightnessModel + +// The display brightness keys, arriving as global shortcuts. On the internal +// panel they step the backlight here: one brightnessctl write and the OSD in +// process, instead of omarchy-brightness-display resolving the monitor and +// device, reading, writing and reading back, then an IPC client for the OSD. +// They step, clamp and read back the way that script does, so either path +// lands on the same level and OSD. External and Apple displays go through the +// script, which drives them over DDC or their own helper. +Item { + id: root + + // The shell host, for summoning the OSD. + property var host: null + // The backlight omarchy-hw-display picks. Devices do not come and go at + // runtime, but each press refreshes it for the next. + property string device: "" + readonly property string devicePath: device ? "/sys/class/backlight/" + device : "" + + // Returns false when the focused display is not one to handle here, so the + // caller falls back to the script. + function handle(action) { + var monitor = Hyprland.focusedMonitor + var name = monitor ? String(monitor.name || "") : "" + if (!/^(eDP|LVDS|DSI)-/.test(name) || !device) return false + if (action !== "raise" && action !== "lower") return false + + // The script drops a press that overlaps one still being applied, so key + // repeat cannot race the writes. + if (setProc.running) return true + + var max = readNumber(maxFile) + if (!(max > 0)) return false + var current = Math.round(100 * readNumber(brightnessFile) / max) + + setProc.command = ["brightnessctl", "-q", "-d", device, "set", BrightnessModel.brightnessKeyTarget(action, current) + "%"] + setProc.running = true + return true + } + + // reload() reads in the background, so wait for it: text() would otherwise + // still hold the previous reading, and a level changed elsewhere (the + // monitor panel, a script) would step from the wrong place. + function readNumber(file) { + file.reload() + file.waitForJob() + return Number(String(file.text() || "").trim()) + } + + // The payload omarchy-osd builds, from the level read back after the write. + function showOsd() { + var max = readNumber(maxFile) + if (!host || !(max > 0)) return + var percent = Math.round(100 * readNumber(brightnessFile) / max) + host.summon("omarchy.osd", JSON.stringify({ + icon: "brightness", + message: "", + value: String(percent), + progressText: percent + "%", + max: "100", + duration: "" + })) + } + + FileView { + id: brightnessFile + path: root.devicePath ? root.devicePath + "/brightness" : "" + blockLoading: true + printErrors: false + } + + FileView { + id: maxFile + path: root.devicePath ? root.devicePath + "/max_brightness" : "" + blockLoading: true + printErrors: false + } + + Process { + id: setProc + onExited: { + root.showOsd() + if (!deviceProc.running) deviceProc.running = true + } + } + + Process { + id: deviceProc + command: ["omarchy-hw-display"] + running: true + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.device = String(text || "").trim() + } + } +} diff --git a/shell/services/BrightnessModel.js b/shell/services/BrightnessModel.js new file mode 100644 index 0000000000..531d6e36e4 --- /dev/null +++ b/shell/services/BrightnessModel.js @@ -0,0 +1,12 @@ +// omarchy-brightness-display's steps for the brightness keys: 1% at or below +// 5%, otherwise 5%, kept between 1% and 100%. +function brightnessKeyTarget(action, current) { + if (action === "raise") return Math.min(current < 5 ? current + 1 : current + 5, 100) + return Math.max(current <= 5 ? current - 1 : current - 5, 1) +} + +if (typeof module !== "undefined") { + module.exports = { + brightnessKeyTarget: brightnessKeyTarget + } +} diff --git a/shell/shell.qml b/shell/shell.qml index 8af1b7524f..e1ab62f929 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -20,6 +20,7 @@ ShellRoot { property PluginRegistry pluginRegistry: PluginRegistry { } property BarWidgetRegistry barWidgetRegistry: BarWidgetRegistry { } property AppLibrary appLibrary: AppLibrary { } + property BrightnessKeys brightnessKeys: BrightnessKeys { host: shell } property string home: Quickshell.env("HOME") @@ -1526,7 +1527,7 @@ ShellRoot { var lines = String(raw || "").split("\n") for (var i = 0; i < lines.length; i++) { var match = /^([A-Za-z]+)\s+(\S+)\s*$/.exec(lines[i]) - if (match && ["menu", "panel", "audio", "ipc"].indexOf(match[1]) !== -1) + if (match && ["menu", "panel", "audio", "brightness", "ipc"].indexOf(match[1]) !== -1) entries.push({ kind: match[1], target: match[2], name: match[1] + "." + match[2] }) } return entries @@ -1546,6 +1547,9 @@ ShellRoot { var service = shell.serviceFor(shell.ipcShortcutServices[target] || "") if (!service || !service.runShortcut(method)) Util.execArgv(["omarchy-shell", target, method]) + } else if (entry.kind === "brightness") { + if (!shell.brightnessKeys.handle(entry.target)) + Util.execArgv(["omarchy-brightness-display", entry.target === "raise" ? "+5%" : "5%-"]) } else if (entry.kind === "audio") { var media = shell.serviceFor("omarchy.media") if (!media || !media.handleVolumeKey(entry.target)) @@ -1570,7 +1574,7 @@ ShellRoot { appid: "omarchy" name: modelData.name - description: modelData.kind === "audio" ? "Volume " + modelData.target : (modelData.kind === "ipc" ? "Run " + modelData.target : "Toggle the " + modelData.target + " " + modelData.kind) + description: modelData.kind === "audio" || modelData.kind === "brightness" ? (modelData.kind === "audio" ? "Volume " : "Brightness ") + modelData.target : (modelData.kind === "ipc" ? "Run " + modelData.target : "Toggle the " + modelData.target + " " + modelData.kind) onPressed: shell.runShortcut(modelData) } } diff --git a/test/shell.d/brightness-keys-test.sh b/test/shell.d/brightness-keys-test.sh new file mode 100644 index 0000000000..9fa22fee34 --- /dev/null +++ b/test/shell.d/brightness-keys-test.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const model = requireFromRoot('shell/services/BrightnessModel.js') + +// The in-shell brightness keys follow omarchy-brightness-display's steps. +const steps = [ + ['raise', 60, 65], ['lower', 60, 55], + ['raise', 4, 5], ['raise', 5, 10], ['lower', 5, 4], ['lower', 6, 1], + ['raise', 98, 100], ['raise', 100, 100], ['lower', 1, 1], ['lower', 2, 1], +] +for (const [action, current, target] of steps) { + assertEqual(model.brightnessKeyTarget(action, current), target, `brightness ${action} from ${current}% lands on ${target}%`) +} + +const qml = fs.readFileSync(path.join(root, 'shell/services/BrightnessKeys.qml'), 'utf8') +assert( + qml.includes('if (!/^(eDP|LVDS|DSI)-/.test(name) || !device) return false'), + 'brightness keys act in the shell only on the internal panel and defer external and Apple displays to the script' +) +assert( + /if \(setProc\.running\) return true/.test(qml), + 'brightness keys drop a press that overlaps one still being applied, as the script does' +) +assert( + /file\.reload\(\)\s*file\.waitForJob\(\)/.test(qml), + 'brightness keys read the current level fresh, so a level changed elsewhere steps from the right place' +) +assert( + qml.includes('var current = Math.round(100 * readNumber(brightnessFile) / max)') && + qml.includes('var percent = Math.round(100 * readNumber(brightnessFile) / max)'), + 'brightness keys compute percentages as brightnessctl reports them' +) + +const shellQml = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8') +assert( + /if \(!shell\.brightnessKeys\.handle\(entry\.target\)\)\s*Util\.execArgv\(\["omarchy-brightness-display", entry\.target === "raise" \? "\+5%" : "5%-"\]\)/.test(shellQml), + 'a brightness key the shell declines runs omarchy-brightness-display' +) +JS diff --git a/test/shell.d/shell-shortcuts-test.sh b/test/shell.d/shell-shortcuts-test.sh index d259ece8f0..b8a6dc3fc8 100644 --- a/test/shell.d/shell-shortcuts-test.sh +++ b/test/shell.d/shell-shortcuts-test.sh @@ -57,6 +57,7 @@ o.bind("C", "listed panel", { panel = "omarchy.emojis" }) o.bind("D", "unlisted panel", { panel = "omarchy.wifiqr" }) o.bind("E", "listed audio", { audio = "raise" }) o.bind("F", "unlisted audio", { audio = "+1" }) +o.bind("I", "listed brightness", { brightness = "raise" }) o.bind("G", "listed ipc", { ipc = "media.next" }) o.bind("H", "unlisted ipc", { ipc = "media.sourceNext" }) @@ -80,6 +81,7 @@ expect_binding $'global\tomarchy:panel.omarchy.emojis\tlisted panel' "a listed p expect_binding $'exec\tomarchy-shell shell toggle \'omarchy.wifiqr\'\tunlisted panel' "an unlisted panel falls back to the command" expect_binding $'global\tomarchy:audio.raise\tlisted audio' "a listed volume key binds its global shortcut" expect_binding $'exec\tomarchy-audio-output-volume \'+1\'\tunlisted audio' "an unlisted volume step falls back to the script" +expect_binding $'global\tomarchy:brightness.raise\tlisted brightness' "a listed brightness key binds its global shortcut" expect_binding $'global\tomarchy:ipc.media.next\tlisted ipc' "a listed IPC call binds its global shortcut" expect_binding $'exec\tomarchy-shell \'media\' \'sourceNext\'\tunlisted ipc' "an unlisted IPC call falls back to omarchy-shell" pass "shell bindings use global shortcuts only for what the shell registers" @@ -94,6 +96,8 @@ expect_binding $'global\tomarchy:panel.omarchy.clipboard\tClipboard manager' "th expect_binding $'global\tomarchy:audio.raise\tVolume up' "the volume up key steps the volume in the shell" expect_binding $'global\tomarchy:audio.lower\tVolume down' "the volume down key steps the volume in the shell" expect_binding $'global\tomarchy:audio.mute-toggle\tMute' "the mute key toggles mute in the shell" +expect_binding $'global\tomarchy:brightness.raise\tBrightness up' "the brightness up key steps the backlight in the shell" +expect_binding $'global\tomarchy:brightness.lower\tBrightness down' "the brightness down key steps the backlight in the shell" expect_binding $'global\tomarchy:ipc.media.playPause\tPlay' "the play key reaches the media service directly" expect_binding $'global\tomarchy:ipc.notifications.dismissOne\tDismiss last notification' "dismissing a notification reaches the service directly" ! grep -E $'^exec\tomarchy-shell (media|notifications) ' <<<"$bindings" || @@ -103,7 +107,7 @@ pass "default bindings toggle menus and panels through global shortcuts" # The shell and the helpers read the same list, in the same format. while IFS= read -r line; do [[ -z $line || $line == \#* ]] && continue - [[ $line =~ ^(menu|panel|audio|ipc)\ [^[:space:]]+$ ]] || fail "shortcuts lines are a kind and a target: $line" + [[ $line =~ ^(menu|panel|audio|brightness|ipc)\ [^[:space:]]+$ ]] || fail "shortcuts lines are a kind and a target: $line" if [[ $line == ipc\ * ]]; then [[ $line =~ ^ipc\ (media|notifications)\.[A-Za-z]+$ ]] || fail "ipc shortcuts name a mapped target and method: $line" fi