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 +5 -0
- winjitsu/actions.py +169 -0
- winjitsu/cache.py +68 -0
- winjitsu/cli.py +127 -0
- winjitsu/config.py +65 -0
- winjitsu/daemon.py +129 -0
- winjitsu/screen.py +73 -0
- winjitsu/window.py +53 -0
- winjitsu-0.2.0.dist-info/METADATA +888 -0
- winjitsu-0.2.0.dist-info/RECORD +14 -0
- winjitsu-0.2.0.dist-info/WHEEL +5 -0
- winjitsu-0.2.0.dist-info/entry_points.txt +2 -0
- winjitsu-0.2.0.dist-info/licenses/LICENSE +674 -0
- winjitsu-0.2.0.dist-info/top_level.txt +1 -0
winjitsu/__init__.py
ADDED
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"]
|