WinJitsu 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
winjitsu/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = "0.2.0"
2
+
3
+ from .cli import main
4
+
5
+ __all__ = ["main"]
winjitsu/actions.py ADDED
@@ -0,0 +1,169 @@
1
+ from .config import _CFG
2
+ from .screen import get_screens, get_screen_for_window
3
+ from .window import get_window_position, get_wm_class, move_window
4
+ from .cache import load_state, save_state, clear_cache, _update_state
5
+
6
+
7
+ VALID_ACTIONS = ["N", "S", "E", "W", "NE", "NW", "SE", "SW", "C", "F", "U", "TF", "TD", "CC"]
8
+ DIRECTION_ACTIONS = {"N", "S", "E", "W", "NE", "NW", "SE", "SW", "C"}
9
+
10
+
11
+ # --- Grid snapping ---
12
+ def direction(direction_code):
13
+ window = get_window_position()
14
+ screen_width, screen_height, screen_origin_x, screen_origin_y = get_screen_for_window(window)
15
+ half_width = screen_width / 2
16
+ half_height = screen_height / 2
17
+
18
+ centered_x = screen_origin_x + (screen_width - window["WIDTH"]) / 2
19
+ centered_y = screen_origin_y + (screen_height - window["HEIGHT"]) / 2
20
+
21
+ targets_by_direction = {
22
+ "N": {"width": screen_width, "height": half_height, "x": screen_origin_x, "y": screen_origin_y},
23
+ "S": {"width": screen_width, "height": half_height, "x": screen_origin_x, "y": screen_origin_y + half_height},
24
+ "E": {"width": half_width, "height": screen_height,"x": screen_origin_x + half_width, "y": screen_origin_y},
25
+ "W": {"width": half_width, "height": screen_height,"x": screen_origin_x, "y": screen_origin_y},
26
+ "NE": {"width": half_width, "height": half_height, "x": screen_origin_x + half_width, "y": screen_origin_y},
27
+ "NW": {"width": half_width, "height": half_height, "x": screen_origin_x, "y": screen_origin_y},
28
+ "SE": {"width": half_width, "height": half_height, "x": screen_origin_x + half_width, "y": screen_origin_y + half_height},
29
+ "SW": {"width": half_width, "height": half_height, "x": screen_origin_x, "y": screen_origin_y + half_height},
30
+ "C": {"width": window["WIDTH"], "height": window["HEIGHT"], "x": centered_x, "y": centered_y},
31
+ }
32
+
33
+ target = targets_by_direction[direction_code]
34
+ target_width, target_height = target["width"], target["height"]
35
+ target_x, target_y = target["x"], target["y"]
36
+
37
+ _update_state(window, target_x, target_y, target_width, target_height)
38
+ move_window(
39
+ target_width, target_height,
40
+ window["WINDOW"],
41
+ window["WIDTH"], window["HEIGHT"],
42
+ window["X"], window["Y"],
43
+ target_x, target_y,
44
+ )
45
+
46
+
47
+ # --- Fullscreen ---
48
+ def fullscreen(window=None, screen_width=None, screen_height=None, screen_origin_x=None, screen_origin_y=None):
49
+ window = window or get_window_position()
50
+ if screen_width is None:
51
+ screen_width, screen_height, screen_origin_x, screen_origin_y = get_screen_for_window(window)
52
+
53
+ padding = _CFG.padding
54
+ target_x = screen_origin_x + padding
55
+ target_y = screen_origin_y + padding
56
+ target_width = screen_width - 2 * padding
57
+ target_height = screen_height - 2 * padding
58
+
59
+ _update_state(window, target_x, target_y, target_width, target_height)
60
+ move_window(
61
+ target_width, target_height,
62
+ window["WINDOW"],
63
+ window["WIDTH"], window["HEIGHT"],
64
+ window["X"], window["Y"],
65
+ target_x, target_y,
66
+ )
67
+
68
+
69
+ def restore(window=None):
70
+ window = window or get_window_position()
71
+ window_id = window["WINDOW"]
72
+ saved_state = load_state(window_id, get_wm_class(window_id))
73
+ if saved_state is None:
74
+ return
75
+
76
+ move_window(
77
+ saved_state["WIDTH"], saved_state["HEIGHT"],
78
+ window_id,
79
+ window["WIDTH"], window["HEIGHT"],
80
+ window["X"], window["Y"],
81
+ saved_state["X"], saved_state["Y"],
82
+ )
83
+
84
+
85
+ def toggle_fullscreen(window=None):
86
+ window = window or get_window_position()
87
+ screen_width, screen_height, screen_origin_x, screen_origin_y = get_screen_for_window(window)
88
+
89
+ padding = _CFG.padding
90
+ fullscreen_width = screen_width - 2 * padding
91
+ fullscreen_height = screen_height - 2 * padding
92
+
93
+ is_already_fullscreen = (
94
+ window["WIDTH"] == fullscreen_width
95
+ and window["HEIGHT"] == fullscreen_height
96
+ )
97
+
98
+ if is_already_fullscreen:
99
+ restore(window)
100
+ else:
101
+ fullscreen(window, screen_width, screen_height, screen_origin_x, screen_origin_y)
102
+
103
+
104
+ # --- Display ---
105
+ def toggle_display():
106
+ window = get_window_position()
107
+ primary_screen, other_screens = get_screens()
108
+ if not primary_screen or not other_screens:
109
+ return
110
+
111
+ all_screens = [primary_screen] + other_screens
112
+ window_center_x = window["X"] + window["WIDTH"] / 2
113
+ window_center_y = window["Y"] + window["HEIGHT"] / 2
114
+
115
+ def _distance_to_screen_center(screen):
116
+ screen_center_x = screen["x"] + screen["width"] / 2
117
+ screen_center_y = screen["y"] + screen["height"] / 2
118
+ return (window_center_x - screen_center_x) ** 2 + (window_center_y - screen_center_y) ** 2
119
+
120
+ def _contains_window_center(screen):
121
+ return (screen["x"] <= window_center_x < screen["x"] + screen["width"]
122
+ and screen["y"] <= window_center_y < screen["y"] + screen["height"])
123
+
124
+ current_screen = next(
125
+ (screen for screen in all_screens if _contains_window_center(screen)),
126
+ min(all_screens, key=_distance_to_screen_center),
127
+ )
128
+ current_screen_index = all_screens.index(current_screen)
129
+ target_screen_index = (current_screen_index + 1) % len(all_screens)
130
+ target_screen = all_screens[target_screen_index]
131
+
132
+ # Preserve the window's relative offset on the new screen
133
+ target_x = target_screen["x"] + (window["X"] - current_screen["x"])
134
+ target_y = target_screen["y"] + (window["Y"] - current_screen["y"])
135
+
136
+ window_id = window["WINDOW"]
137
+ wm_class = get_wm_class(window_id)
138
+ target_home_state = {
139
+ "WINDOW": window_id,
140
+ "X": target_x, "Y": target_y,
141
+ "WIDTH": window["WIDTH"], "HEIGHT": window["HEIGHT"],
142
+ "SCREEN": target_screen_index,
143
+ }
144
+ save_state(window_id, target_home_state, target_x, target_y, window["WIDTH"], window["HEIGHT"], wm_class)
145
+ move_window(
146
+ window["WIDTH"], window["HEIGHT"],
147
+ window_id,
148
+ window["WIDTH"], window["HEIGHT"],
149
+ window["X"], window["Y"],
150
+ target_x, target_y,
151
+ )
152
+
153
+
154
+ # --- Dispatcher ---
155
+ _ACTION_HANDLERS = {
156
+ "F": fullscreen,
157
+ "U": restore,
158
+ "TF": toggle_fullscreen,
159
+ "TD": toggle_display,
160
+ "CC": clear_cache,
161
+ }
162
+
163
+ def dispatch(action_code):
164
+ if action_code in DIRECTION_ACTIONS:
165
+ direction(action_code)
166
+ elif action_code in _ACTION_HANDLERS:
167
+ _ACTION_HANDLERS[action_code]()
168
+ else:
169
+ raise ValueError(f"Unknown action: {action_code!r}")
winjitsu/cache.py ADDED
@@ -0,0 +1,68 @@
1
+ import json
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ from .window import get_wm_class
6
+
7
+
8
+ CACHE_DIR = Path.home() / ".cache" / "winjitsu"
9
+
10
+
11
+ def load_state(window_id, wm_class):
12
+ cache_file_path = CACHE_DIR / f"{window_id}.json"
13
+ if not cache_file_path.exists():
14
+ return None
15
+ try:
16
+ with open(cache_file_path) as f:
17
+ cached_state = json.load(f)
18
+ except (json.JSONDecodeError, ValueError):
19
+ return None
20
+ # X11 window IDs are recycled across sessions — reject cache if the app changed
21
+ if cached_state.get("WM_CLASS") != wm_class:
22
+ return None
23
+ return cached_state
24
+
25
+
26
+ def save_state(window_id, home_state, target_x, target_y, target_width, target_height, wm_class):
27
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
28
+ with open(CACHE_DIR / f"{window_id}.json", "w") as f:
29
+ json.dump({
30
+ "WINDOW": home_state["WINDOW"],
31
+ "X": home_state["X"], "Y": home_state["Y"],
32
+ "WIDTH": home_state["WIDTH"], "HEIGHT": home_state["HEIGHT"],
33
+ "SCREEN": home_state.get("SCREEN", 0),
34
+ "WM_CLASS": wm_class,
35
+ "_last_X": target_x, "_last_Y": target_y,
36
+ "_last_W": target_width, "_last_H": target_height,
37
+ }, f)
38
+
39
+
40
+ def _resolve_home(window, cached_state):
41
+ # If the window is still at the last position we animated it to, the original
42
+ # home hasn't changed — reuse it. Otherwise the window moved elsewhere and its
43
+ # current position becomes the new home.
44
+ if cached_state is None:
45
+ return window
46
+ last_target_geometry = (
47
+ cached_state.get("_last_X"), cached_state.get("_last_Y"),
48
+ cached_state.get("_last_W"), cached_state.get("_last_H")
49
+ )
50
+ if None in last_target_geometry:
51
+ return window
52
+ current_geometry = (window["X"], window["Y"],
53
+ window["WIDTH"], window["HEIGHT"])
54
+ if current_geometry == last_target_geometry:
55
+ return {k: cached_state[k] for k in ("WINDOW", "X", "Y", "WIDTH", "HEIGHT", "SCREEN")}
56
+ return window
57
+
58
+
59
+ def _update_state(window, target_x, target_y, target_width, target_height):
60
+ wm_class = get_wm_class(window["WINDOW"])
61
+ cached_state = load_state(window["WINDOW"], wm_class)
62
+ home_state = _resolve_home(window, cached_state)
63
+ save_state(window["WINDOW"], home_state, target_x, target_y, target_width, target_height, wm_class)
64
+
65
+
66
+ def clear_cache():
67
+ if CACHE_DIR.exists():
68
+ shutil.rmtree(CACHE_DIR)
winjitsu/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ import signal
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from .config import _CFG, _load_config, _write_config
9
+ from .actions import dispatch, VALID_ACTIONS
10
+ from .daemon import _fork_daemon, send_command, PID_PATH
11
+
12
+
13
+ _HELP_EPILOG = """
14
+ actions:
15
+ grid snapping:
16
+ N / S snap to top / bottom half of the screen
17
+ E / W snap to right / left half of the screen
18
+ NE / NW snap to top-right / top-left quarter
19
+ SE / SW snap to bottom-right / bottom-left quarter
20
+ C center window (keeps current size)
21
+
22
+ fullscreen:
23
+ F fullscreen (covers the entire monitor)
24
+ U restore window to its home position
25
+ TF toggle fullscreen / restore home position
26
+
27
+ multi-monitor:
28
+ TD move window to the next monitor
29
+
30
+ cache:
31
+ CC clear the window state cache
32
+
33
+ config file: ~/.config/winjitsu/config.ini (XDG_CONFIG_HOME honoured)
34
+ --write-config create config file with defaults (all options commented out)
35
+ --read-config use a different config file path
36
+ --see-config show active config file and current values
37
+ """
38
+
39
+
40
+ def main():
41
+ import winjitsu.config as _config_module
42
+
43
+ # Pre-parse --read-config before the main parser so globals are updated
44
+ # before any action uses them.
45
+ pre_parser = argparse.ArgumentParser(add_help=False)
46
+ pre_parser.add_argument("--read-config", metavar="PATH")
47
+ pre_parsed_args, _ = pre_parser.parse_known_args()
48
+ if pre_parsed_args.read_config:
49
+ _config_module._CFG = _load_config(Path(pre_parsed_args.read_config))
50
+
51
+ parser = argparse.ArgumentParser(
52
+ description="Animated window management tool for Linux X11.",
53
+ formatter_class=argparse.RawDescriptionHelpFormatter,
54
+ epilog=_HELP_EPILOG,
55
+ )
56
+ parser.add_argument("action", nargs="?", choices=VALID_ACTIONS, metavar="ACTION",
57
+ help="window management action (see below)")
58
+ parser.add_argument("--daemon", action="store_true", help="start background daemon")
59
+ parser.add_argument("--reload-daemon", action="store_true", help="restart the background daemon (preserves cache)")
60
+ parser.add_argument("--write-config", action="store_true", help="create config file with defaults and exit")
61
+ parser.add_argument("--read-config", metavar="PATH", help="use a custom config file path")
62
+ parser.add_argument("--see-config", action="store_true", help="print current config values and exit")
63
+ parser.add_argument("--steps", type=int, metavar="N", help="animation steps, overrides config (default: 25)")
64
+ parser.add_argument("--padding", type=int, metavar="PX", help="fullscreen padding in pixels, overrides config (default: 0)")
65
+ parser.add_argument("--delay-ms", type=int, metavar="MS", help="action delay in milliseconds, overrides config (default: 250)")
66
+ parsed_args = parser.parse_args()
67
+
68
+ config = _config_module._CFG
69
+
70
+ # CLI flags override config file and defaults
71
+ if parsed_args.steps is not None: config.steps = parsed_args.steps
72
+ if parsed_args.padding is not None: config.padding = parsed_args.padding
73
+ if parsed_args.delay_ms is not None: config.delay_ms = parsed_args.delay_ms
74
+
75
+ if parsed_args.write_config:
76
+ _write_config(config.path, config)
77
+ return
78
+
79
+ if parsed_args.see_config:
80
+ status = "exists" if config.path.exists() else "not found — using defaults"
81
+ print(f"config file : {config.path} ({status})")
82
+ print(f"steps : {config.steps}")
83
+ print(f"padding : {config.padding}")
84
+ print(f"delay_ms : {config.delay_ms}")
85
+ return
86
+
87
+ if parsed_args.daemon:
88
+ _fork_daemon()
89
+ return
90
+
91
+ if parsed_args.reload_daemon:
92
+ if not PID_PATH.exists():
93
+ print("No daemon running.", file=sys.stderr)
94
+ sys.exit(1)
95
+ try:
96
+ old_pid = int(PID_PATH.read_text().strip())
97
+ os.kill(old_pid, signal.SIGTERM)
98
+ except (ValueError, ProcessLookupError):
99
+ print("No daemon running (stale PID file).", file=sys.stderr)
100
+ PID_PATH.unlink(missing_ok=True)
101
+ sys.exit(1)
102
+ except PermissionError:
103
+ print("Permission denied sending SIGTERM.", file=sys.stderr)
104
+ sys.exit(1)
105
+ for _ in range(50): # poll up to 5 s (50 × 0.1 s)
106
+ try:
107
+ os.kill(old_pid, 0) # signal 0: liveness check, no signal sent
108
+ time.sleep(0.1)
109
+ except ProcessLookupError:
110
+ break
111
+ else:
112
+ # loop completed without break — daemon did not stop in time
113
+ print("Daemon did not stop within 5 seconds.", file=sys.stderr)
114
+ sys.exit(1)
115
+ _fork_daemon(clear_cache_on_stop=False)
116
+ return
117
+
118
+ if parsed_args.action is None:
119
+ parser.print_help()
120
+ sys.exit(1)
121
+
122
+ try:
123
+ if not send_command(parsed_args.action):
124
+ dispatch(parsed_args.action)
125
+ except RuntimeError as e:
126
+ print(f"Error: {e}", file=sys.stderr)
127
+ sys.exit(1)
winjitsu/config.py ADDED
@@ -0,0 +1,65 @@
1
+ import os
2
+ import configparser
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ _CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "winjitsu" / "config.ini"
8
+
9
+
10
+
11
+ @dataclass
12
+ class _Config:
13
+ steps: int
14
+ padding: int
15
+ delay_ms: int
16
+ path: Path
17
+
18
+
19
+ def _load_config(path=None):
20
+ ini_parser = configparser.ConfigParser()
21
+ ini_parser.read_dict({
22
+ "animation": {"steps": "25"},
23
+ "display": {"padding": "0"},
24
+ "daemon": {"delay_ms": "250"},
25
+ })
26
+ config_path = path or _CONFIG_PATH
27
+ ini_parser.read(config_path)
28
+ return _Config(
29
+ steps=ini_parser.getint("animation", "steps"),
30
+ padding=ini_parser.getint("display", "padding"),
31
+ delay_ms=ini_parser.getint("daemon", "delay_ms"),
32
+ path=config_path,
33
+ )
34
+
35
+
36
+ def _write_config(config_path, active_config):
37
+ if config_path.exists():
38
+ overwrite_response = input(f"Config already exists: {config_path}\nOverwrite? [y/N] ").strip().lower()
39
+ if overwrite_response not in ("y", "yes"):
40
+ print("Aborted.")
41
+ return
42
+ config_path.parent.mkdir(parents=True, exist_ok=True)
43
+ content = f"""\
44
+ # WinJitsu configuration
45
+
46
+ [animation]
47
+ # Steps in the window movement animation.
48
+ # Higher = smoother but slower. Default: 25
49
+ steps = {active_config.steps}
50
+
51
+ [display]
52
+ # Gap in pixels around the window when using F (fullscreen).
53
+ # 0 = true fullscreen, 5 = small gap on all sides. Default: 0
54
+ padding = {active_config.padding}
55
+
56
+ [daemon]
57
+ # Delay in milliseconds. Rapid actions within this window are
58
+ # collapsed into one — only the last one fires. Default: 250
59
+ delay_ms = {active_config.delay_ms}
60
+ """
61
+ config_path.write_text(content)
62
+ print(f"Config written to: {config_path}")
63
+
64
+
65
+ _CFG = _load_config()
winjitsu/daemon.py ADDED
@@ -0,0 +1,129 @@
1
+ import os
2
+ import sys
3
+ import socket
4
+ import socketserver
5
+ import signal
6
+ import threading
7
+
8
+ from .config import _CFG
9
+ from .actions import dispatch, VALID_ACTIONS
10
+ from .cache import clear_cache
11
+
12
+
13
+ _XDG_DATA_HOME = os.environ.get("XDG_DATA_HOME", str(os.path.join(os.path.expanduser("~"), ".local", "share")))
14
+
15
+ from pathlib import Path
16
+ _RUNTIME_DIR = Path(_XDG_DATA_HOME) / "winjitsu"
17
+ SOCKET_PATH = _RUNTIME_DIR / "winjitsu.sock"
18
+ PID_PATH = _RUNTIME_DIR / "winjitsu.pid"
19
+
20
+
21
+ _pending_timer = None
22
+ _pending_lock = threading.Lock()
23
+
24
+
25
+ def _schedule_action(action):
26
+ # Delay: cancel any pending action and restart the timer. Only the last
27
+ # action in a rapid burst fires, preventing duplicate moves from key repeat.
28
+ global _pending_timer
29
+ with _pending_lock:
30
+ if _pending_timer is not None:
31
+ _pending_timer.cancel()
32
+ _pending_timer = threading.Timer(_CFG.delay_ms / 1000, _run_action, args=(action,))
33
+ _pending_timer.start()
34
+
35
+
36
+ def _run_action(action):
37
+ global _pending_timer
38
+ with _pending_lock:
39
+ _pending_timer = None
40
+ dispatch(action)
41
+
42
+
43
+ class _CommandHandler(socketserver.StreamRequestHandler):
44
+ timeout = 5
45
+
46
+ def handle(self):
47
+ received_action = self.rfile.readline(256).decode().strip()
48
+ if not received_action or received_action not in VALID_ACTIONS:
49
+ self.wfile.write(b"ERROR: invalid action\n")
50
+ return
51
+ self.wfile.write(b"OK\n")
52
+ self.wfile.flush()
53
+ _schedule_action(received_action)
54
+
55
+
56
+ def _cleanup_stale_runtime():
57
+ if PID_PATH.exists():
58
+ try:
59
+ stored_pid = int(PID_PATH.read_text().strip())
60
+ os.kill(stored_pid, 0)
61
+ print(f"winjitsu daemon already running (PID {stored_pid})", file=sys.stderr)
62
+ sys.exit(1)
63
+ except (ValueError, ProcessLookupError):
64
+ PID_PATH.unlink(missing_ok=True)
65
+ except PermissionError:
66
+ print("winjitsu daemon already running", file=sys.stderr)
67
+ sys.exit(1)
68
+ SOCKET_PATH.unlink(missing_ok=True)
69
+
70
+
71
+ def _cleanup_runtime_files():
72
+ SOCKET_PATH.unlink(missing_ok=True)
73
+ PID_PATH.unlink(missing_ok=True)
74
+
75
+
76
+ def run_daemon(clear_cache_on_stop=True):
77
+ _RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
78
+ _cleanup_stale_runtime()
79
+ PID_PATH.write_text(str(os.getpid()))
80
+
81
+ socket_server = socketserver.ThreadingUnixStreamServer(str(SOCKET_PATH), _CommandHandler)
82
+ stop_event = threading.Event()
83
+
84
+ def _on_signal(signum, frame):
85
+ stop_event.set()
86
+
87
+ signal.signal(signal.SIGTERM, _on_signal)
88
+ signal.signal(signal.SIGINT, _on_signal)
89
+
90
+ threading.Thread(target=socket_server.serve_forever, daemon=True).start()
91
+ stop_event.wait()
92
+
93
+ try:
94
+ if clear_cache_on_stop:
95
+ clear_cache()
96
+ finally:
97
+ socket_server.shutdown()
98
+ socket_server.server_close()
99
+ _cleanup_runtime_files()
100
+
101
+
102
+ def _fork_daemon(clear_cache_on_stop=True):
103
+ _cleanup_stale_runtime()
104
+ child_pid = os.fork()
105
+ if child_pid > 0:
106
+ print(f"winjitsu daemon starting (PID {child_pid})")
107
+ sys.exit(0)
108
+ os.setsid() # detach from the controlling terminal; become session leader
109
+ # Redirect stdin/stdout/stderr to /dev/null — daemon must not touch the terminal
110
+ devnull_file = open(os.devnull, "r+")
111
+ for fd in (0, 1, 2):
112
+ os.dup2(devnull_file.fileno(), fd)
113
+ devnull_file.close()
114
+ run_daemon(clear_cache_on_stop)
115
+
116
+
117
+ def send_command(action):
118
+ if not SOCKET_PATH.exists():
119
+ return False
120
+ try:
121
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as unix_socket:
122
+ unix_socket.settimeout(2.0)
123
+ unix_socket.connect(str(SOCKET_PATH))
124
+ unix_socket.sendall(f"{action}\n".encode())
125
+ with unix_socket.makefile("r") as response_reader:
126
+ response = response_reader.readline().strip()
127
+ return response == "OK"
128
+ except (FileNotFoundError, ConnectionRefusedError, TimeoutError, OSError):
129
+ return False
winjitsu/screen.py ADDED
@@ -0,0 +1,73 @@
1
+ import threading
2
+
3
+ from Xlib import display as Xdisplay
4
+ from Xlib.ext import randr
5
+
6
+
7
+ _display: "Xdisplay.Display | None" = None
8
+ _display_lock = threading.Lock()
9
+
10
+
11
+ def _get_display() -> Xdisplay.Display:
12
+ global _display
13
+ if _display is None:
14
+ with _display_lock:
15
+ if _display is None:
16
+ _display = Xdisplay.Display()
17
+ return _display
18
+
19
+
20
+ def get_screens():
21
+ display = _get_display()
22
+ if not display.has_extension('RANDR'):
23
+ raise RuntimeError("RandR extension not available.")
24
+ root = display.screen().root
25
+ resources = root.xrandr_get_screen_resources()
26
+ config_timestamp = resources.config_timestamp # config_timestamp, NOT timestamp — timestamp causes BadMatch on some drivers
27
+ primary_id = root.xrandr_get_output_primary().output
28
+
29
+ primary = None
30
+ others = []
31
+
32
+ for output_id in resources.outputs:
33
+ output_info = display.xrandr_get_output_info(output_id, config_timestamp)
34
+ if output_info.connection != randr.Connected or not output_info.crtc:
35
+ continue
36
+ crtc_info = display.xrandr_get_crtc_info(output_info.crtc, config_timestamp)
37
+ screen_geometry = {"width": crtc_info.width, "height": crtc_info.height,
38
+ "x": crtc_info.x, "y": crtc_info.y}
39
+ if output_id == primary_id:
40
+ primary = screen_geometry
41
+ else:
42
+ others.append(screen_geometry)
43
+
44
+ return primary, others
45
+
46
+
47
+ def get_screen_for_window(window):
48
+ primary, others = get_screens()
49
+ if not primary:
50
+ default_screen = _get_display().screen()
51
+ return default_screen.width_in_pixels, default_screen.height_in_pixels, 0, 0
52
+
53
+ all_screens = [primary] + others
54
+ window_center_x = window["X"] + window["WIDTH"] / 2
55
+ window_center_y = window["Y"] + window["HEIGHT"] / 2
56
+
57
+ def _dist(screen_candidate):
58
+ screen_center_x = screen_candidate["x"] + screen_candidate["width"] / 2
59
+ screen_center_y = screen_candidate["y"] + screen_candidate["height"] / 2
60
+ x_distance = window_center_x - screen_center_x
61
+ y_distance = window_center_y - screen_center_y
62
+ return x_distance ** 2 + y_distance ** 2
63
+
64
+ # Use the screen whose bounds contain the window center. If the center falls
65
+ # outside all screens (e.g. window is mostly off-screen), fall back to the
66
+ # nearest screen by distance from its center.
67
+ screen = next(
68
+ (screen_candidate for screen_candidate in all_screens
69
+ if screen_candidate["x"] <= window_center_x < screen_candidate["x"] + screen_candidate["width"]
70
+ and screen_candidate["y"] <= window_center_y < screen_candidate["y"] + screen_candidate["height"]),
71
+ min(all_screens, key=_dist),
72
+ )
73
+ return screen["width"], screen["height"], screen["x"], screen["y"]