diff --git a/bin/omarchy-hyprland-monitor-refresh-rate b/bin/omarchy-hyprland-monitor-refresh-rate new file mode 100755 index 0000000000..fa1dc18831 --- /dev/null +++ b/bin/omarchy-hyprland-monitor-refresh-rate @@ -0,0 +1,94 @@ +#!/bin/bash + +# omarchy:summary=Show or set the focused Hyprland monitor refresh rate +# omarchy:args=[RATE] +# omarchy:examples=omarchy hyprland monitor refresh rate | omarchy hyprland monitor refresh rate 144 + +usage() { + echo "Usage: omarchy-hyprland-monitor-refresh-rate [RATE]" +} + +focused_monitor() { + hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)' +} + +normalize_rate() { + awk 'NR == 1 { printf "%g\n", $0 }' +} + +# Rates the focused mode can reach. A mode at another resolution is a different +# picture, not a different refresh rate, so it is not offered here. +rates_for_mode() { + jq -r ' + . as $monitor + | [(.availableModes // [])[] + | capture("^(?[0-9]+)x(?[0-9]+)@(?[0-9.]+)Hz$") + | select((.width | tonumber) == $monitor.width and (.height | tonumber) == $monitor.height) + | .rate | tonumber] + | unique + | reverse + | .[]' +} + +# Hyprland lists a mode as 143.91Hz while the driver reports 143.912, so a +# requested rate matches the nearest mode within half a hertz rather than +# exactly. Anything further away is a mode the monitor does not have, and +# handing it to Hyprland anyway leaves it to pick a fallback. +match_rate() { + local requested="$1" + + awk -v requested="$requested" ' + { + distance = requested - $0 + if (distance < 0) distance = -distance + if (NR == 1 || distance < best_distance) { best_distance = distance; best = $0 } + } + END { if (NR > 0 && best_distance <= 0.5) printf "%g\n", best } + ' +} + +set_refresh_rate() { + local requested="$1" + local monitor_info="$(focused_monitor)" + local active_monitor="$(jq -r '.name' <<<"$monitor_info")" + local width="$(jq -r '.width' <<<"$monitor_info")" + local height="$(jq -r '.height' <<<"$monitor_info")" + local scale="$(jq -r '.scale' <<<"$monitor_info")" + + # active_monitor is written into the Lua string eval'd below, so only a plain + # connector name may pass; a hostile output name could execute otherwise. + if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Refusing unsafe monitor name" >&2 + exit 1 + fi + + local new_rate="$(rates_for_mode <<<"$monitor_info" | match_rate "$requested")" + if [[ -z $new_rate ]]; then + echo "$active_monitor has no ${requested}Hz mode at ${width}x${height}" >&2 + exit 1 + fi + + # Deliberately not persisted to monitors.lua. Scaling can be, because the + # default config carries one scale for every output; a refresh rate belongs + # to a single monitor's mode, and writing it into that same catch-all would + # force one display's rate on all of them. Pin a rate with a per-output + # hl.monitor entry to keep it past a reboot. + hyprctl eval "hl.monitor({ output = \"$active_monitor\", mode = \"${width}x${height}@${new_rate}\", position = \"auto\", scale = $scale })" >/dev/null +} + +case "${1:-}" in +"") + focused_monitor | jq -r '.refreshRate' | normalize_rate + ;; +-h | --help) + usage + ;; +*) + if [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]; then + set_refresh_rate "$1" + else + usage >&2 + exit 1 + fi + ;; +esac diff --git a/bin/omarchy-monitor-state b/bin/omarchy-monitor-state index fe0e72f433..5165d0946f 100755 --- a/bin/omarchy-monitor-state +++ b/bin/omarchy-monitor-state @@ -19,5 +19,17 @@ printf '%s\n' "$monitors_json" | jq -r ' printf '%s\n' "$focused_monitor" omarchy-hyprland-monitor-scaling 2>/dev/null || echo -printf '%s\n' "$monitors_json" | jq -c \ - '[.[] | {name, enabled:(.disabled != true), focused:(.focused == true), width, height}]' +# Only the modes at the display's current resolution: a mode at another +# resolution is a different picture, not a different refresh rate, and the +# panel offers rates rather than resolutions. The `+ 0` on the rate drops jq +# 1.7's preserved input literal, so 60.00000 reaches the panel as 60. +printf '%s\n' "$monitors_json" | jq -c ' + [.[] | . as $monitor | { + name, + enabled: (.disabled != true), + focused: (.focused == true), + width, + height, + refreshRate: ((.refreshRate // 0) + 0), + modes: [(.availableModes // [])[] | select(startswith("\($monitor.width)x\($monitor.height)@"))] + }]' diff --git a/manual/05-the-top-bar.md b/manual/05-the-top-bar.md index 09a028986f..5a04af1bfc 100644 --- a/manual/05-the-top-bar.md +++ b/manual/05-the-top-bar.md @@ -53,7 +53,7 @@ The panels aren't read-outs. They're where you actually do the thing: - **Network** scans for Wi-Fi, shows signal strength, connects, and lets you pick a DNS provider. - **Bluetooth** lists your devices with connect/disconnect and battery levels. - **Power** shows battery stats, switches power profiles (it remembers a separate choice for battery and AC), and prints some system info. -- **Display** carries a brightness slider, text size, monitor scaling presets, and — when you have more than one screen — per-monitor controls. See [monitors](33-monitors.md) for the deeper story. +- **Display** carries a brightness slider, text size, monitor scaling presets, the refresh rates your screen can run at, and — when you have more than one screen — per-monitor controls. See [monitors](33-monitors.md) for the deeper story. - **Clock** opens a month grid with ISO week numbers and month stepping. Every panel takes the keyboard as well as the mouse: arrows move, Return activates, Tab steps to the neighbouring panel, and Escape closes. diff --git a/manual/33-monitors.md b/manual/33-monitors.md index e6a5e30e02..d83ee0407d 100644 --- a/manual/33-monitors.md +++ b/manual/33-monitors.md @@ -20,6 +20,24 @@ Changes to `GDK_SCALE` apply to applications started after the change (and GTK o You can also quickly step through the major monitor scaling ratios (1x, 1.25x, 1.6x, 2x, 3x, 4x) using `Super + /` to go higher and `Super + Alt + /` to go lower. If you have the default configuration, these changes will also persist past reboot. +### Changing the refresh rate + +A laptop that ships with a 144Hz panel doesn't always come up at 144Hz, and a screen that's fast when it's plugged in can drop to 60Hz on battery. Open the **Display** panel in the top bar and you'll find a **Refresh rate** row beneath the scaling presets, listing every rate your screen can run at without changing resolution. Pick one and it applies immediately. Screens with only one rate don't get the row at all. + +The same thing from the command line: + +``` +omarchy hyprland monitor refresh rate 144 +``` + +Run it without an argument to see the rate you're on. It only accepts a rate your current resolution already supports, so it won't drop you onto a mode your screen can't display. + +Refresh rate changes don't survive a reboot the way scaling does. Scaling is one setting for every screen, so Omarchy can write it back to the catch-all in `~/.config/hypr/monitors.lua`; a refresh rate belongs to one screen's mode, and putting it in that same catch-all would force it on all of them. To make a rate stick, pin it to the output by hand (via _Setup > Monitors_ in the Omarchy menu): + +```lua +hl.monitor({ output = "eDP-1", mode = "1920x1200@144", position = "auto", scale = 2 }) +``` + ### Making text bigger or smaller Monitor scaling changes the size of everything. If all you want is bigger or smaller _text_, there's a single knob for that: diff --git a/shell/plugins/panels/monitor/Model.js b/shell/plugins/panels/monitor/Model.js index 215972b9b5..687f995aa4 100644 --- a/shell/plugins/panels/monitor/Model.js +++ b/shell/plugins/panels/monitor/Model.js @@ -79,6 +79,69 @@ function availableScales(scales, width, height) { .map(function(candidate) { return candidate.value }) } +function normalizeRefreshRate(rate) { + var n = parseFloat(String(rate || "")) + if (!isFinite(n) || n <= 0) return "" + return String(Math.round(n * 100) / 100) +} + +// A display is sold by its whole hertz, so that is what a rate is labelled +// with, even where the mode itself is 143.91Hz. +function refreshRateLabel(rate) { + var n = parseFloat(String(rate || "")) + if (!isFinite(n) || n <= 0) return "" + return String(Math.round(n)) +} + +// Rates the current mode can reach, fastest first. Modes arrive as Hyprland +// mode strings ("2560x1440@143.91Hz"); anything at another resolution is a +// resolution change rather than a rate, and never reaches here. +function availableRefreshRates(modes, width, height) { + if (!Array.isArray(modes)) return [] + + var modeWidth = Number(width) + var modeHeight = Number(height) + var byLabel = {} + + for (var i = 0; i < modes.length; i++) { + var parts = /^(\d+)x(\d+)@([0-9.]+)Hz$/.exec(String(modes[i] || "")) + if (!parts) continue + if (isFinite(modeWidth) && modeWidth > 0 && Number(parts[1]) !== modeWidth) continue + if (isFinite(modeHeight) && modeHeight > 0 && Number(parts[2]) !== modeHeight) continue + + var rate = normalizeRefreshRate(parts[3]) + if (rate === "") continue + + // 59.94 and 60 are both sold as 60Hz. Keep the faster of the two so every + // pill lands on a rate the one beside it doesn't. + var label = refreshRateLabel(rate) + if (!byLabel[label] || Number(rate) > Number(byLabel[label])) byLabel[label] = rate + } + + return Object.keys(byLabel) + .map(function(label) { return byLabel[label] }) + .sort(function(a, b) { return Number(b) - Number(a) }) +} + +// Hyprland lists the mode as 143.91Hz and reports the live rate as 143.912, so +// the active pill is the nearest rate rather than an equal one. Half a hertz +// out is a mode the display is no longer in, not a rounding gap. +function matchingRefreshRateIndex(rates, currentRate) { + var current = Number(currentRate) + if (!Array.isArray(rates) || !isFinite(current) || current <= 0) return -1 + + var bestIndex = -1 + var bestDistance = Infinity + for (var i = 0; i < rates.length; i++) { + var distance = Math.abs(Number(rates[i]) - current) + if (distance < bestDistance) { + bestIndex = i + bestDistance = distance + } + } + return bestDistance <= 0.5 ? bestIndex : -1 +} + function brightnessName(percent) { var p = Math.round(percent) if (p >= 95) return "Sun blast" @@ -118,6 +181,10 @@ if (typeof module !== "undefined") { cleanScale: cleanScale, matchingScaleIndex: matchingScaleIndex, availableScales: availableScales, + normalizeRefreshRate: normalizeRefreshRate, + refreshRateLabel: refreshRateLabel, + availableRefreshRates: availableRefreshRates, + matchingRefreshRateIndex: matchingRefreshRateIndex, brightnessName: brightnessName, parseDisplays: parseDisplays } diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index bec38820ba..96bb5a8610 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -27,6 +27,15 @@ Panel { property var displays: [] property int enabledDisplayCount: 0 + // Scale and mode belong to whichever display has focus, so almost every + // derived value starts by finding it. + readonly property var focusedDisplay: { + for (var i = 0; i < displays.length; i++) { + if (displays[i] && displays[i].focused) return displays[i] + } + return null + } + // Carry sub-notch touchpad deltas between wheel events. property real wheelAccumulator: 0 @@ -42,14 +51,18 @@ Panel { // Mouse hover on a target updates root state via the components' `hovered` // signal so keyboard cursor and pointer share one highlight. readonly property var scalePresets: ["1", "1.25", "1.6", "2", "3", "4"] - readonly property var scaleValues: { - for (var i = 0; i < displays.length; i++) { - var display = displays[i] - if (display && display.focused) - return Model.availableScales(scalePresets, display.width, display.height) - } - return scalePresets - } + readonly property var scaleValues: focusedDisplay + ? Model.availableScales(scalePresets, focusedDisplay.width, focusedDisplay.height) + : scalePresets + + // Rates the focused display's current mode can reach. A display with only + // one is not offering a choice, so the section stays out of the panel. + readonly property var refreshRateValues: focusedDisplay + ? Model.availableRefreshRates(focusedDisplay.modes, focusedDisplay.width, focusedDisplay.height) + : [] + readonly property string monitorRefreshRate: focusedDisplay + ? Model.normalizeRefreshRate(focusedDisplay.refreshRate) + : "" property string focusSection: "scale" property int selectedIndex: 0 property bool cursorActive: false @@ -77,6 +90,7 @@ Panel { if (brightnessAvailable) list.push("brightness") list.push("textsize") list.push("scale") + if (refreshRateValues.length > 1) list.push("refreshrate") if (displays.length > 1) list.push("monitors") return list } @@ -85,13 +99,16 @@ Panel { 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 === "refreshrate") return refreshRateValues.length 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 and refresh rate + // presets sit horizontally. + return section === "brightness" || section === "textsize" + || section === "scale" || section === "refreshrate" } function sectionFirstIndex(section) { @@ -129,14 +146,15 @@ 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: in the scale and refresh rate sections, walks the preset row; + // everywhere else, no-op because adjustBrightness handles horizontal motion + // on the brightness slider. function moveCursorH(delta) { - if (focusSection !== "scale") return + if (focusSection !== "scale" && focusSection !== "refreshrate") return + var count = sectionCount(focusSection) var next = selectedIndex + delta if (next < 0) next = 0 - if (next > scaleValues.length - 1) next = scaleValues.length - 1 + if (next > count - 1) next = count - 1 selectedIndex = next } @@ -151,6 +169,10 @@ Panel { setScale(scaleValues[selectedIndex]) return } + if (focusSection === "refreshrate" && selectedIndex >= 0 && selectedIndex < refreshRateValues.length) { + setRefreshRate(refreshRateValues[selectedIndex]) + return + } if (focusSection === "monitors" && selectedIndex >= 0 && selectedIndex < displays.length) { var d = displays[selectedIndex] if (d) toggleDisplay(d.name, d.enabled) @@ -168,7 +190,8 @@ Panel { } var count = sectionCount(focusSection) if (sectionIsSingleRow(focusSection)) { - // brightness/text size use the -1 sentinel; scale clamps into the presets. + // brightness/text size use the -1 sentinel; scale and refresh rate clamp + // into their presets. if (focusSection === "brightness" || focusSection === "textsize") selectedIndex = -1 else if (selectedIndex < 0 || selectedIndex >= count) selectedIndex = 0 return @@ -213,6 +236,7 @@ Panel { brightnessAvailable: root.brightnessAvailable, focusedMonitor: root.focusedMonitor, scale: root.monitorScale, + refreshRate: root.monitorRefreshRate, displays: root.displays }) } @@ -266,21 +290,13 @@ Panel { } function activeScaleIndex() { - for (var i = 0; i < displays.length; i++) { - var display = displays[i] - if (display && display.focused) - return Model.matchingScaleIndex(scaleValues, monitorScale, display.width, display.height) - } - return -1 + if (!focusedDisplay) return -1 + return Model.matchingScaleIndex(scaleValues, monitorScale, focusedDisplay.width, focusedDisplay.height) } function effectiveScale(scale) { - for (var i = 0; i < displays.length; i++) { - var display = displays[i] - if (display && display.focused) - return Model.cleanScale(scale, display.width, display.height) - } - return normalizeScale(scale) + if (!focusedDisplay) return normalizeScale(scale) + return Model.cleanScale(scale, focusedDisplay.width, focusedDisplay.height) } // Playful mood-name for a given brightness percent. Bands intentionally @@ -309,6 +325,19 @@ Panel { if (!actionProc.running) actionProc.running = true } + function setRefreshRate(rate) { + actionProc.command = ["omarchy-hyprland-monitor-refresh-rate", String(rate)] + if (!actionProc.running) actionProc.running = true + } + + function activeRefreshRateIndex() { + return Model.matchingRefreshRateIndex(refreshRateValues, monitorRefreshRate) + } + + function refreshRateLabel(rate) { + return Model.refreshRateLabel(rate) + } + // ---- Text size (shell base font + GTK text-scaling, via one CLI) ---- function nearestTextStop(px) { var best = 0 @@ -371,6 +400,7 @@ Panel { onBrightnessAvailableChanged: clampCursor() onDisplaysChanged: clampCursor() onScaleValuesChanged: clampCursor() + onRefreshRateValuesChanged: clampCursor() onVisibleSectionsChanged: clampCursor() // Only poll while the panel is open; the bar glyph tracks monitor count via @@ -500,7 +530,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() @@ -790,6 +820,50 @@ Panel { } } + // ---------- Refresh rate ---------- + PanelSeparator { + visible: root.refreshRateValues.length > 1 + foreground: root.bar.foreground + } + + Column { + width: parent.width + spacing: Style.space(10) + visible: root.refreshRateValues.length > 1 + + // No rate beside the header: the active pill already carries it, + // the way SCALE leaves its own presets to speak for themselves. + PanelSectionHeader { + text: "REFRESH RATE" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + } + + Grid { + id: refreshRateRow + width: parent.width + columns: root.refreshRateValues.length + spacing: Style.spacing.xs + + readonly property real cellWidth: root.refreshRateValues.length > 0 + ? (width - spacing * (columns - 1)) / columns + : 0 + + Repeater { + model: root.refreshRateValues + + RefreshRatePill { + required property string modelData + required property int index + + rateValue: modelData + rateIndex: index + width: refreshRateRow.cellWidth + } + } + } + } + // ---------- Monitors ---------- PanelSeparator { visible: root.displays.length > 1 @@ -855,6 +929,31 @@ Panel { } } + component RefreshRatePill: Button { + id: ratePill + required property string rateValue + required property int rateIndex + + text: root.refreshRateLabel(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.activeRefreshRateIndex() === rateIndex + hasCursor: root.cursorActive && root.focusSection === "refreshrate" && root.selectedIndex === rateIndex + + onClicked: root.setRefreshRate(rateValue) + onHovered: function(isHovered) { + if (!isHovered || root.reflowingText) return + root.cursorActive = true + root.focusSection = "refreshrate" + root.selectedIndex = ratePill.rateIndex + } + } + component MonitorRow: CursorSurface { id: monitorRow required property var display diff --git a/test/shell.d/monitor-refresh-rate-test.sh b/test/shell.d/monitor-refresh-rate-test.sh new file mode 100755 index 0000000000..34a3573303 --- /dev/null +++ b/test/shell.d/monitor-refresh-rate-test.sh @@ -0,0 +1,109 @@ +#!/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" +monitors_file="$test_tmp/monitors.json" + +mkdir -p "$stub_bin" + +cat >"$stub_bin/hyprctl" <<'SH' +#!/bin/bash + +if [[ $1 == "monitors" && $2 == "-j" ]]; then + cat "$FAKE_MONITORS" +elif [[ $1 == "eval" ]]; then + printf '%s\n' "$2" >"$OMARCHY_TEST_HYPRCTL_EVAL_OUT" +else + exit 1 +fi +SH +chmod +x "$stub_bin/hyprctl" + +# Hyprland lists a 144Hz panel's mode as 143.91Hz while reporting the driver's +# 143.912 as the live rate, so the fixture keeps that mismatch. +write_monitors() { + printf '%s\n' "${1:-$default_monitors}" >"$monitors_file" +} + +default_monitors='[ + { + "name": "eDP-1", + "focused": true, + "scale": 2, + "width": 2880, + "height": 1800, + "refreshRate": 60.00000, + "availableModes": ["2880x1800@143.91Hz", "2880x1800@60.00Hz", "1920x1080@120.00Hz"] + } +]' + +run_refresh_rate() { + rm -f "$eval_out" + + FAKE_MONITORS="$monitors_file" \ + OMARCHY_TEST_HYPRCTL_EVAL_OUT="$eval_out" \ + PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-hyprland-monitor-refresh-rate" "$@" +} + +write_monitors +[[ $(run_refresh_rate) == "60" ]] || fail "monitor refresh rate reports the live rate" +pass "monitor refresh rate reports the live rate" + +write_monitors +run_refresh_rate 144 >/dev/null +grep -F 'mode = "2880x1800@143.91"' "$eval_out" >/dev/null || + fail "monitor refresh rate snaps a whole-hertz request onto the real mode" "actual: $(cat "$eval_out")" +pass "monitor refresh rate snaps a whole-hertz request onto the real mode" + +# The rate change must not disturb the scale; hl.monitor rewrites the whole +# output, so an omitted scale would silently reset it. +write_monitors +run_refresh_rate 144 >/dev/null +grep -F 'scale = 2' "$eval_out" >/dev/null || fail "monitor refresh rate keeps the current scale" +pass "monitor refresh rate keeps the current scale" + +write_monitors +run_refresh_rate 60 >/dev/null +grep -F 'mode = "2880x1800@60"' "$eval_out" >/dev/null || + fail "monitor refresh rate applies an exact mode" "actual: $(cat "$eval_out")" +pass "monitor refresh rate applies an exact mode" + +# 120Hz exists, but only at another resolution. Applying it would ask Hyprland +# to change the picture, not the rate. +write_monitors +if run_refresh_rate 120 2>/dev/null; then + fail "monitor refresh rate refuses a rate the current mode cannot reach" +fi +[[ ! -s $eval_out ]] || fail "monitor refresh rate applies nothing when it refuses a rate" +pass "monitor refresh rate refuses a rate the current mode cannot reach" + +write_monitors +if run_refresh_rate nope 2>/dev/null; then + fail "monitor refresh rate rejects a non-numeric rate" +fi +pass "monitor refresh rate rejects a non-numeric rate" + +write_monitors '[ + { + "name": "eDP-1\"; os.execute(\"touch /tmp/pwned\"); --", + "focused": true, + "scale": 2, + "width": 2880, + "height": 1800, + "refreshRate": 60.00000, + "availableModes": ["2880x1800@60.00Hz"] + } +]' +if run_refresh_rate 60 2>/dev/null; then + fail "monitor refresh rate refuses an unsafe monitor name" +fi +[[ ! -s $eval_out ]] || fail "monitor refresh rate evaluates nothing for an unsafe monitor name" +pass "monitor refresh rate refuses an unsafe monitor name" diff --git a/test/shell.d/monitor-state-test.sh b/test/shell.d/monitor-state-test.sh index b568172fdc..be5d5627cb 100755 --- a/test/shell.d/monitor-state-test.sh +++ b/test/shell.d/monitor-state-test.sh @@ -57,8 +57,8 @@ assert_line_count() { } extended='[ - { "name": "eDP-1", "mirrorOf": "none", "disabled": false, "focused": false, "width": 1920, "height": 1080 }, - { "name": "DP-1", "mirrorOf": "none", "disabled": false, "focused": true, "width": 2560, "height": 1440 } + { "name": "eDP-1", "mirrorOf": "none", "disabled": false, "focused": false, "width": 1920, "height": 1080, "refreshRate": 60.00000, "availableModes": ["1920x1080@60.00Hz"] }, + { "name": "DP-1", "mirrorOf": "none", "disabled": false, "focused": true, "width": 2560, "height": 1440, "refreshRate": 143.912, "availableModes": ["2560x1440@143.91Hz", "2560x1440@60.00Hz", "1920x1080@120.00Hz"] } ]' # Omarchy mirrors by pointing the external at the internal, so `mirrorOf` lands @@ -76,7 +76,7 @@ reverse_mirrored='[ clamshell='[ { "name": "eDP-1", "mirrorOf": "none", "disabled": true, "focused": false, "width": 0, "height": 0 }, - { "name": "DP-1", "mirrorOf": "none", "disabled": false, "focused": true, "width": 2560, "height": 1440 } + { "name": "DP-1", "mirrorOf": "none", "disabled": false, "focused": true, "width": 2560, "height": 1440, "refreshRate": 143.912, "availableModes": ["2560x1440@143.91Hz", "2560x1440@60.00Hz"] } ]' monitor_state "$extended" @@ -109,9 +109,16 @@ assert_line 4 "" "monitor state reports no mirror while clamshelled" pass "monitor state separates a disabled internal monitor from a missing one" monitor_state "$extended" -[[ ${state_lines[7]-} == '[{"name":"eDP-1","enabled":true,"focused":false,"width":1920,"height":1080},{"name":"DP-1","enabled":true,"focused":true,"width":2560,"height":1440}]' ]] || +[[ ${state_lines[7]-} == '[{"name":"eDP-1","enabled":true,"focused":false,"width":1920,"height":1080,"refreshRate":60,"modes":["1920x1080@60.00Hz"]},{"name":"DP-1","enabled":true,"focused":true,"width":2560,"height":1440,"refreshRate":143.912,"modes":["2560x1440@143.91Hz","2560x1440@60.00Hz"]}]' ]] || fail "monitor state lists every display for the panel" "actual: ${state_lines[7]-}" monitor_state "$clamshell" -[[ ${state_lines[7]-} == '[{"name":"eDP-1","enabled":false,"focused":false,"width":0,"height":0},{"name":"DP-1","enabled":true,"focused":true,"width":2560,"height":1440}]' ]] || +[[ ${state_lines[7]-} == '[{"name":"eDP-1","enabled":false,"focused":false,"width":0,"height":0,"refreshRate":0,"modes":[]},{"name":"DP-1","enabled":true,"focused":true,"width":2560,"height":1440,"refreshRate":143.912,"modes":["2560x1440@143.91Hz","2560x1440@60.00Hz"]}]' ]] || fail "monitor state lists every display for the panel" "actual: ${state_lines[7]-}" pass "monitor state lists every display with its enabled and focused state" + +# A 1920x1080 mode on a 2560x1440 display is a resolution change, not a rate +# the panel can offer, so it must not reach the display list. +monitor_state "$extended" +[[ ${state_lines[7]-} != *"1920x1080@120.00Hz"* ]] || + fail "monitor state drops modes at another resolution" "actual: ${state_lines[7]-}" +pass "monitor state lists only the modes at each display's own resolution" diff --git a/test/shell.d/monitor-test.sh b/test/shell.d/monitor-test.sh index 30014b22b9..5fae69624a 100644 --- a/test/shell.d/monitor-test.sh +++ b/test/shell.d/monitor-test.sh @@ -54,6 +54,43 @@ assertDeepEqual( 'monitor keeps presets until display dimensions are known' ) +assertEqual(monitor.normalizeRefreshRate('60.00000'), '60', 'monitor normalizes a whole refresh rate') +assertEqual(monitor.normalizeRefreshRate('143.912'), '143.91', 'monitor normalizes a fractional refresh rate') +assertEqual(monitor.normalizeRefreshRate(0), '', 'monitor rejects a missing refresh rate') +assertEqual(monitor.refreshRateLabel('143.91'), '144', 'monitor labels a rate by its whole hertz') + +assertDeepEqual( + monitor.availableRefreshRates( + ['2880x1800@143.91Hz', '2880x1800@60.00Hz', '1920x1080@120.00Hz'], + 2880, + 1800 + ), + ['143.91', '60'], + 'monitor offers the rates the current mode can reach, fastest first' +) +assertDeepEqual( + monitor.availableRefreshRates(['2880x1800@59.94Hz', '2880x1800@60.00Hz'], 2880, 1800), + ['60'], + 'monitor collapses rates that share a whole hertz' +) +assertDeepEqual( + monitor.availableRefreshRates(['2880x1800@60.00Hz'], 0, 0), + ['60'], + 'monitor keeps rates until display dimensions are known' +) +assertDeepEqual(monitor.availableRefreshRates(null, 2880, 1800), [], 'monitor handles a missing mode list') + +assertEqual( + monitor.matchingRefreshRateIndex(['143.91', '60'], 143.912), + 0, + 'monitor matches the rate the driver reports to its listed mode' +) +assertEqual( + monitor.matchingRefreshRateIndex(['143.91', '60'], 90), + -1, + 'monitor matches no rate while the display is in another mode' +) + assertEqual(monitor.brightnessName(96), 'Sun blast', 'monitor names very bright displays') assertEqual(monitor.brightnessName(12), 'Candlelit', 'monitor names dim displays')