From 08e4e583a3f2aee65a6cf8927cdb12c3727d6dae Mon Sep 17 00:00:00 2001 From: The_miro Date: Tue, 23 Jun 2026 09:52:45 +0200 Subject: [PATCH] feat(hyprlua): snap T/G scale to valid Hyprland steps for current resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- desktopenvs/hyprlua/scripts/monitor-manager | 56 ++++++++++++++++----- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/desktopenvs/hyprlua/scripts/monitor-manager b/desktopenvs/hyprlua/scripts/monitor-manager index 29d3b61..6f0d3f1 100755 --- a/desktopenvs/hyprlua/scripts/monitor-manager +++ b/desktopenvs/hyprlua/scripts/monitor-manager @@ -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)" - return + 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]