Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions bin/omarchy-hyprland-monitor-refresh-rate
Original file line number Diff line number Diff line change
@@ -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("^(?<width>[0-9]+)x(?<height>[0-9]+)@(?<rate>[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
16 changes: 14 additions & 2 deletions bin/omarchy-monitor-state
Original file line number Diff line number Diff line change
Expand Up @@ -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)@"))]
}]'
2 changes: 1 addition & 1 deletion manual/05-the-top-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions manual/33-monitors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
67 changes: 67 additions & 0 deletions shell/plugins/panels/monitor/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
Loading