Compare commits
4 Commits
a09d509da8
...
0ab535f772
| Author | SHA1 | Date |
|---|---|---|
|
|
0ab535f772 | |
|
|
10a5fbb33b | |
|
|
a1d5185ac8 | |
|
|
32420fd2c8 |
|
|
@ -23,5 +23,5 @@ hl.on("hyprland.start", function()
|
||||||
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/presence-detect.sh")
|
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/presence-detect.sh")
|
||||||
hl.exec_cmd("chamel")
|
hl.exec_cmd("chamel")
|
||||||
hl.exec_cmd("ydotoold")
|
hl.exec_cmd("ydotoold")
|
||||||
hl.exec_cmd("flatpak run com.mairtech.OpenDeck")
|
hl.exec_cmd("opendeck")
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -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({
|
hl.monitor({
|
||||||
output = "DP-1",
|
output = "", -- empty output = wildcard, matches every monitor
|
||||||
mode = "1920x1080@60",
|
mode = "preferred", -- use each monitor's preferred resolution/refresh
|
||||||
position = "3816x0",
|
position = "auto", -- let Hyprland place outputs side by side
|
||||||
scale = 0.75,
|
scale = "auto", -- pick a sensible scale per monitor
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,19 @@ source "$(dirname "${BASH_SOURCE[0]}")/../../lib/logging.sh"
|
||||||
log "Installing ydotool (pacman)..."
|
log "Installing ydotool (pacman)..."
|
||||||
sudo pacman -S --noconfirm --needed ydotool
|
sudo pacman -S --noconfirm --needed ydotool
|
||||||
|
|
||||||
log "Installing OpenDeck (Flatpak)..."
|
log "Installing OpenDeck (AUR)..."
|
||||||
ensure_flatpak
|
# opendeck is not in the official Arch repos; install the native package from
|
||||||
flatpak install -y flathub com.mairtech.OpenDeck
|
# 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..."
|
log "Wiring ydotoold + OpenDeck into Hyprland autostart..."
|
||||||
AUTOSTART="$HOME/Dotfiles/desktopenvs/hyprlua/hypr/usr/autostart.lua"
|
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."
|
skip "ydotoold/OpenDeck already present in autostart.lua — skipping."
|
||||||
else
|
else
|
||||||
# Insert before the closing end) so entries appear inside the on("hyprland.start") block
|
# 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."
|
log "Autostart entries added."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue