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