diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 48fdb76..61cb0a3 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -1,4 +1,4 @@ -"""FeNigma: GTK4/libadwaita app entry point. +"""FEnigma: GTK4/libadwaita app entry point. Layout: a row of category dropdowns on top (+ a universal clipboard-fetch button), the map/grid filling the center. Coordinates can be set by exact @@ -14,6 +14,8 @@ from __future__ import annotations import io import json +import tempfile +from pathlib import Path import gi @@ -24,14 +26,25 @@ gi.require_version("Gdk", "4.0") from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 from PIL import Image # noqa: E402 -from . import ballistics, icons, ocr, solver # noqa: E402 +from . import ballistics, icons, map_import, ocr, solver # noqa: E402 from .coord_dialog import CoordDialog # noqa: E402 from .firing_panel import FiringPanel # noqa: E402 +from .grid_fix_dialog import GridFixDialog # noqa: E402 from .grid_widget import COLS, ROWS, GridCanvas # noqa: E402 -from .models import Board, Location, Target, TargetType # noqa: E402 +from .models import ( # noqa: E402 + Ally, + Board, + Coord, + Location, + Nest, + ReferencePoint, + Spotter, + Target, + TargetType, +) from .shells import Shell # noqa: E402 -APP_ID = "eu.dominik-roth.FeNigma" +APP_ID = "eu.dominik-roth.FEnigma" def _apply_location(obj, location: Location) -> None: @@ -45,6 +58,21 @@ def _apply_location(obj, location: Location) -> None: obj.location.clues = location.clues +def _idle(fn, *args): + """Hand a worker's result to the UI thread. GLib.idle_add's callback must + return False or it is called forever.""" + GLib.idle_add(lambda: (fn(*args), False)[1]) + + +def _coord_from_proposal(p) -> Coord | None: + """map_vision reports "K8" plus sub-cell 0..9 in each axis, matching + Coord's own convention (see GridSolution.lattice_to_grid).""" + try: + return Coord(X=p.label[0], Y=int(p.label[1:]), x=p.sub_x, y=p.sub_y) + except (ValueError, IndexError): + return None + + def _row( name: str, *, @@ -224,12 +252,14 @@ def _install_css() -> None: class MainWindow(Adw.ApplicationWindow): def __init__(self, app: Adw.Application) -> None: - super().__init__(application=app, title="FeNigma") + super().__init__(application=app, title="FEnigma") self.set_default_size(1100, 750) _install_css() self.board = Board() self._clipboard_watch_handler = None + self._import_job = None # in-flight map_import.ImportJob, if any + self.screenshot_import = None # the map screenshot currently on the board self.toast_overlay = Adw.ToastOverlay() self.set_content(self.toast_overlay) @@ -256,10 +286,16 @@ class MainWindow(Adw.ApplicationWindow): clear_btn.connect("clicked", lambda _b: self._clear_board()) header.pack_start(clear_btn) - clip_btn = Gtk.Button(icon_name="edit-paste-symbolic") - clip_btn.set_tooltip_text("Fetch screenshot or text from clipboard (Ctrl+P)") - clip_btn.connect("clicked", lambda _b: self._fetch_clipboard()) - header.pack_start(clip_btn) + self._clip_btn = Gtk.Button(icon_name="edit-paste-symbolic") + self._clip_btn.set_tooltip_text("Fetch screenshot or text from clipboard (Ctrl+P)") + self._clip_btn.connect("clicked", lambda _b: self._fetch_clipboard()) + header.pack_start(self._clip_btn) + + # Shown INSIDE the paste button while the map-vision worker runs (solve() + # takes 10-20s, so it has to be visible that something is happening). + # Taking the button's place rather than sitting next to it keeps the + # header from shifting sideways every time a screenshot is read. + self._import_spinner = Gtk.Spinner(spinning=True) self._watch_btn = Gtk.ToggleButton(icon_name="media-playback-start-symbolic") self._watch_btn.set_tooltip_text( @@ -296,6 +332,23 @@ class MainWindow(Adw.ApplicationWindow): scout_btn.connect("clicked", lambda _b: self._add_scout_flight()) header.pack_start(scout_btn) + # Screenshot-import actions, last in the row and only present while + # there is an imported screenshot to act on: their own separator is + # hidden with them so no divider dangles on an empty group. + self._import_sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL, visible=False) + header.pack_start(self._import_sep) + + self._accept_all_btn = Gtk.Button(icon_name="object-select-symbolic", visible=False) + self._accept_all_btn.set_tooltip_text("Accept every proposed unit from the screenshot") + self._accept_all_btn.connect("clicked", lambda _b: self._accept_all_proposals()) + header.pack_start(self._accept_all_btn) + + self._drop_shot_btn = Gtk.Button(icon_name="edit-delete-symbolic", visible=False) + self._drop_shot_btn.set_tooltip_text( + "Remove the imported screenshot (drops unconfirmed units)") + self._drop_shot_btn.connect("clicked", lambda _b: self._remove_screenshot()) + header.pack_start(self._drop_shot_btn) + firing_btn = Gtk.Button(icon_name="sidebar-show-right-symbolic") firing_btn.set_tooltip_text("Firing commands") firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel()) @@ -321,6 +374,7 @@ class MainWindow(Adw.ApplicationWindow): on_toggle_hide_dead_map=self._on_toggle_hide_dead_map, ) self.canvas.on_select = self._set_selection + self.canvas.on_proposal_click = self._open_proposal_menu self.canvas.on_hover_change = self._on_map_hover_change self.canvas.on_cursor_move = self._on_cursor_move self.canvas.on_right_click = self._on_map_right_click @@ -462,15 +516,225 @@ class MainWindow(Adw.ApplicationWindow): self.toast("Clipboard has no image. Copy a screenshot first.") return + png = texture.save_to_png_bytes().get_data() + # A clipboard image is either a typewriter/field-log screenshot (text, + # OCR) or a shot of the map table (geometry, map_vision). Deciding + # which happens in the import worker, and the text path resumes here + # if it turns out not to be a map, so the same button covers both. + self._start_map_import(png, lambda: self._ocr_png(png, on_parsed)) + + def _ocr_png(self, png: bytes, on_parsed) -> None: try: - pil_image = Image.open(io.BytesIO(texture.save_to_png_bytes().get_data())) - info = ocr.run(pil_image) + info = ocr.run(Image.open(io.BytesIO(png))) except Exception as exc: # OCR/parsing hiccups shouldn't crash the app self.toast(f"OCR failed: {exc}") return - on_parsed(info) + def _start_map_import(self, png: bytes, not_a_map) -> None: + """Try to read the clipboard image as a map screenshot, off-thread. + + solve() takes 10-20s, far too long for the UI thread, so it runs in a + worker (see map_import.ImportJob) and comes back through GLib.idle_add. + `not_a_map` is called instead when the gate says this is text. + """ + if self._import_job is not None and not self._import_job.cancelled: + self.toast("Still reading the previous screenshot.") + return + tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + tmp.write(png) + tmp.close() + path = Path(tmp.name) + + def done(result, error): + self._import_job = None + self._set_import_busy(False) + path.unlink(missing_ok=True) + if result is None: + # solve() rejecting is the authoritative "not a map" verdict: + # it accepts none of the 122 writer screenshots, while the + # cheap gate lets ~6% through. So a rejection always falls + # through to the text path rather than being reported as a + # failure -- otherwise a text screenshot that trips the gate + # never gets OCR'd at all. The reason is still surfaced when + # the gate thought it was a map, because then it probably was + # one and the user wants to know why it didn't take. + if error != map_import.NOT_A_MAP: + self.toast(f"Couldn't read the grid ({error}), trying as text.") + not_a_map() + return + self._on_map_import_ready(result) + + self._import_job = map_import.ImportJob(schedule=_idle) + self._set_import_busy(True) + self._import_job.start(path, done) + + def _set_import_busy(self, busy: bool) -> None: + self._clip_btn.set_sensitive(not busy) + if busy: + self._clip_btn.set_child(self._import_spinner) + self.toast("Reading map screenshot…") + else: + self._clip_btn.set_child(None) + self._clip_btn.set_icon_name("edit-paste-symbolic") + + def _on_map_import_ready(self, imp) -> None: + """Grid first, units second. + + The only thing to confirm here is the geometry: it is what every unit + position is expressed in, so it has to be right before detection is + worth running at all. Units come back afterwards as proposals ON the + map, where they can be judged against the screenshot they came from. + """ + GridFixDialog( + image=imp.image, + solution=imp.solution, + on_accept=lambda sol: self._accept_grid(imp, sol), + on_discard=lambda: self.toast("Screenshot discarded."), + ).present(self) + + def _accept_grid(self, imp, solution) -> None: + """Grid confirmed: rectify the screenshot onto the board, then detect.""" + imp.solution = solution + self.screenshot_import = imp + imp.build_overlay() + self.canvas.set_screenshot(imp.overlay, imp.px_per_km) + self._refresh_proposals() + self._start_marker_detection(imp) + + def _start_marker_detection(self, imp) -> None: + def done(result, error): + self._import_job = None + self._set_import_busy(False) + if result is None: + self.toast(f"Unit detection failed: {error}.") + return + self._refresh_proposals() + n = len(result.proposals) + self.toast(f"{n} unit(s) proposed, right-click one to accept it." + if n else "No units found in the screenshot.") + + self._import_job = map_import.ImportJob(schedule=_idle) + self._set_import_busy(True) + self._import_job.find_markers(imp, done) + + def _refresh_proposals(self) -> None: + imp = self.screenshot_import + pairs = [] + if imp is not None: + for p in imp.proposals: + coord = _coord_from_proposal(p) + if coord is not None: + pairs.append((p, coord)) + self.canvas.set_proposals(pairs) + self._update_import_actions() + + def _update_import_actions(self) -> None: + """The screenshot-specific header buttons only exist while there is a + screenshot to act on.""" + imp = self.screenshot_import + self._import_sep.set_visible(imp is not None) + self._accept_all_btn.set_visible(imp is not None) + self._drop_shot_btn.set_visible(imp is not None) + self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending())) + + def _accept_proposal(self, proposal, type_=None) -> None: + coord = _coord_from_proposal(proposal) + if coord is None: + return + if type_ is None: + type_ = icons.target_type_from_icon(proposal.unit) or TargetType.UNKNOWN + if proposal.side == "friendly": + self.board.add_ally(type_, coord) + else: + self.board.add_target(type_, coord) + proposal.accepted = True + + def _accept_all_proposals(self) -> None: + imp = self.screenshot_import + if imp is None: + return + pending = imp.pending() + for proposal in pending: + self._accept_proposal(proposal) + self._refresh() + self._refresh_proposals() + self.toast(f"Accepted {len(pending)} unit(s).") + + def _remove_screenshot(self) -> None: + """Dropping the screenshot also drops every proposal never accepted: + they were only ever readings OF that screenshot, so without it there is + nothing left to judge them against.""" + imp = self.screenshot_import + if imp is None: + return + dropped = len(imp.pending()) + imp.drop_unaccepted() + self.screenshot_import = None + self.canvas.set_screenshot(None, 0) + self._refresh_proposals() + self.toast(f"Screenshot removed, {dropped} unconfirmed unit(s) dropped." + if dropped else "Screenshot removed.") + + def _open_proposal_menu(self, proposal, x: float, y: float) -> None: + """Right-click on a detected-but-unconfirmed unit: accept it (with the + detected type, or a corrected one) or reject it.""" + popover = self._popover_at(x, y) + + def page(): + return Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, + margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) + + def button(box, label, handler, *, css="flat"): + btn = Gtk.Button(label=label, css_classes=[css]) + if btn.get_child() is not None: + btn.get_child().set_xalign(0.0) + btn.connect("clicked", lambda _b: handler()) + box.append(btn) + + detected = icons.target_type_from_icon(proposal.unit) + + def accept(type_=None): + popover.popdown() + self._accept_proposal(proposal, type_) + self._refresh() + self._refresh_proposals() + + def reject(): + popover.popdown() + proposal.rejected = True + self._refresh_proposals() + + def show_main(): + box = page() + lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2) + side = "friendly" if proposal.side == "friendly" else "hostile" + lbl.set_markup( + f"{GLib.markup_escape_text(proposal.coord)} — {side}, " + f"{detected.value if detected else 'type unknown'}") + box.append(lbl) + box.append(Gtk.Separator(margin_top=2, margin_bottom=2)) + button(box, f"Accept as {detected.value if detected else TargetType.UNKNOWN.value}", + accept, css="suggested-action") + button(box, "Accept as…", show_type) + button(box, "Reject", reject, css="destructive-action") + popover.set_child(box) + + def show_type(): + box = page() + scroller = Gtk.ScrolledWindow(propagate_natural_height=True, + max_content_height=340, + hscrollbar_policy=Gtk.PolicyType.NEVER) + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + for t in TargetType: + button(inner, t.value, lambda t=t: accept(t)) + scroller.set_child(inner) + box.append(scroller) + popover.set_child(box) + + show_main() + popover.popup() + def _on_toggle_clipboard_watch(self, btn: Gtk.ToggleButton) -> None: """Auto-watch toggle: while on, every clipboard change that looks like an image (not e.g. text copied elsewhere) is OCR'd and merged @@ -1038,10 +1302,8 @@ class MainWindow(Adw.ApplicationWindow): ) self.toast(f"Click the map to place {target.name}, Esc to cancel.") - def _on_map_right_click(self, coord, x: float, y: float) -> None: - """Right-click anywhere on the map: quick-add a Target or Strike - right there, no dialog, for when you already know exactly where - you're pointing and don't need to type coordinates.""" + def _popover_at(self, x: float, y: float) -> Gtk.Popover: + """A popover anchored to a point on the canvas, self-unparenting.""" popover = Gtk.Popover() popover.set_parent(self.canvas) # NOT Gdk.Rectangle(x=..., y=..., ...): verified directly that this @@ -1054,6 +1316,174 @@ class MainWindow(Adw.ApplicationWindow): rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1 popover.set_pointing_to(rect) popover.connect("closed", lambda _p: popover.unparent()) + return popover + + def _on_map_right_click(self, coord, x: float, y: float, obj=None, point=None) -> None: + """Right-click on the map. On an entity that's the edit menu for it; + on empty map it's the quick-add menu.""" + if isinstance(obj, map_import.Proposal): + self._open_proposal_menu(obj, x, y) + return + if obj is not None: + self._set_selection(obj, point) + self._open_entity_menu(obj, x, y) + return + self._open_quick_add_menu(coord, x, y) + + def _open_entity_menu(self, obj, x: float, y: float) -> None: + """Right-click on a marker: everything you'd want to fix about the + thing you're pointing at, without hunting for it in a side list. + + Which actions appear is driven by what the entity actually has, not by + a fixed menu: only Target/Ally carry a TargetType, only some have an + editable id (Nest has no id at all, Spotter's is an int), and the Nest + is singular so it can't be deleted. Type and id are edited in place by + swapping the popover's contents rather than opening a dialog, since + both are one click / a few keystrokes and a modal for that is heavier + than the edit. + """ + popover = self._popover_at(x, y) + + def page(): + return Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, + margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) + + def button(box, label, handler, *, css="flat"): + btn = Gtk.Button(label=label) + btn.add_css_class(css) + btn.set_halign(Gtk.Align.FILL) + if btn.get_child() is not None: + btn.get_child().set_xalign(0.0) + btn.connect("clicked", lambda _b: handler()) + box.append(btn) + return btn + + def heading(box, text): + lbl = Gtk.Label(xalign=0, margin_start=4, margin_bottom=2) + lbl.set_markup(f"{GLib.markup_escape_text(text)}") + box.append(lbl) + + def show_main(): + box = page() + where = obj.coord.label() if getattr(obj, "coord", None) else "unplaced" + heading(box, f"{obj.name} — {where}") + box.append(Gtk.Separator(margin_top=2, margin_bottom=2)) + if hasattr(obj, "type"): + button(box, f"Change type ({obj.type.value})", show_type) + if self._id_field_of(obj) is not None: + button(box, "Change ID", show_id) + button(box, "Change position (click the map)", change_position) + if not isinstance(obj, Nest): + button(box, "Delete", delete, css="destructive-action") + popover.set_child(box) + + def show_type(): + box = page() + heading(box, "Type") + scroller = Gtk.ScrolledWindow(propagate_natural_height=True, + max_content_height=340, + hscrollbar_policy=Gtk.PolicyType.NEVER) + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + for t in TargetType: + label = f"• {t.value}" if t is obj.type else f" {t.value}" + button(inner, label, lambda t=t: set_type(t)) + scroller.set_child(inner) + box.append(scroller) + popover.set_child(box) + + def set_type(t): + obj.type = t + self._refresh() + popover.popdown() + self.toast(f"{obj.name} is now a {t.value}.") + + def show_id(): + box = page() + heading(box, "ID") + entry = Gtk.Entry(text=str(self._id_of(obj)), activates_default=True) + entry.set_width_chars(16) + box.append(entry) + button(box, "Apply", lambda: set_id(entry.get_text()), css="suggested-action") + entry.connect("activate", lambda _e: set_id(entry.get_text())) + popover.set_child(box) + entry.grab_focus() + + def set_id(text): + field = self._id_field_of(obj) + text = text.strip() + if not text: + self.toast("An ID can't be empty.") + return + if field == "id" and isinstance(obj, Spotter): + # Spotter ids are ints and its name is derived from them, so a + # non-integer would silently break Spotter#N naming and the + # clue references that match on it. + if not text.isdigit(): + self.toast("A Spotter's ID has to be a number.") + return + value = int(text) + if any(s is not obj and s.id == value for s in self.board.spotters): + self.toast(f"Spotter#{value} already exists.") + return + else: + value = text + old = obj.name + setattr(obj, field, value) + self._refresh() + popover.popdown() + self.toast(f"{old} renamed to {obj.name}.") + + def change_position(): + popover.popdown() + if isinstance(obj, Target): + self._start_target_placement(obj) + return + self.canvas.start_placement( + lambda c: self._apply_and_refresh(obj, Location.from_coord(c))) + self.toast(f"Click the map to place {obj.name}, Esc to cancel.") + + def delete(): + popover.popdown() + name = obj.name + if self.canvas.selected is obj: + self._set_selection(None) + if isinstance(obj, Target): + self.board.remove_target(obj) + elif isinstance(obj, Ally): + self.board.remove_ally(obj) + elif isinstance(obj, Spotter): + self.board.remove_spotter(obj) + elif isinstance(obj, ReferencePoint): + self.board.remove_reference_point(obj) + else: + self.toast(f"{name} can't be removed.") + return + self._refresh() + self.toast(f"{name} removed.") + + show_main() + popover.popup() + + @staticmethod + def _id_field_of(obj) -> str | None: + """Which attribute holds this entity's editable id, if any.""" + if isinstance(obj, ReferencePoint): + return "rp_name" + if isinstance(obj, (Target, Ally, Spotter)): + return "id" + return None + + def _id_of(self, obj): + field = self._id_field_of(obj) + return getattr(obj, field) if field else "" + + def _open_quick_add_menu(self, coord, x: float, y: float) -> None: + """Right-click on empty map: quick-add a Target or Strike + right there, no dialog, for when you already know exactly where + you're pointing and don't need to type coordinates.""" + if coord is None: + return + popover = self._popover_at(x, y) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) @@ -1148,7 +1578,7 @@ class MainWindow(Adw.ApplicationWindow): rebuild() -class FeNigmaApp(Adw.Application): +class FEnigmaApp(Adw.Application): def __init__(self) -> None: super().__init__(application_id=APP_ID) @@ -1160,7 +1590,7 @@ class FeNigmaApp(Adw.Application): def main() -> int: - app = FeNigmaApp() + app = FEnigmaApp() return app.run(None) diff --git a/src/fenigma/grid_fix_dialog.py b/src/fenigma/grid_fix_dialog.py new file mode 100644 index 0000000..1e53b93 --- /dev/null +++ b/src/fenigma/grid_fix_dialog.py @@ -0,0 +1,278 @@ +"""The one modal in the map-import flow: confirm or fix the detected grid. + +Nothing else belongs here. Unit detection happens *after* this dialog closes, +because every unit position is expressed in grid coordinates -- detecting +against a grid the user is about to drag would only be thrown away. + +The correction handles are the four corners of one cell, not of the whole +screenshot. A homography has 8 degrees of freedom and each dragged corner +contributes 2, so four corners of a single known cell pin it exactly, and a +cell near the frame centre is the one whose corners are easiest to place +accurately by eye. Dragging any handle refits the whole grid immediately, so +the feedback is the entire reconstructed lattice moving, not just a dot. +""" +from __future__ import annotations + +import math + +import cairo +import gi +import numpy as np + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw, Gtk # noqa: E402 + +from . import map_vision # noqa: E402 + +HANDLE_R = 9.0 # drawn radius of a corner handle, widget px +GRAB_R = 22.0 # how close a press has to be to grab one + + +def _surface_from_bgr(img): + """A cairo surface over a numpy BGR image. + + cairo's RGB24 is a 32-bit pixel laid out as B,G,R,x in memory on a + little-endian machine, which is exactly BGRA, so the converted array can + back the surface directly with no per-pixel work. The array is kept alive + by the caller holding it: create_for_data does not copy. + """ + import cv2 + + bgra = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA) + bgra = np.ascontiguousarray(bgra) + h, w = bgra.shape[:2] + surface = cairo.ImageSurface.create_for_data( + memoryview(bgra), cairo.FORMAT_RGB24, w, h, w * 4) + return surface, bgra + + +class GridFixDialog(Adw.Dialog): + """Shows the screenshot with the reconstructed grid drawn over it, plus + four draggable corner handles. on_accept(solution) gets whatever grid is + on screen when Accept is pressed.""" + + def __init__(self, *, image, solution, on_accept, on_discard=None): + super().__init__(title="Check the detected grid", + content_width=900, content_height=760) + self._image = image + self._auto = solution + self._sol = solution + self._on_accept = on_accept + self._on_discard = on_discard + self._surface, self._keepalive = _surface_from_bgr(image) + self._dragging = None # index of the handle being dragged + self._drag_from = None # its position when the drag began + + quad = map_vision.centre_cell_quad(solution, image.shape) + # (label, [4 pixel corners], [4 grid corners]); the pixel corners move + # with the mouse, the grid corners are what they are supposed to BE and + # never change -- that pairing is the correspondence set refitted from. + self._label = quad[0] if quad else None + self._px = list(quad[1]) if quad else [] + self._grid = list(quad[2]) if quad else [] + + view = Adw.ToolbarView() + view.add_top_bar(Adw.HeaderBar()) + self.set_child(view) + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, + margin_top=10, margin_bottom=10, margin_start=10, margin_end=10) + + self._area = Gtk.DrawingArea(vexpand=True, hexpand=True) + self._area.set_draw_func(self._draw) + drag = Gtk.GestureDrag() + drag.connect("drag-begin", self._on_drag_begin) + drag.connect("drag-update", self._on_drag_update) + drag.connect("drag-end", lambda *_a: setattr(self, "_dragging", None)) + self._area.add_controller(drag) + outer.append(self._area) + + cell = self._label or "?" + self._hint = Gtk.Label(xalign=0, css_classes=["dim-label"], wrap=True) + self._hint.set_label( + f"Grid solved from {solution.votes} label read(s). " + f"If it is off, drag the four handles onto the corners of cell {cell}." + if self._px else + f"Grid solved from {solution.votes} label read(s)." + ) + outer.append(self._hint) + + buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, + halign=Gtk.Align.END) + discard = Gtk.Button(label="Discard", css_classes=["pill"]) + discard.connect("clicked", lambda _b: self._discard()) + buttons.append(discard) + if self._px: + reset = Gtk.Button(label="Reset", css_classes=["pill"], + tooltip_text="Back to the automatically detected grid") + reset.connect("clicked", lambda _b: self._reset()) + buttons.append(reset) + accept = Gtk.Button(label="Use this grid", + css_classes=["pill", "suggested-action"]) + accept.connect("clicked", lambda _b: self._accept()) + buttons.append(accept) + outer.append(buttons) + + view.set_content(outer) + + # -- geometry ------------------------------------------------------------ + def _fit(self): + """(scale, ox, oy) letterboxing the screenshot into the drawing area.""" + w, h = self._area.get_width(), self._area.get_height() + ih, iw = self._image.shape[:2] + if not w or not h: + return 1.0, 0.0, 0.0 + s = min(w / iw, h / ih) + return s, (w - iw * s) / 2, (h - ih * s) / 2 + + def _to_widget(self, p): + s, ox, oy = self._fit() + return p[0] * s + ox, p[1] * s + oy + + def _to_image(self, x, y): + s, ox, oy = self._fit() + return (x - ox) / s, (y - oy) / s + + def _refit(self): + """Rebuild the grid from the four handle positions. + + A bad drag (two handles on top of each other) makes the homography + degenerate; keep the previous grid rather than crash, the next drag + update recovers. + """ + try: + self._sol = map_vision.solution_from_correspondences( + list(zip(self._grid, self._px))) + except (ValueError, np.linalg.LinAlgError): + pass + self._area.queue_draw() + + def _reset(self): + quad = map_vision.centre_cell_quad(self._auto, self._image.shape) + if quad: + self._px = list(quad[1]) + self._sol = self._auto + self._area.queue_draw() + + # -- input --------------------------------------------------------------- + def _on_drag_begin(self, _gesture, x, y): + self._dragging = None + best = GRAB_R + for i, p in enumerate(self._px): + wx, wy = self._to_widget(p) + d = ((wx - x) ** 2 + (wy - y) ** 2) ** 0.5 + if d < best: + best, self._dragging = d, i + if self._dragging is not None: + self._drag_from = self._px[self._dragging] + + def _on_drag_update(self, _gesture, dx, dy): + if self._dragging is None: + return + s, _ox, _oy = self._fit() + if s <= 0: + return + fx, fy = self._drag_from + self._px[self._dragging] = (fx + dx / s, fy + dy / s) + self._refit() + + def _accept(self): + self.close() + self._on_accept(self._sol) + + def _discard(self): + self.close() + if self._on_discard is not None: + self._on_discard() + + # -- drawing ------------------------------------------------------------- + def _draw(self, _area, cr, width, height): + cr.set_source_rgb(0.08, 0.08, 0.08) + cr.paint() + s, ox, oy = self._fit() + + cr.save() + cr.translate(ox, oy) + cr.scale(s, s) + cr.set_source_surface(self._surface, 0, 0) + cr.get_source().set_filter(cairo.FILTER_GOOD) + cr.paint() + cr.restore() + + self._draw_grid(cr) + + for i, p in enumerate(self._px): + wx, wy = self._to_widget(p) + # new_path() before every arc: cairo's arc() joins the current point + # to the arc's start, and _draw_grid leaves one behind at the last + # cell name it drew. Without this, the first handle gets a stray + # line reaching across the whole screenshot from that label. + cr.new_path() + cr.set_source_rgb(1.0, 0.85, 0.1) + cr.arc(wx, wy, HANDLE_R, 0, 2 * math.pi) + cr.fill_preserve() + cr.set_source_rgb(0.1, 0.1, 0.1) + cr.set_line_width(2.0) + cr.stroke() + if i == self._dragging: + cr.new_path() + cr.set_source_rgb(1.0, 1.0, 1.0) + cr.arc(wx, wy, HANDLE_R + 4, 0, 2 * math.pi) + cr.set_line_width(1.5) + cr.stroke() + + def _draw_grid(self, cr): + """Every in-range cell the grid puts inside the frame, with its name + drawn where the game draws it. A wrong grid is obvious precisely + because those names land off the painted labels.""" + sol = self._sol + h, w = self._image.shape[:2] + inv = np.linalg.inv(sol.H) + corners = inv @ np.array([[0, w, w, 0], [0, 0, h, h], [1, 1, 1, 1.0]]) + if np.any(np.abs(corners[2]) < 1e-9): + return + ij = corners[:2] / corners[2] + cr.set_line_width(1.6) + cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + L2G = sol.lattice_to_grid() + for i in range(int(np.floor(ij[0].min())) - 1, int(np.ceil(ij[0].max())) + 2): + for j in range(int(np.floor(ij[1].min())) - 1, int(np.ceil(ij[1].max())) + 2): + g = L2G @ np.array([i, j, 1.0]) + col, row = int(round(g[0])), int(round(g[1])) + if not (0 <= col < map_vision.COLS and 1 <= row <= map_vision.ROWS): + continue + quad = sol.H @ np.array([[i, i + 1, i + 1, i], + [j, j, j + 1, j + 1], [1, 1, 1, 1.0]]) + if np.any(np.abs(quad[2]) < 1e-9): + continue + pts = [self._to_widget(p) for p in (quad[:2] / quad[2]).T] + cr.new_path() + cr.set_source_rgba(1.0, 1.0, 0.2, 0.75) + cr.move_to(*pts[0]) + for p in pts[1:]: + cr.line_to(*p) + cr.close_path() + cr.stroke() + + # The game pads a cell's label in from its top-left corner by a + # fixed fraction of the cell, which is also how the solver finds + # labels in the first place (see map_vision.PAD_L/PAD_T). + lx = i + (map_vision.PAD_L if sol.si > 0 else 1 - map_vision.PAD_L) + ly = j + (map_vision.PAD_T if sol.sj > 0 else 1 - map_vision.PAD_T) + t = sol.H @ np.array([lx, ly, 1.0]) + if abs(t[2]) < 1e-9: + continue + tx, ty = self._to_widget((t[0] / t[2], t[1] / t[2])) + side = float(np.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1])) + cr.set_font_size(max(9.0, min(30.0, side * 0.16))) + name = f"{map_vision.LARGE_X[col]}{row}" + cr.move_to(tx, ty) + cr.set_source_rgba(0, 0, 0, 0.8) + cr.text_path(name) + cr.set_line_width(3.0) + cr.stroke() + cr.move_to(tx, ty) + cr.set_source_rgb(0.3, 1.0, 0.3) + cr.show_text(name) diff --git a/src/fenigma/grid_widget.py b/src/fenigma/grid_widget.py index 7f16654..1549845 100644 --- a/src/fenigma/grid_widget.py +++ b/src/fenigma/grid_widget.py @@ -11,6 +11,7 @@ from collections import namedtuple import cairo import gi +import numpy as np gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") @@ -30,6 +31,9 @@ MARGIN_BOTTOM = 30 LABEL_PAD = 8 # gap between a marker and its name label HOVER_RADIUS_PX = 12 +# An imported screenshot is a backdrop, not the subject: slightly transparent so +# the grid lines and markers drawn over it stay legible. +SCREENSHOT_ALPHA = 0.88 OVERLAY_RAY_LENGTH_KM = 30.0 # long enough to cross the 20x10 map from any origin MIN_ZOOM = 1.0 # the whole 20x10 map fits, the default @@ -212,9 +216,26 @@ class GridCanvas(Gtk.DrawingArea): self.on_select = None # callback(obj | None, point | None), fired on click self.on_hover_change = None # callback(obj | None, point | None), fired on hover change self.on_cursor_move = None # callback((col, row) km | None), fired on every motion/leave - self.on_right_click = None # callback(Coord, x, y), fired on right-click (unless placing) + # callback(proposal, x, y): fired when an imported screenshot's pending + # proposal is clicked with either button. A proposal exists only to be + # accepted or rejected, so plain clicking it offers that rather than + # selecting something the board doesn't contain yet. + self.on_proposal_click = None + # callback(Coord, x, y, obj, point): fired on right-click unless placing. + # obj/point are the entity under the cursor when there is one (same + # hit test as left-click selection), so the handler can offer actions + # on that entity instead of the place-something-here menu. + self.on_right_click = None self.hide_dead_from_map = False # off by default; toggled from the firing panel toolbar + # An imported map screenshot, rectified into board space, drawn under + # everything else, plus the units detected in it as [(proposal, Coord)]. + # Proposals are kept separate from board entities on purpose: they are + # not on the board until accepted, so nothing that walks the board can + # see them, and they get their own hit test. + self._screenshot = None # (cairo surface, backing array, px_per_km) + self.proposals = [] + # Which large cell the cursor is currently over, (col, row) both # floored, or None off the map/off the widget entirely. Redrawn # only when this actually changes cell (not on every pixel of @@ -510,6 +531,78 @@ class GridCanvas(Gtk.DrawingArea): for candidate in obj.location.potential_coords: yield obj, candidate + # -- imported screenshot --------------------------------------------------- + def set_screenshot(self, bgra, px_per_km: int) -> None: + """Show a rectified map screenshot as the board's backdrop. + + `bgra` covers the whole board (COLS x ROWS km at px_per_km), transparent + wherever the screenshot didn't reach, so a partial view of the table + doesn't blank out the rest of the map. Pre-warping into board space is + what makes this drawable at all: cairo has no projective transform, but + once the image is rectified a plain scale and translate places it. + """ + if bgra is None: + self._screenshot = None + self.queue_draw() + return + buf = np.ascontiguousarray(bgra) + h, w = buf.shape[:2] + surface = cairo.ImageSurface.create_for_data( + memoryview(buf), cairo.FORMAT_ARGB32, w, h, w * 4) + # The array must outlive the surface: create_for_data does not copy. + self._screenshot = (surface, buf, px_per_km) + self.queue_draw() + + def has_screenshot(self) -> bool: + return self._screenshot is not None + + def set_proposals(self, proposals) -> None: + """proposals is [(proposal, Coord)]; the widget only reads the Coord and + the proposal's accepted/rejected flags, so it stays ignorant of + map_import's own coordinate format.""" + self.proposals = list(proposals) + self.queue_draw() + + def _pending_proposals(self): + return [(p, c) for p, c in self.proposals if p.pending] + + def hit_test_proposal(self, view: _View, x: float, y: float): + """The pending proposal nearest the cursor within range, or None.""" + best, best_dist = None, HOVER_RADIUS_PX + for p, coord in self._pending_proposals(): + px, py = self._km_to_px(view, coord.as_fraction()) + dist = math.hypot(px - x, py - y) + if dist < best_dist: + best_dist, best = dist, p + return best + + def _draw_screenshot(self, cr, view) -> None: + surface, buf, px_per_km = self._screenshot + # Board space runs col 0..COLS rightward and row 0..ROWS upward, so the + # image's top-left pixel is (col 0, row ROWS) -- the top-left corner. + x0, y0 = self._km_to_px(view, (0, ROWS)) + x1, y1 = self._km_to_px(view, (COLS, 0)) + ih, iw = buf.shape[:2] + if iw <= 0 or ih <= 0: + return + cr.save() + cr.translate(x0, y0) + cr.scale((x1 - x0) / iw, (y1 - y0) / ih) + cr.set_source_surface(surface, 0, 0) + cr.get_source().set_filter(cairo.FILTER_GOOD) + cr.paint_with_alpha(SCREENSHOT_ALPHA) + cr.restore() + + def _draw_proposals(self, cr, view, width, height) -> None: + """Detected-but-unconfirmed units. Drawn hollow, the same shape the map + already uses for "this might be where it is", because that is exactly + what a proposal is until the user accepts it.""" + for p, coord in self._pending_proposals(): + color = CATEGORY_COLOR["ally" if p.side == "friendly" else "target"] + self._draw_marker(cr, view, coord.as_fraction(), color, + f"? {coord.label()}", width, height, + hollow=True, coord=coord) + def _hit_test(self, view: _View, x: float, y: float): """Returns (obj, coord) of the nearest marker within range, or (None, None), coord disambiguates which candidate of an @@ -591,6 +684,11 @@ class GridCanvas(Gtk.DrawingArea): callback(coord) return + proposal = self.hit_test_proposal(view, x, y) + if proposal is not None and self.on_proposal_click is not None: + self.on_proposal_click(proposal, x, y) + return + hit, coord = self._hit_test(view, x, y) self.set_selected(hit, coord) if self.on_select is not None: @@ -603,9 +701,17 @@ class GridCanvas(Gtk.DrawingArea): if self.on_right_click is None: return view = self._view(self.get_width(), self.get_height()) + # A pending proposal wins over a board entity underneath it: it is the + # thing the user is being asked to decide about, and it disappears as + # soon as they do, so whatever it overlaps becomes reachable again. + hit = self.hit_test_proposal(view, x, y) + point = None + if hit is None: + hit, point = self._hit_test(view, x, y) coord = solver.point_to_coord(self._px_to_km(view, x, y)) - if coord is not None: - self.on_right_click(coord, x, y) + if coord is None and hit is None: + return + self.on_right_click(coord, x, y, hit, point) # -- drawing ---------------------------------------------------------------- def _draw(self, _area, cr, width, height) -> None: @@ -692,6 +798,11 @@ class GridCanvas(Gtk.DrawingArea): cr.rectangle(MARGIN_LEFT + view.pad_x, MARGIN_TOP + view.pad_y, view.grid_w, view.grid_h) cr.clip() + # Under everything: the imported screenshot is the backdrop the rest of + # the map is drawn on top of. + if self._screenshot is not None: + self._draw_screenshot(cr, view) + self._draw_hover_subgrid(cr, view) self._draw_geo_overlays(cr, view) self._draw_firing_arrows(cr, view) @@ -720,6 +831,8 @@ class GridCanvas(Gtk.DrawingArea): selected=is_selected, coord=candidate, extra_line=getattr(obj, "requested_time", None)) + self._draw_proposals(cr, view, width, height) + for sf in self.board.scout_flights: if sf.hidden: continue # hidden means gone from the map, not just darkened, no selection to reinstate it diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py index 171b53a..2f71d44 100644 --- a/src/fenigma/icons.py +++ b/src/fenigma/icons.py @@ -49,6 +49,27 @@ _TARGET_ICON_BASENAME = { } +def target_type_from_icon(basename: str | None) -> TargetType | None: + """Inverse of _TARGET_ICON_BASENAME, for the map-vision marker classifier, + which names what it matched by icon file rather than by TargetType. + + Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one + resolves to MECHANIZED and the user retypes it if it was a Tank (map + right-click -> Change type). Icons with no TargetType at all give None, + which callers treat as UNKNOWN. + """ + if not basename: + return None + name = basename if basename.lower().endswith(".png") else f"{basename}.png" + for prefix in ("Enemy_", "Friendly_"): + if name.startswith(prefix): + name = name[len(prefix):] + for type_, base in _TARGET_ICON_BASENAME.items(): + if base == name: + return type_ + return None + + def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None: """Icon file for a Target or Ally's type, or None if there isn't a good one. `is_ally` picks the Friendly_ set over the Enemy_ one, diff --git a/src/fenigma/map_import.py b/src/fenigma/map_import.py new file mode 100644 index 0000000..b023e1d --- /dev/null +++ b/src/fenigma/map_import.py @@ -0,0 +1,181 @@ +"""State and threading for importing a map screenshot. + +Deliberately free of any GTK import so it can be exercised headlessly. The +dialog and the map overlay sit on top of this; everything here is plain +Python and numpy. + +Two jobs: + +* run the vision pipeline OFF the UI thread. `solve()` takes 10-20s, which + would freeze the window, so it runs in a worker and the result is handed + back through a scheduler callback (GLib.idle_add in the app, called + directly in tests). A thread is sufficient rather than a process: the work + is numpy/OpenCV, which releases the GIL and already multithreads + internally. +* hold the review state. Detections arrive as PROPOSALS, not as board + entries: each is accepted or rejected individually (or all at once), the + unit type can be corrected, and dropping the screenshot discards whatever + was never accepted. +""" +from __future__ import annotations + +import threading +from dataclasses import dataclass, field + +from . import map_vision + +# Distinguishable on_done error: this screenshot is typewriter text, so the +# caller should send it down its normal OCR path rather than report a failure. +NOT_A_MAP = "not a map screenshot" + + +@dataclass +class Proposal: + """One detected marker awaiting the user's decision.""" + side: str # "hostile" | "friendly" + label: str # e.g. "K8" + sub_x: int + sub_y: int + unit: str | None # game unit name, or None when unsure + centre: tuple # pixel centre in the solved image + box: tuple + accepted: bool = False + rejected: bool = False + + @property + def coord(self) -> str: + return f"{self.label} {self.sub_x}:{self.sub_y}" + + @property + def pending(self) -> bool: + return not (self.accepted or self.rejected) + + +@dataclass +class ScreenshotImport: + """An accepted screenshot plus its proposals, as shown over the map.""" + solution: object + image: object + proposals: list = field(default_factory=list) + overlay: object = None # BGRA array in map space + px_per_km: int = 0 + + def set_proposals(self, markers): + self.proposals = [ + Proposal(side=m["side"], label=m["label"], sub_x=m["sub_x"], + sub_y=m["sub_y"], unit=m.get("unit"), + centre=m["centre"], box=m["box"]) for m in markers] + return self.proposals + + def build_overlay(self, px_per_km=100): + """Rectify the screenshot into map space, ready to draw under the grid.""" + self.overlay, self.px_per_km = map_vision.warp_to_map( + self.image, self.solution, px_per_km=px_per_km) + return self.overlay + + def accept_all(self): + for p in self.proposals: + if p.pending: + p.accepted = True + + def reject_all(self): + for p in self.proposals: + if p.pending: + p.rejected = True + + def accepted(self): + return [p for p in self.proposals if p.accepted] + + def pending(self): + return [p for p in self.proposals if p.pending] + + def drop_unaccepted(self): + """Removing the screenshot discards everything never accepted.""" + self.proposals = [p for p in self.proposals if p.accepted] + + +class ImportJob: + """Runs the vision pipeline in a worker thread. + + `on_done(result, error)` is delivered through `schedule`, which the app + sets to GLib.idle_add so the callback lands on the UI thread. Nothing here + may touch a widget. + """ + + def __init__(self, schedule=None): + self.schedule = schedule or (lambda fn, *a: fn(*a)) + self._cancelled = threading.Event() + self._thread = None + + @property + def cancelled(self) -> bool: + return self._cancelled.is_set() + + def cancel(self): + """Ask the worker to stop. The result is simply dropped -- the vision + code is pure and side-effect free, so abandoning it is safe.""" + self._cancelled.set() + + def looks_like_map(self, img) -> bool: + """Cheap synchronous routing test (~0.3s), safe to call inline. + + Measured: gates in 9 of 10 map screenshots and 6% of 122 writer + screenshots. Its false positives only cost time, because solve() is + the real decision and accepts none of the 122. + """ + return map_vision.looks_like_map(img) + + def start(self, path, on_done, gate=True): + """Run the pipeline for `path`, delivering on_done(result, error). + + With `gate` on, the routing test runs in the worker too and a text + screenshot comes back as error NOT_A_MAP. That keeps the whole + map-or-text decision off the UI thread: the gate is only ~0.3s, but + the caller is on the clipboard path, where a hitch is felt. + + Only the GRID is solved here. Marker detection is a separate phase + (find_markers) run after the user has confirmed or corrected the grid, + because every marker position is expressed in grid coordinates: finding + them against a grid that's about to be dragged would only be thrown + away and redone. + """ + def work(): + if gate and not map_vision.looks_like_map(map_vision.load(path)): + return None, NOT_A_MAP + sol, img, err = map_vision.solve_path(path) + if sol is None: + return None, err + return ScreenshotImport(solution=sol, image=img), None + + return self._run(work, on_done, "map-import") + + def find_markers(self, imp, on_done): + """Second phase: detect units against the now-confirmed grid. + + Fills imp.proposals and delivers on_done(imp, error). Its own thread, + because the user's grid correction sits between the two phases. + """ + def work(): + imp.set_proposals(map_vision.find_markers(imp.image, imp.solution)) + return imp, None + + return self._run(work, on_done, "map-markers") + + def _run(self, work, on_done, name): + """Run work() in a thread and marshal its (result, error) back. + + work() only computes and returns; delivery and the cancellation check + live here, so no phase can deliver into a UI the user has moved on from. + """ + def guarded(): + try: + result, error = work() + except Exception as exc: # worker must never die silently + result, error = None, f"{type(exc).__name__}: {exc}" + if not self._cancelled.is_set(): + self.schedule(on_done, result, error) + + self._cancelled.clear() + self._thread = threading.Thread(target=guarded, daemon=True, name=name) + self._thread.start() + return self._thread