From 1f88d4efc230c1c62f370b5d0c72890a9079b0c0 Mon Sep 17 00:00:00 2001 From: Paul Hontz Date: Mon, 27 Jul 2026 13:43:38 -0400 Subject: [PATCH 1/2] Add a command for the focused monitor's refresh rate Offers only the rates the display advertises for the resolution already in use, so a request cannot silently fall back to preferred. A refresh rate the link cannot carry leaves a black screen, and unlike a bad scale the compositor cannot tell: Hyprland reports the mode it asked for whether or not the panel is showing it. A rate is therefore applied live and reverts on its own unless it is confirmed, and nothing reaches monitors.lua until then. That ordering is also what makes reverting reliable, since the config on disk is still the last known good state. Hyprland replaces a matching monitor rule wholesale rather than merging it, so a rate cannot be layered on as its own rule without dropping that monitor's scale, bitdepth, and colour settings. Persisting therefore means owning the rule, which is only safe when the user has not written one; a hand-configured monitor keeps the change for the session and says why. --- bin/omarchy-hyprland-monitor-refresh-rate | 400 ++++++++++++++++++++++ test/shell.d/monitor-refresh-rate-test.sh | 213 ++++++++++++ 2 files changed, 613 insertions(+) create mode 100755 bin/omarchy-hyprland-monitor-refresh-rate create mode 100644 test/shell.d/monitor-refresh-rate-test.sh diff --git a/bin/omarchy-hyprland-monitor-refresh-rate b/bin/omarchy-hyprland-monitor-refresh-rate new file mode 100755 index 0000000000..9021ad1631 --- /dev/null +++ b/bin/omarchy-hyprland-monitor-refresh-rate @@ -0,0 +1,400 @@ +#!/bin/bash + +# omarchy:summary=Show, set, or adjust the focused Hyprland monitor refresh rate +# omarchy:args=[up|down|list|pending|confirm|revert|RATE] +# omarchy:examples=omarchy hyprland monitor refresh rate | omarchy hyprland monitor refresh rate 144 | omarchy hyprland monitor refresh rate up + +# A refresh rate the link cannot carry leaves a black screen, and unlike a bad +# scale the compositor cannot tell: Hyprland reports the mode it asked for +# whether or not the panel is showing it. So a rate is applied live and reverts +# on its own unless it is confirmed, and nothing is written to disk until then. +# That ordering is what makes revert reliable — the config on disk is still the +# last known good state, so reverting is just a reload. + +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy" +RATE_LOG="$STATE_DIR/monitor-refresh-rate.log" +PENDING_FILE="$STATE_DIR/monitor-refresh-rate-pending.json" +MONITOR_LUA="$HOME/.config/hypr/monitors.lua" +MANAGED_MARKER="-- Managed by omarchy hyprland monitor refresh rate." +CONFIRM_TIMEOUT="${OMARCHY_MONITOR_REFRESH_TIMEOUT:-15}" + +usage() { + cat <<'USAGE' +Usage: omarchy-hyprland-monitor-refresh-rate [up|down|list|pending|confirm|revert|RATE] + + (no args) Print the focused monitor's current refresh rate + list Print every rate available at the current resolution, low to high + RATE Apply a rate, reverting automatically unless confirmed + up|down Step to the next or previous available rate + pending Print JSON describing a change awaiting confirmation + confirm Keep the pending rate and persist it when Omarchy owns the config + revert Drop the pending rate immediately +USAGE +} + +focused_monitor() { + hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)' +} + +# Hyprland reports 143.99899 while availableModes advertises 144.00Hz. Rounding +# to two decimals lets both sides compare as the same string. +normalize_rate() { + awk 'NR == 1 { printf "%g\n", int($0 * 100 + 0.5) / 100 }' +} + +# A rate only means anything alongside a resolution, so only offer the rates +# the panel advertises for the mode already in use. Sorted low to high to match +# the scale row in the shell panel, which runs 1x on the left to 4x on the right. +available_rates() { + local width="$1" height="$2" + + hyprctl monitors all -j | + jq -r --arg prefix "${width}x${height}@" \ + '.[] | select(.focused == true) | .availableModes[] | select(startswith($prefix))' | + sed -E 's/^.*@//; s/Hz$//' | + while read -r rate; do printf '%s\n' "$rate" | normalize_rate; done | + sort -g | uniq +} + +nearest_rate() { + local requested="$1" width="$2" height="$3" + + available_rates "$width" "$height" | + awk -v want="$requested" ' + { + distance = $1 - want + if (distance < 0) distance = -distance + if (NR == 1 || distance < best) { best = distance; nearest = $1 } + } + END { if (NR) print nearest }' +} + +stepped_rate() { + local direction="$1" current="$2" width="$3" height="$4" + + available_rates "$width" "$height" | + awk -v current="$current" -v direction="$direction" ' + { + rates[NR] = $1 + distance = $1 - current + if (distance < 0) distance = -distance + if (NR == 1 || distance < best) { best = distance; at = NR } + } + END { + if (!NR) exit 1 + target = (direction == "up") ? at + 1 : at - 1 + if (target < 1) target = 1 + if (target > NR) target = NR + print rates[target] + }' +} + +cmdline_for_pid() { + local pid="$1" + + [[ -r /proc/$pid/cmdline ]] || return 0 + tr '\0\t\n' ' ' <"/proc/$pid/cmdline" | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]+$//' +} + +audit_rate_event() { + local event="$1" monitor="$2" from="$3" to="$4" detail="$5" + local parent_pid="$PPID" + local grandparent_pid parent_cmd grandparent_cmd + + mkdir -p "$STATE_DIR" || return 0 + + grandparent_pid=$(ps -o ppid= -p "$parent_pid" 2>/dev/null | tr -d ' ') + parent_cmd=$(cmdline_for_pid "$parent_pid") + grandparent_cmd=$(cmdline_for_pid "$grandparent_pid") + + printf 'at=%s\tevent=%s\tmonitor=%s\tfrom=%s\tto=%s\tdetail=%s\tpid=%s\tppid=%s\tparent=%s\tgppid=%s\tgrandparent=%s\n' \ + "$(date --iso-8601=seconds)" \ + "$event" "$monitor" "$from" "$to" "$detail" \ + "$$" "$parent_pid" "$parent_cmd" "$grandparent_pid" "$grandparent_cmd" >>"$RATE_LOG" +} + +pending_deadline() { + [[ -f $PENDING_FILE ]] || return 1 + jq -er '.deadline // empty' "$PENDING_FILE" 2>/dev/null +} + +# A record whose deadline has passed (timer killed, machine suspended) counts as +# absent, so a stale file can never block a later change. +pending_is_live() { + local deadline + + deadline=$(pending_deadline) || return 1 + (($(date +%s) < deadline)) +} + +clear_pending() { + rm -f "$PENDING_FILE" +} + +# monitors.lua is still the last known good state while a change is pending, so +# a reload is a complete and reliable revert. +reload_hyprland() { + hyprctl reload >/dev/null +} + +# Which rule governs this monitor: one Omarchy wrote, one the user wrote, or +# none at all. Comments are skipped so commented-out examples never count. +rule_owner() { + local monitor="$1" description="$2" + + [[ -f $MONITOR_LUA ]] || { + echo "none" + return 0 + } + + awk -v monitor="$monitor" -v description="$description" -v marker="$MANAGED_MARKER" ' + $0 == marker { managed_next = 1; next } + /^[[:space:]]*--/ { next } + { + if (match($0, /output[[:space:]]*=[[:space:]]*"[^"]*"/)) { + target = substr($0, RSTART, RLENGTH) + sub(/^output[[:space:]]*=[[:space:]]*"/, "", target) + sub(/"$/, "", target) + + matched = 0 + if (target != "" && target == monitor) matched = 1 + else if (index(target, "desc:") == 1 && index(description, substr(target, 6)) == 1) matched = 1 + + if (matched) { + # exit still runs END, so mark the answer as already printed. + print managed_next ? "managed" : "user" + answered = 1 + exit + } + } + managed_next = 0 + } + END { if (!answered) print "none" } + ' "$MONITOR_LUA" +} + +# Only ever written as a single line so updating it later is a line replacement +# rather than a Lua block parse. scale references omarchy_monitor_scale when the +# config still declares it, so `omarchy hyprland monitor scaling` keeps composing +# with this rule instead of being overridden by a stale literal. +managed_rule_line() { + local description="$1" mode="$2" scale="$3" + local scale_expression + + if grep -q '^local omarchy_monitor_scale = ' "$MONITOR_LUA"; then + scale_expression="omarchy_monitor_scale" + else + scale_expression=$(awk -v scale="$scale" 'BEGIN { printf "%g\n", scale }') + fi + + printf 'hl.monitor({ output = "desc:%s", mode = "%s", position = "auto", scale = %s })\n' \ + "$description" "$mode" "$scale_expression" +} + +# Hyprland replaces a matching monitor rule wholesale rather than merging it, so +# a rate cannot be layered on top as its own rule without dropping that monitor's +# scale, bitdepth, and colour settings. Persisting therefore means owning the +# rule outright, which is only safe when the user has not written one. +persist_rate() { + local monitor="$1" description="$2" mode="$3" scale="$4" + local owner rule + + [[ -f $MONITOR_LUA ]] || { + echo "no-config" + return 0 + } + + owner=$(rule_owner "$monitor" "$description") + rule=$(managed_rule_line "$description" "$mode" "$scale") + + case $owner in + user) + echo "user-owned" + ;; + managed) + # Replace the line directly after our marker. + awk -v marker="$MANAGED_MARKER" -v rule="$rule" ' + replace_next { print rule; replace_next = 0; next } + $0 == marker { print; replace_next = 1; next } + { print } + ' "$MONITOR_LUA" >"$MONITOR_LUA.omarchy-tmp" && + mv "$MONITOR_LUA.omarchy-tmp" "$MONITOR_LUA" && + echo "updated" || echo "failed" + ;; + *) + # Appended so it lands after the catch-all rule it needs to override. + printf '\n%s\n%s\n' "$MANAGED_MARKER" "$rule" >>"$MONITOR_LUA" && echo "added" || echo "failed" + ;; + esac +} + +apply_rate() { + local requested="$1" + local monitor name description width height scale current target deadline + + monitor=$(focused_monitor) || { + echo "No focused monitor" >&2 + exit 1 + } + name=$(jq -r '.name' <<<"$monitor") + description=$(jq -r '.description' <<<"$monitor") + width=$(jq -r '.width' <<<"$monitor") + height=$(jq -r '.height' <<<"$monitor") + scale=$(jq -r '.scale' <<<"$monitor") + current=$(jq -r '.refreshRate' <<<"$monitor" | normalize_rate) + + # Snap onto a mode the panel advertises; Hyprland silently falls back to + # preferred when handed a rate the display does not support. + target=$(nearest_rate "$requested" "$width" "$height") + if [[ -z $target ]]; then + echo "No refresh rates available for ${width}x${height} on $name" >&2 + exit 1 + fi + + # Collapsing an in-flight change keeps revert unambiguous: without this, a + # second change would make reverting undo both. + if pending_is_live; then + clear_pending + reload_hyprland + else + clear_pending + fi + + mkdir -p "$STATE_DIR" || exit 1 + deadline=$(($(date +%s) + CONFIRM_TIMEOUT)) + + hyprctl eval "hl.monitor({ output = \"$name\", mode = \"${width}x${height}@${target}\", position = \"auto\", scale = $scale })" >/dev/null + + jq -n \ + --arg monitor "$name" --arg description "$description" \ + --arg mode "${width}x${height}@${target}" --arg rate "$target" \ + --arg previous "$current" --argjson scale "$scale" \ + --argjson deadline "$deadline" --argjson timeout "$CONFIRM_TIMEOUT" \ + '{monitor: $monitor, description: $description, mode: $mode, rate: $rate, + previous: $previous, scale: $scale, deadline: $deadline, timeout: $timeout}' \ + >"$PENDING_FILE" + + # Detached so the countdown outlives this process; the shell panel's Process + # object exits as soon as this returns. + setsid "$0" await-revert "$deadline" >/dev/null 2>&1 0)) && sleep "$remaining" + + # Only revert the change this timer was armed for; a newer change owns the + # file by then and has its own timer. + [[ $(pending_deadline 2>/dev/null) == "$deadline" ]] || return 0 + + local monitor rate previous + monitor=$(jq -r '.monitor' "$PENDING_FILE" 2>/dev/null) + rate=$(jq -r '.rate' "$PENDING_FILE" 2>/dev/null) + previous=$(jq -r '.previous' "$PENDING_FILE" 2>/dev/null) + + clear_pending + reload_hyprland + audit_rate_event auto-reverted "$monitor" "$rate" "$previous" "unconfirmed" + omarchy-notification-send -g 󰍹 "Reverted to ${previous}Hz" +} + +confirm_rate() { + local monitor description mode rate scale previous persisted + + if ! pending_is_live; then + echo "No refresh rate change is awaiting confirmation" >&2 + exit 1 + fi + + monitor=$(jq -r '.monitor' "$PENDING_FILE") + description=$(jq -r '.description' "$PENDING_FILE") + mode=$(jq -r '.mode' "$PENDING_FILE") + rate=$(jq -r '.rate' "$PENDING_FILE") + scale=$(jq -r '.scale' "$PENDING_FILE") + previous=$(jq -r '.previous' "$PENDING_FILE") + + clear_pending + persisted=$(persist_rate "$monitor" "$description" "$mode" "$scale") + audit_rate_event confirmed "$monitor" "$previous" "$rate" "$persisted" + + case $persisted in + user-owned) + echo "Keeping ${rate}Hz for this session. ~/.config/hypr/monitors.lua already configures $monitor, so edit its mode there to make this permanent." >&2 + ;; + no-config | failed) + echo "Keeping ${rate}Hz for this session; could not write ~/.config/hypr/monitors.lua." >&2 + ;; + esac +} + +revert_rate() { + local monitor rate previous + + if ! pending_is_live; then + clear_pending + return 0 + fi + + monitor=$(jq -r '.monitor' "$PENDING_FILE") + rate=$(jq -r '.rate' "$PENDING_FILE") + previous=$(jq -r '.previous' "$PENDING_FILE") + + clear_pending + reload_hyprland + audit_rate_event reverted "$monitor" "$rate" "$previous" "requested" +} + +print_pending() { + if ! pending_is_live; then + echo '{"pending":false,"secondsLeft":0}' + return 0 + fi + + jq --argjson now "$(date +%s)" \ + '{pending: true, secondsLeft: (.deadline - $now), monitor: .monitor, rate: .rate, previous: .previous}' \ + "$PENDING_FILE" +} + +case "${1:-}" in + "") + focused_monitor | jq -r '.refreshRate' | normalize_rate + ;; + -h | --help) + usage + ;; + list) + monitor=$(focused_monitor) || exit 1 + available_rates "$(jq -r '.width' <<<"$monitor")" "$(jq -r '.height' <<<"$monitor")" + ;; + pending) + print_pending + ;; + confirm) + confirm_rate + ;; + revert) + revert_rate + ;; + await-revert) + await_revert "${2:-0}" + ;; + up | down) + monitor=$(focused_monitor) || exit 1 + apply_rate "$(stepped_rate "$1" \ + "$(jq -r '.refreshRate' <<<"$monitor" | normalize_rate)" \ + "$(jq -r '.width' <<<"$monitor")" "$(jq -r '.height' <<<"$monitor")")" + ;; + *) + if [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] && awk -v rate="$1" 'BEGIN { exit !(rate >= 20 && rate <= 1000) }'; then + apply_rate "$1" + else + usage >&2 + exit 1 + fi + ;; +esac diff --git a/test/shell.d/monitor-refresh-rate-test.sh b/test/shell.d/monitor-refresh-rate-test.sh new file mode 100644 index 0000000000..74992e57f5 --- /dev/null +++ b/test/shell.d/monitor-refresh-rate-test.sh @@ -0,0 +1,213 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +eval_out="$test_tmp/hyprctl-eval" +reload_out="$test_tmp/hyprctl-reload" +home_dir="$test_tmp/home" +monitor_lua="$home_dir/.config/hypr/monitors.lua" +rate_log="$home_dir/.local/state/omarchy/monitor-refresh-rate.log" +pending_file="$home_dir/.local/state/omarchy/monitor-refresh-rate-pending.json" + +mkdir -p "$stub_bin" "$home_dir/.config/hypr" + +cat >"$stub_bin/hyprctl" <<'SH' +#!/bin/bash + +monitor_json() { + printf '[{"name":"DP-1","description":"Acme Displays A1 SERIAL42","focused":true,"scale":1,"width":%s,"height":%s,"refreshRate":%s,"availableModes":["2560x1440@59.95Hz","2560x1440@99.95Hz","2560x1440@120.00Hz","2560x1440@144.00Hz","1920x1080@60.00Hz","3840x2160@30.00Hz"]}]' \ + "${OMARCHY_TEST_MONITOR_WIDTH:-2560}" "${OMARCHY_TEST_MONITOR_HEIGHT:-1440}" "${OMARCHY_TEST_MONITOR_RATE:-120}" +} + +if [[ $1 == "monitors" && $2 == "-j" ]]; then + monitor_json +elif [[ $1 == "monitors" && $2 == "all" && $3 == "-j" ]]; then + monitor_json +elif [[ $1 == "eval" ]]; then + printf '%s\n' "$2" >"$OMARCHY_TEST_HYPRCTL_EVAL_OUT" +elif [[ $1 == "reload" ]]; then + printf 'reloaded\n' >>"$OMARCHY_TEST_HYPRCTL_RELOAD_OUT" +else + exit 1 +fi +SH +chmod +x "$stub_bin/hyprctl" + +# Keep the auto-revert countdown from being spawned; the timer is exercised +# directly through the await-revert subcommand instead. +printf '#!/bin/bash\nexit 0\n' >"$stub_bin/setsid" +chmod +x "$stub_bin/setsid" + +printf '#!/bin/bash\nexit 0\n' >"$stub_bin/omarchy-notification-send" +chmod +x "$stub_bin/omarchy-notification-send" + +write_stock_config() { + cat >"$monitor_lua" <<'LUA' +local omarchy_gdk_scale = 1 +local omarchy_monitor_scale = 1 + +hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) +hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale }) + +-- Configure a specific monitor. +-- hl.monitor({ output = "DP-2", mode = "2560x1440@144", position = "0x0", scale = 1 }) +LUA +} + +write_user_configured() { + write_stock_config + cat >>"$monitor_lua" <<'LUA' + +hl.monitor({ output = "DP-1", mode = "2560x1440@120", position = "auto", scale = 1, bitdepth = 10 }) +LUA +} + +run_rate() { + HOME="$home_dir" \ + XDG_STATE_HOME="$home_dir/.local/state" \ + PATH="$stub_bin:$PATH" \ + OMARCHY_TEST_HYPRCTL_EVAL_OUT="$eval_out" \ + OMARCHY_TEST_HYPRCTL_RELOAD_OUT="$reload_out" \ + "$ROOT/bin/omarchy-hyprland-monitor-refresh-rate" "$@" +} + +reset_state() { + rm -f "$eval_out" "$reload_out" "$pending_file" +} + +# --- Reporting ------------------------------------------------------------- + +reset_state +write_stock_config +rate=$(OMARCHY_TEST_MONITOR_RATE=143.99899 run_rate) +[[ $rate == "144" ]] || fail "monitor refresh rate normalizes the reported rate" "actual: $rate" +pass "monitor refresh rate normalizes the reported rate" + +rates=$(run_rate list | tr '\n' ' ') +[[ $rates == "59.95 99.95 120 144 " ]] || + fail "monitor refresh rate lists current-resolution rates low to high" "actual: $rates" +pass "monitor refresh rate lists current-resolution rates low to high" + +rates=$(OMARCHY_TEST_MONITOR_WIDTH=1920 OMARCHY_TEST_MONITOR_HEIGHT=1080 run_rate list | tr '\n' ' ') +[[ $rates == "60 " ]] || fail "monitor refresh rate filters rates to the active resolution" "actual: $rates" +pass "monitor refresh rate filters rates to the active resolution" + +# --- Applying is live only ------------------------------------------------- + +reset_state +write_stock_config +before=$(cat "$monitor_lua") +run_rate 144 >/dev/null +grep -F 'mode = "2560x1440@144"' "$eval_out" >/dev/null || fail "monitor refresh rate applies the requested rate" +[[ $(cat "$monitor_lua") == "$before" ]] || + fail "monitor refresh rate does not persist before confirmation" "monitors.lua changed while pending" +[[ -f $pending_file ]] || fail "monitor refresh rate records a pending change" +pass "monitor refresh rate applies live without persisting" + +pending=$(run_rate pending) +[[ $(jq -r '.pending' <<<"$pending") == "true" ]] || fail "monitor refresh rate reports a pending change" +[[ $(jq -r '.rate' <<<"$pending") == "144" ]] || fail "monitor refresh rate reports the pending rate" +pass "monitor refresh rate reports a pending change" + +# --- Confirming persists --------------------------------------------------- + +run_rate confirm +grep -F 'hl.monitor({ output = "desc:Acme Displays A1 SERIAL42", mode = "2560x1440@144", position = "auto", scale = omarchy_monitor_scale })' \ + "$monitor_lua" >/dev/null || fail "monitor refresh rate persists a desc-keyed rule" "$(cat "$monitor_lua")" +grep -F 'event=confirmed' "$rate_log" >/dev/null || fail "monitor refresh rate writes an audit entry" +[[ ! -f $pending_file ]] || fail "monitor refresh rate clears the pending change on confirm" +pass "monitor refresh rate persists a desc-keyed rule on confirm" + +# A second change updates the managed rule instead of stacking another one. +reset_state +OMARCHY_TEST_MONITOR_RATE=144 run_rate 120 >/dev/null +run_rate confirm +(($(grep -c 'Managed by omarchy hyprland monitor refresh rate' "$monitor_lua") == 1)) || + fail "monitor refresh rate keeps a single managed rule" "$(cat "$monitor_lua")" +grep -F 'mode = "2560x1440@120"' "$monitor_lua" >/dev/null || + fail "monitor refresh rate updates the managed rule in place" "$(cat "$monitor_lua")" +pass "monitor refresh rate updates its managed rule in place" + +# --- Hand-written rules are left alone ------------------------------------- + +reset_state +write_user_configured +before=$(cat "$monitor_lua") +run_rate 144 >/dev/null +message=$(run_rate confirm 2>&1 >/dev/null || true) +[[ $(cat "$monitor_lua") == "$before" ]] || + fail "monitor refresh rate leaves a user-configured monitor rule untouched" "$(diff <(printf '%s' "$before") "$monitor_lua")" +[[ $message == *"already configures"* ]] || + fail "monitor refresh rate explains why it did not persist" "actual: $message" +pass "monitor refresh rate leaves user-configured rules untouched" + +# --- Stepping and snapping ------------------------------------------------- + +reset_state +write_stock_config +OMARCHY_TEST_MONITOR_RATE=120 run_rate up >/dev/null +grep -F 'mode = "2560x1440@144"' "$eval_out" >/dev/null || fail "monitor refresh rate steps up to the next rate" +pass "monitor refresh rate steps up to the next rate" + +reset_state +OMARCHY_TEST_MONITOR_RATE=120 run_rate down >/dev/null +grep -F 'mode = "2560x1440@99.95"' "$eval_out" >/dev/null || fail "monitor refresh rate steps down to the previous rate" +pass "monitor refresh rate steps down to the previous rate" + +reset_state +OMARCHY_TEST_MONITOR_RATE=144 run_rate up >/dev/null +grep -F 'mode = "2560x1440@144"' "$eval_out" >/dev/null || fail "monitor refresh rate clamps at the highest rate" +pass "monitor refresh rate clamps at the highest rate" + +reset_state +run_rate 165 >/dev/null +grep -F 'mode = "2560x1440@144"' "$eval_out" >/dev/null || + fail "monitor refresh rate snaps an unsupported rate onto an advertised mode" +pass "monitor refresh rate snaps an unsupported rate onto an advertised mode" + +# --- Reverting ------------------------------------------------------------- + +reset_state +write_stock_config +run_rate 144 >/dev/null +run_rate revert +[[ -f $reload_out ]] || fail "monitor refresh rate reloads Hyprland to revert" +[[ ! -f $pending_file ]] || fail "monitor refresh rate clears the pending change on revert" +pass "monitor refresh rate reverts a pending change" + +# The countdown reverts a change that is never confirmed. +reset_state +write_stock_config +before=$(cat "$monitor_lua") +run_rate 144 >/dev/null +deadline=$(jq -r '.deadline' "$pending_file") +run_rate await-revert "$deadline" +[[ ! -f $pending_file ]] || fail "monitor refresh rate auto-reverts an unconfirmed change" +[[ -f $reload_out ]] || fail "monitor refresh rate reloads Hyprland when auto-reverting" +[[ $(cat "$monitor_lua") == "$before" ]] || fail "monitor refresh rate persists nothing when auto-reverting" +grep -F 'event=auto-reverted' "$rate_log" >/dev/null || fail "monitor refresh rate audits the auto-revert" +pass "monitor refresh rate auto-reverts an unconfirmed change" + +# A timer armed for a superseded change must not revert the newer one. +reset_state +write_stock_config +run_rate 120 >/dev/null +stale_deadline=$(jq -r '.deadline' "$pending_file") +run_rate 144 >/dev/null +rm -f "$reload_out" +run_rate await-revert "$((stale_deadline - 1))" +[[ -f $pending_file ]] || fail "monitor refresh rate keeps a newer pending change" +[[ ! -f $reload_out ]] || fail "monitor refresh rate ignores a superseded countdown" +pass "monitor refresh rate ignores a superseded countdown" + +# --- Confirming nothing ---------------------------------------------------- + +reset_state +run_rate confirm 2>/dev/null && fail "monitor refresh rate rejects confirming with nothing pending" +pass "monitor refresh rate rejects confirming with nothing pending" From 7647ae379647daeb039de7a7f38c7ead99c60be4 Mon Sep 17 00:00:00 2001 From: Paul Hontz Date: Mon, 27 Jul 2026 13:43:50 -0400 Subject: [PATCH 2/2] Show and change the refresh rate in the display panel Adds a REFRESH RATE row to the display panel, next to SCALE and following the same conventions: presets in a horizontal row, keyboard and pointer sharing one cursor, and the focused monitor named when more than one is in play. The row is hidden unless the display offers more than one rate. Because a change reverts itself unless confirmed, the panel grows a Keep / Revert row with a countdown while one is in flight. Revert sits on the left and Keep on the right, with a single confirmKeepIndex that both the buttons and the keyboard handler read, so the row cannot be reordered into disagreeing with what its buttons do. The rate, the available rates, and any pending change are appended to omarchy-monitor-state so the panel keeps polling one command rather than shelling out separately. --- bin/omarchy-monitor-state | 9 + shell/plugins/panels/monitor/Model.js | 58 ++++- shell/plugins/panels/monitor/Panel.qml | 314 ++++++++++++++++++++++++- test/shell.d/monitor-test.sh | 45 ++++ 4 files changed, 417 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-monitor-state b/bin/omarchy-monitor-state index 13452679d6..f73de24f47 100755 --- a/bin/omarchy-monitor-state +++ b/bin/omarchy-monitor-state @@ -20,3 +20,12 @@ omarchy-hyprland-monitor-scaling 2>/dev/null || echo printf '%s\n' "$monitors_json" | jq -c \ '[.[] | {name, enabled:(.disabled != true), focused:(.focused == true), width, height}]' + +# Refresh rate is reported for the focused monitor only, like scaling above. +# Delegated rather than derived from $monitors_json so the rate normalization +# and mode filtering live in one place. +omarchy-hyprland-monitor-refresh-rate 2>/dev/null || echo +omarchy-hyprland-monitor-refresh-rate list 2>/dev/null | + jq -R -s -c 'split("\n") | map(select(length > 0))' +omarchy-hyprland-monitor-refresh-rate pending 2>/dev/null | jq -c . || + echo '{"pending":false,"secondsLeft":0}' diff --git a/shell/plugins/panels/monitor/Model.js b/shell/plugins/panels/monitor/Model.js index 215972b9b5..3f9545de2f 100644 --- a/shell/plugins/panels/monitor/Model.js +++ b/shell/plugins/panels/monitor/Model.js @@ -91,6 +91,58 @@ function brightnessName(percent) { return "Night owl" } +// Trims the trailing zeros Hyprland reports (144.00) without rounding away a +// rate that genuinely differs by a fraction (59.94 vs 59.95). +function rateLabel(rate) { + var value = Number(rate) + if (!isFinite(value)) return "" + return String(Math.round(value * 100) / 100) +} + +function parseRates(raw) { + var rates = [] + try { + rates = raw ? JSON.parse(String(raw)) : [] + } catch (e) { + rates = [] + } + if (!Array.isArray(rates)) rates = [] + + return rates + .map(function(rate) { return rateLabel(rate) }) + .filter(function(rate) { return rate !== "" }) +} + +function matchingRateIndex(rates, currentRate) { + if (!Array.isArray(rates)) return -1 + + var current = rateLabel(currentRate) + if (current === "") return -1 + + for (var i = 0; i < rates.length; i++) { + if (rateLabel(rates[i]) === current) return i + } + return -1 +} + +function parsePendingRate(raw) { + var pending = null + try { + pending = raw ? JSON.parse(String(raw)) : null + } catch (e) { + pending = null + } + + if (!pending || pending.pending !== true) return { pending: false, secondsLeft: 0, rate: "" } + + var secondsLeft = Number(pending.secondsLeft) + return { + pending: true, + secondsLeft: isFinite(secondsLeft) && secondsLeft > 0 ? Math.round(secondsLeft) : 0, + rate: rateLabel(pending.rate) + } +} + function parseDisplays(raw) { var displays = [] try { @@ -119,6 +171,10 @@ if (typeof module !== "undefined") { matchingScaleIndex: matchingScaleIndex, availableScales: availableScales, brightnessName: brightnessName, - parseDisplays: parseDisplays + parseDisplays: parseDisplays, + rateLabel: rateLabel, + parseRates: parseRates, + matchingRateIndex: matchingRateIndex, + parsePendingRate: parsePendingRate } } diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index d822629c15..c92143870f 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -27,6 +27,17 @@ Panel { property var displays: [] property int enabledDisplayCount: 0 + // Rates the focused monitor advertises for the resolution it is already in, + // low to high so the fastest sits on the right like the scale row. + property var rateValues: [] + property string currentRate: "" + // A rate the link cannot carry blanks the display, and Hyprland cannot tell: + // it reports the mode it asked for either way. So a rate is applied live and + // reverts on its own unless it is confirmed here. + property bool ratePending: false + property int rateSecondsLeft: 0 + property string pendingRate: "" + // Cursor model shared by keyboard and mouse. Sections: // "brightness" - single slider row, selectedIndex = -1 sentinel // (mirrors Audio's slider rows). Only present if a @@ -74,25 +85,44 @@ Panel { if (brightnessAvailable) list.push("brightness") list.push("textsize") list.push("scale") + // Hidden unless the display actually offers a choice of rate. + if (rateValues.length > 1) list.push("rate") + // Present only while a rate is waiting to be kept or reverted. + if (ratePending) list.push("confirm") if (displays.length > 1) list.push("monitors") return list } + // Which confirm button keeps the change. Both the buttons and the keyboard + // handler read this, so the row cannot be reordered into disagreeing with + // what its buttons do. + readonly property int confirmKeepIndex: 1 + function confirmIndexKeeps(index) { + return index === confirmKeepIndex + } + function sectionCount(section) { if (section === "brightness") return 0 // only the slider sentinel at -1 if (section === "textsize") return 0 // slider sentinel at -1, like brightness if (section === "scale") return scaleValues.length + if (section === "rate") return rateValues.length + if (section === "confirm") return 2 // revert, keep if (section === "monitors") return displays.length return 0 } function sectionIsSingleRow(section) { - // brightness and text size are lone sliders; scale presets sit horizontally. - return section === "brightness" || section === "textsize" || section === "scale" + // brightness and text size are lone sliders; scale presets, rate presets, + // and the confirm buttons each sit in a horizontal row. + return section === "brightness" || section === "textsize" + || section === "scale" || section === "rate" || section === "confirm" } function sectionFirstIndex(section) { if (section === "brightness" || section === "textsize") return -1 + // Land on Keep: doing nothing already reverts, so the button worth + // reaching for is the affirmative one. + if (section === "confirm") return confirmKeepIndex return 0 } @@ -126,14 +156,17 @@ Panel { } } - // h/l: in scale section, walks the preset row; everywhere else, no-op - // because adjustBrightness handles horizontal motion on the brightness - // slider. + // h/l: walks whichever horizontal row has focus. The sliders are excluded by + // their callers, which route horizontal motion to adjustBrightness and + // adjustTextSize instead. function moveCursorH(delta) { - if (focusSection !== "scale") return + if (!sectionIsSingleRow(focusSection)) return + var max = sectionCount(focusSection) - 1 + if (max < 0) return + var next = selectedIndex + delta if (next < 0) next = 0 - if (next > scaleValues.length - 1) next = scaleValues.length - 1 + if (next > max) next = max selectedIndex = next } @@ -148,6 +181,15 @@ Panel { setScale(scaleValues[selectedIndex]) return } + if (focusSection === "rate" && selectedIndex >= 0 && selectedIndex < rateValues.length) { + setRate(rateValues[selectedIndex]) + return + } + if (focusSection === "confirm") { + if (confirmIndexKeeps(selectedIndex)) confirmRate() + else revertRate() + return + } if (focusSection === "monitors" && selectedIndex >= 0 && selectedIndex < displays.length) { var d = displays[selectedIndex] if (d) toggleDisplay(d.name, d.enabled) @@ -298,6 +340,47 @@ Panel { if (!actionProc.running) actionProc.running = true } + // Rate changes go through the CLI rather than hyprctl so the panel and + // `omarchy hyprland monitor refresh rate` share one implementation of the + // auto-revert and persistence rules. + function setRate(rate) { + if (!rate || root.ratePending) return + runRateCommand(String(rate)) + } + + function confirmRate() { + runRateCommand("confirm") + } + + function revertRate() { + runRateCommand("revert") + } + + function runRateCommand(argument) { + actionProc.command = ["omarchy-hyprland-monitor-refresh-rate", argument] + if (!actionProc.running) actionProc.running = true + } + + function updateRateState(currentRate, ratesJson, pendingJson) { + root.currentRate = Model.rateLabel(currentRate) + root.rateValues = Model.parseRates(ratesJson) + + var pending = Model.parsePendingRate(pendingJson) + root.ratePending = pending.pending + root.rateSecondsLeft = pending.secondsLeft + root.pendingRate = pending.rate + } + + function activeRateIndex() { + return Model.matchingRateIndex(rateValues, monitorRateOrPending()) + } + + // While a change is in flight the pill for the pending rate is the one worth + // showing as selected, even though the confirmed value has not moved yet. + function monitorRateOrPending() { + return root.ratePending && root.pendingRate !== "" ? root.pendingRate : root.currentRate + } + // ---- Text size (shell base font + GTK text-scaling, via one CLI) ---- function nearestTextStop(px) { var best = 0 @@ -360,8 +443,28 @@ Panel { onBrightnessAvailableChanged: clampCursor() onDisplaysChanged: clampCursor() onScaleValuesChanged: clampCursor() + onRateValuesChanged: clampCursor() onVisibleSectionsChanged: clampCursor() + // The confirm row appears and disappears under the cursor, so follow it in + // and step back onto the rate row when the change resolves. + onRatePendingChanged: { + if (ratePending) { + // visibleSections is a binding on ratePending and has not necessarily + // re-evaluated yet, so "confirm" may still be missing from it. Clamping + // here would read that stale list, fail to find the section, and bounce + // focus to the first one. onVisibleSectionsChanged clamps once it lands. + focusSection = "confirm" + selectedIndex = sectionFirstIndex("confirm") + return + } + if (focusSection === "confirm") { + focusSection = "rate" + selectedIndex = Math.max(0, activeRateIndex()) + } + clampCursor() + } + // Only poll while the panel is open; the bar glyph tracks monitor count via // Quickshell.screens, and open-time refresh + Component.onCompleted cover the // rest. External brightness changes are reflected whenever the panel is open. @@ -389,10 +492,27 @@ Panel { root.focusedMonitor = String(lines[5] || "").trim() root.monitorScale = root.normalizeScale(String(lines[6] || "").trim()) root.updateDisplays(String(lines[7] || "[]").trim()) + root.updateRateState( + String(lines[8] || "").trim(), + String(lines[9] || "[]").trim(), + String(lines[10] || "").trim() + ) } } } + // Counts the pending change down locally rather than re-polling every second, + // then refreshes once at zero to pick up the state the auto-revert left behind. + Timer { + interval: 1000 + running: root.ratePending + repeat: true + onTriggered: { + if (root.rateSecondsLeft > 0) root.rateSecondsLeft = root.rateSecondsLeft - 1 + if (root.rateSecondsLeft <= 0) root.refresh() + } + } + Timer { id: brightnessDebounce interval: 180 @@ -484,7 +604,7 @@ Panel { else if (dx !== 0) { if (root.focusSection === "brightness") root.adjustBrightness(dx * 5) else if (root.focusSection === "textsize") root.adjustTextSize(dx) - else if (root.focusSection === "scale") root.moveCursorH(dx) + else root.moveCursorH(dx) } } onActivateRequested: if (root.cursorActive) root.activateCursor() @@ -769,6 +889,131 @@ Panel { } } + // ---------- Refresh rate ---------- + PanelSeparator { + visible: root.rateValues.length > 1 + foreground: root.bar.foreground + } + + Column { + width: parent.width + spacing: Style.space(10) + visible: root.rateValues.length > 1 + + Item { + width: parent.width + implicitHeight: Math.max(rateHeader.implicitHeight, rateMonitor.implicitHeight) + + PanelSectionHeader { + id: rateHeader + text: "REFRESH RATE" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + // Like SCALE, this applies to the focused monitor only. + Text { + id: rateMonitor + text: root.focusedMonitor + visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1 + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + } + } + + Grid { + id: rateRow + width: parent.width + // Wrap rather than shrink: some displays advertise many rates. + columns: Math.min(root.rateValues.length, 4) + spacing: Style.spacing.xs + + readonly property real cellWidth: columns > 0 + ? (width - spacing * (columns - 1)) / columns + : 0 + + Repeater { + model: root.rateValues + + RatePill { + required property string modelData + required property int index + + rateValue: modelData + rateIndex: index + width: rateRow.cellWidth + } + } + } + } + + // ---------- Keep or revert ---------- + PanelSeparator { + visible: root.ratePending + foreground: root.bar.foreground + } + + Column { + width: parent.width + spacing: Style.space(6) + visible: root.ratePending + + Item { + width: parent.width + implicitHeight: Math.max(confirmHeader.implicitHeight, confirmCountdown.implicitHeight) + + PanelSectionHeader { + id: confirmHeader + text: "KEEP THIS CHANGE?" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: confirmCountdown + text: "REVERTING IN " + root.rateSecondsLeft + "S" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 1.2 + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + } + } + + Grid { + id: confirmRow + width: parent.width + columns: 2 + spacing: Style.spacing.xs + + readonly property real cellWidth: (width - spacing) / 2 + + ConfirmPill { + confirmLabel: "Revert now" + confirmIndex: 0 + width: confirmRow.cellWidth + } + + ConfirmPill { + confirmLabel: "Keep " + root.pendingRate + "Hz" + confirmIndex: root.confirmKeepIndex + width: confirmRow.cellWidth + } + } + } + // ---------- Monitors ---------- PanelSeparator { visible: root.displays.length > 1 @@ -834,6 +1079,59 @@ Panel { } } + component RatePill: Button { + id: ratePill + required property string rateValue + required property int rateIndex + + text: Model.rateLabel(rateValue) + "Hz" + fontSize: Style.font.caption + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + horizontalPadding: Style.spacing.sm + verticalPadding: Style.spacing.controlPaddingY + bordered: true + + active: root.activeRateIndex() === rateIndex + hasCursor: root.cursorActive && root.focusSection === "rate" && root.selectedIndex === rateIndex + // Settle the pending change first; a second change would make reverting + // ambiguous about which one it undoes. + opacity: root.ratePending ? 0.45 : 1.0 + + onClicked: root.setRate(rateValue) + onHovered: function(isHovered) { + if (!isHovered || root.reflowingText) return + root.cursorActive = true + root.focusSection = "rate" + root.selectedIndex = ratePill.rateIndex + } + } + + component ConfirmPill: Button { + id: confirmPill + required property string confirmLabel + required property int confirmIndex + + text: confirmLabel + fontSize: Style.font.caption + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + horizontalPadding: Style.spacing.sm + verticalPadding: Style.spacing.controlPaddingY + bordered: true + + active: root.confirmIndexKeeps(confirmIndex) + hasCursor: root.cursorActive && root.focusSection === "confirm" && root.selectedIndex === confirmIndex + + onClicked: if (root.confirmIndexKeeps(confirmPill.confirmIndex)) root.confirmRate(); else root.revertRate() + onHovered: function(isHovered) { + if (!isHovered || root.reflowingText) return + root.cursorActive = true + root.focusSection = "confirm" + root.selectedIndex = confirmPill.confirmIndex + } + } + component MonitorRow: CursorSurface { id: monitorRow required property var display diff --git a/test/shell.d/monitor-test.sh b/test/shell.d/monitor-test.sh index 30014b22b9..b232a8fe4d 100644 --- a/test/shell.d/monitor-test.sh +++ b/test/shell.d/monitor-test.sh @@ -75,4 +75,49 @@ assertDeepEqual( ) assertDeepEqual(monitor.parseDisplays('{'), { displays: [], enabledDisplayCount: 0 }, 'monitor handles invalid display JSON') + +assertEqual(monitor.rateLabel('144.00'), '144', 'monitor trims a whole refresh rate') +assertEqual(monitor.rateLabel(143.99899), '144', 'monitor rounds a reported refresh rate') +assertEqual(monitor.rateLabel(59.946), '59.95', 'monitor keeps a fractional refresh rate distinct') +assertEqual(monitor.rateLabel('nope'), '', 'monitor rejects an invalid refresh rate') + +assertDeepEqual( + monitor.parseRates('["59.95","99.95","120","144"]'), + ['59.95', '99.95', '120', '144'], + 'monitor parses available refresh rates' +) +assertDeepEqual(monitor.parseRates('nope'), [], 'monitor handles invalid refresh rate JSON') +assertDeepEqual(monitor.parseRates(''), [], 'monitor handles missing refresh rates') + +assertEqual( + monitor.matchingRateIndex(['59.95', '99.95', '120', '144'], 143.99899), + 3, + 'monitor selects the reported refresh rate' +) +assertEqual( + monitor.matchingRateIndex(['59.95', '99.95', '120', '144'], 165), + -1, + 'monitor selects no rate when none match' +) + +assertDeepEqual( + monitor.parsePendingRate('{"pending":true,"secondsLeft":12,"rate":"144.00"}'), + { pending: true, secondsLeft: 12, rate: '144' }, + 'monitor parses a pending refresh rate' +) +assertDeepEqual( + monitor.parsePendingRate('{"pending":false,"secondsLeft":0}'), + { pending: false, secondsLeft: 0, rate: '' }, + 'monitor parses an idle refresh rate state' +) +assertDeepEqual( + monitor.parsePendingRate('{"pending":true,"secondsLeft":-4,"rate":"120"}'), + { pending: true, secondsLeft: 0, rate: '120' }, + 'monitor clamps an expired countdown' +) +assertDeepEqual( + monitor.parsePendingRate('{'), + { pending: false, secondsLeft: 0, rate: '' }, + 'monitor handles invalid pending refresh rate JSON' +) JS