diff --git a/bin/omarchy-hyprland-monitor-scaling b/bin/omarchy-hyprland-monitor-scaling index f90c078e..24060ecc 100755 --- a/bin/omarchy-hyprland-monitor-scaling +++ b/bin/omarchy-hyprland-monitor-scaling @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Show, set, or adjust focused Hyprland monitor scaling -# omarchy:args=[up|down|1|1.25|1.6|2|3|4] +# omarchy:args=[up|down|SCALE] # omarchy:examples=omarchy hyprland monitor scaling | omarchy hyprland monitor scaling 1.6 | omarchy hyprland monitor scaling up | omarchy hyprland monitor scaling down SCALES=(1 1.25 1.6 2 3 4) @@ -9,7 +9,7 @@ STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy" SCALE_LOG="$STATE_DIR/monitor-scaling.log" usage() { - echo "Usage: omarchy-hyprland-monitor-scaling [up|down|1|1.25|1.6|2|3|4]" + echo "Usage: omarchy-hyprland-monitor-scaling [up|down|SCALE]" } focused_monitor_scale() { @@ -54,8 +54,7 @@ audit_scale_change() { # Hyprland only accepts scales where the mode divides into whole logical # pixels (in 1/120 steps), so clean scales are divisors of gcd(w*120, h*120). -# Round the requested scale up to the nearest one, so 3x on a 1280x800 QEMU -# display becomes 3.2x instead of a red error overlay. +# Round the requested scale up to the nearest clean value. clean_scale() { awk -v scale="$1" -v width="$2" -v height="$3" ' function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a } @@ -68,6 +67,10 @@ clean_scale() { }' } +normalize_scale() { + awk 'NR == 1 { printf "%g\n", $0 }' +} + set_scale() { local requested_scale="$1" local requested="${2:-$requested_scale}" @@ -100,21 +103,50 @@ set_scale() { scale_from_current() { local direction="${1:-}" + local width="${2:-}" + local height="${3:-}" - awk -v direction="$direction" -v list="${SCALES[*]}" ' + awk -v direction="$direction" -v list="${SCALES[*]}" -v width="$width" -v height="$height" ' + function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a } + function clean(scale, g, k) { + g = gcd(width * 120, height * 120) + k = int(scale * 120 + 0.5) + if (k > g) k = g + while (g % k != 0) k++ + return k / 120 + } NR == 1 { scale = $0; found = 1 } END { if (!found) exit 1 - n = split(list, scales, " ") + preset_count = split(list, presets, " ") + for (i = 1; i <= preset_count; i++) { + effective = clean(presets[i]) + key = sprintf("%.8f", effective) + distance = presets[i] - effective + if (distance < 0) distance = -distance - # Snap to the nearest preset first. Hyprland reports scales as floating - # point values, so a scale set to 3 can come back as 3.0000000000000004. - # Without snapping, the directional comparisons below can misidentify - # the current preset and refuse to step down (or up) any further. + # Multiple presets can collapse to the same clean scale. Keep only the + # closest label so stepping always moves to a distinct effective value. + if (!(key in effective_index)) { + effective_index[key] = ++n + effective_scales[n] = effective + scales[n] = presets[i] + distances[n] = distance + } else { + idx = effective_index[key] + if (distance < distances[idx]) { + scales[idx] = presets[i] + distances[idx] = distance + } + } + } + + # Snap to the nearest effective scale first. Hyprland reports floating + # point values, so exact comparisons can otherwise get stuck. best = 1; best_diff = 1e9 for (i = 1; i <= n; i++) { - diff = scale - scales[i]; if (diff < 0) diff = -diff + diff = scale - effective_scales[i]; if (diff < 0) diff = -diff if (diff < best_diff) { best_diff = diff; best = i } } @@ -130,22 +162,31 @@ scale_from_current() { case "${1:-}" in "") - focused_monitor_scale | scale_from_current + focused_monitor_scale | normalize_scale ;; -h | --help) usage ;; up) - set_scale "$(focused_monitor_scale | scale_from_current next)" "up" + monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)') + set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current next \ + "$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "up" ;; down) - set_scale "$(focused_monitor_scale | scale_from_current previous)" "down" + monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)') + set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current previous \ + "$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "down" ;; 1 | 1.25 | 1.6 | 2 | 3 | 4) set_scale "$1" "$1" ;; *) - usage >&2 - exit 1 + if [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] && + awk -v scale="$1" 'BEGIN { exit !(scale >= 1 && scale <= 4) }'; then + set_scale "$1" "$1" + else + usage >&2 + exit 1 + fi ;; esac diff --git a/shell/plugins/panels/monitor/Model.js b/shell/plugins/panels/monitor/Model.js index d67e25b6..215972b9 100644 --- a/shell/plugins/panels/monitor/Model.js +++ b/shell/plugins/panels/monitor/Model.js @@ -60,10 +60,7 @@ function availableScales(scales, width, height) { var requested = Number(scales[i]) var effective = Number(cleanScale(requested, width, height)) - // Clean scales round upward. If the mode cannot reach the requested - // scale, cleanScale caps at its largest valid value and this preset is - // not actually available. - if (!isFinite(requested) || !isFinite(effective) || effective < requested) continue + if (!isFinite(requested) || !isFinite(effective)) continue var key = normalizeScale(effective) var existing = byEffectiveScale[key] diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index 34cbd3bb..77820314 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -263,6 +263,15 @@ Panel { return -1 } + 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) + } + // Playful mood-name for a given brightness percent. Bands intentionally // span ~10–20 points so casual tweaks change the label, while small // nudges within one band don't. @@ -781,7 +790,7 @@ Panel { required property string scaleValue required property int scaleIndex - text: scaleValue + "x" + text: root.effectiveScale(scaleValue) + "x" fontSize: Style.font.caption foreground: root.bar.foreground fontFamily: root.bar.fontFamily diff --git a/test/shell.d/monitor-scaling-test.sh b/test/shell.d/monitor-scaling-test.sh index 01887c62..4a0519cf 100644 --- a/test/shell.d/monitor-scaling-test.sh +++ b/test/shell.d/monitor-scaling-test.sh @@ -74,14 +74,42 @@ scale=$(OMARCHY_TEST_MONITOR_SCALE=3 run_scaling) [[ $scale == "3" ]] || fail "monitor scaling reports explicit 3x scale" "actual: $scale" pass "monitor scaling reports explicit 3x scale" -# 1280x800 (QEMU virtio-gpu) can't divide cleanly by 3; expect a snap up to 3.2. +scale=$(OMARCHY_TEST_MONITOR_SCALE=3.2 run_scaling) +[[ $scale == "3.2" ]] || fail "monitor scaling reports the actual non-preset scale" "actual: $scale" +pass "monitor scaling reports the actual non-preset scale" + +# 1280x800 approximates the 3x preset as 3.2x. write_monitor_config OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling 3 -grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling snaps unclean 3x up to 3.2x" -grep -Fx 'local omarchy_monitor_scale = 3.2' "$monitor_lua" >/dev/null || fail "monitor scaling persists snapped 3.2x" -pass "monitor scaling snaps unclean 3x up to 3.2x" +grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling approximates explicit 3x as 3.2x" +grep -Fx 'local omarchy_monitor_scale = 3.2' "$monitor_lua" >/dev/null || + fail "monitor scaling persists approximated 3.2x" +pass "monitor scaling approximates explicit 3x as 3.2x" write_monitor_config OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling up -grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling up snaps unclean preset to 3.2x" -pass "monitor scaling up snaps unclean preset to 3.2x" +grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling up reaches approximated 3.2x" +pass "monitor scaling up reaches approximated 3.2x" + +write_monitor_config +OMARCHY_TEST_MONITOR_SCALE=4 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling down +grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling down reaches approximated 3.2x" +pass "monitor scaling down reaches approximated 3.2x" + +write_monitor_config +OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=6016 OMARCHY_TEST_MONITOR_HEIGHT=3384 run_scaling 1.25 +grep -F 'scale = 1.33333' "$eval_out" >/dev/null || fail "monitor scaling approximates explicit 1.25x" +pass "monitor scaling approximates explicit 1.25x" + +write_monitor_config +OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling 3.2 +grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling accepts displayed approximate values" +pass "monitor scaling accepts displayed approximate values" + +# On a mode where both 3x and 4x resolve to 4x, the duplicate is one step. +write_monitor_config +OMARCHY_TEST_MONITOR_SCALE=4 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=804 run_scaling down +grep -F 'scale = 2' "$eval_out" >/dev/null || fail "monitor scaling down skips duplicate 4x approximation" +grep -Fx 'local omarchy_monitor_scale = 2' "$monitor_lua" >/dev/null || + fail "monitor scaling down persists 2x after skipping duplicate approximation" +pass "monitor scaling down skips duplicate approximation" diff --git a/test/shell.d/monitor-test.sh b/test/shell.d/monitor-test.sh index 1917c102..30014b22 100644 --- a/test/shell.d/monitor-test.sh +++ b/test/shell.d/monitor-test.sh @@ -21,17 +21,27 @@ assertEqual(monitor.cleanScale(1.6, 0, 800), '', 'monitor rejects a missing disp assertEqual( monitor.matchingScaleIndex(['1', '1.25', '1.6', '2', '3', '4'], 3.2, 1280, 800), 4, - 'monitor selects requested 3x for effective VM scale' + 'monitor selects an approximated VM scale' ) assertEqual( monitor.matchingScaleIndex(['1', '1.25', '1.6', '2', '3', '4'], 4, 4, 4), 5, - 'monitor selects only the closest preset when clean scales collide' + 'monitor selects an exact preset' ) assertDeepEqual( monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 1280, 800), ['1', '1.25', '1.6', '2', '3', '4'], - 'monitor keeps presets that produce unique reachable VM scales' + 'monitor keeps distinct approximated VM scales' +) +assertDeepEqual( + monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 6016, 3384), + ['1', '1.25', '1.6', '2', '3', '4'], + 'monitor keeps distinct approximated physical display scales' +) +assertDeepEqual( + monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 1280, 804), + ['1', '1.25', '2', '4'], + 'monitor collapses presets with duplicate effective scales' ) assertDeepEqual( monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 5968, 3230),