feat(monitor-manager): write live config and resolve snap overlaps

Target ~/.config/hypr/usr/monitors.lua (the config Hyprland actually
loads) so `hyprctl reload` applies layouts immediately; the Dotfiles
repo copy stays the deploy source and is no longer written.

Add overlap geometry helpers and integrate them into the apply flow:
- block moves that would drive a monitor into a neighbor (TUI coords)
- snap positions to the MOVE_STEP_FINE grid to avoid frozen digits
- auto-resolve snap-induced collisions by re-reading the live layout
  and nudging the moved monitor clear, up to MAX_RESOLVE_ITERS
- warn on residual overlap after apply and after save/reload

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-06-26 16:00:52 +02:00
parent a1d5185ac8
commit 10a5fbb33b
1 changed files with 160 additions and 27 deletions

View File

@ -35,7 +35,11 @@ from typing import List, Optional
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MONITORS_LUA = Path.home() / "Dotfiles/desktopenvs/hyprlua/hypr/usr/monitors.lua" # The live config Hyprland actually loads (hyprland.lua does `require("usr.monitors")`,
# which resolves to ~/.config/hypr/usr/monitors.lua). Writing here means `hyprctl
# reload` applies the layout immediately. The Dotfiles repo copy is the deploy
# source and is intentionally NOT written by this tool.
MONITORS_LUA = Path.home() / ".config/hypr/usr/monitors.lua"
MOVE_STEP = 50 MOVE_STEP = 50
MOVE_STEP_FINE = 10 MOVE_STEP_FINE = 10
MIN_SCALE = 0.25 MIN_SCALE = 0.25
@ -45,6 +49,7 @@ MIN_BOX_W = 14
MIN_BOX_H = 4 MIN_BOX_H = 4
INFO_W = 32 INFO_W = 32
STATUS_ROWS = 2 # status + help rows at the bottom STATUS_ROWS = 2 # status + help rows at the bottom
MAX_RESOLVE_ITERS = 8 # auto-resolve nudges before giving up
TRANSFORM_LABEL = { TRANSFORM_LABEL = {
0: "↕ 0°", 0: "↕ 0°",
@ -185,6 +190,36 @@ def apply_monitor(m: MonitorState) -> Optional[str]:
return (r.stderr or r.stdout).strip() return (r.stderr or r.stdout).strip()
return None return None
# ---------------------------------------------------------------------------
# Overlap geometry
# ---------------------------------------------------------------------------
def _logical_rect(m: "MonitorState"):
return (m.x, m.y, m.x + m.logical_width, m.y + m.logical_height)
def _overlap_px(a, b):
"""Return (ox, oy) — overlapping width/height of two (x0,y0,x1,y1) rects."""
ox = max(0, min(a[2], b[2]) - max(a[0], b[0]))
oy = max(0, min(a[3], b[3]) - max(a[1], b[1]))
return ox, oy
def find_overlaps(monitors: List["MonitorState"]):
"""Return list of (name_a, name_b, ox, oy) for every overlapping pair.
Mirrored outputs are excluded — they intentionally share their target's
region and don't occupy independent layout space.
"""
active = [m for m in monitors if not m.mirror_of]
out = []
for i in range(len(active)):
for j in range(i + 1, len(active)):
ox, oy = _overlap_px(_logical_rect(active[i]), _logical_rect(active[j]))
if ox > 0 and oy > 0:
out.append((active[i].name, active[j].name, ox, oy))
return out
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Save # Save
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -418,10 +453,7 @@ class App:
elif ch == ord("m"): elif ch == ord("m"):
if mon.mirror_of: if mon.mirror_of:
mon.mirror_of = "" mon.mirror_of = ""
err = apply_monitor(mon) self._apply_and_status(mon, f"Un-mirrored {mon.name}")
mon.dirty = True
self.dirty = True
self.status_msg = err or f"Un-mirrored {mon.name}"
elif len(self.monitors) < 2: elif len(self.monitors) < 2:
self.status_msg = "Need 2+ monitors to mirror" self.status_msg = "Need 2+ monitors to mirror"
else: else:
@ -475,14 +507,122 @@ class App:
# Actions # Actions
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
def move_monitor(self, dx: int, dy: int): def _live_overlap_warning(self) -> Optional[str]:
mon = self.monitors[self.selected_idx] """Re-fetch live monitor positions and report any actual overlap.
mon.x = max(0, mon.x + dx)
mon.y = max(0, mon.y + dy) Hyprland re-snaps fractionally-scaled/rotated outputs at apply time, so
the positions it ends up with can differ from what this TUI set. Only a
check against the live state catches snap-induced collisions.
"""
try:
ov = find_overlaps(fetch_monitors())
except Exception:
return None
if not ov:
return None
a, b, ox, _ = ov[0]
extra = f" (+{len(ov) - 1} more)" if len(ov) > 1 else ""
return f"{a} overlaps {b} by {ox}px{extra}"
def _auto_resolve(self, mon: MonitorState) -> bool:
"""Nudge `mon` until its *live* rectangle no longer overlaps a neighbor.
Hyprland may snap a fractionally-scaled/rotated output to a position
that collides with its neighbor. We can't predict the snap, so we read
the live layout, push `mon` by the minimal grid-aligned translation that
would separate it from its worst overlapper, re-apply, and repeat. Only
`mon` moves; other monitors are left where the user put them. Returns
True if any adjustment was made.
"""
if mon.mirror_of:
return False
moved = False
for _ in range(MAX_RESOLVE_ITERS):
try:
live = fetch_monitors()
except Exception:
break
me = next((lm for lm in live if lm.name == mon.name), None)
if me is None or me.mirror_of:
break
a = _logical_rect(me)
worst = None # (overlap_area, neighbor_rect)
for o in live:
if o.name == mon.name or o.mirror_of:
continue
ox, oy = _overlap_px(a, _logical_rect(o))
if ox > 0 and oy > 0 and (worst is None or ox * oy > worst[0]):
worst = (ox * oy, _logical_rect(o))
if worst is None:
break # no overlap left — resolved
b = worst[1]
# Minimal separating translation of `mon` along either axis.
axis, delta = min(
(("x", b[2] - a[0]), ("x", b[0] - a[2]),
("y", b[3] - a[1]), ("y", b[1] - a[3])),
key=lambda c: abs(c[1]),
)
# Round magnitude up to the grid so the next snap keeps a margin.
step = MOVE_STEP_FINE
mag = ((abs(delta) + step - 1) // step) * step
delta = mag if delta > 0 else -mag
if axis == "x":
mon.x = max(0, mon.x + delta)
else:
mon.y = max(0, mon.y + delta)
if apply_monitor(mon):
break
moved = True
return moved
def _apply_and_status(self, mon: MonitorState, success_msg: str):
"""Apply a monitor, mark dirty, auto-resolve snap overlap, set status."""
err = apply_monitor(mon) err = apply_monitor(mon)
mon.dirty = True mon.dirty = True
self.dirty = True self.dirty = True
self.status_msg = err or f"Moved {mon.name} to {mon.x},{mon.y}" if err:
self.status_msg = err
return
resolved = self._auto_resolve(mon)
warn = self._live_overlap_warning()
if warn:
self.status_msg = f"{success_msg} ⚠ {warn}"
elif resolved:
self.status_msg = f"{success_msg} (auto-resolved → {mon.x},{mon.y})"
else:
self.status_msg = success_msg
def _would_overlap(self, idx: int) -> Optional[str]:
"""Name of the first monitor that monitor `idx` overlaps (TUI coords)."""
mon = self.monitors[idx]
if mon.mirror_of:
return None
a = _logical_rect(mon)
for j, other in enumerate(self.monitors):
if j == idx or other.mirror_of:
continue
ox, oy = _overlap_px(a, _logical_rect(other))
if ox > 0 and oy > 0:
return other.name
return None
def move_monitor(self, dx: int, dy: int):
mon = self.monitors[self.selected_idx]
# Snap to a round grid so positions stay multiples of MOVE_STEP_FINE.
# The seed comes from hyprctl, which may report a snapped value (e.g.
# a fractionally-scaled/rotated monitor), so raw addition would freeze
# the last digit. Rounding normalizes any odd seed on the first move.
base = MOVE_STEP_FINE
old_x, old_y = mon.x, mon.y
mon.x = max(0, round((mon.x + dx) / base) * base)
mon.y = max(0, round((mon.y + dy) / base) * base)
# Block moves that would drive this monitor into another (TUI coords).
clash = self._would_overlap(self.selected_idx)
if clash:
mon.x, mon.y = old_x, old_y
self.status_msg = f"Blocked: {mon.name} would overlap {clash}"
return
self._apply_and_status(mon, f"Moved {mon.name} to {mon.x},{mon.y}")
def rotate_monitor(self, direction: int): def rotate_monitor(self, direction: int):
mon = self.monitors[self.selected_idx] mon = self.monitors[self.selected_idx]
@ -490,10 +630,7 @@ class App:
mon.transform = rotate_cw(mon.transform) mon.transform = rotate_cw(mon.transform)
else: else:
mon.transform = rotate_ccw(mon.transform) mon.transform = rotate_ccw(mon.transform)
err = apply_monitor(mon) self._apply_and_status(mon, f"Rotated {mon.name} → {TRANSFORM_LABEL[mon.transform]}")
mon.dirty = True
self.dirty = True
self.status_msg = err or f"Rotated {mon.name} → {TRANSFORM_LABEL[mon.transform]}"
def scale_monitor(self, direction: int): def scale_monitor(self, direction: int):
mon = self.monitors[self.selected_idx] mon = self.monitors[self.selected_idx]
@ -512,10 +649,7 @@ class App:
return return
new_scale = candidates[-1] new_scale = candidates[-1]
mon.scale = new_scale mon.scale = new_scale
err = apply_monitor(mon) self._apply_and_status(mon, f"{mon.name} scale → {new_scale}x")
mon.dirty = True
self.dirty = True
self.status_msg = err or f"{mon.name} scale → {new_scale}x"
def cycle_mode(self, delta: int): def cycle_mode(self, delta: int):
mon = self.monitors[self.selected_idx] mon = self.monitors[self.selected_idx]
@ -528,19 +662,13 @@ class App:
mon.width = int(mo.group(1)) mon.width = int(mo.group(1))
mon.height = int(mo.group(2)) mon.height = int(mo.group(2))
mon.refresh_rate = float(mo.group(3)) mon.refresh_rate = float(mo.group(3))
err = apply_monitor(mon) self._apply_and_status(mon, f"{mon.name} → {mon.mode_str}")
mon.dirty = True
self.dirty = True
self.status_msg = err or f"{mon.name} → {mon.mode_str}"
def set_mirror(self, src_idx: int, tgt_idx: int): def set_mirror(self, src_idx: int, tgt_idx: int):
src = self.monitors[src_idx] src = self.monitors[src_idx]
tgt = self.monitors[tgt_idx] tgt = self.monitors[tgt_idx]
src.mirror_of = tgt.name src.mirror_of = tgt.name
err = apply_monitor(src) self._apply_and_status(src, f"Mirroring {src.name} → {tgt.name}")
src.dirty = True
self.dirty = True
self.status_msg = err or f"Mirroring {src.name} → {tgt.name}"
def _save(self): def _save(self):
try: try:
@ -548,8 +676,13 @@ class App:
for m in self.monitors: for m in self.monitors:
m.dirty = False m.dirty = False
self.dirty = False self.dirty = False
self.status_msg = f"Saved to {MONITORS_LUA.name}"
subprocess.run(["hyprctl", "reload"], capture_output=True, check=False) subprocess.run(["hyprctl", "reload"], capture_output=True, check=False)
# Reload re-applies the saved config; re-check the live layout so a
# snap-induced collision is surfaced rather than silently saved.
warn = self._live_overlap_warning()
self.status_msg = (
f"Saved — ⚠ {warn}" if warn else f"Saved to {MONITORS_LUA.name}"
)
except Exception as e: except Exception as e:
self.status_msg = f"Save failed: {e}" self.status_msg = f"Save failed: {e}"