"""Region Selector V2 — fullscreen overlay with multi-region editing. Features: - Show existing regions (numbered, with coordinates) - Draw new regions by click-and-drag - Edit existing regions: drag to move, resize handles on corners/edges - Number badges centered on each region - Coordinate labels on sides - ESC = cancel, Enter/double-click = confirm all, Right-click = delete hovered Flow: 1. Take a screenshot of the entire primary monitor (mss). 2. Show a fullscreen, borderless, topmost tkinter window with the screenshot. 3. Display existing regions from passed list (numbered, colored). 4. User can draw new regions, drag-move existing, resize via handles. 5. Return all regions as list on confirm, or None on cancel. Runs in a separate thread to avoid tkinter ↔ Qt conflicts. """ from __future__ import annotations import threading from src.utils.logger import logger def select_region_on_screen( existing_regions: list[dict] | None = None, auto_confirm: bool = False, ) -> list[dict] | None: """Open fullscreen overlay for region selection/editing. Args: existing_regions: List of dicts with {x, y, width, height} to show. These are editable and will be included in the return value. auto_confirm: If True, automatically confirm after drawing a single new region (no Enter/double-click needed). Used by Quick OCR. Returns: List of {x, y, width, height} dicts (all regions), or None if cancelled. """ result: list[dict] | None = None error: Exception | None = None done_event = threading.Event() if existing_regions is None: existing_regions = [] _auto_confirm = auto_confirm def _run_selector() -> None: nonlocal result, error try: import tkinter as tk import tkinter.font as tkfont import mss # ── Colors for up to 8 regions ── COLORS = [ "#00ff88", "#ff6b6b", "#4ecdc4", "#ffe66d", "#a8e6cf", "#ff8a5c", "#88d8f7", "#d4a5ff", ] HANDLE_SIZE = 8 # ── 1. Get screen dimensions ── with mss.mss() as sct: monitor = sct.monitors[1] scr_w, scr_h = monitor["width"], monitor["height"] # ── 2. Tkinter window ── root = tk.Tk() root.title("Region Selector") root.attributes("-fullscreen", True) root.attributes("-topmost", True) root.configure(cursor="crosshair") root.overrideredirect(True) # Semi-transparent overlay — game visible underneath root.attributes("-alpha", 0.35) canvas = tk.Canvas(root, width=scr_w, height=scr_h, highlightthickness=0, cursor="crosshair", bg="#0d0d1a") canvas.pack(fill=tk.BOTH, expand=True) # ── Region storage ── # Each region: {"x": int, "y": int, "width": int, "height": int} regions: list[dict] = [dict(r) for r in existing_regions] # Canvas item IDs for each region: {index: {rect, label, coords, handles[]}} region_items: dict[int, dict] = {} # ── State ── drawing = False draw_start_x = draw_start_y = 0 draw_rect_id = None dragging_idx: int | None = None dragging_handle: str | None = None # "move", "nw", "ne", "sw", "se", "n", "s", "e", "w" drag_offset_x = drag_offset_y = 0 drag_orig: dict | None = None bold_font = tkfont.Font(family="Segoe UI", size=16, weight="bold") coord_font = tkfont.Font(family="Consolas", size=10) info_font = tkfont.Font(family="Segoe UI", size=13, weight="bold") # ── Instructions ── INSTRUCTIONS_PL = "Przeciągnij = Nowy Region | Przesuń Istniejący | Narożniki = Zmień Rozmiar | PPM = Usuń | Enter = Zatwierdź | ESC = Anuluj" INSTRUCTIONS_EN = "Drag = New Region | Move Existing | Corners = Resize | RMB = Delete | Enter = Confirm | ESC = Cancel" canvas.create_text( scr_w // 2, 28, text=INSTRUCTIONS_PL, fill="#ffffff", font=info_font, ) canvas.create_text( scr_w // 2, 52, text=INSTRUCTIONS_EN, fill="#aaaaaa", font=coord_font, ) # ── Draw Helpers ── def _color(idx: int) -> str: return COLORS[idx % len(COLORS)] def _draw_region(idx: int) -> None: """Draw/redraw a single region with label, coords, handles.""" r = regions[idx] x, y, w, h = r["x"], r["y"], r["width"], r["height"] color = _color(idx) # Remove old items if re-drawing if idx in region_items: for key, item_id in region_items[idx].items(): if key == "handles": for hid in item_id: canvas.delete(hid) else: canvas.delete(item_id) # Rectangle rect_id = canvas.create_rectangle( x, y, x + w, y + h, outline=color, width=2, ) # Number badge in center cx, cy = x + w // 2, y + h // 2 label_id = canvas.create_text( cx, cy, text=str(idx + 1), fill=color, font=bold_font, ) # Coordinate labels on sides coord_text = f"{x},{y} {w}×{h}" coord_id = canvas.create_text( x + w // 2, y - 12, text=coord_text, fill=color, font=coord_font, ) # Resize handles (8 positions) handles = [] handle_positions = { "nw": (x, y), "ne": (x + w, y), "sw": (x, y + h), "se": (x + w, y + h), "n": (x + w // 2, y), "s": (x + w // 2, y + h), "w": (x, y + h // 2), "e": (x + w, y + h // 2), } for hname, (hx, hy) in handle_positions.items(): hid = canvas.create_rectangle( hx - HANDLE_SIZE, hy - HANDLE_SIZE, hx + HANDLE_SIZE, hy + HANDLE_SIZE, fill=color, outline="white", width=1, tags=(f"handle_{idx}_{hname}",), ) handles.append(hid) region_items[idx] = { "rect": rect_id, "label": label_id, "coords": coord_id, "handles": handles, } def _redraw_all() -> None: """Redraw all regions.""" # Clear old items for removed regions for idx in list(region_items.keys()): if idx >= len(regions): for key, item_id in region_items[idx].items(): if key == "handles": for hid in item_id: canvas.delete(hid) else: canvas.delete(item_id) del region_items[idx] for i in range(len(regions)): _draw_region(i) def _hit_test(mx: int, my: int) -> tuple[int | None, str]: """Find which region/handle is under mouse cursor. Returns (region_index, handle_name) or (None, ''). """ # Check handles first (higher priority) for idx in range(len(regions) - 1, -1, -1): r = regions[idx] x, y, w, h = r["x"], r["y"], r["width"], r["height"] handle_positions = { "nw": (x, y), "ne": (x + w, y), "sw": (x, y + h), "se": (x + w, y + h), "n": (x + w // 2, y), "s": (x + w // 2, y + h), "w": (x, y + h // 2), "e": (x + w, y + h // 2), } for hname, (hx, hy) in handle_positions.items(): if abs(mx - hx) <= HANDLE_SIZE + 2 and abs(my - hy) <= HANDLE_SIZE + 2: return idx, hname # Check region body (for move) for idx in range(len(regions) - 1, -1, -1): r = regions[idx] if (r["x"] <= mx <= r["x"] + r["width"] and r["y"] <= my <= r["y"] + r["height"]): return idx, "move" return None, "" # ── Initial draw ── _redraw_all() # ── Event Handlers ── def on_press(event: tk.Event) -> None: nonlocal drawing, draw_start_x, draw_start_y, draw_rect_id nonlocal dragging_idx, dragging_handle, drag_offset_x, drag_offset_y, drag_orig idx, handle = _hit_test(event.x, event.y) if idx is not None: # Start dragging existing region dragging_idx = idx dragging_handle = handle r = regions[idx] drag_offset_x = event.x - r["x"] drag_offset_y = event.y - r["y"] drag_orig = dict(r) drawing = False else: # Start drawing new region drawing = True draw_start_x, draw_start_y = event.x, event.y draw_rect_id = canvas.create_rectangle( event.x, event.y, event.x, event.y, outline="#00ffaa", width=3, dash=(8, 4), ) dragging_idx = None def on_drag(event: tk.Event) -> None: nonlocal draw_rect_id if drawing and draw_rect_id is not None: canvas.coords(draw_rect_id, draw_start_x, draw_start_y, event.x, event.y) return if dragging_idx is not None and drag_orig is not None: r = regions[dragging_idx] ox, oy = drag_orig["x"], drag_orig["y"] ow, oh = drag_orig["width"], drag_orig["height"] if dragging_handle == "move": r["x"] = event.x - drag_offset_x r["y"] = event.y - drag_offset_y elif dragging_handle == "se": r["width"] = max(20, event.x - ox) r["height"] = max(20, event.y - oy) elif dragging_handle == "nw": r["x"] = min(event.x, ox + ow - 20) r["y"] = min(event.y, oy + oh - 20) r["width"] = ox + ow - r["x"] r["height"] = oy + oh - r["y"] elif dragging_handle == "ne": r["y"] = min(event.y, oy + oh - 20) r["width"] = max(20, event.x - ox) r["height"] = oy + oh - r["y"] elif dragging_handle == "sw": r["x"] = min(event.x, ox + ow - 20) r["width"] = ox + ow - r["x"] r["height"] = max(20, event.y - oy) elif dragging_handle == "n": r["y"] = min(event.y, oy + oh - 20) r["height"] = oy + oh - r["y"] elif dragging_handle == "s": r["height"] = max(20, event.y - oy) elif dragging_handle == "w": r["x"] = min(event.x, ox + ow - 20) r["width"] = ox + ow - r["x"] elif dragging_handle == "e": r["width"] = max(20, event.x - ox) _draw_region(dragging_idx) def on_release(event: tk.Event) -> None: nonlocal drawing, draw_rect_id, dragging_idx, drag_orig if drawing and draw_rect_id is not None: canvas.delete(draw_rect_id) draw_rect_id = None ex, ey = event.x, event.y x1, y1 = min(draw_start_x, ex), min(draw_start_y, ey) x2, y2 = max(draw_start_x, ex), max(draw_start_y, ey) w, h = x2 - x1, y2 - y1 if w >= 10 and h >= 10: regions.append({"x": x1, "y": y1, "width": w, "height": h}) _redraw_all() # Auto-confirm: if exactly 1 region drawn and auto_confirm enabled if _auto_confirm and len(regions) == 1: root.after(120, on_confirm) drawing = False dragging_idx = None drag_orig = None def on_right_click(event: tk.Event) -> None: """Right-click = delete hovered region.""" idx, _ = _hit_test(event.x, event.y) if idx is not None: regions.pop(idx) _redraw_all() def on_confirm(_event: tk.Event = None) -> None: nonlocal result if regions: result = regions else: result = None root.destroy() def on_cancel(_event: tk.Event = None) -> None: nonlocal result result = None root.destroy() def on_double_click(_event: tk.Event) -> None: """Double-click = confirm.""" on_confirm() canvas.bind("", on_press) canvas.bind("", on_drag) canvas.bind("", on_release) canvas.bind("", on_right_click) canvas.bind("", on_double_click) root.bind("", on_confirm) root.bind("", on_cancel) root.mainloop() except Exception as exc: error = exc logger.error("Region selector error: {}", exc) finally: done_event.set() # Run tkinter in its own thread t = threading.Thread(target=_run_selector, daemon=True, name="region-selector") t.start() done_event.wait(timeout=120) if error: raise error return result