Compare commits

...

4 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki 0ab535f772 fix(installer): install OpenDeck from AUR instead of Flatpak
opendeck is not in the official repos, so install the native AUR package
with yay (matching the repo's other AUR modules) and drop the Flatpak
path. Wire `opendeck` into Hyprland autostart instead of
`flatpak run com.mairtech.OpenDeck`, and update the idempotency guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:04:13 +02:00
Amir Alexander Abdelbaki 10a5fbb33b 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>
2026-06-26 16:00:52 +02:00
Amir Alexander Abdelbaki a1d5185ac8 fix(hyprlua): launch OpenDeck via native opendeck binary
Replace the flatpak invocation with the native `opendeck` command in
autostart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:00:42 +02:00
Amir Alexander Abdelbaki 32420fd2c8 refactor(hyprlua): make monitors.lua a generic single-screen default
Replace the machine-specific DP-1/DP-3/HDMI-A-1 layout with a wildcard
catch-all rule (preferred mode, auto position, auto scale) that brings up
any connected output. Per-machine layout stays the job of monitor-manager,
which overwrites this file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:59:29 +02:00
4 changed files with 181 additions and 55 deletions

View File

@ -23,5 +23,5 @@ hl.on("hyprland.start", function()
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/presence-detect.sh")
hl.exec_cmd("chamel")
hl.exec_cmd("ydotoold")
hl.exec_cmd("flatpak run com.mairtech.OpenDeck")
hl.exec_cmd("opendeck")
end)

View File

@ -1,26 +1,17 @@
-- generated by monitor-manager -- do not edit by hand
-- Default monitor configuration.
--
-- This is a functional fallback only: a single catch-all rule that brings up
-- every connected output at its preferred mode, auto-positioned with an
-- automatic scale. It is enough to get a usable screen on any machine.
--
-- Per-machine layout (resolution, position, scale, rotation, mirroring) is
-- managed by scripts/monitor-manager, which overwrites this file. Don't hand-
-- edit it for a specific setup -- run the manager instead.
hl.monitor({
output = "DP-1",
mode = "1920x1080@60",
position = "3816x0",
scale = 0.75,
transform = 1,
})
hl.monitor({
output = "DP-3",
mode = "1280x1024@76",
position = "0x0",
scale = 1.0,
transform = 0,
})
hl.monitor({
output = "HDMI-A-1",
mode = "3840x2160@60",
position = "1280x0",
scale = 1.5,
transform = 0,
output = "", -- empty output = wildcard, matches every monitor
mode = "preferred", -- use each monitor's preferred resolution/refresh
position = "auto", -- let Hyprland place outputs side by side
scale = "auto", -- pick a sensible scale per monitor
})
hl.config({

View File

@ -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}"

View File

@ -5,17 +5,19 @@ source "$(dirname "${BASH_SOURCE[0]}")/../../lib/logging.sh"
log "Installing ydotool (pacman)..."
sudo pacman -S --noconfirm --needed ydotool
log "Installing OpenDeck (Flatpak)..."
ensure_flatpak
flatpak install -y flathub com.mairtech.OpenDeck
log "Installing OpenDeck (AUR)..."
# opendeck is not in the official Arch repos; install the native package from
# the AUR instead of Flatpak so it runs as a plain `opendeck` binary.
# --answerdiff None / --answerclean All suppress interactive PKGBUILD/clean prompts.
yay -S --answerdiff None --answerclean All --noconfirm opendeck
log "Wiring ydotoold + OpenDeck into Hyprland autostart..."
AUTOSTART="$HOME/Dotfiles/desktopenvs/hyprlua/hypr/usr/autostart.lua"
if grep -q "ydotoold\|OpenDeck\|com.mairtech.OpenDeck" "$AUTOSTART"; then
if grep -q "ydotoold\|opendeck\|OpenDeck" "$AUTOSTART"; then
skip "ydotoold/OpenDeck already present in autostart.lua — skipping."
else
# Insert before the closing end) so entries appear inside the on("hyprland.start") block
sed -i 's|^end)$| hl.exec_cmd("ydotoold")\n hl.exec_cmd("flatpak run com.mairtech.OpenDeck")\nend)|' "$AUTOSTART"
sed -i 's|^end)$| hl.exec_cmd("ydotoold")\n hl.exec_cmd("opendeck")\nend)|' "$AUTOSTART"
log "Autostart entries added."
fi