|
|
"""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 = [ |
|
|
"#00ff88", "#ff6b6b", "#4ecdc4", "#ffe66d", |
|
|
"#a8e6cf", "#ff8a5c", "#88d8f7", "#d4a5ff", |
|
|
] |
|
|
HANDLE_SIZE = 8 |
|
|
|
|
|
|
|
|
with mss.mss() as sct: |
|
|
monitor = sct.monitors[1] |
|
|
scr_w, scr_h = monitor["width"], monitor["height"] |
|
|
|
|
|
|
|
|
root = tk.Tk() |
|
|
root.title("Region Selector") |
|
|
root.attributes("-fullscreen", True) |
|
|
root.attributes("-topmost", True) |
|
|
root.configure(cursor="crosshair") |
|
|
root.overrideredirect(True) |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
regions: list[dict] = [dict(r) for r in existing_regions] |
|
|
|
|
|
|
|
|
region_items: dict[int, dict] = {} |
|
|
|
|
|
|
|
|
drawing = False |
|
|
draw_start_x = draw_start_y = 0 |
|
|
draw_rect_id = None |
|
|
|
|
|
dragging_idx: int | None = None |
|
|
dragging_handle: str | None = None |
|
|
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_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, |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
rect_id = canvas.create_rectangle( |
|
|
x, y, x + w, y + h, |
|
|
outline=color, width=2, |
|
|
) |
|
|
|
|
|
|
|
|
cx, cy = x + w // 2, y + h // 2 |
|
|
label_id = canvas.create_text( |
|
|
cx, cy, |
|
|
text=str(idx + 1), |
|
|
fill=color, font=bold_font, |
|
|
) |
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
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, ''). |
|
|
""" |
|
|
|
|
|
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 |
|
|
|
|
|
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, "" |
|
|
|
|
|
|
|
|
_redraw_all() |
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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: |
|
|
|
|
|
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() |
|
|
|
|
|
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("<ButtonPress-1>", on_press) |
|
|
canvas.bind("<B1-Motion>", on_drag) |
|
|
canvas.bind("<ButtonRelease-1>", on_release) |
|
|
canvas.bind("<ButtonPress-3>", on_right_click) |
|
|
canvas.bind("<Double-Button-1>", on_double_click) |
|
|
root.bind("<Return>", on_confirm) |
|
|
root.bind("<Escape>", on_cancel) |
|
|
|
|
|
root.mainloop() |
|
|
|
|
|
except Exception as exc: |
|
|
error = exc |
|
|
logger.error("Region selector error: {}", exc) |
|
|
finally: |
|
|
done_event.set() |
|
|
|
|
|
|
|
|
t = threading.Thread(target=_run_selector, daemon=True, name="region-selector") |
|
|
t.start() |
|
|
done_event.wait(timeout=120) |
|
|
|
|
|
if error: |
|
|
raise error |
|
|
return result |
|
|
|