/ de-omarchy / bin / omarchy-hyprland-monitor-scaling
swadhinbiswas Initial de-omarchy: Omarchy UI layer for Arch-based distros · 8/22/2026
9f393b2
196 lines · 6.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#!/bin/bash

# omarchy:summary=Show, set, or adjust focused Hyprland monitor scaling
# 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)
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|SCALE]"
}

focused_monitor_scale() {
  hyprctl monitors -j | jq -er '.[] | select(.focused == true) | .scale'
}

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_scale_change() {
  local requested="$1"
  local active_monitor="$2"
  local current_scale="$3"
  local new_scale="$4"
  local parent_pid="$PPID"
  local grandparent_pid
  local parent_cmd
  local 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\trequested=%s\tcurrent=%s\tnew=%s\tmonitor=%s\tpid=%s\tppid=%s\tparent=%s\tgppid=%s\tgrandparent=%s\n' \
    "$(date --iso-8601=seconds)" \
    "$requested" \
    "$current_scale" \
    "$new_scale" \
    "$active_monitor" \
    "$$" \
    "$parent_pid" \
    "$parent_cmd" \
    "$grandparent_pid" \
    "$grandparent_cmd" >>"$SCALE_LOG"
}

# 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 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 }
    BEGIN {
      g = gcd(width * 120, height * 120)
      k = int(scale * 120 + 0.5)
      if (k > g) k = g
      while (g % k != 0) k++
      printf "%g\n", k / 120
    }'
}

normalize_scale() {
  awk 'NR == 1 { printf "%g\n", $0 }'
}

set_scale() {
  local requested_scale="$1"
  local requested="${2:-$requested_scale}"
  local monitor_info="$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)')"
  local active_monitor="$(echo "$monitor_info" | jq -r '.name')"
  local current_scale="$(echo "$monitor_info" | jq -r '.scale')"
  local width="$(echo "$monitor_info" | jq -r '.width')"
  local height="$(echo "$monitor_info" | jq -r '.height')"
  local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
  local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
  # GTK only honors integer GDK_SCALE values, so persist the nearest whole
  # factor even when the monitor scale itself is fractional.
  local new_gdk_scale="$(awk -v scale="$new_scale" 'BEGIN { printf "%d", int(scale + 0.5) }')"
  local monitor_lua="$HOME/.config/hypr/monitors.lua"

  hyprctl eval "hl.monitor({ output = \"$active_monitor\", mode = \"${width}x${height}@${refresh_rate}\", position = \"auto\", scale = $new_scale })" >/dev/null
  audit_scale_change "$requested" "$active_monitor" "$current_scale" "$new_scale"

  # Persist to monitors.lua if the user still has Omarchy's generic catch-all
  # defaults, so the scale survives reboots.
  if [[ -f $monitor_lua ]] && grep -q '^local omarchy_monitor_scale = ' "$monitor_lua"; then
    sed -i -E \
      -e "s|^local omarchy_monitor_scale = .*|local omarchy_monitor_scale = ${new_scale}|" \
      -e "s|^local omarchy_gdk_scale = .*|local omarchy_gdk_scale = ${new_gdk_scale}|" \
      "$monitor_lua"
  elif [[ -f $monitor_lua ]] && grep -Eq '^hl\.monitor\(\{ output = "", mode = "preferred", position = "auto", scale = ("auto"|[0-9.]+) \}\)' "$monitor_lua"; then
    sed -i -E \
      -e "s|^(hl\.monitor\(\{ output = \"\", mode = \"preferred\", position = \"auto\", scale = )([^ ]+)( \}\))|\\1${new_scale}\\3|" \
      -e 's|^hl\.env\("GDK_SCALE", ".*"\)|hl.env("GDK_SCALE", "'"$new_gdk_scale"'")|' \
      "$monitor_lua"
  fi
}

scale_from_current() {
  local direction="${1:-}"
  local width="${2:-}"
  local height="${3:-}"

  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

      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

        # 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 - effective_scales[i]; if (diff < 0) diff = -diff
        if (diff < best_diff) { best_diff = diff; best = i }
      }

      if (direction == "next") {
        print scales[(best < n ? best + 1 : n)]
      } else if (direction == "previous") {
        print scales[(best > 1 ? best - 1 : 1)]
      } else {
        print scales[best]
      }
    }'
}

case "${1:-}" in
"")
  focused_monitor_scale | normalize_scale
  ;;
-h | --help)
  usage
  ;;
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)
  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"
  ;;
*)
  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