diff options
| author | krolyxon <me@krolyxon.com> | 2026-08-14 10:52:34 +0530 |
|---|---|---|
| committer | krolyxon <me@krolyxon.com> | 2026-08-14 10:52:34 +0530 |
| commit | 79fb5e7ad21678a1310ee16356ca5796e4135502 (patch) | |
| tree | 9e6657b4bc607a8b91d9cd3edad19557d8844d78 /.local/bin | |
| parent | f130f7e92715d76633e6739ca02d67aadd49cc3b (diff) | |
hyprland: hypr_screen_rotate.sh -> hypr_screen_rotate.py
Diffstat (limited to '.local/bin')
| -rwxr-xr-x | .local/bin/hypr/hypr_screen_rotate.py | 358 | ||||
| -rwxr-xr-x | .local/bin/hypr/hypr_screen_rotate.sh | 114 |
2 files changed, 358 insertions, 114 deletions
diff --git a/.local/bin/hypr/hypr_screen_rotate.py b/.local/bin/hypr/hypr_screen_rotate.py new file mode 100755 index 0000000..ec9d91b --- /dev/null +++ b/.local/bin/hypr/hypr_screen_rotate.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +""" +screen_rotate.py — Hyprland 0.55+ IPC-only Screen Rotation Utility + +Rotates the focused monitor 90° clockwise (+90) or counter-clockwise (-90). + +Usage: + screen_rotate.py +90 + screen_rotate.py -90 + +Design principles: + • Zero config-file access — reads/writes nothing on disk. + • Temporary by design — changes reset on `hyprctl reload`. + • Mode-string fidelity — resolves active mode natively without resolution corruption. + • Flip-transform aware — rotates within the current flip state (0-3 / 4-7). + • Keybind safe — pipes critical errors to notify-send and debounces rapid inputs. +""" + +from __future__ import annotations + +import fcntl +import json +import os +import subprocess +import sys +import tempfile +import time +from typing import Any + + +# ── Concurrency Lock ─────────────────────────────────────────────────────────── + +def acquire_lock() -> None: + """ + Acquire an exclusive lock. If another instance is running, exit silently. + This prevents race conditions when the keybind is spammed. + """ + lock_file = os.path.join(tempfile.gettempdir(), "hypr_screen_rotate.lock") + fd = os.open(lock_file, os.O_CREAT | os.O_RDWR) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + # Another instance is running. Silently drop this execution. + sys.exit(0) + # The file descriptor remains open and locked until the process exits. + # The OS automatically releases the lock when the script finishes. + + +# ── ANSI colour helpers ──────────────────────────────────────────────────────── + +_COLOURS = sys.stderr.isatty() or sys.stdout.isatty() + +def _c(code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if _COLOURS else text + +def _red(t: str) -> str: return _c("31", t) +def _green(t: str) -> str: return _c("32", t) +def _yellow(t: str) -> str: return _c("33", t) +def _blue(t: str) -> str: return _c("34", t) +def _bold(t: str) -> str: return _c("1", t) + + +# ── Logging ──────────────────────────────────────────────────────────────────── + +def log_info(msg: str) -> None: print(f"{_blue('[INFO]')} {msg}") +def log_ok(msg: str) -> None: print(f"{_green('[OK]')} {msg}") +def log_warn(msg: str) -> None: print(f"{_yellow('[WARN]')} {msg}", file=sys.stderr) +def log_payload(msg: str) -> None: print(f"{_yellow('[PAYLOAD]')} {msg}") + +def die(msg: str, code: int = 1) -> None: + """Print to stderr and send a desktop notification so keybinds don't fail silently.""" + print(f"{_red('[ERROR]')} {msg}", file=sys.stderr) + try: + subprocess.run( + [ + "notify-send", + "--app-name=screen_rotate.py", + "--urgency=critical", + "Screen Rotation Error", + msg + ], + capture_output=True, + timeout=2 + ) + except Exception: + pass + sys.exit(code) + + +# ── Dependency / environment checks ─────────────────────────────────────────── + +def check_environment() -> None: + if os.geteuid() == 0: + die("Do not run as root — hyprctl requires user-space socket access.") + + if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): + die( + "HYPRLAND_INSTANCE_SIGNATURE is not set.\n" + "Is Hyprland running, and are you in the correct session?" + ) + + result = subprocess.run( + ["hyprctl", "--version"], + capture_output=True, + text=True, + ) + if result.returncode != 0 and "not found" in (result.stderr or "").lower(): + die("'hyprctl' not found. Install Hyprland.") + + +# ── Argument parsing ─────────────────────────────────────────────────────────── + +def parse_direction() -> int: + prog = os.path.basename(sys.argv[0]) + + # Graceful help handler + if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"): + print(f"Usage: {prog} [+90|-90]") + print("Rotates the focused monitor 90° clockwise (+90) or counter-clockwise (-90).") + sys.exit(0) + + # Strict arg checking for actual usage + if len(sys.argv) != 2 or sys.argv[1] not in ("+90", "-90"): + die(f"Invalid argument.\nUsage: {prog} [+90|-90]") + + return 1 if sys.argv[1] == "+90" else -1 + + +# ── hyprctl IPC wrappers ─────────────────────────────────────────────────────── + +def hyprctl_json(args: list[str]) -> Any: + try: + proc = subprocess.run( + ["hyprctl", "-j"] + args, + capture_output=True, + text=True, + timeout=5, + ) + except FileNotFoundError: + die("'hyprctl' not found.") + except subprocess.TimeoutExpired: + die("hyprctl timed out — is Hyprland responsive?") + + if proc.returncode != 0: + die(f"hyprctl exited {proc.returncode}: {proc.stderr.strip() or '(no stderr)'}") + + raw = proc.stdout.strip() + + if raw and not raw[0] in ("[", "{"): + for i, line in enumerate(raw.splitlines()): + line = line.strip() + if line.startswith(("[", "{")): + raw = "\n".join(raw.splitlines()[i:]) + break + + if not raw: + die("hyprctl returned empty output.") + + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + die(f"Failed to parse hyprctl JSON: {exc}\nRaw output: {raw[:300]!r}") + + +def hyprctl_eval(lua_str: str) -> bool: + try: + proc = subprocess.run( + ["hyprctl", "eval", lua_str], + capture_output=True, + text=True, + timeout=5, + ) + except subprocess.TimeoutExpired: + die("hyprctl eval timed out — compositor may be unresponsive.") + + return proc.returncode == 0 + + +# ── Mode resolution ──────────────────────────────────────────────────────────── + +def _parse_hz(mode_str: str) -> float | None: + try: + _, hz_part = mode_str.split("@", 1) + return float(hz_part.rstrip("HhZz")) + except (ValueError, IndexError): + return None + + +def resolve_active_mode(monitor: dict[str, Any]) -> str: + """ + Return the best-matching physical mode string for `hl.monitor()` + (WITHOUT the Hz suffix, e.g. "1920x1080@144.00"). + """ + # Hyprland IPC `width` and `height` ALWAYS represent the physical + # native resolution. They do not swap when rotated. + native_w: int = int(monitor["width"]) + native_h: int = int(monitor["height"]) + refresh: float = float(monitor["refreshRate"]) + available: list = monitor.get("availableModes", []) + + target_prefix = f"{native_w}x{native_h}@" + + candidates: list[tuple[float, str]] = [] + for mode in available: + if not mode.lower().startswith(target_prefix.lower()): + continue + hz = _parse_hz(mode) + if hz is None: + continue + clean = mode.rstrip("HhZz") + candidates.append((hz, clean)) + + if not candidates: + log_warn( + f"No availableModes entry for {native_w}x{native_h} " + f"(IPC refreshRate={refresh:.5f}). " + "Reconstructing mode string from IPC data." + ) + return f"{native_w}x{native_h}@{refresh:.2f}" + + best_hz, best_clean = min(candidates, key=lambda c: abs(c[0] - refresh)) + return best_clean + + +# ── Transform arithmetic ─────────────────────────────────────────────────────── + +def compute_new_transform(current: int, direction: int) -> int: + flip_bit = current & 4 + rotation_bits = current & 3 + new_rotation = (rotation_bits + direction + 4) % 4 + return flip_bit | new_rotation + + +# ── Scale formatting ─────────────────────────────────────────────────────────── + +def format_scale(scale: float) -> str: + rounded = f"{scale:.6f}".rstrip("0").rstrip(".") + return rounded + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main() -> None: + # 1. Establish concurrency lock immediately to debounce keybind spam + acquire_lock() + + check_environment() + direction = parse_direction() + + monitors: list[dict] = hyprctl_json(["monitors"]) + + if not isinstance(monitors, list) or not monitors: + die("hyprctl returned no monitor data.") + + focused: dict | None = next( + (m for m in monitors if m.get("focused") is True), None + ) + if focused is None: + log_warn("No focused monitor found; using first monitor.") + focused = monitors[0] + + name: str = str(focused.get("name", "")) + current_transform = int(focused.get("transform", 0)) + x: int = int(focused.get("x", 0)) + y: int = int(focused.get("y", 0)) + scale: float = float(focused.get("scale", 1.0)) + disabled: bool = bool(focused.get("disabled", False)) + + if not name or name == "null": + die("Could not read monitor name from IPC.") + + if disabled: + die(f"Monitor '{name}' is currently disabled; cannot rotate.") + + if current_transform not in range(8): + die(f"Unexpected transform value '{current_transform}' from IPC. Expected 0-7.") + + new_transform = compute_new_transform(current_transform, direction) + active_mode = resolve_active_mode(focused) + + pos_str = f"{x}x{y}" + scale_str = format_scale(scale) + lua_name = name.replace("\\", "\\\\").replace('"', '\\"') + + lua_call = ( + f'hl.monitor({{ output = "{lua_name}", mode = "{active_mode}", ' + f'position = "{pos_str}", scale = {scale_str}, transform = {new_transform} }})' + ) + + print() + log_info(f"Monitor : {_bold(name)}") + log_info(f"Mode : {active_mode}") + log_info(f"Position : {pos_str} Scale: {scale_str}") + log_info(f"Transform : {current_transform} → {new_transform} ({direction:+d} × 90°)") + log_payload(lua_call) + print() + + eval_ok = hyprctl_eval(lua_call) + if not eval_ok: + die( + "hyprctl eval returned a non-zero exit code.\n" + "Is Hyprland 0.55+ running and the socket accessible?\n" + f"Lua: {lua_call}" + ) + + actual_transform = current_transform + for _ in range(25): + time.sleep(0.1) + try: + polled = hyprctl_json(["monitors"]) + for m in polled: + if m.get("name") == name: + actual_transform = int(m.get("transform", current_transform)) + break + except SystemExit: + break + if actual_transform != current_transform: + break + + if actual_transform != new_transform: + die( + f"Transform did not change after eval (expected {new_transform}, IPC reports {actual_transform}).\n" + "This may indicate the compositor overrode the value.\n" + f"Lua sent: {lua_call}" + ) + + log_ok(f"Rotation applied — transform {current_transform} → {new_transform}") + _notify(name, new_transform, active_mode) + + +def _notify(monitor: str, transform: int, mode: str) -> None: + _TRANSFORM_NAMES = { + 0: "0° (normal)", 1: "90°", 2: "180°", 3: "270°", + 4: "0° (flipped)", 5: "90°+flip", 6: "180°+flip", 7: "270°+flip", + } + label = _TRANSFORM_NAMES.get(transform, str(transform)) + body = f"{monitor} • {label}" + + try: + subprocess.run( + [ + "notify-send", + "--app-name=hypr-rotate", + "--icon=object-rotate-right", + "-t", "1500", + "Display Rotated", + body, + "--hint=string:x-canonical-private-synchronous:display-rotate", + ], + capture_output=True, + timeout=3, + ) + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/.local/bin/hypr/hypr_screen_rotate.sh b/.local/bin/hypr/hypr_screen_rotate.sh deleted file mode 100755 index 5fdf062..0000000 --- a/.local/bin/hypr/hypr_screen_rotate.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -# Script taken from and modified little bit from: -# https://github.com/dusklinux/dusky/blob/f8a28f425743e478cddefc327e6e554dfcf9521d/user_scripts/hypr/screen_rotate.sh - -# 1. Strict Mode & Safety (Bash 5+ Standards) -# ------------------------------------------------------------------------------ -set -euo pipefail -IFS=$'\n\t' - -# 2. Global Constants (ANSI-C Quoting for "Elite" Color Handling) -# ------------------------------------------------------------------------------ -readonly C_RED=$'\e[31m' -readonly C_GREEN=$'\e[32m' -readonly C_YELLOW=$'\e[33m' -readonly C_BLUE=$'\e[34m' -readonly C_BOLD=$'\e[1m' -readonly C_RESET=$'\e[0m' - -# cleanup_trap: Ensures clean exit codes are respected. -cleanup_trap() { - local exit_code=$? - if [[ $exit_code -ne 0 ]]; then - printf "%s[ERROR]%s Script aborted unexpectedly (Exit Code: %d).\n" \ - "$C_RED" "$C_RESET" "$exit_code" >&2 - fi -} -trap cleanup_trap EXIT - -# 3. Environment & Privilege Checks -# ------------------------------------------------------------------------------ -# Dependency Check: We need 'jq' for JSON parsing. -if ! command -v jq &> /dev/null; then - printf "%s[ERROR]%s 'jq' is missing. Install it with: sudo pacman -S jq\n" \ - "$C_RED" "$C_RESET" >&2 - exit 1 -fi - -# Root Check: Hyprland IPC fails if executed as root/sudo due to socket ownership. -if [[ $EUID -eq 0 ]]; then - printf "%s[ERROR]%s Root detected. Please run this as your normal user to access the Hyprland socket.\n" \ - "$C_RED" "$C_RESET" >&2 - exit 1 -fi - -# 4. Argument Parsing (+90 or -90) -# ------------------------------------------------------------------------------ -DIRECTION=0 - -if [[ $# -ne 1 ]]; then - printf "%s[INFO]%s Usage: %s [+90|-90]\n" \ - "$C_YELLOW" "$C_RESET" "${0##*/}" - exit 1 -fi - -case "$1" in - "+90") DIRECTION=1 ;; # Clockwise - "-90") DIRECTION=-1 ;; # Counter-Clockwise - *) - printf "%s[ERROR]%s Invalid flag '%s'. Use +90 or -90.\n" \ - "$C_RED" "$C_RESET" "$1" >&2 - exit 1 - ;; -esac - -# 5. Hardware Detection (Smart Query) -# ------------------------------------------------------------------------------ -# We fetch the entire JSON blob once to minimize IPC calls (Performance). -# We select the current focused monitor -MON_STATE=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true)') - -# Extract precise values using jq -NAME=$(printf "%s" "$MON_STATE" | jq -r '.name') -SCALE=$(printf "%s" "$MON_STATE" | jq -r '.scale') -CURRENT_TRANSFORM=$(printf "%s" "$MON_STATE" | jq -r '.transform') - -# Validation: Ensure we actually found a monitor -if [[ -z "$NAME" || "$NAME" == "null" ]]; then - printf "%s[ERROR]%s No active monitors detected via Hyprland IPC.\n" \ - "$C_RED" "$C_RESET" >&2 - exit 1 -fi - -# 6. Transformation Logic (Modulo Arithmetic) -# ------------------------------------------------------------------------------ -# Hyprland Transforms: 0=Normal, 1=90, 2=180, 3=270 -# The '+ 4' ensures we handle negative wraparounds correctly in Bash logic. -NEW_TRANSFORM=$(( (CURRENT_TRANSFORM + DIRECTION + 4) % 4 )) - -# 7. Execution (State overwrite) -# ------------------------------------------------------------------------------ -# We use 'preferred' and 'auto' to remain robust against resolution changes, -# but we STRICTLY inject the detected $SCALE to prevent UI scaling issues. - -printf "%s[INFO]%s Rotating %s%s%s (Scale: %s): %d -> %d\n" \ - "$C_BLUE" "$C_RESET" "$C_BOLD" "$NAME" "$C_RESET" "$SCALE" "$CURRENT_TRANSFORM" "$NEW_TRANSFORM" - -# Apply the new configuration immediately via IPC -if hyprctl keyword monitor "${NAME}, preferred, auto, ${SCALE}, transform, ${NEW_TRANSFORM}" > /dev/null; then - printf "%s[SUCCESS]%s Rotation applied successfully.\n" \ - "$C_GREEN" "$C_RESET" - - # Notify user visually if notify-send is available (optional UX improvement) - if command -v notify-send &> /dev/null; then - notify-send -a "System" "Display Rotated" "Monitor: $NAME\nTransform: $NEW_TRANSFORM" -h string:x-canonical-private-synchronous:display-rotate - fi -else - printf "%s[ERROR]%s Failed to apply Hyprland keyword.\n" \ - "$C_RED" "$C_RESET" >&2 - exit 1 -fi - -# Clean exit -trap - EXIT -exit 0 |
