GameSentenceMiner 2.7.11__py3-none-any.whl → 2.7.13__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.
- GameSentenceMiner/anki.py +6 -2
- GameSentenceMiner/obs.py +6 -9
- GameSentenceMiner/ocr/gsm_ocr_config.py +48 -7
- GameSentenceMiner/ocr/owocr_area_selector.py +775 -172
- GameSentenceMiner/ocr/owocr_helper.py +16 -16
- {gamesentenceminer-2.7.11.dist-info → gamesentenceminer-2.7.13.dist-info}/METADATA +1 -1
- {gamesentenceminer-2.7.11.dist-info → gamesentenceminer-2.7.13.dist-info}/RECORD +11 -11
- {gamesentenceminer-2.7.11.dist-info → gamesentenceminer-2.7.13.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.7.11.dist-info → gamesentenceminer-2.7.13.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.7.11.dist-info → gamesentenceminer-2.7.13.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.7.11.dist-info → gamesentenceminer-2.7.13.dist-info}/top_level.txt +0 -0
@@ -1,294 +1,897 @@
|
|
1
|
+
import json
|
1
2
|
import sys
|
2
3
|
from multiprocessing import Process, Manager
|
4
|
+
from pathlib import Path
|
5
|
+
|
3
6
|
import mss
|
4
|
-
from
|
7
|
+
from PIL import Image, ImageTk, ImageDraw
|
5
8
|
|
6
|
-
from GameSentenceMiner
|
7
|
-
from
|
8
|
-
import
|
9
|
-
|
10
|
-
|
11
|
-
import
|
9
|
+
from GameSentenceMiner import obs # Import your actual obs module
|
10
|
+
from GameSentenceMiner.ocr.owocr_helper import get_ocr_config
|
11
|
+
from GameSentenceMiner.util import sanitize_filename # Import your actual util module
|
12
|
+
|
13
|
+
try:
|
14
|
+
import pygetwindow as gw
|
15
|
+
except ImportError:
|
16
|
+
print("Error: pygetwindow library not found. Please install it: pip install pygetwindow")
|
17
|
+
gw = None # Handle missing library
|
12
18
|
|
13
19
|
try:
|
14
20
|
import tkinter as tk
|
21
|
+
|
15
22
|
selector_available = True
|
16
|
-
except:
|
23
|
+
except ImportError:
|
24
|
+
print("Error: tkinter library not found. GUI selection is unavailable.")
|
17
25
|
selector_available = False
|
18
26
|
|
19
27
|
MIN_RECT_WIDTH = 25 # Minimum width in pixels
|
20
|
-
MIN_RECT_HEIGHT = 25
|
28
|
+
MIN_RECT_HEIGHT = 25 # Minimum height in pixels
|
29
|
+
|
30
|
+
# --- Constants for coordinate systems ---
|
31
|
+
COORD_SYSTEM_ABSOLUTE = "absolute_pixels"
|
32
|
+
COORD_SYSTEM_RELATIVE = "relative_pixels" # Kept for potential backward compatibility loading
|
33
|
+
COORD_SYSTEM_PERCENTAGE = "percentage"
|
34
|
+
|
35
|
+
|
36
|
+
# --- ---
|
21
37
|
|
22
38
|
class ScreenSelector:
|
23
39
|
def __init__(self, result, window_name):
|
24
|
-
|
40
|
+
if not selector_available:
|
41
|
+
raise RuntimeError("tkinter is not available.")
|
42
|
+
if not gw:
|
43
|
+
raise ImportError("pygetwindow is required but not installed.")
|
44
|
+
|
45
|
+
obs.connect_to_obs() # Connect to OBS (using mock or real)
|
25
46
|
self.window_name = window_name
|
26
|
-
print(window_name)
|
47
|
+
print(f"Target window name: {window_name or 'None (Absolute Mode)'}")
|
27
48
|
self.sct = mss.mss()
|
28
|
-
self.monitors = self.sct.monitors[1:]
|
49
|
+
self.monitors = self.sct.monitors[1:] # Skip the 'all monitors' entry
|
50
|
+
if not self.monitors: # If only one monitor exists
|
51
|
+
if len(self.sct.monitors) >= 1:
|
52
|
+
self.monitors = self.sct.monitors[0:1] # Use the primary monitor entry
|
53
|
+
else:
|
54
|
+
raise RuntimeError("No monitors found by mss.")
|
55
|
+
# Assign index to each monitor dictionary
|
56
|
+
for i, monitor in enumerate(self.monitors):
|
57
|
+
monitor['index'] = i
|
58
|
+
|
29
59
|
self.root = None
|
30
|
-
self.result = result
|
31
|
-
|
32
|
-
self.
|
60
|
+
self.result = result # Manager dict for results
|
61
|
+
# Internal storage ALWAYS uses absolute pixel coordinates
|
62
|
+
self.rectangles = [] # List to store (monitor_dict, (abs_x, abs_y, abs_w, abs_h), is_excluded) tuples
|
63
|
+
self.drawn_rect_ids = [] # List to store canvas rectangle IDs *per canvas* (needs careful management)
|
33
64
|
self.current_rect_id = None
|
34
|
-
self.start_x = None
|
35
|
-
self.start_y = None
|
36
|
-
self.canvas_windows = {} # Dictionary
|
37
|
-
self.load_existing_rectangles()
|
65
|
+
self.start_x = None # Canvas coordinates
|
66
|
+
self.start_y = None # Canvas coordinates
|
67
|
+
self.canvas_windows = {} # Dictionary mapping monitor_index -> canvas widget
|
38
68
|
self.image_mode = True
|
39
|
-
self.monitor_windows = {}
|
40
|
-
self.redo_stack = []
|
69
|
+
self.monitor_windows = {} # Stores {'window': tk.Toplevel, 'canvas': tk.Canvas, 'bg_img_id': int} per monitor index
|
70
|
+
self.redo_stack = [] # Stores undone actions: (monitor_dict, abs_coords, is_excluded, canvas_id)
|
71
|
+
|
72
|
+
# --- Window Awareness ---
|
73
|
+
self.target_window = self._find_target_window()
|
74
|
+
self.target_window_geometry = self._get_window_geometry(self.target_window)
|
75
|
+
if self.target_window_geometry:
|
76
|
+
print(f"Found target window '{self.window_name}' at: {self.target_window_geometry}")
|
77
|
+
elif self.window_name:
|
78
|
+
print(f"Warning: Could not find window '{self.window_name}'. Coordinates will be absolute.")
|
79
|
+
else:
|
80
|
+
print("No target window specified. Using absolute coordinates.")
|
81
|
+
# --- End Window Awareness ---
|
82
|
+
|
83
|
+
self.load_existing_rectangles() # Load AFTER finding the window
|
84
|
+
|
85
|
+
def _find_target_window(self):
|
86
|
+
"""Finds the window matching self.window_name."""
|
87
|
+
if not self.window_name:
|
88
|
+
return None
|
89
|
+
try:
|
90
|
+
windows = gw.getWindowsWithTitle(self.window_name)
|
91
|
+
if windows:
|
92
|
+
# TODO: Handle multiple matches if necessary (e.g., let user choose?)
|
93
|
+
if len(windows) > 1:
|
94
|
+
print(f"Warning: Multiple windows found with title '{self.window_name}'. Using the first one.")
|
95
|
+
return windows[0]
|
96
|
+
else:
|
97
|
+
return None
|
98
|
+
except Exception as e:
|
99
|
+
print(f"Error finding window '{self.window_name}': {e}")
|
100
|
+
return None
|
101
|
+
|
102
|
+
def _get_window_geometry(self, window):
|
103
|
+
"""Gets the geometry (left, top, width, height) of a pygetwindow object."""
|
104
|
+
if window:
|
105
|
+
try:
|
106
|
+
# Ensure width/height are positive. Use max(1, ...) to avoid zero dimensions.
|
107
|
+
width = max(1, window.width)
|
108
|
+
height = max(1, window.height)
|
109
|
+
return {"left": window.left, "top": window.top, "width": width, "height": height}
|
110
|
+
except Exception as e:
|
111
|
+
print(f"Error getting geometry for window '{window.title}': {e}")
|
112
|
+
# Handle specific states gracefully if possible
|
113
|
+
try:
|
114
|
+
if window.isMinimized or window.isMaximized:
|
115
|
+
print(f"Window '{window.title}' might be minimized or maximized, geometry may be inaccurate.")
|
116
|
+
# Attempt to get geometry anyway, might work depending on OS/lib version
|
117
|
+
width = max(1, window.width)
|
118
|
+
height = max(1, window.height)
|
119
|
+
return {"left": window.left, "top": window.top, "width": width, "height": height}
|
120
|
+
except: # Catch potential errors accessing window state attributes
|
121
|
+
pass # Fall through to return None
|
122
|
+
return None
|
41
123
|
|
42
124
|
def get_scene_ocr_config(self):
|
43
|
-
"""Return the path to the OCR config file
|
125
|
+
"""Return the path to the OCR config file (scene.json)."""
|
44
126
|
app_dir = Path.home() / "AppData" / "Roaming" / "GameSentenceMiner"
|
45
127
|
ocr_config_dir = app_dir / "ocr_config"
|
46
128
|
ocr_config_dir.mkdir(parents=True, exist_ok=True)
|
47
|
-
|
48
|
-
|
129
|
+
try:
|
130
|
+
# Get scene name (use mock or real OBS)
|
131
|
+
current_scene = obs.get_current_scene()
|
132
|
+
scene = sanitize_filename(current_scene or "default_scene")
|
133
|
+
except Exception as e:
|
134
|
+
print(f"Error getting OBS scene: {e}. Using default config name.")
|
135
|
+
scene = "default_scene"
|
136
|
+
|
137
|
+
# Use only the scene name for the config file
|
138
|
+
config_filename = f"{scene}.json"
|
139
|
+
config_path = ocr_config_dir / config_filename
|
49
140
|
return config_path
|
50
141
|
|
51
142
|
def load_existing_rectangles(self):
|
143
|
+
"""Loads rectangles from the config file, converting to absolute pixels."""
|
52
144
|
config_path = self.get_scene_ocr_config()
|
145
|
+
# Get CURRENT window geometry for potential conversion
|
146
|
+
current_window_geometry = self._get_window_geometry(self._find_target_window())
|
147
|
+
|
53
148
|
try:
|
54
|
-
with open(config_path, 'r') as f:
|
149
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
55
150
|
config_data = json.load(f)
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
151
|
+
|
152
|
+
# --- Determine coordinate system ---
|
153
|
+
saved_coord_system = config_data.get("coordinate_system")
|
154
|
+
# Check for window_name match if config stores it (optional enhancement)
|
155
|
+
# saved_window_name = config_data.get("window")
|
156
|
+
saved_window_geometry = config_data.get("window_geometry")
|
157
|
+
|
158
|
+
# Backward compatibility and system determination logic
|
159
|
+
if saved_coord_system:
|
160
|
+
coordinate_system = saved_coord_system
|
161
|
+
elif saved_window_geometry: # If geo exists but system doesn't, assume old relative format
|
162
|
+
coordinate_system = COORD_SYSTEM_RELATIVE
|
163
|
+
print(f"Loading using inferred '{COORD_SYSTEM_RELATIVE}' system (for backward compatibility).")
|
164
|
+
else: # Otherwise assume old absolute format
|
165
|
+
coordinate_system = COORD_SYSTEM_ABSOLUTE
|
166
|
+
print(f"Loading using inferred '{COORD_SYSTEM_ABSOLUTE}' system (for backward compatibility).")
|
167
|
+
# --- ---
|
168
|
+
|
169
|
+
print(f"Using coordinate system: {coordinate_system} from config {config_path}")
|
170
|
+
|
171
|
+
rectangles_data = config_data.get("rectangles", [])
|
172
|
+
self.rectangles = [] # Clear existing internal rectangles
|
173
|
+
loaded_count = 0
|
174
|
+
skipped_count = 0
|
175
|
+
|
176
|
+
for rect_data in rectangles_data:
|
177
|
+
monitor_data = rect_data.get("monitor") # Monitor info might be needed if not just index
|
178
|
+
coords = rect_data.get("coordinates") # Could be %, relative px, or absolute px
|
179
|
+
is_excluded = rect_data.get("is_excluded", False)
|
180
|
+
|
181
|
+
# Basic validation of loaded data structure
|
182
|
+
if not (monitor_data and isinstance(monitor_data, dict) and 'index' in monitor_data and
|
183
|
+
isinstance(coords, list) and len(coords) == 4):
|
184
|
+
print(f"Skipping invalid rectangle data structure: {rect_data}")
|
185
|
+
skipped_count += 1
|
186
|
+
continue
|
187
|
+
|
188
|
+
abs_coords = None # Will hold the final absolute pixel coordinates (x, y, w, h)
|
189
|
+
|
190
|
+
# --- Convert loaded coords to absolute pixels ---
|
191
|
+
try:
|
192
|
+
if coordinate_system == COORD_SYSTEM_PERCENTAGE:
|
193
|
+
if current_window_geometry:
|
194
|
+
win_w = current_window_geometry['width']
|
195
|
+
win_h = current_window_geometry['height']
|
196
|
+
win_l = current_window_geometry['left']
|
197
|
+
win_t = current_window_geometry['top']
|
198
|
+
# Ensure dimensions are valid for calculation
|
199
|
+
if win_w <= 0 or win_h <= 0:
|
200
|
+
raise ValueError("Current window dimensions are invalid for percentage calculation.")
|
201
|
+
|
202
|
+
x_pct, y_pct, w_pct, h_pct = map(float, coords) # Ensure float
|
203
|
+
|
204
|
+
# Calculate absolute pixel values
|
205
|
+
x_abs = (x_pct * win_w) + win_l
|
206
|
+
y_abs = (y_pct * win_h) + win_t
|
207
|
+
w_abs = w_pct * win_w
|
208
|
+
h_abs = h_pct * win_h
|
209
|
+
abs_coords = (int(x_abs), int(y_abs), int(w_abs), int(h_abs))
|
210
|
+
else:
|
211
|
+
raise ValueError(f"Cannot convert percentage coords {coords}, target window not found now.")
|
212
|
+
|
213
|
+
elif coordinate_system == COORD_SYSTEM_RELATIVE:
|
214
|
+
if current_window_geometry:
|
215
|
+
x_rel, y_rel, w_pix, h_pix = map(float, coords) # Read as float first
|
216
|
+
x_abs = x_rel + current_window_geometry['left']
|
217
|
+
y_abs = y_rel + current_window_geometry['top']
|
218
|
+
abs_coords = (int(x_abs), int(y_abs), int(w_pix), int(h_pix))
|
219
|
+
else:
|
220
|
+
raise ValueError(
|
221
|
+
f"Cannot convert relative pixel coords {coords}, target window not found now.")
|
222
|
+
|
223
|
+
elif coordinate_system == COORD_SYSTEM_ABSOLUTE:
|
224
|
+
# Assume absolute pixels
|
225
|
+
abs_coords = tuple(int(float(c)) for c in coords) # Allow float->int conversion
|
226
|
+
else:
|
227
|
+
# Fallback for unknown system: treat as absolute
|
228
|
+
print(
|
229
|
+
f"Warning: Unknown coordinate system '{coordinate_system}'. Treating coords {coords} as absolute.")
|
230
|
+
abs_coords = tuple(int(float(c)) for c in coords)
|
231
|
+
|
232
|
+
except (ValueError, TypeError, KeyError, IndexError) as e:
|
233
|
+
print(f"Error processing coords {coords} with system '{coordinate_system}': {e}. Skipping rect.")
|
234
|
+
skipped_count += 1
|
235
|
+
continue # Skip this rectangle if conversion failed
|
236
|
+
# --- End Conversion ---
|
237
|
+
|
238
|
+
# Validate size using the final absolute pixel coordinates
|
239
|
+
if abs_coords and abs_coords[2] >= MIN_RECT_WIDTH and abs_coords[3] >= MIN_RECT_HEIGHT:
|
240
|
+
# Find the correct monitor dict from self.monitors based on index
|
241
|
+
monitor_index = monitor_data['index']
|
242
|
+
target_monitor = next((m for m in self.monitors if m['index'] == monitor_index), None)
|
243
|
+
if target_monitor:
|
244
|
+
# Store the monitor dict from *this* instance and absolute pixel coordinates internally
|
245
|
+
self.rectangles.append((target_monitor, abs_coords, is_excluded))
|
246
|
+
loaded_count += 1
|
247
|
+
else:
|
248
|
+
print(
|
249
|
+
f"Warning: Monitor with index {monitor_index} not found in current setup. Skipping rect {abs_coords}.")
|
250
|
+
skipped_count += 1
|
251
|
+
elif abs_coords:
|
252
|
+
print(
|
253
|
+
f"Skipping small rectangle (pixels): W={abs_coords[2]}, H={abs_coords[3]} (from original {coords})")
|
254
|
+
skipped_count += 1
|
255
|
+
# else: conversion failed, already printed message
|
256
|
+
|
257
|
+
print(f"Loaded {loaded_count} rectangles, skipped {skipped_count} from {config_path}")
|
258
|
+
|
69
259
|
except FileNotFoundError:
|
70
|
-
print(f"No existing config found at {config_path}")
|
260
|
+
print(f"No existing config found at {config_path}. Starting fresh.")
|
71
261
|
except json.JSONDecodeError:
|
72
|
-
print(f"Error decoding JSON from {config_path}")
|
262
|
+
print(f"Error decoding JSON from {config_path}. Check file format. Starting fresh.")
|
73
263
|
except Exception as e:
|
74
|
-
print(f"An error occurred while loading rectangles: {e}")
|
264
|
+
print(f"An unexpected error occurred while loading rectangles: {e}")
|
265
|
+
import traceback
|
266
|
+
traceback.print_exc() # More detail on unexpected errors
|
75
267
|
|
76
268
|
def save_rects(self, event=None):
|
269
|
+
"""Saves rectangles to the config file, using percentages if window is targeted."""
|
270
|
+
# Use the window geometry found during __init__ for consistency during save
|
271
|
+
window_geom_to_save = self.target_window_geometry
|
272
|
+
save_coord_system = COORD_SYSTEM_ABSOLUTE # Default if no window
|
273
|
+
|
274
|
+
config_path = self.get_scene_ocr_config()
|
275
|
+
print(f"Saving rectangles to: {config_path}")
|
276
|
+
|
77
277
|
try:
|
78
|
-
|
79
|
-
|
80
|
-
|
278
|
+
# --- Determine coordinate system for saving ---
|
279
|
+
if window_geom_to_save:
|
280
|
+
# We have a window, try to save as percentages
|
281
|
+
win_l = window_geom_to_save['left']
|
282
|
+
win_t = window_geom_to_save['top']
|
283
|
+
win_w = window_geom_to_save['width']
|
284
|
+
win_h = window_geom_to_save['height']
|
285
|
+
# Basic check for valid dimensions needed for percentage calculation
|
286
|
+
if win_w > 0 and win_h > 0 and win_h > 0 and win_t > 0:
|
287
|
+
save_coord_system = COORD_SYSTEM_PERCENTAGE
|
288
|
+
win_l = max(0, window_geom_to_save['left'])
|
289
|
+
win_t = max(0, window_geom_to_save['top'])
|
290
|
+
print(f"Saving using coordinate system: {save_coord_system} relative to {window_geom_to_save}")
|
291
|
+
else:
|
292
|
+
print(
|
293
|
+
f"Warning: Window dimensions are invalid ({win_w}x{win_h}). Saving as absolute pixels instead.")
|
294
|
+
save_coord_system = COORD_SYSTEM_ABSOLUTE
|
295
|
+
window_geom_to_save = None # Don't save invalid geometry
|
296
|
+
else:
|
297
|
+
# No window found, save as absolute pixels
|
298
|
+
save_coord_system = COORD_SYSTEM_ABSOLUTE
|
299
|
+
print(f"Saving using coordinate system: {save_coord_system}")
|
300
|
+
# --- ---
|
301
|
+
|
81
302
|
serializable_rects = []
|
82
|
-
for
|
303
|
+
for monitor_dict, abs_coords, is_excluded in self.rectangles:
|
304
|
+
# abs_coords are the internal absolute pixels (x_abs, y_abs, w_abs, h_abs)
|
305
|
+
x_abs, y_abs, w_abs, h_abs = abs_coords
|
306
|
+
coords_to_save = []
|
307
|
+
|
308
|
+
# --- Convert absolute pixels to the chosen system ---
|
309
|
+
if save_coord_system == COORD_SYSTEM_PERCENTAGE and window_geom_to_save and 0 <= win_l < monitor_dict['left'] and 0 <= win_t < monitor_dict['top']:
|
310
|
+
# Calculate percentages (handle potential float precision issues if necessary)
|
311
|
+
x_pct = (x_abs - win_l) / win_w
|
312
|
+
y_pct = (y_abs - win_t) / win_h
|
313
|
+
w_pct = w_abs / win_w
|
314
|
+
h_pct = h_abs / win_h
|
315
|
+
# Round percentages slightly to avoid overly long floats? Optional.
|
316
|
+
# precision = 6
|
317
|
+
# coords_to_save = [round(x_pct, precision), round(y_pct, precision), round(w_pct, precision), round(h_pct, precision)]
|
318
|
+
coords_to_save = [x_pct, y_pct, w_pct, h_pct]
|
319
|
+
else:
|
320
|
+
# Save absolute pixel coordinates
|
321
|
+
coords_to_save = list(abs_coords)
|
322
|
+
save_coord_system = COORD_SYSTEM_ABSOLUTE # Ensure we note this system
|
323
|
+
|
324
|
+
# --- End Conversion ---
|
325
|
+
|
326
|
+
# Create serializable monitor info (e.g., just index)
|
327
|
+
monitor_info_to_save = {'index': monitor_dict['index']} # Save minimal info
|
328
|
+
|
83
329
|
rect_data = {
|
84
|
-
"monitor":
|
85
|
-
"coordinates":
|
330
|
+
"monitor": monitor_info_to_save,
|
331
|
+
"coordinates": coords_to_save,
|
86
332
|
"is_excluded": is_excluded
|
87
333
|
}
|
88
334
|
serializable_rects.append(rect_data)
|
89
335
|
|
90
|
-
|
336
|
+
# Prepare final data structure for JSON
|
337
|
+
save_data = {
|
338
|
+
"scene": obs.get_current_scene() or "default_scene",
|
339
|
+
"window": self.window_name, # Store targeted window name
|
340
|
+
"coordinate_system": save_coord_system, # Explicitly save the system used
|
341
|
+
"rectangles": serializable_rects
|
342
|
+
}
|
343
|
+
# Only add window_geometry if it was valid and used for non-absolute saving
|
344
|
+
if window_geom_to_save and save_coord_system != COORD_SYSTEM_ABSOLUTE:
|
345
|
+
save_data["window_geometry"] = window_geom_to_save # Save geometry used for % calc
|
346
|
+
|
347
|
+
# Write to JSON file
|
91
348
|
with open(config_path, 'w', encoding="utf-8") as f:
|
92
|
-
json.dump(
|
93
|
-
|
94
|
-
|
95
|
-
|
349
|
+
json.dump(save_data, f, indent=4, ensure_ascii=False)
|
350
|
+
|
351
|
+
print(f"Successfully saved {len(serializable_rects)} rectangles.")
|
352
|
+
# Pass back the internally stored ABSOLUTE coordinates and context
|
353
|
+
# Need to convert internal tuples to lists for the manager dict
|
354
|
+
abs_rects_list = [(r[0], list(r[1]), r[2]) for r in self.rectangles]
|
355
|
+
self.result['rectangles'] = abs_rects_list
|
356
|
+
self.result['window_geometry'] = window_geom_to_save # Pass back geometry used (or None)
|
357
|
+
self.result['coordinate_system'] = save_coord_system # Pass back system used for saving
|
358
|
+
|
359
|
+
self.quit_app() # Close the selector UI after saving
|
360
|
+
|
96
361
|
except Exception as e:
|
97
362
|
print(f"Failed to save rectangles: {e}")
|
363
|
+
import traceback
|
364
|
+
traceback.print_exc()
|
365
|
+
# Optionally: Show an error message to the user in the UI
|
366
|
+
# Do not destroy the root window on error, allow user to retry or quit
|
367
|
+
# self.root.destroy()
|
98
368
|
|
99
369
|
def undo_last_rect(self, event=None):
|
370
|
+
"""Removes the last drawn rectangle."""
|
100
371
|
if self.rectangles and self.drawn_rect_ids:
|
101
|
-
|
102
|
-
|
372
|
+
# Pop the internal data
|
373
|
+
last_rect_tuple = self.rectangles.pop() # (monitor_dict, abs_coords, is_excluded)
|
374
|
+
# Pop the corresponding canvas ID (fragile if IDs aren't managed carefully)
|
375
|
+
# This assumes drawn_rect_ids corresponds directly to rectangles, which might break
|
376
|
+
# if deletes happened without updating both lists perfectly.
|
377
|
+
# A better approach links the canvas ID directly to the rectangle data.
|
378
|
+
# For now, we'll assume simple append/pop correspondence.
|
379
|
+
|
380
|
+
# Find the canvas ID associated with this rectangle
|
381
|
+
monitor_index = last_rect_tuple[0]['index']
|
382
|
+
canvas = self.canvas_windows.get(monitor_index)
|
383
|
+
|
384
|
+
# Find the *specific* ID on that canvas to delete
|
385
|
+
# We need a way to map the internal rect tuple to the canvas ID
|
386
|
+
# Let's store ID with the rect temporarily: self.rectangles stores (mon, coords, excluded, canvas_id)
|
387
|
+
# Redo stack needs update too. Simpler for now: Assume last ID is correct.
|
388
|
+
|
389
|
+
if self.drawn_rect_ids: # Check if the list is not empty
|
390
|
+
last_rect_id = self.drawn_rect_ids.pop() # Get the assumed corresponding ID
|
391
|
+
|
392
|
+
# Add to redo stack (including the ID)
|
393
|
+
self.redo_stack.append((*last_rect_tuple, last_rect_id)) # (mon, coords, excluded, id)
|
394
|
+
|
395
|
+
if canvas:
|
396
|
+
try:
|
397
|
+
# Check if ID exists on this canvas before deleting
|
398
|
+
if last_rect_id in canvas.find_all():
|
399
|
+
canvas.delete(last_rect_id)
|
400
|
+
print(f"Undo: Deleted rectangle ID {last_rect_id} from canvas {monitor_index}")
|
401
|
+
else:
|
402
|
+
print(f"Warning: Undo - Rect ID {last_rect_id} not found on canvas {monitor_index}.")
|
403
|
+
except tk.TclError as e:
|
404
|
+
print(f"Warning: TclError during undo delete: {e}")
|
405
|
+
pass # Ignore if already deleted or canvas gone
|
406
|
+
else:
|
407
|
+
print(f"Warning: Undo - Canvas for monitor index {monitor_index} not found.")
|
408
|
+
else:
|
409
|
+
print("Warning: Undo failed - drawn_rect_ids list is empty.")
|
410
|
+
# Put the rectangle back if ID list was empty?
|
411
|
+
self.rectangles.append(last_rect_tuple)
|
103
412
|
|
104
|
-
monitor, coords, is_excluded = last_rect
|
105
|
-
self.redo_stack.append((monitor, coords, is_excluded, last_rect_id))
|
106
413
|
|
107
|
-
for canvas in self.canvas_windows.values():
|
108
|
-
try:
|
109
|
-
canvas.delete(last_rect_id)
|
110
|
-
except tk.TclError:
|
111
|
-
pass
|
112
414
|
elif self.current_rect_id is not None:
|
415
|
+
# If undoing during drag (before rectangle is finalized in self.rectangles)
|
416
|
+
active_canvas = None
|
417
|
+
# Find which canvas holds the temporary rectangle
|
113
418
|
for canvas in self.canvas_windows.values():
|
419
|
+
if self.current_rect_id in canvas.find_all():
|
420
|
+
active_canvas = canvas
|
421
|
+
break
|
422
|
+
if active_canvas:
|
114
423
|
try:
|
115
|
-
|
424
|
+
active_canvas.delete(self.current_rect_id)
|
425
|
+
print("Undo: Deleted temporary rectangle.")
|
116
426
|
except tk.TclError:
|
117
|
-
pass
|
427
|
+
pass # Ignore if already deleted
|
118
428
|
self.current_rect_id = None
|
119
429
|
self.start_x = None
|
120
430
|
self.start_y = None
|
121
431
|
|
122
|
-
|
123
432
|
def redo_last_rect(self, event=None):
|
433
|
+
"""Redraws the last undone rectangle."""
|
124
434
|
if not self.redo_stack:
|
435
|
+
print("Redo: Nothing to redo.")
|
125
436
|
return
|
126
437
|
|
127
|
-
monitor, coords, is_excluded,
|
128
|
-
|
438
|
+
# Pop monitor, absolute coords, is_excluded, and the original canvas ID
|
439
|
+
monitor, abs_coords, is_excluded, old_rect_id = self.redo_stack.pop()
|
440
|
+
monitor_index = monitor['index']
|
441
|
+
canvas = self.canvas_windows.get(monitor_index)
|
442
|
+
|
129
443
|
if canvas:
|
130
|
-
|
444
|
+
x_abs, y_abs, w_abs, h_abs = abs_coords # Use absolute coords for drawing
|
131
445
|
outline_color = 'green' if not is_excluded else 'orange'
|
132
|
-
new_rect_id = canvas.create_rectangle(x, y, x + w, y + h, outline=outline_color, width=2)
|
133
|
-
self.rectangles.append((monitor, coords, is_excluded))
|
134
|
-
self.drawn_rect_ids.append(new_rect_id)
|
135
446
|
|
447
|
+
# Convert absolute screen coords to canvas-local coords for drawing
|
448
|
+
canvas_x = x_abs - monitor['left']
|
449
|
+
canvas_y = y_abs - monitor['top']
|
450
|
+
|
451
|
+
try:
|
452
|
+
# Draw using coordinates relative to the canvas/monitor window
|
453
|
+
# IMPORTANT: This creates a *new* canvas ID
|
454
|
+
new_rect_id = canvas.create_rectangle(
|
455
|
+
canvas_x, canvas_y, canvas_x + w_abs, canvas_y + h_abs,
|
456
|
+
outline=outline_color, width=2
|
457
|
+
)
|
458
|
+
|
459
|
+
# Store the absolute coordinates and the *new* ID internally again
|
460
|
+
self.rectangles.append((monitor, abs_coords, is_excluded))
|
461
|
+
self.drawn_rect_ids.append(new_rect_id) # Add the NEW ID
|
462
|
+
print(f"Redo: Restored rectangle with new ID {new_rect_id} on canvas {monitor_index}")
|
463
|
+
|
464
|
+
except tk.TclError as e:
|
465
|
+
print(f"Warning: TclError during redo draw: {e}")
|
466
|
+
# If drawing fails, put the item back on the redo stack?
|
467
|
+
self.redo_stack.append((monitor, abs_coords, is_excluded, old_rect_id))
|
468
|
+
else:
|
469
|
+
print(f"Warning: Redo - Canvas for monitor index {monitor_index} not found.")
|
470
|
+
# Put the item back on the redo stack if canvas not found
|
471
|
+
self.redo_stack.append((monitor, abs_coords, is_excluded, old_rect_id))
|
136
472
|
|
137
473
|
def create_window(self, monitor):
|
138
|
-
|
139
|
-
|
474
|
+
"""Creates the transparent overlay window for a single monitor."""
|
475
|
+
monitor_index = monitor['index'] # Assumes index is set
|
476
|
+
monitor_left, monitor_top = monitor['left'], monitor['top']
|
477
|
+
monitor_width, monitor_height = monitor['width'], monitor['height']
|
140
478
|
|
141
|
-
|
142
|
-
|
479
|
+
try:
|
480
|
+
# Grab screenshot for this specific monitor
|
481
|
+
screenshot = self.sct.grab(monitor)
|
482
|
+
img = Image.frombytes('RGB', (screenshot.width, screenshot.height), screenshot.rgb)
|
483
|
+
|
484
|
+
# Resize if screenshot dimensions don't match monitor info (e.g., DPI scaling)
|
485
|
+
if img.width != monitor_width or img.height != monitor_height:
|
486
|
+
print(
|
487
|
+
f"Monitor {monitor_index}: Resizing screenshot from {img.size} to monitor size {monitor_width}x{monitor_height}")
|
488
|
+
img = img.resize((monitor_width, monitor_height), Image.Resampling.LANCZOS)
|
143
489
|
|
144
|
-
|
490
|
+
except Exception as e:
|
491
|
+
print(f"Error grabbing screenshot for monitor {monitor_index}: {e}")
|
492
|
+
# Create a blank placeholder image on error
|
493
|
+
img = Image.new('RGB', (monitor_width, monitor_height), color='grey')
|
494
|
+
draw = ImageDraw.Draw(img)
|
495
|
+
draw.text((10, 10), f"Error grabbing screen {monitor_index}", fill="red")
|
496
|
+
|
497
|
+
# Create the Toplevel window
|
145
498
|
window = tk.Toplevel(self.root)
|
146
|
-
window.geometry(f"{
|
147
|
-
window.overrideredirect(1)
|
148
|
-
window.attributes('-topmost', 1)
|
499
|
+
window.geometry(f"{monitor_width}x{monitor_height}+{monitor_left}+{monitor_top}")
|
500
|
+
window.overrideredirect(1) # Frameless window
|
501
|
+
window.attributes('-topmost', 1) # Keep on top
|
502
|
+
window.attributes("-alpha", 1.0 if self.image_mode else 0.2) # Initial transparency
|
149
503
|
|
150
504
|
img_tk = ImageTk.PhotoImage(img)
|
151
505
|
|
152
|
-
canvas
|
506
|
+
# Create the canvas covering the entire window
|
507
|
+
canvas = tk.Canvas(window, cursor='cross', highlightthickness=0,
|
508
|
+
width=monitor_width, height=monitor_height)
|
153
509
|
canvas.pack(fill=tk.BOTH, expand=True)
|
510
|
+
# Keep a reference to the PhotoImage to prevent garbage collection!
|
154
511
|
canvas.image = img_tk
|
155
|
-
|
156
|
-
|
512
|
+
# Draw the background image onto the canvas
|
513
|
+
bg_img_id = canvas.create_image(0, 0, image=img_tk, anchor=tk.NW)
|
514
|
+
# Store the canvas widget, mapping from monitor index
|
515
|
+
self.canvas_windows[monitor_index] = canvas
|
157
516
|
|
158
|
-
#
|
159
|
-
self.monitor_windows[
|
517
|
+
# Store references for potential refreshing or cleanup
|
518
|
+
self.monitor_windows[monitor_index] = {
|
160
519
|
'window': window,
|
161
520
|
'canvas': canvas,
|
162
|
-
'bg_img_id':
|
521
|
+
'bg_img_id': bg_img_id, # ID of the background image item
|
163
522
|
}
|
164
523
|
|
165
|
-
# Draw existing rectangles
|
166
|
-
|
167
|
-
|
168
|
-
|
524
|
+
# --- Draw existing rectangles loaded from config ---
|
525
|
+
# These are already converted to absolute pixel coordinates in self.rectangles
|
526
|
+
drawn_on_this_canvas = []
|
527
|
+
for mon_data, abs_coords, is_excluded in self.rectangles:
|
528
|
+
if mon_data['index'] == monitor_index:
|
529
|
+
x_abs, y_abs, w_abs, h_abs = abs_coords
|
530
|
+
# Convert absolute screen coords to canvas-local coords for drawing
|
531
|
+
canvas_x = x_abs - monitor_left
|
532
|
+
canvas_y = y_abs - monitor_top
|
169
533
|
outline_color = 'green' if not is_excluded else 'orange'
|
170
|
-
|
171
|
-
|
172
|
-
|
534
|
+
try:
|
535
|
+
rect_id = canvas.create_rectangle(
|
536
|
+
canvas_x, canvas_y, canvas_x + w_abs, canvas_y + h_abs,
|
537
|
+
outline=outline_color, width=2
|
538
|
+
)
|
539
|
+
# IMPORTANT: Store the generated ID. Needs careful mapping back to self.rectangles later
|
540
|
+
# For simplicity now, just add to the global list.
|
541
|
+
self.drawn_rect_ids.append(rect_id)
|
542
|
+
drawn_on_this_canvas.append(rect_id)
|
543
|
+
except tk.TclError as e:
|
544
|
+
print(f"Warning: TclError drawing existing rectangle {abs_coords} on canvas {monitor_index}: {e}")
|
545
|
+
print(f"Drew {len(drawn_on_this_canvas)} existing rectangles on monitor {monitor_index}")
|
546
|
+
|
547
|
+
# --- Define Event Handlers specific to this canvas instance ---
|
173
548
|
def on_click(event):
|
549
|
+
# event.x, event.y are relative to the canvas widget
|
174
550
|
if self.current_rect_id is None:
|
175
|
-
self.start_x, self.start_y = event.x, event.y
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
551
|
+
self.start_x, self.start_y = event.x, event.y # Store canvas coords
|
552
|
+
# Determine color/state (Shift key for exclusion)
|
553
|
+
is_exclusion = bool(event.state & 0x0001)
|
554
|
+
outline_color = 'purple' if is_exclusion else 'red' # Temp color while drawing
|
555
|
+
try:
|
556
|
+
# Create rectangle on *this* specific canvas
|
557
|
+
self.current_rect_id = canvas.create_rectangle(
|
558
|
+
self.start_x, self.start_y, self.start_x, self.start_y,
|
559
|
+
outline=outline_color, width=2
|
560
|
+
)
|
561
|
+
except tk.TclError as e:
|
562
|
+
print(f"Warning: TclError creating rectangle on canvas {monitor_index}: {e}")
|
563
|
+
self.current_rect_id = None # Reset state if creation failed
|
181
564
|
|
182
565
|
def on_drag(event):
|
566
|
+
# Update the temporary rectangle being drawn on *this* canvas
|
183
567
|
if self.current_rect_id:
|
184
|
-
|
568
|
+
try:
|
569
|
+
# Use canvas coords directly
|
570
|
+
canvas.coords(self.current_rect_id, self.start_x, self.start_y, event.x, event.y)
|
571
|
+
except tk.TclError as e:
|
572
|
+
print(f"Warning: TclError updating coords during drag on canvas {monitor_index}: {e}")
|
573
|
+
# Option: delete the rect and stop drag?
|
574
|
+
# canvas.delete(self.current_rect_id)
|
575
|
+
# self.current_rect_id = None
|
576
|
+
pass
|
185
577
|
|
186
578
|
def on_release(event):
|
579
|
+
# Finalize the rectangle drawn on *this* canvas
|
187
580
|
if self.current_rect_id:
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
581
|
+
current_rect_id_local = self.current_rect_id # Store locally in case it's reset
|
582
|
+
self.current_rect_id = None # Reset global temp ID tracker
|
583
|
+
|
584
|
+
try:
|
585
|
+
# Get final coords relative to this canvas
|
586
|
+
canvas_x1, canvas_y1, canvas_x2, canvas_y2 = canvas.coords(current_rect_id_local)
|
587
|
+
|
588
|
+
# Convert canvas-local coords to absolute screen coords for storage
|
589
|
+
# Ensure correct order (x1,y1 is top-left)
|
590
|
+
abs_x1 = int(min(canvas_x1, canvas_x2) + monitor_left)
|
591
|
+
abs_y1 = int(min(canvas_y1, canvas_y2) + monitor_top)
|
592
|
+
abs_x2 = int(max(canvas_x1, canvas_x2) + monitor_left)
|
593
|
+
abs_y2 = int(max(canvas_y1, canvas_y2) + monitor_top)
|
594
|
+
|
595
|
+
# Calculate absolute width/height
|
596
|
+
width_abs = abs_x2 - abs_x1
|
597
|
+
height_abs = abs_y2 - abs_y1
|
598
|
+
|
599
|
+
# Check against minimum size
|
600
|
+
if width_abs >= MIN_RECT_WIDTH and height_abs >= MIN_RECT_HEIGHT:
|
601
|
+
is_excluded = bool(event.state & 0x0001) # Check Shift key state at release
|
602
|
+
final_outline = 'orange' if is_excluded else 'green' # Final color
|
603
|
+
canvas.itemconfig(current_rect_id_local, outline=final_outline)
|
604
|
+
|
605
|
+
# Store the absolute coordinates and monitor info internally
|
606
|
+
abs_coords_tuple = (abs_x1, abs_y1, width_abs, height_abs)
|
607
|
+
# Add to internal list (using the correct monitor dictionary)
|
608
|
+
self.rectangles.append((monitor, abs_coords_tuple, is_excluded))
|
609
|
+
# Add the ID of the rectangle just drawn on this canvas
|
610
|
+
self.drawn_rect_ids.append(current_rect_id_local)
|
611
|
+
# Clear redo stack on new action
|
612
|
+
self.redo_stack.clear()
|
613
|
+
print(
|
614
|
+
f"Stored rectangle: Abs={abs_coords_tuple}, Excluded={is_excluded}, ID={current_rect_id_local}")
|
615
|
+
else:
|
616
|
+
# Rectangle too small, delete it from the canvas
|
617
|
+
canvas.delete(current_rect_id_local)
|
618
|
+
print(f"Skipping small rectangle: W={width_abs}, H={height_abs}")
|
619
|
+
|
620
|
+
except tk.TclError as e:
|
621
|
+
print(f"Warning: TclError processing rectangle on release on canvas {monitor_index}: {e}")
|
622
|
+
# Attempt cleanup if ID still exists
|
623
|
+
try:
|
624
|
+
if current_rect_id_local in canvas.find_all():
|
625
|
+
canvas.delete(current_rect_id_local)
|
626
|
+
except tk.TclError:
|
627
|
+
pass # Ignore cleanup error
|
628
|
+
|
629
|
+
finally:
|
630
|
+
# Always reset start coordinates
|
631
|
+
self.start_x = None
|
632
|
+
self.start_y = None
|
206
633
|
|
207
634
|
def on_right_click(event):
|
208
|
-
item
|
209
|
-
if
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
635
|
+
# Find rectangle item under cursor on *this* canvas
|
636
|
+
# Use find_closest initially, then check if it's overlapping and a rectangle we manage
|
637
|
+
# find_closest returns a tuple, possibly empty
|
638
|
+
items = canvas.find_closest(event.x, event.y)
|
639
|
+
target_id_to_delete = None
|
640
|
+
if items:
|
641
|
+
item_id = items[0] # Get the closest item ID
|
642
|
+
# Check if this ID is one we drew and if the click is actually inside its bbox
|
643
|
+
if item_id in self.drawn_rect_ids and canvas.type(item_id) == 'rectangle':
|
644
|
+
bbox = canvas.bbox(item_id)
|
645
|
+
if bbox and bbox[0] <= event.x <= bbox[2] and bbox[1] <= event.y <= bbox[3]:
|
646
|
+
target_id_to_delete = item_id
|
647
|
+
|
648
|
+
if target_id_to_delete is not None:
|
649
|
+
try:
|
650
|
+
# --- Find the corresponding rectangle in self.rectangles ---
|
651
|
+
# This requires matching the canvas ID or recalculating absolute coords
|
652
|
+
found_rect_index = -1
|
653
|
+
|
654
|
+
# Method 1: Match by recalculating absolute coords (more robust)
|
655
|
+
canvas_coords = canvas.coords(target_id_to_delete) # canvas x1,y1,x2,y2
|
656
|
+
rect_abs_x = int(min(canvas_coords[0], canvas_coords[2]) + monitor_left)
|
657
|
+
rect_abs_y = int(min(canvas_coords[1], canvas_coords[3]) + monitor_top)
|
658
|
+
rect_w = int(abs(canvas_coords[2] - canvas_coords[0]))
|
659
|
+
rect_h = int(abs(canvas_coords[3] - canvas_coords[1]))
|
660
|
+
|
661
|
+
for i, (mon, abs_coords, excluded) in enumerate(self.rectangles):
|
662
|
+
# Compare recalculated coords with stored absolute coords (allow small tolerance)
|
663
|
+
if mon['index'] == monitor_index and \
|
664
|
+
abs(abs_coords[0] - rect_abs_x) < 2 and \
|
665
|
+
abs(abs_coords[1] - rect_abs_y) < 2 and \
|
666
|
+
abs(abs_coords[2] - rect_w) < 2 and \
|
667
|
+
abs(abs_coords[3] - rect_h) < 2:
|
668
|
+
found_rect_index = i
|
669
|
+
break
|
670
|
+
|
671
|
+
# --- Delete if found ---
|
672
|
+
if found_rect_index != -1:
|
673
|
+
print(
|
674
|
+
f"Deleting rectangle index {found_rect_index} (ID {target_id_to_delete}) on canvas {monitor_index}")
|
675
|
+
# Remove from internal list
|
676
|
+
del self.rectangles[found_rect_index]
|
677
|
+
# Remove from the ID list
|
678
|
+
self.drawn_rect_ids.remove(target_id_to_delete)
|
679
|
+
# Clear redo stack on new action
|
680
|
+
self.redo_stack.clear()
|
681
|
+
# Delete from canvas
|
682
|
+
canvas.delete(target_id_to_delete)
|
683
|
+
else:
|
684
|
+
print(
|
685
|
+
f"Warning: Could not find internal rectangle data matching canvas ID {target_id_to_delete} for deletion.")
|
686
|
+
# Optionally still delete from canvas if found, but lists will be inconsistent
|
687
|
+
# canvas.delete(target_id_to_delete)
|
688
|
+
# if target_id_to_delete in self.drawn_rect_ids: self.drawn_rect_ids.remove(target_id_to_delete)
|
689
|
+
|
690
|
+
except tk.TclError as e:
|
691
|
+
print(f"Warning: TclError deleting rectangle ID {target_id_to_delete} on right click: {e}")
|
692
|
+
except ValueError:
|
693
|
+
# This happens if ID was already removed from drawn_rect_ids somehow
|
694
|
+
print(
|
695
|
+
f"Warning: Rectangle ID {target_id_to_delete} not found in drawn_rect_ids list during delete.")
|
696
|
+
# Still try to delete from canvas if possible
|
697
|
+
try:
|
698
|
+
canvas.delete(target_id_to_delete)
|
699
|
+
except:
|
700
|
+
pass
|
223
701
|
|
224
702
|
def toggle_image_mode(event=None):
|
703
|
+
"""Toggles the transparency of the overlay window."""
|
225
704
|
self.image_mode = not self.image_mode
|
226
|
-
if self.image_mode
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
canvas
|
235
|
-
canvas.bind('<
|
236
|
-
|
237
|
-
|
705
|
+
alpha = 1.0 if self.image_mode else 0.25 # Adjust alpha value as needed
|
706
|
+
# Apply alpha to all monitor windows
|
707
|
+
for mon_idx in self.monitor_windows:
|
708
|
+
try:
|
709
|
+
self.monitor_windows[mon_idx]['window'].attributes("-alpha", alpha)
|
710
|
+
except Exception as e:
|
711
|
+
print(f"Error setting alpha for monitor {mon_idx}: {e}")
|
712
|
+
|
713
|
+
# --- Bind Events to the canvas ---
|
714
|
+
canvas.bind('<ButtonPress-1>', on_click) # Left click start
|
715
|
+
canvas.bind('<B1-Motion>', on_drag) # Left drag
|
716
|
+
canvas.bind('<ButtonRelease-1>', on_release) # Left click release
|
717
|
+
canvas.bind('<Button-3>', on_right_click) # Right click delete
|
718
|
+
|
719
|
+
# --- Bind Global Actions to the window (apply to all windows) ---
|
720
|
+
# Use lambdas to ensure the correct toggle function is called if needed,
|
721
|
+
# but here toggle_image_mode already affects all windows.
|
722
|
+
window.bind('<Control-s>', self.save_rects) # Save
|
723
|
+
window.bind('<Control-z>', self.undo_last_rect) # Undo
|
724
|
+
window.bind('<Control-y>', self.redo_last_rect) # Redo
|
725
|
+
# Optional: Add non-Ctrl versions if desired
|
238
726
|
window.bind('<s>', self.save_rects)
|
239
727
|
window.bind('<z>', self.undo_last_rect)
|
240
|
-
window.bind("<Escape>", self.quit_app)
|
241
|
-
window.bind("<m>", toggle_image_mode)
|
242
|
-
window.bind('<Control-y>', self.redo_last_rect)
|
243
728
|
window.bind('<y>', self.redo_last_rect)
|
729
|
+
window.bind("<Escape>", self.quit_app) # Quit
|
730
|
+
window.bind("<m>", toggle_image_mode) # Toggle image mode (alpha)
|
244
731
|
|
245
732
|
def start(self):
|
733
|
+
"""Initializes the Tkinter root and creates windows for each monitor."""
|
246
734
|
self.root = tk.Tk()
|
247
|
-
self.root.withdraw()
|
735
|
+
self.root.withdraw() # Hide the main useless root window
|
248
736
|
|
737
|
+
if not self.monitors:
|
738
|
+
print("Error: No monitors available to display.")
|
739
|
+
self.result['error'] = "No monitors found" # Report back error
|
740
|
+
self.root.destroy()
|
741
|
+
return
|
742
|
+
|
743
|
+
print(f"Creating selection windows for {len(self.monitors)} monitor(s)...")
|
249
744
|
for monitor in self.monitors:
|
250
|
-
|
251
|
-
|
745
|
+
self.create_window(monitor) # Create overlay for each monitor
|
746
|
+
|
747
|
+
# Check if any canvas windows were actually created
|
748
|
+
if not self.canvas_windows:
|
749
|
+
print("Error: Failed to create any monitor selection windows.")
|
750
|
+
self.result['error'] = "Failed to create monitor windows"
|
751
|
+
self.root.destroy()
|
752
|
+
return
|
252
753
|
|
253
|
-
|
754
|
+
print("Starting Tkinter main loop. Press Esc to quit, Ctrl+S to save.")
|
755
|
+
self.root.mainloop() # Start the event loop
|
254
756
|
|
255
757
|
def quit_app(self, event=None):
|
256
|
-
|
257
|
-
|
758
|
+
"""Initiates the cleanup and shutdown process."""
|
759
|
+
print("Quit signal received, closing application...")
|
760
|
+
self.on_close() # Trigger cleanup
|
258
761
|
|
259
762
|
def on_close(self):
|
260
|
-
|
763
|
+
"""Cleans up Tkinter resources."""
|
764
|
+
# Check if root exists and hasn't been destroyed already
|
765
|
+
if self.root and self.root.winfo_exists():
|
766
|
+
print("Destroying Tkinter windows...")
|
767
|
+
try:
|
768
|
+
# Explicitly destroy child windows first (Toplevels)
|
769
|
+
for monitor_index in list(self.monitor_windows.keys()): # Iterate over keys copy
|
770
|
+
win_info = self.monitor_windows.pop(monitor_index, None)
|
771
|
+
if win_info and win_info.get('window'):
|
772
|
+
try:
|
773
|
+
if win_info['window'].winfo_exists():
|
774
|
+
win_info['window'].destroy()
|
775
|
+
except tk.TclError:
|
776
|
+
pass # Ignore if already destroyed
|
777
|
+
# Now destroy the root window
|
778
|
+
self.root.quit() # Exit mainloop first
|
779
|
+
self.root.destroy()
|
780
|
+
print("Tkinter windows destroyed.")
|
781
|
+
except Exception as e:
|
782
|
+
print(f"Error during Tkinter cleanup: {e}")
|
783
|
+
finally:
|
784
|
+
self.root = None # Ensure root is marked as gone
|
785
|
+
self.canvas_windows.clear()
|
786
|
+
self.monitor_windows.clear()
|
787
|
+
else:
|
788
|
+
print("Cleanup: Root window already destroyed or not initialized.")
|
789
|
+
|
261
790
|
|
791
|
+
# --- Multiprocessing Functions ---
|
262
792
|
|
263
|
-
def run_screen_selector(
|
264
|
-
|
265
|
-
|
793
|
+
def run_screen_selector(result_dict, window_name):
|
794
|
+
"""Target function for the separate process. Handles setup and running."""
|
795
|
+
try:
|
796
|
+
selector = ScreenSelector(result_dict, window_name)
|
797
|
+
selector.start()
|
798
|
+
# Result dictionary is updated directly by selector.save_rects or on error
|
799
|
+
print("ScreenSelector process finished.")
|
800
|
+
except ImportError as e:
|
801
|
+
print(f"Import error in subprocess: {e}")
|
802
|
+
result_dict['error'] = str(e) # Report error back via manager dict
|
803
|
+
except Exception as e:
|
804
|
+
print(f"Error running ScreenSelector in subprocess: {e}")
|
805
|
+
import traceback
|
806
|
+
traceback.print_exc() # Print detailed traceback in the subprocess console
|
807
|
+
result_dict['error'] = f"Runtime error: {e}" # Report error back
|
266
808
|
|
267
809
|
|
268
810
|
def get_screen_selection(window_name):
|
811
|
+
"""
|
812
|
+
Launches the ScreenSelector in a separate process and returns the result.
|
813
|
+
Returns None on failure or cancellation, otherwise a dict containing:
|
814
|
+
{'rectangles': list_of_abs_coords, 'window_geometry': dict_or_None, 'coordinate_system': str}
|
815
|
+
"""
|
269
816
|
if not selector_available:
|
270
|
-
|
817
|
+
print('Fatal Error: tkinter is not installed or available.')
|
818
|
+
return None
|
819
|
+
if not gw:
|
820
|
+
print('Fatal Error: pygetwindow is not installed or available.')
|
821
|
+
return None
|
271
822
|
|
272
823
|
with Manager() as manager:
|
273
|
-
|
274
|
-
|
824
|
+
# Use a Manager dictionary for safe inter-process communication
|
825
|
+
result_data = manager.dict()
|
826
|
+
process = Process(target=run_screen_selector, args=(result_data, window_name))
|
275
827
|
|
828
|
+
print(f"Starting ScreenSelector process for window: '{window_name or 'None'}'...")
|
276
829
|
process.start()
|
277
|
-
process.join()
|
278
|
-
|
279
|
-
|
280
|
-
|
830
|
+
process.join() # Wait for the ScreenSelector process to complete
|
831
|
+
print("ScreenSelector process joined.")
|
832
|
+
|
833
|
+
# Process results from the Manager dictionary
|
834
|
+
if 'error' in result_data:
|
835
|
+
print(f"ScreenSelector process reported an error: {result_data['error']}")
|
836
|
+
return None # Indicate failure
|
837
|
+
elif 'rectangles' in result_data:
|
838
|
+
print("Screen selection successful.")
|
839
|
+
# Return a standard dictionary copy from the Manager dict
|
840
|
+
return {
|
841
|
+
"rectangles": result_data.get('rectangles'), # List of (monitor, [abs_coords], excluded)
|
842
|
+
"window_geometry": result_data.get('window_geometry'), # Dict or None
|
843
|
+
"coordinate_system": result_data.get('coordinate_system') # String constant
|
844
|
+
}
|
281
845
|
else:
|
282
|
-
|
846
|
+
# This case usually means the user quit (Esc) without saving
|
847
|
+
print("No selection saved or process was cancelled by user.")
|
848
|
+
return {} # Return empty dict to indicate cancellation without error
|
283
849
|
|
284
850
|
|
851
|
+
# --- Main Execution Block ---
|
285
852
|
if __name__ == "__main__":
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
print("
|
291
|
-
|
292
|
-
|
853
|
+
target_window_title = None # Default to absolute coordinates
|
854
|
+
# Check for command line arguments to specify window title
|
855
|
+
if len(sys.argv) > 1:
|
856
|
+
target_window_title = sys.argv[1]
|
857
|
+
print(f"Attempting to target window title from args: '{target_window_title}'")
|
858
|
+
else:
|
859
|
+
print("Usage: python your_script_name.py [\"Target Window Title\"]")
|
860
|
+
print("No window title provided. Using absolute screen coordinates.")
|
861
|
+
# Example: uncomment below to target Calculator on Windows by default if no arg given
|
862
|
+
# if sys.platform == "win32": target_window_title = "Calculator"
|
863
|
+
|
864
|
+
if not target_window_title:
|
865
|
+
target_window_title = get_ocr_config().window
|
866
|
+
|
867
|
+
# Get the selection result
|
868
|
+
selection_result = get_screen_selection(target_window_title)
|
869
|
+
|
870
|
+
# --- Process and display the result ---
|
871
|
+
if selection_result is None:
|
872
|
+
print("\n--- Screen selection failed due to an error. ---")
|
873
|
+
elif not selection_result: # Empty dict means cancelled
|
874
|
+
print("\n--- Screen selection was cancelled by the user. ---")
|
875
|
+
elif 'rectangles' in selection_result:
|
876
|
+
print("\n--- Selection Result ---")
|
877
|
+
rectangles = selection_result.get('rectangles', [])
|
878
|
+
window_geom = selection_result.get('window_geometry')
|
879
|
+
coord_sys = selection_result.get('coordinate_system')
|
880
|
+
|
881
|
+
print(f"Coordinate system used for saving: {coord_sys}")
|
882
|
+
if window_geom:
|
883
|
+
print(f"Saved relative to window geometry: {window_geom}")
|
884
|
+
# else: It was COORD_SYSTEM_ABSOLUTE, no geometry saved/used
|
885
|
+
|
886
|
+
print(f"Selected rectangles returned ({len(rectangles)}):")
|
887
|
+
# The returned coordinates are always absolute pixels for external use
|
888
|
+
for i, (monitor, coords, is_excluded) in enumerate(rectangles):
|
889
|
+
# Safely access monitor info
|
890
|
+
monitor_info = f"Idx:{monitor.get('index', 'N/A')} Pos:({monitor.get('left', '?')},{monitor.get('top', '?')}) W:{monitor.get('width', '?')} H:{monitor.get('height', '?')}"
|
891
|
+
# Format absolute pixel coordinates
|
892
|
+
coord_str = f"({coords[0]}, {coords[1]}, W:{coords[2]}, H:{coords[3]})"
|
893
|
+
print(f" Rect {i + 1}: Monitor={monitor_info}, Coords={coord_str}, Excluded={is_excluded}")
|
293
894
|
else:
|
294
|
-
|
895
|
+
# Should not happen if get_screen_selection returns correctly, but handles unexpected cases
|
896
|
+
print("\n--- Screen selection returned an unexpected result. ---")
|
897
|
+
print(selection_result)
|