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.
@@ -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, ImageDraw
8
+ from PIL import Image, ImageTk
9
9
 
10
- from GameSentenceMiner import obs # Import your actual obs module
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 # Handle missing library
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 # Minimum width in pixels
29
- MIN_RECT_HEIGHT = 25 # Minimum height in pixels
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 gw:
44
- raise ImportError("pygetwindow is required but not installed.")
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() # Connect to OBS (using mock or real)
43
+ obs.connect_to_obs_sync()
47
44
  self.window_name = window_name
48
- print(f"Target window name: {window_name or 'None (Absolute Mode)'}")
45
+ print(f"Targeting window: '{window_name}'")
46
+
49
47
  self.sct = mss.mss()
50
- self.monitors = self.sct.monitors[1:] # Skip the 'all monitors' entry
51
- if not self.monitors: # If only one monitor exists
52
- if len(self.sct.monitors) >= 1:
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 # Manager dict for results
62
- # Internal storage ALWAYS uses absolute pixel coordinates
63
- self.rectangles = [] # List to store (monitor_dict, (abs_x, abs_y, abs_w, abs_h), is_excluded) tuples
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 = None # Canvas coordinates
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.monitor_windows = {} # Stores {'window': tk.Toplevel, 'canvas': tk.Canvas, 'bg_img_id': int} per monitor index
71
- self.redo_stack = [] # Stores undone actions: (monitor_dict, abs_coords, is_excluded, canvas_id)
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() # Load AFTER finding the window
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. Use max(1, ...) to avoid zero dimensions.
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 as e:
105
- print(f"Error getting geometry for window '{window.title}': {e}")
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
- # Get scene name (use mock or real OBS)
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 the config file, converting to absolute pixels."""
104
+ """Loads rectangles from config, converting from percentage to absolute pixels for use."""
138
105
  config_path = self.get_scene_ocr_config()
139
- # Get CURRENT window geometry for potential conversion
140
- current_window_geometry = self._get_window_geometry(self._find_target_window())
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
- # --- Determine coordinate system ---
147
- saved_coord_system = config_data.get("coordinate_system")
148
- # Check for window_name match if config stores it (optional enhancement)
149
- # saved_window_name = config_data.get("window")
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
- for rect_data in rectangles_data:
171
- monitor_data = rect_data.get("monitor") # Monitor info might be needed if not just index
172
- coords = rect_data.get("coordinates") # Could be %, relative px, or absolute px
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
- # Basic validation of loaded data structure
176
- if not (monitor_data and isinstance(monitor_data, dict) and 'index' in monitor_data and
177
- isinstance(coords, list) and len(coords) == 4):
178
- print(f"Skipping invalid rectangle data structure: {rect_data}")
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
- abs_coords = None # Will hold the final absolute pixel coordinates (x, y, w, h)
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
- # --- Convert loaded coords to absolute pixels ---
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
- # Store the monitor dict from *this* instance and absolute pixel coordinates internally
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
- else:
242
- print(
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 existing config found at {config_path}. Starting fresh.")
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"An unexpected error occurred while loading rectangles: {e}")
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 the config file, using percentages if window is targeted."""
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
- print(f"Saving rectangles to: {config_path}")
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
- try:
272
- # --- Determine coordinate system for saving ---
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
- except Exception as e:
358
- print(f"Failed to save rectangles: {e}")
359
- import traceback
360
- traceback.print_exc()
361
- # Optionally: Show an error message to the user in the UI
362
- # Do not destroy the root window on error, allow user to retry or quit
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
- # Pop the internal data
369
- last_rect_tuple = self.rectangles.pop() # (monitor_dict, abs_coords, is_excluded)
370
- # Pop the corresponding canvas ID (fragile if IDs aren't managed carefully)
371
- # This assumes drawn_rect_ids corresponds directly to rectangles, which might break
372
- # if deletes happened without updating both lists perfectly.
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
- """Redraws the last undone rectangle."""
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
- monitor_index = monitor['index']
437
- canvas = self.canvas_windows.get(monitor_index)
438
-
439
- if canvas:
440
- x_abs, y_abs, w_abs, h_abs = abs_coords # Use absolute coords for drawing
441
- outline_color = 'green' if not is_excluded else 'orange'
442
-
443
- # Convert absolute screen coords to canvas-local coords for drawing
444
- canvas_x = x_abs - monitor['left']
445
- canvas_y = y_abs - monitor['top']
446
-
447
- try:
448
- # Draw using coordinates relative to the canvas/monitor window
449
- # IMPORTANT: This creates a *new* canvas ID
450
- new_rect_id = canvas.create_rectangle(
451
- canvas_x, canvas_y, canvas_x + w_abs, canvas_y + h_abs,
452
- outline=outline_color, width=2
453
- )
454
-
455
- # Store the absolute coordinates and the *new* ID internally again
456
- self.rectangles.append((monitor, abs_coords, is_excluded))
457
- self.drawn_rect_ids.append(new_rect_id) # Add the NEW ID
458
- print(f"Redo: Restored rectangle with new ID {new_rect_id} on canvas {monitor_index}")
459
-
460
- except tk.TclError as e:
461
- print(f"Warning: TclError during redo draw: {e}")
462
- # If drawing fails, put the item back on the redo stack?
463
- self.redo_stack.append((monitor, abs_coords, is_excluded, old_rect_id))
464
- else:
465
- print(f"Warning: Redo - Canvas for monitor index {monitor_index} not found.")
466
- # Put the item back on the redo stack if canvas not found
467
- self.redo_stack.append((monitor, abs_coords, is_excluded, old_rect_id))
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 create_window(self, monitor):
470
- """Creates the transparent overlay window for a single monitor."""
471
- monitor_index = monitor['index'] # Assumes index is set
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
- # Resize if screenshot dimensions don't match monitor info (e.g., DPI scaling)
481
- if img.width != monitor_width or img.height != monitor_height:
482
- print(
483
- f"Monitor {monitor_index}: Resizing screenshot from {img.size} to monitor size {monitor_width}x{monitor_height}")
484
- img = img.resize((monitor_width, monitor_height), Image.Resampling.LANCZOS)
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
- except Exception as e:
487
- print(f"Error grabbing screenshot for monitor {monitor_index}: {e}")
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"{monitor_width}x{monitor_height}+{monitor_left}+{monitor_top}")
496
- window.overrideredirect(1) # Frameless window
497
- window.attributes('-topmost', 1) # Keep on top
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
- # Create the canvas covering the entire window
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
- # Keep a reference to the PhotoImage to prevent garbage collection!
507
- canvas.image = img_tk
508
- # Draw the background image onto the canvas
509
- bg_img_id = canvas.create_image(0, 0, image=img_tk, anchor=tk.NW)
510
- # Store the canvas widget, mapping from monitor index
511
- self.canvas_windows[monitor_index] = canvas
512
-
513
- # Store references for potential refreshing or cleanup
514
- self.monitor_windows[monitor_index] = {
515
- 'window': window,
516
- 'canvas': canvas,
517
- 'bg_img_id': bg_img_id, # ID of the background image item
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
- # event.x, event.y are relative to the canvas widget
546
- if self.current_rect_id is None:
547
- self.start_x, self.start_y = event.x, event.y # Store canvas coords
548
- # Determine color/state (Shift key for exclusion)
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
- # Update the temporary rectangle being drawn on *this* canvas
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
- # Finalize the rectangle drawn on *this* canvas
576
- if self.current_rect_id:
577
- current_rect_id_local = self.current_rect_id # Store locally in case it's reset
578
- self.current_rect_id = None # Reset global temp ID tracker
579
-
580
- try:
581
- # Get final coords relative to this canvas
582
- canvas_x1, canvas_y1, canvas_x2, canvas_y2 = canvas.coords(current_rect_id_local)
583
-
584
- # Convert canvas-local coords to absolute screen coords for storage
585
- # Ensure correct order (x1,y1 is top-left)
586
- abs_x1 = int(min(canvas_x1, canvas_x2) + monitor_left)
587
- abs_y1 = int(min(canvas_y1, canvas_y2) + monitor_top)
588
- abs_x2 = int(max(canvas_x1, canvas_x2) + monitor_left)
589
- abs_y2 = int(max(canvas_y1, canvas_y2) + monitor_top)
590
-
591
- # Calculate absolute width/height
592
- width_abs = abs_x2 - abs_x1
593
- height_abs = abs_y2 - abs_y1
594
-
595
- # Check against minimum size
596
- if width_abs >= MIN_RECT_WIDTH and height_abs >= MIN_RECT_HEIGHT:
597
- is_excluded = bool(event.state & 0x0001) # Check Shift key state at release
598
- final_outline = 'orange' if is_excluded else 'green' # Final color
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
- # Find rectangle item under cursor on *this* canvas
632
- # Use find_closest initially, then check if it's overlapping and a rectangle we manage
633
- # find_closest returns a tuple, possibly empty
634
- items = canvas.find_closest(event.x, event.y)
635
- target_id_to_delete = None
636
- if items:
637
- item_id = items[0] # Get the closest item ID
638
- # Check if this ID is one we drew and if the click is actually inside its bbox
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
- canvas.focus_set()
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
- def start(self):
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
- if not self.monitors:
742
- print("Error: No monitors available to display.")
743
- self.result['error'] = "No monitors found" # Report back error
744
- self.root.destroy()
745
- return
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
- print(f"Creating selection windows for {len(self.monitors)} monitor(s)...")
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
- # Check if any canvas windows were actually created
752
- if not self.canvas_windows:
753
- print("Error: Failed to create any monitor selection windows.")
754
- self.result['error'] = "Failed to create monitor windows"
755
- self.root.destroy()
756
- return
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
- print("Starting Tkinter main loop. Press Esc to quit, Ctrl+S to save.")
759
- self.root.mainloop() # Start the event loop
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
- """Initiates the cleanup and shutdown process."""
763
- print("Quit signal received, closing application...")
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 running ScreenSelector in subprocess: {e}")
405
+ print(f"Error in selector process: {e}", file=sys.stderr)
809
406
  import traceback
810
- traceback.print_exc() # Print detailed traceback in the subprocess console
811
- result_dict['error'] = f"Runtime error: {e}" # Report error back
407
+ traceback.print_exc()
408
+ result_dict['error'] = str(e)
812
409
 
813
410
 
814
411
  def get_screen_selection(window_name):
815
- """
816
- Launches the ScreenSelector in a separate process and returns the result.
817
- Returns None on failure or cancellation, otherwise a dict containing:
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() # Wait for the ScreenSelector process to complete
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"ScreenSelector process reported an error: {result_data['error']}")
840
- return None # Indicate failure
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
- # Return a standard dictionary copy from the Manager dict
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
- # This case usually means the user quit (Esc) without saving
851
- print("No selection saved or process was cancelled by user.")
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 due to an error. ---")
881
- elif not selection_result: # Empty dict means cancelled
882
- print("\n--- Screen selection was cancelled by the user. ---")
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
- rectangles = selection_result.get('rectangles', [])
886
- window_geom = selection_result.get('window_geometry')
887
- coord_sys = selection_result.get('coordinate_system')
888
-
889
- print(f"Coordinate system used for saving: {coord_sys}")
890
- if window_geom:
891
- print(f"Saved relative to window geometry: {window_geom}")
892
- # else: It was COORD_SYSTEM_ABSOLUTE, no geometry saved/used
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}")