Krolyxon
Commit 79fb5e7

Branches

Refs →

aurora79fb5e7

hyprland: hypr_screen_rotate.sh -> hypr_screen_rotate.py

Author
krolyxon
Date
2026-08-14 10:52 +0530
Parent
f130f7e
Commit
79fb5e7ad21678a1310ee16356ca5796e4135502

+365 −121

5 files changed

  1. M .config/hypr/hyprland.lua +1 −2
  2. M .config/hypr/source/appearance.lua +4 −3
  3. M .config/hypr/source/keybinds.lua +2 −2
  4. A .local/bin/hypr/hypr_screen_rotate.py +358 −0
  5. D .local/bin/hypr/hypr_screen_rotate.sh +0 −114
M .config/hypr/hyprland.lua View
@@ -11,8 +11,7 @@ HOME = os.getenv("HOME")
1111
1212
1313 -- Theme (pywal)
14−require(os.getenv("HOME") .. "/.cache/wal/colors.lua")
15−
14+dofile(HOME .. "/.cache/wal/colors.lua")
1615
1716 -- -----------------------------------------------------
1817 -- Autostart
M .config/hypr/source/appearance.lua View
@@ -15,12 +15,13 @@ hl.config({
1515
1616 layout = "master",
1717
18− -- Pywal colors
1918 -- active_border = "$color14 $color14 $color14 $color14 45deg",
2019 -- inactive_border = "$color0 $color0 $color0 $color0 45deg",
2120
22− -- Gradient example
23− -- active_border = "$color14 $color0 $color0 $color14 45deg",
21+ ["col.inactive_border"] = color0, -- Border color for inactive windows
22+ ["col.active_border"] = color14, -- Border color for the active window
23+ ["col.nogroup_border"] = color0 , -- Inactive border color for window that cannot be added to a group
24+ ["col.nogroup_border_active"] = color0, -- Active border color for window that cannot be added to a group
2425 },
2526
2627 --------------------------------------------------
M .config/hypr/source/keybinds.lua View
@@ -118,9 +118,9 @@ hl.bind(
118118 )
119119
120120 -- Screen Rotate
121−hl.bind(mainMod .. " + CTRL + comma", hl.dsp.exec_cmd("hypr_screen_rotate.sh +90"),
121+hl.bind(mainMod .. " + CTRL + comma", hl.dsp.exec_cmd("hypr_screen_rotate.py +90"),
122122 { locked = true, desc = "Rotate Screen Anti-Clockwise" })
123−hl.bind(mainMod .. " + CTRL + period", hl.dsp.exec_cmd("hypr_screen_rotate.sh -90"),
123+hl.bind(mainMod .. " + CTRL + period", hl.dsp.exec_cmd("hypr_screen_rotate.py -90"),
124124 { locked = true, desc = "Rotate Screen Clockwise" })
125125
126126 -- --- Hyprshade (Visual Filters) ---
A .local/bin/hypr/hypr_screen_rotate.py View
@@ -0,0 +1,358 @@
1+#!/usr/bin/env python3
2+"""
3+screen_rotate.py — Hyprland 0.55+ IPC-only Screen Rotation Utility
4+
5+Rotates the focused monitor 90° clockwise (+90) or counter-clockwise (-90).
6+
7+Usage:
8+ screen_rotate.py +90
9+ screen_rotate.py -90
10+
11+Design principles:
12+ • Zero config-file access — reads/writes nothing on disk.
13+ • Temporary by design — changes reset on `hyprctl reload`.
14+ • Mode-string fidelity — resolves active mode natively without resolution corruption.
15+ • Flip-transform aware — rotates within the current flip state (0-3 / 4-7).
16+ • Keybind safe — pipes critical errors to notify-send and debounces rapid inputs.
17+"""
18+
19+from __future__ import annotations
20+
21+import fcntl
22+import json
23+import os
24+import subprocess
25+import sys
26+import tempfile
27+import time
28+from typing import Any
29+
30+
31+# ── Concurrency Lock ───────────────────────────────────────────────────────────
32+
33+def acquire_lock() -> None:
34+ """
35+ Acquire an exclusive lock. If another instance is running, exit silently.
36+ This prevents race conditions when the keybind is spammed.
37+ """
38+ lock_file = os.path.join(tempfile.gettempdir(), "hypr_screen_rotate.lock")
39+ fd = os.open(lock_file, os.O_CREAT | os.O_RDWR)
40+ try:
41+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
42+ except BlockingIOError:
43+ # Another instance is running. Silently drop this execution.
44+ sys.exit(0)
45+ # The file descriptor remains open and locked until the process exits.
46+ # The OS automatically releases the lock when the script finishes.
47+
48+
49+# ── ANSI colour helpers ────────────────────────────────────────────────────────
50+
51+_COLOURS = sys.stderr.isatty() or sys.stdout.isatty()
52+
53+def _c(code: str, text: str) -> str:
54+ return f"\033[{code}m{text}\033[0m" if _COLOURS else text
55+
56+def _red(t: str) -> str: return _c("31", t)
57+def _green(t: str) -> str: return _c("32", t)
58+def _yellow(t: str) -> str: return _c("33", t)
59+def _blue(t: str) -> str: return _c("34", t)
60+def _bold(t: str) -> str: return _c("1", t)
61+
62+
63+# ── Logging ────────────────────────────────────────────────────────────────────
64+
65+def log_info(msg: str) -> None: print(f"{_blue('[INFO]')} {msg}")
66+def log_ok(msg: str) -> None: print(f"{_green('[OK]')} {msg}")
67+def log_warn(msg: str) -> None: print(f"{_yellow('[WARN]')} {msg}", file=sys.stderr)
68+def log_payload(msg: str) -> None: print(f"{_yellow('[PAYLOAD]')} {msg}")
69+
70+def die(msg: str, code: int = 1) -> None:
71+ """Print to stderr and send a desktop notification so keybinds don't fail silently."""
72+ print(f"{_red('[ERROR]')} {msg}", file=sys.stderr)
73+ try:
74+ subprocess.run(
75+ [
76+ "notify-send",
77+ "--app-name=screen_rotate.py",
78+ "--urgency=critical",
79+ "Screen Rotation Error",
80+ msg
81+ ],
82+ capture_output=True,
83+ timeout=2
84+ )
85+ except Exception:
86+ pass
87+ sys.exit(code)
88+
89+
90+# ── Dependency / environment checks ───────────────────────────────────────────
91+
92+def check_environment() -> None:
93+ if os.geteuid() == 0:
94+ die("Do not run as root — hyprctl requires user-space socket access.")
95+
96+ if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
97+ die(
98+ "HYPRLAND_INSTANCE_SIGNATURE is not set.\n"
99+ "Is Hyprland running, and are you in the correct session?"
100+ )
101+
102+ result = subprocess.run(
103+ ["hyprctl", "--version"],
104+ capture_output=True,
105+ text=True,
106+ )
107+ if result.returncode != 0 and "not found" in (result.stderr or "").lower():
108+ die("'hyprctl' not found. Install Hyprland.")
109+
110+
111+# ── Argument parsing ───────────────────────────────────────────────────────────
112+
113+def parse_direction() -> int:
114+ prog = os.path.basename(sys.argv[0])
115+
116+ # Graceful help handler
117+ if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"):
118+ print(f"Usage: {prog} [+90|-90]")
119+ print("Rotates the focused monitor 90° clockwise (+90) or counter-clockwise (-90).")
120+ sys.exit(0)
121+
122+ # Strict arg checking for actual usage
123+ if len(sys.argv) != 2 or sys.argv[1] not in ("+90", "-90"):
124+ die(f"Invalid argument.\nUsage: {prog} [+90|-90]")
125+
126+ return 1 if sys.argv[1] == "+90" else -1
127+
128+
129+# ── hyprctl IPC wrappers ───────────────────────────────────────────────────────
130+
131+def hyprctl_json(args: list[str]) -> Any:
132+ try:
133+ proc = subprocess.run(
134+ ["hyprctl", "-j"] + args,
135+ capture_output=True,
136+ text=True,
137+ timeout=5,
138+ )
139+ except FileNotFoundError:
140+ die("'hyprctl' not found.")
141+ except subprocess.TimeoutExpired:
142+ die("hyprctl timed out — is Hyprland responsive?")
143+
144+ if proc.returncode != 0:
145+ die(f"hyprctl exited {proc.returncode}: {proc.stderr.strip() or '(no stderr)'}")
146+
147+ raw = proc.stdout.strip()
148+
149+ if raw and not raw[0] in ("[", "{"):
150+ for i, line in enumerate(raw.splitlines()):
151+ line = line.strip()
152+ if line.startswith(("[", "{")):
153+ raw = "\n".join(raw.splitlines()[i:])
154+ break
155+
156+ if not raw:
157+ die("hyprctl returned empty output.")
158+
159+ try:
160+ return json.loads(raw)
161+ except json.JSONDecodeError as exc:
162+ die(f"Failed to parse hyprctl JSON: {exc}\nRaw output: {raw[:300]!r}")
163+
164+
165+def hyprctl_eval(lua_str: str) -> bool:
166+ try:
167+ proc = subprocess.run(
168+ ["hyprctl", "eval", lua_str],
169+ capture_output=True,
170+ text=True,
171+ timeout=5,
172+ )
173+ except subprocess.TimeoutExpired:
174+ die("hyprctl eval timed out — compositor may be unresponsive.")
175+
176+ return proc.returncode == 0
177+
178+
179+# ── Mode resolution ────────────────────────────────────────────────────────────
180+
181+def _parse_hz(mode_str: str) -> float | None:
182+ try:
183+ _, hz_part = mode_str.split("@", 1)
184+ return float(hz_part.rstrip("HhZz"))
185+ except (ValueError, IndexError):
186+ return None
187+
188+
189+def resolve_active_mode(monitor: dict[str, Any]) -> str:
190+ """
191+ Return the best-matching physical mode string for `hl.monitor()`
192+ (WITHOUT the Hz suffix, e.g. "1920x1080@144.00").
193+ """
194+ # Hyprland IPC `width` and `height` ALWAYS represent the physical
195+ # native resolution. They do not swap when rotated.
196+ native_w: int = int(monitor["width"])
197+ native_h: int = int(monitor["height"])
198+ refresh: float = float(monitor["refreshRate"])
199+ available: list = monitor.get("availableModes", [])
200+
201+ target_prefix = f"{native_w}x{native_h}@"
202+
203+ candidates: list[tuple[float, str]] = []
204+ for mode in available:
205+ if not mode.lower().startswith(target_prefix.lower()):
206+ continue
207+ hz = _parse_hz(mode)
208+ if hz is None:
209+ continue
210+ clean = mode.rstrip("HhZz")
211+ candidates.append((hz, clean))
212+
213+ if not candidates:
214+ log_warn(
215+ f"No availableModes entry for {native_w}x{native_h} "
216+ f"(IPC refreshRate={refresh:.5f}). "
217+ "Reconstructing mode string from IPC data."
218+ )
219+ return f"{native_w}x{native_h}@{refresh:.2f}"
220+
221+ best_hz, best_clean = min(candidates, key=lambda c: abs(c[0] - refresh))
222+ return best_clean
223+
224+
225+# ── Transform arithmetic ───────────────────────────────────────────────────────
226+
227+def compute_new_transform(current: int, direction: int) -> int:
228+ flip_bit = current & 4
229+ rotation_bits = current & 3
230+ new_rotation = (rotation_bits + direction + 4) % 4
231+ return flip_bit | new_rotation
232+
233+
234+# ── Scale formatting ───────────────────────────────────────────────────────────
235+
236+def format_scale(scale: float) -> str:
237+ rounded = f"{scale:.6f}".rstrip("0").rstrip(".")
238+ return rounded
239+
240+
241+# ── Main ───────────────────────────────────────────────────────────────────────
242+
243+def main() -> None:
244+ # 1. Establish concurrency lock immediately to debounce keybind spam
245+ acquire_lock()
246+
247+ check_environment()
248+ direction = parse_direction()
249+
250+ monitors: list[dict] = hyprctl_json(["monitors"])
251+
252+ if not isinstance(monitors, list) or not monitors:
253+ die("hyprctl returned no monitor data.")
254+
255+ focused: dict | None = next(
256+ (m for m in monitors if m.get("focused") is True), None
257+ )
258+ if focused is None:
259+ log_warn("No focused monitor found; using first monitor.")
260+ focused = monitors[0]
261+
262+ name: str = str(focused.get("name", ""))
263+ current_transform = int(focused.get("transform", 0))
264+ x: int = int(focused.get("x", 0))
265+ y: int = int(focused.get("y", 0))
266+ scale: float = float(focused.get("scale", 1.0))
267+ disabled: bool = bool(focused.get("disabled", False))
268+
269+ if not name or name == "null":
270+ die("Could not read monitor name from IPC.")
271+
272+ if disabled:
273+ die(f"Monitor '{name}' is currently disabled; cannot rotate.")
274+
275+ if current_transform not in range(8):
276+ die(f"Unexpected transform value '{current_transform}' from IPC. Expected 0-7.")
277+
278+ new_transform = compute_new_transform(current_transform, direction)
279+ active_mode = resolve_active_mode(focused)
280+
281+ pos_str = f"{x}x{y}"
282+ scale_str = format_scale(scale)
283+ lua_name = name.replace("\\", "\\\\").replace('"', '\\"')
284+
285+ lua_call = (
286+ f'hl.monitor({{ output = "{lua_name}", mode = "{active_mode}", '
287+ f'position = "{pos_str}", scale = {scale_str}, transform = {new_transform} }})'
288+ )
289+
290+ print()
291+ log_info(f"Monitor : {_bold(name)}")
292+ log_info(f"Mode : {active_mode}")
293+ log_info(f"Position : {pos_str} Scale: {scale_str}")
294+ log_info(f"Transform : {current_transform} → {new_transform} ({direction:+d} × 90°)")
295+ log_payload(lua_call)
296+ print()
297+
298+ eval_ok = hyprctl_eval(lua_call)
299+ if not eval_ok:
300+ die(
301+ "hyprctl eval returned a non-zero exit code.\n"
302+ "Is Hyprland 0.55+ running and the socket accessible?\n"
303+ f"Lua: {lua_call}"
304+ )
305+
306+ actual_transform = current_transform
307+ for _ in range(25):
308+ time.sleep(0.1)
309+ try:
310+ polled = hyprctl_json(["monitors"])
311+ for m in polled:
312+ if m.get("name") == name:
313+ actual_transform = int(m.get("transform", current_transform))
314+ break
315+ except SystemExit:
316+ break
317+ if actual_transform != current_transform:
318+ break
319+
320+ if actual_transform != new_transform:
321+ die(
322+ f"Transform did not change after eval (expected {new_transform}, IPC reports {actual_transform}).\n"
323+ "This may indicate the compositor overrode the value.\n"
324+ f"Lua sent: {lua_call}"
325+ )
326+
327+ log_ok(f"Rotation applied — transform {current_transform} → {new_transform}")
328+ _notify(name, new_transform, active_mode)
329+
330+
331+def _notify(monitor: str, transform: int, mode: str) -> None:
332+ _TRANSFORM_NAMES = {
333+ 0: "0° (normal)", 1: "90°", 2: "180°", 3: "270°",
334+ 4: "0° (flipped)", 5: "90°+flip", 6: "180°+flip", 7: "270°+flip",
335+ }
336+ label = _TRANSFORM_NAMES.get(transform, str(transform))
337+ body = f"{monitor} • {label}"
338+
339+ try:
340+ subprocess.run(
341+ [
342+ "notify-send",
343+ "--app-name=hypr-rotate",
344+ "--icon=object-rotate-right",
345+ "-t", "1500",
346+ "Display Rotated",
347+ body,
348+ "--hint=string:x-canonical-private-synchronous:display-rotate",
349+ ],
350+ capture_output=True,
351+ timeout=3,
352+ )
353+ except Exception:
354+ pass
355+
356+
357+if __name__ == "__main__":
358+ main()
D .local/bin/hypr/hypr_screen_rotate.sh View
@@ -1,114 +0,0 @@
1−#!/usr/bin/env bash
2−# Script taken from and modified little bit from:
3−# https://github.com/dusklinux/dusky/blob/f8a28f425743e478cddefc327e6e554dfcf9521d/user_scripts/hypr/screen_rotate.sh
4−
5−# 1. Strict Mode & Safety (Bash 5+ Standards)
6−# ------------------------------------------------------------------------------
7−set -euo pipefail
8−IFS=$'\n\t'
9−
10−# 2. Global Constants (ANSI-C Quoting for "Elite" Color Handling)
11−# ------------------------------------------------------------------------------
12−readonly C_RED=$'\e[31m'
13−readonly C_GREEN=$'\e[32m'
14−readonly C_YELLOW=$'\e[33m'
15−readonly C_BLUE=$'\e[34m'
16−readonly C_BOLD=$'\e[1m'
17−readonly C_RESET=$'\e[0m'
18−
19−# cleanup_trap: Ensures clean exit codes are respected.
20−cleanup_trap() {
21− local exit_code=$?
22− if [[ $exit_code -ne 0 ]]; then
23− printf "%s[ERROR]%s Script aborted unexpectedly (Exit Code: %d).\n" \
24− "$C_RED" "$C_RESET" "$exit_code" >&2
25− fi
26−}
27−trap cleanup_trap EXIT
28−
29−# 3. Environment & Privilege Checks
30−# ------------------------------------------------------------------------------
31−# Dependency Check: We need 'jq' for JSON parsing.
32−if ! command -v jq &> /dev/null; then
33− printf "%s[ERROR]%s 'jq' is missing. Install it with: sudo pacman -S jq\n" \
34− "$C_RED" "$C_RESET" >&2
35− exit 1
36−fi
37−
38−# Root Check: Hyprland IPC fails if executed as root/sudo due to socket ownership.
39−if [[ $EUID -eq 0 ]]; then
40− printf "%s[ERROR]%s Root detected. Please run this as your normal user to access the Hyprland socket.\n" \
41− "$C_RED" "$C_RESET" >&2
42− exit 1
43−fi
44−
45−# 4. Argument Parsing (+90 or -90)
46−# ------------------------------------------------------------------------------
47−DIRECTION=0
48−
49−if [[ $# -ne 1 ]]; then
50− printf "%s[INFO]%s Usage: %s [+90|-90]\n" \
51− "$C_YELLOW" "$C_RESET" "${0##*/}"
52− exit 1
53−fi
54−
55−case "$1" in
56− "+90") DIRECTION=1 ;; # Clockwise
57− "-90") DIRECTION=-1 ;; # Counter-Clockwise
58− *)
59− printf "%s[ERROR]%s Invalid flag '%s'. Use +90 or -90.\n" \
60− "$C_RED" "$C_RESET" "$1" >&2
61− exit 1
62− ;;
63−esac
64−
65−# 5. Hardware Detection (Smart Query)
66−# ------------------------------------------------------------------------------
67−# We fetch the entire JSON blob once to minimize IPC calls (Performance).
68−# We select the current focused monitor
69−MON_STATE=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true)')
70−
71−# Extract precise values using jq
72−NAME=$(printf "%s" "$MON_STATE" | jq -r '.name')
73−SCALE=$(printf "%s" "$MON_STATE" | jq -r '.scale')
74−CURRENT_TRANSFORM=$(printf "%s" "$MON_STATE" | jq -r '.transform')
75−
76−# Validation: Ensure we actually found a monitor
77−if [[ -z "$NAME" || "$NAME" == "null" ]]; then
78− printf "%s[ERROR]%s No active monitors detected via Hyprland IPC.\n" \
79− "$C_RED" "$C_RESET" >&2
80− exit 1
81−fi
82−
83−# 6. Transformation Logic (Modulo Arithmetic)
84−# ------------------------------------------------------------------------------
85−# Hyprland Transforms: 0=Normal, 1=90, 2=180, 3=270
86−# The '+ 4' ensures we handle negative wraparounds correctly in Bash logic.
87−NEW_TRANSFORM=$(( (CURRENT_TRANSFORM + DIRECTION + 4) % 4 ))
88−
89−# 7. Execution (State overwrite)
90−# ------------------------------------------------------------------------------
91−# We use 'preferred' and 'auto' to remain robust against resolution changes,
92−# but we STRICTLY inject the detected $SCALE to prevent UI scaling issues.
93−
94−printf "%s[INFO]%s Rotating %s%s%s (Scale: %s): %d -> %d\n" \
95− "$C_BLUE" "$C_RESET" "$C_BOLD" "$NAME" "$C_RESET" "$SCALE" "$CURRENT_TRANSFORM" "$NEW_TRANSFORM"
96−
97−# Apply the new configuration immediately via IPC
98−if hyprctl keyword monitor "${NAME}, preferred, auto, ${SCALE}, transform, ${NEW_TRANSFORM}" > /dev/null; then
99− printf "%s[SUCCESS]%s Rotation applied successfully.\n" \
100− "$C_GREEN" "$C_RESET"
101−
102− # Notify user visually if notify-send is available (optional UX improvement)
103− if command -v notify-send &> /dev/null; then
104− notify-send -a "System" "Display Rotated" "Monitor: $NAME\nTransform: $NEW_TRANSFORM" -h string:x-canonical-private-synchronous:display-rotate
105− fi
106−else
107− printf "%s[ERROR]%s Failed to apply Hyprland keyword.\n" \
108− "$C_RED" "$C_RESET" >&2
109− exit 1
110−fi
111−
112−# Clean exit
113−trap - EXIT
114−exit 0