diff --git a/desktopenvs/hyprlua/scripts/monitor-manager b/desktopenvs/hyprlua/scripts/monitor-manager index d6ea8b0..75b8ec1 100755 --- a/desktopenvs/hyprlua/scripts/monitor-manager +++ b/desktopenvs/hyprlua/scripts/monitor-manager @@ -35,7 +35,11 @@ from typing import List, Optional # 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_FINE = 10 MIN_SCALE = 0.25 @@ -45,6 +49,7 @@ MIN_BOX_W = 14 MIN_BOX_H = 4 INFO_W = 32 STATUS_ROWS = 2 # status + help rows at the bottom +MAX_RESOLVE_ITERS = 8 # auto-resolve nudges before giving up TRANSFORM_LABEL = { 0: "↕ 0°", @@ -185,6 +190,36 @@ def apply_monitor(m: MonitorState) -> Optional[str]: return (r.stderr or r.stdout).strip() 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 # --------------------------------------------------------------------------- @@ -418,10 +453,7 @@ class App: elif ch == ord("m"): if mon.mirror_of: mon.mirror_of = "" - err = apply_monitor(mon) - mon.dirty = True - self.dirty = True - self.status_msg = err or f"Un-mirrored {mon.name}" + self._apply_and_status(mon, f"Un-mirrored {mon.name}") elif len(self.monitors) < 2: self.status_msg = "Need 2+ monitors to mirror" else: @@ -475,14 +507,122 @@ class App: # Actions # ----------------------------------------------------------------------- - def move_monitor(self, dx: int, dy: int): - mon = self.monitors[self.selected_idx] - mon.x = max(0, mon.x + dx) - mon.y = max(0, mon.y + dy) + def _live_overlap_warning(self) -> Optional[str]: + """Re-fetch live monitor positions and report any actual overlap. + + 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) mon.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): mon = self.monitors[self.selected_idx] @@ -490,10 +630,7 @@ class App: mon.transform = rotate_cw(mon.transform) else: mon.transform = rotate_ccw(mon.transform) - err = apply_monitor(mon) - mon.dirty = True - self.dirty = True - self.status_msg = err or f"Rotated {mon.name} → {TRANSFORM_LABEL[mon.transform]}" + self._apply_and_status(mon, f"Rotated {mon.name} → {TRANSFORM_LABEL[mon.transform]}") def scale_monitor(self, direction: int): mon = self.monitors[self.selected_idx] @@ -512,10 +649,7 @@ class App: 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 → {new_scale}x" + self._apply_and_status(mon, f"{mon.name} scale → {new_scale}x") def cycle_mode(self, delta: int): mon = self.monitors[self.selected_idx] @@ -528,19 +662,13 @@ class App: mon.width = int(mo.group(1)) mon.height = int(mo.group(2)) mon.refresh_rate = float(mo.group(3)) - err = apply_monitor(mon) - mon.dirty = True - self.dirty = True - self.status_msg = err or f"{mon.name} → {mon.mode_str}" + self._apply_and_status(mon, f"{mon.name} → {mon.mode_str}") def set_mirror(self, src_idx: int, tgt_idx: int): src = self.monitors[src_idx] tgt = self.monitors[tgt_idx] src.mirror_of = tgt.name - err = apply_monitor(src) - src.dirty = True - self.dirty = True - self.status_msg = err or f"Mirroring {src.name} → {tgt.name}" + self._apply_and_status(src, f"Mirroring {src.name} → {tgt.name}") def _save(self): try: @@ -548,8 +676,13 @@ class App: for m in self.monitors: m.dirty = False self.dirty = False - self.status_msg = f"Saved to {MONITORS_LUA.name}" 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: self.status_msg = f"Save failed: {e}"