feat(hyprlua): snap T/G scale to valid Hyprland steps for current resolution

Replaces fixed 0.25 increments with mathematically valid scales p/q
(lowest terms, q≤6) where both width/s and height/s are integers.
For 1920x1200 this gives 25 steps including 2.4, matching what
Hyprland actually applies — no more mismatch between TUI display
and live value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-06-23 09:52:45 +02:00
parent 8343a741b8
commit 08e4e583a3
1 changed files with 44 additions and 12 deletions

View File

@ -7,7 +7,7 @@ Keys (normal mode):
h j k l move monitor (50 px)
H J K L move monitor (10 px fine)
u / i rotate CCW / CW
T / G scale up / scale down (0.25 steps)
T / G scale up / scale down (valid Hyprland steps)
m toggle mirror (pick target) / un-mirror
n / N cycle display mode forward / backward
s save to hypr/usr/monitors.lua
@ -22,6 +22,7 @@ Mirror-pick mode:
import curses
import json
import math
import os
import re
import subprocess
@ -37,9 +38,9 @@ from typing import List, Optional
MONITORS_LUA = Path.home() / "Dotfiles/desktopenvs/hyprlua/hypr/usr/monitors.lua"
MOVE_STEP = 50
MOVE_STEP_FINE = 10
SCALE_STEP = 0.25
MIN_SCALE = 0.25
MAX_SCALE = 4.0
_SCALE_MAX_DENOM = 6 # max denominator when enumerating valid Hyprland scales
MIN_BOX_W = 14
MIN_BOX_H = 4
INFO_W = 32
@ -68,6 +69,29 @@ def rotate_cw(t: int) -> int:
def rotate_ccw(t: int) -> int:
return (t & 4) | ((t - 1) & 3)
# ---------------------------------------------------------------------------
# Scale helpers
# ---------------------------------------------------------------------------
def valid_scales(width: int, height: int) -> List[float]:
"""Return sorted list of scales valid for (width, height).
A scale s = p/q (in lowest terms) is valid iff both width/s and height/s
are integers, i.e. p divides gcd(width, height). We limit q ≤
_SCALE_MAX_DENOM to keep the step count practical (~20 steps per monitor).
"""
g = math.gcd(width, height)
divisors = [k for k in range(1, g + 1) if g % k == 0]
result: set = set()
for p in divisors:
for q in range(1, _SCALE_MAX_DENOM + 1):
if math.gcd(p, q) != 1:
continue
s = p / q
if MIN_SCALE <= s <= MAX_SCALE:
result.add(round(s, 10))
return sorted(result)
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@ -387,9 +411,9 @@ class App:
self.rotate_monitor(+1)
# Scale
elif ch == ord("T"):
self.scale_monitor(+SCALE_STEP)
self.scale_monitor(+1)
elif ch == ord("G"):
self.scale_monitor(-SCALE_STEP)
self.scale_monitor(-1)
# Mirror
elif ch == ord("m"):
if mon.mirror_of:
@ -471,19 +495,27 @@ class App:
self.dirty = True
self.status_msg = err or f"Rotated {mon.name} → {TRANSFORM_LABEL[mon.transform]}"
def scale_monitor(self, delta: float):
def scale_monitor(self, direction: int):
mon = self.monitors[self.selected_idx]
new_scale = round(max(MIN_SCALE, min(MAX_SCALE, mon.scale + delta)), 10)
# round to nearest SCALE_STEP to avoid floating-point drift
new_scale = round(new_scale / SCALE_STEP) * SCALE_STEP
if new_scale == mon.scale:
self.status_msg = f"Scale already at {mon.scale}x (limit)"
scales = valid_scales(mon.width, mon.height)
cur = round(mon.scale, 10)
if direction > 0:
candidates = [s for s in scales if s > cur + 1e-9]
if not candidates:
self.status_msg = f"Scale already at max ({mon.scale}x)"
return
new_scale = candidates[0]
else:
candidates = [s for s in scales if s < cur - 1e-9]
if not candidates:
self.status_msg = f"Scale already at min ({mon.scale}x)"
return
new_scale = candidates[-1]
mon.scale = new_scale
err = apply_monitor(mon)
mon.dirty = True
self.dirty = True
self.status_msg = err or f"{mon.name} scale → {mon.scale}x"
self.status_msg = err or f"{mon.name} scale → {new_scale}x"
def cycle_mode(self, delta: int):
mon = self.monitors[self.selected_idx]