"""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 numeric input, by a free-text relative description ("Bearing 293 from Alpha"), or extracted from a clipboard screenshot via the text OCR pipeline. A solver resolves relative descriptions into absolute coordinates whenever their dependencies become known (see solver.py); every mutation runs through _refresh(), which is the single choke point for that. """ from __future__ import annotations import io import json import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") 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, ocr, solver # noqa: E402 from .coord_dialog import CoordDialog # noqa: E402 from .firing_panel import FiringPanel # noqa: E402 from .grid_widget import GridCanvas # noqa: E402 from .models import Board, Location, Target, TargetType # noqa: E402 from .shells import Shell # noqa: E402 APP_ID = "eu.dominik-roth.FeNigma" def _apply_location(obj, location: Location) -> None: """Apply a Location patch from CoordDialog: a resolved coord and a description are independent, so only touch whichever half is set, leaving the other (already on `obj`) alone.""" if location.coord is not None: obj.coord = location.coord # preserves existing desc_raw/clues, clears potential_coords if location.desc_raw is not None: obj.location.desc_raw = location.desc_raw obj.location.clues = location.clues def _row( name: str, *, on_input, on_screenshot, on_remove=None, hidden=False, on_toggle_hidden=None, show_geo=False, on_toggle_show_geo=None, alive=None, on_toggle_alive=None, ) -> Gtk.Widget: """One list entry: a label plus input/screenshot/geo/hide[/alive][/remove] action buttons.""" box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) box.set_margin_top(4) box.set_margin_bottom(4) box.set_margin_start(8) box.set_margin_end(8) label = Gtk.Label(xalign=0, hexpand=True) if alive is False: label.set_markup(f"{GLib.markup_escape_text(name)}") else: label.set_label(name) if hidden: label.add_css_class("dim-label") box.append(label) input_btn = Gtk.Button(icon_name="document-edit-symbolic", tooltip_text="Set coords from input") input_btn.connect("clicked", lambda _b: on_input()) box.append(input_btn) shot_btn = Gtk.Button(icon_name="insert-image-symbolic", tooltip_text="Set coords from screenshot") shot_btn.connect("clicked", lambda _b: on_screenshot()) box.append(shot_btn) if on_toggle_show_geo is not None: geo_btn = Gtk.Button( icon_name="starred-symbolic" if show_geo else "non-starred-symbolic", tooltip_text="Always show bearing/distance overlay" if not show_geo else "Only show overlay on hover", ) geo_btn.add_css_class("flat") geo_btn.connect("clicked", lambda _b: on_toggle_show_geo()) box.append(geo_btn) if on_toggle_alive is not None: alive_btn = Gtk.Button( icon_name="object-select-symbolic" if alive else "action-unavailable-symbolic", tooltip_text="Mark destroyed" if alive else "Mark alive", ) alive_btn.add_css_class("flat") alive_btn.connect("clicked", lambda _b: on_toggle_alive()) box.append(alive_btn) if on_toggle_hidden is not None: hide_btn = Gtk.Button( icon_name="view-reveal-symbolic" if hidden else "view-conceal-symbolic", tooltip_text="Show on map" if hidden else "Hide from map", ) hide_btn.add_css_class("flat") hide_btn.connect("clicked", lambda _b: on_toggle_hidden()) box.append(hide_btn) if on_remove is not None: rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove") rm_btn.add_css_class("flat") rm_btn.connect("clicked", lambda _b: on_remove()) box.append(rm_btn) return box def _location_status(obj) -> str: if obj.coord is not None: return obj.coord.label() if obj.location.potential_coords: return f"ambiguous ({len(obj.location.potential_coords)} candidates)" if obj.location.desc_raw: return "from description" return "not set" _FIRING_CARD_CSS = """ .firing-card { transition: background-color 150ms ease, border-color 150ms ease; } .firing-card-hovered { background-color: alpha(@accent_color, 0.12); } .firing-card-selected { border: 2px solid @accent_color; } """ def _install_css() -> None: provider = Gtk.CssProvider() provider.load_from_string(_FIRING_CARD_CSS) Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) class MainWindow(Adw.ApplicationWindow): def __init__(self, app: Adw.Application) -> None: super().__init__(application=app, title="FeNigma") self.set_default_size(1100, 750) _install_css() self.board = Board() self.toast_overlay = Adw.ToastOverlay() self.set_content(self.toast_overlay) toolbar_view = Adw.ToolbarView() self.toast_overlay.set_child(toolbar_view) header = Adw.HeaderBar() header.set_title_widget(Gtk.Box()) # drop the window-title label, header's crowded toolbar_view.add_top_bar(header) save_btn = Gtk.Button(icon_name="document-save-symbolic") save_btn.set_tooltip_text("Save board to file") save_btn.connect("clicked", lambda _b: self._save_to_file()) header.pack_start(save_btn) load_btn = Gtk.Button(icon_name="document-open-symbolic") load_btn.set_tooltip_text("Load board from file") load_btn.connect("clicked", lambda _b: self._load_from_file()) header.pack_start(load_btn) clip_btn = Gtk.Button(icon_name="edit-paste-symbolic") clip_btn.set_tooltip_text("Fetch screenshot from clipboard (Ctrl+P)") clip_btn.connect("clicked", lambda _b: self._fetch_clipboard()) header.pack_start(clip_btn) header.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) for label, popover_builder in ( ("Nest", self._build_nest_popover), ("Spotters", self._build_spotters_popover), ("Reference Points", self._build_rp_popover), ("Targets", self._build_targets_popover), ): header.pack_start(self._make_menu_button(label, popover_builder)) strike_btn = Gtk.Button(icon_name="find-location-symbolic") strike_btn.set_tooltip_text("Add strike") strike_btn.connect("clicked", lambda _b: self._add_strike()) header.pack_start(strike_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()) header.pack_end(firing_btn) self.cursor_label = Gtk.Label(xalign=1) self.cursor_label.add_css_class("dim-label") self.cursor_label.add_css_class("numeric") header.pack_end(self.cursor_label) # packed after firing_btn -> sits to its left self.canvas = GridCanvas(self.board) self.firing_panel = FiringPanel( self.board, on_change=self._refresh, on_select=self._set_selection, on_edit_position=self._edit_target_position, on_set_position=self._start_target_placement, on_remove=self._remove_target_via_panel, on_toggle_hide_dead_map=self._on_toggle_hide_dead_map, ) self.canvas.on_select = self._set_selection 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 self.split_view = Adw.OverlaySplitView() self.split_view.set_content(self.canvas) self.split_view.set_sidebar(self.firing_panel) self.split_view.set_sidebar_position(Gtk.PackType.END) self.split_view.set_min_sidebar_width(280) self.split_view.set_max_sidebar_width(360) self.split_view.set_show_sidebar(False) toolbar_view.set_content(self.split_view) controller = Gtk.ShortcutController() controller.add_shortcut( Gtk.Shortcut.new( Gtk.ShortcutTrigger.parse_string("p"), Gtk.CallbackAction.new(lambda *_a: self._fetch_clipboard() or True), ) ) self.add_controller(controller) # -- generic helpers ----------------------------------------------------- def _make_menu_button(self, label: str, build_popover) -> Gtk.MenuButton: """build_popover(rebuild) returns the popover's content widget; rebuild() lets a row's own callback refresh the popover in place (e.g. after toggling hidden, or removing an item) instead of only refreshing next time it's reopened.""" button = Gtk.MenuButton(label=label) popover = Gtk.Popover() popover.set_size_request(280, -1) button.set_popover(popover) def rebuild(): popover.set_child(build_popover(rebuild)) popover.connect("show", lambda _p: rebuild()) return button def toast(self, message: str) -> None: self.toast_overlay.add_toast(Adw.Toast(title=message, timeout=3)) # -- save / load ----------------------------------------------------------- def _save_to_file(self) -> None: dialog = Gtk.FileDialog(initial_name="board.json") dialog.save(self, None, self._on_save_finish) def _on_save_finish(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None: try: gfile = dialog.save_finish(result) except GLib.Error as exc: if exc.matches(Gtk.dialog_error_quark(), Gtk.DialogError.DISMISSED): return self.toast(f"Save failed: {exc.message}") return data = json.dumps(self.board.to_dict(), indent=2) try: gfile.replace_contents( data.encode("utf-8"), None, False, Gio.FileCreateFlags.NONE, None ) except GLib.Error as exc: self.toast(f"Save failed: {exc.message}") return self.toast(f"Saved to {gfile.get_path()}") def _load_from_file(self) -> None: dialog = Gtk.FileDialog() dialog.open(self, None, self._on_load_finish) def _on_load_finish(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None: try: gfile = dialog.open_finish(result) except GLib.Error as exc: if exc.matches(Gtk.dialog_error_quark(), Gtk.DialogError.DISMISSED): return self.toast(f"Load failed: {exc.message}") return try: ok, contents, _etag = gfile.load_contents(None) data = json.loads(contents.decode("utf-8")) self.board.load_from_dict(data) except (GLib.Error, json.JSONDecodeError, KeyError, ValueError) as exc: self.toast(f"Load failed: {exc}") return self._refresh() self.toast(f"Loaded {gfile.get_path()}") # -- OCR plumbing ----------------------------------------------------------- def _fetch_clipboard(self) -> None: """Header button / Ctrl+P: universal fetch, run OCR and merge everything found.""" self._run_ocr_from_clipboard(self._merge_all) def _run_ocr_from_clipboard(self, on_parsed) -> None: """Read the clipboard image, OCR it, and call on_parsed(ParsedInfo).""" clipboard = Gdk.Display.get_default().get_clipboard() clipboard.read_texture_async(None, lambda cb, res: self._on_ocr_texture_ready(res, on_parsed)) def _on_ocr_texture_ready(self, result: Gio.AsyncResult, on_parsed) -> None: clipboard = Gdk.Display.get_default().get_clipboard() try: texture = clipboard.read_texture_finish(result) except GLib.Error as exc: self.toast(f"No image on clipboard ({exc.message}).") return if texture is None: self.toast("Clipboard has no image. Copy a screenshot first.") return try: pil_image = Image.open(io.BytesIO(texture.save_to_png_bytes().get_data())) info = ocr.run(pil_image) except Exception as exc: # OCR/parsing hiccups shouldn't crash the app self.toast(f"OCR failed: {exc}") return on_parsed(info) def _merge_all(self, info: "ocr.ParsedInfo") -> None: changed = [] if info.nest_coord is not None: self.board.nest.coord = info.nest_coord changed.append("Nest") changed.extend(self._merge_spotters(info, toast=False)) changed.extend(self._merge_reference_points(info, toast=False)) changed.extend(self._merge_targets(info, toast=False)) if not changed: self.toast("No relevant info found in screenshot.") else: self.toast("Merged from screenshot: " + ", ".join(changed)) self._refresh() def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]: changed = [] for spotter_id, coord in info.spotters.items(): existing = next((s for s in self.board.spotters if s.id == spotter_id), None) if existing is not None: existing.coord = coord else: existing = self.board.add_spotter(coord, spotter_id) changed.append(existing.name) if toast: self.toast("No spotters found in screenshot." if not changed else "Loaded from screenshot: " + ", ".join(changed)) self._refresh() return changed def _merge_parsed_location(self, existing, raw: str, clues, coord) -> None: """Refresh desc/clues from a fresh OCR read, but never clobber a coord the entity already has, from a prior resolve, an earlier screenshot's Grid ref, or a manual Edit Pos override. Re-reading the same intel later shouldn't undo that; only apply the new coord if there wasn't one already.""" existing.location.desc_raw = raw existing.location.clues = clues if existing.coord is None: existing.coord = coord def _merge_reference_points(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]: changed = [] for name, (raw, clues, coord) in info.reference_points.items(): existing = next((rp for rp in self.board.reference_points if rp.rp_name == name), None) if existing is not None: self._merge_parsed_location(existing, raw, clues, coord) else: existing = self.board.add_reference_point( Location(coord=coord, desc_raw=raw, clues=clues), name=name ) changed.append(existing.name) if toast: self.toast("No reference points found in screenshot." if not changed else "Loaded from screenshot: " + ", ".join(changed)) self._refresh() return changed def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]: changed = [] for (target_type, target_id), (raw, clues, coord) in info.targets.items(): existing = next( (t for t in self.board.targets if t.type == target_type and t.id == target_id), None ) if existing is not None: self._merge_parsed_location(existing, raw, clues, coord) else: existing = self.board.add_target( target_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=target_id ) changed.append(existing.name) # "SupplyCache#1 Destroyed." etc. Mark it dead if we already know # it; if this destruction report is the *first* we've heard of it # (never separately spotted with a position), still record it as a # dead, position-unknown target rather than losing the report, # better a target with no coord than no record it existed at all. for target_type, target_id in info.destroyed: existing = next( (t for t in self.board.targets if t.type == target_type and t.id == target_id), None ) is_new = existing is None if existing is None: existing = self.board.add_target(target_type, id_=target_id) if existing.alive: existing.alive = False suffix = " (destroyed, position unknown)" if is_new else " (destroyed)" changed.append(f"{existing.name}{suffix}") if toast: self.toast("No targets found in screenshot." if not changed else "Loaded from screenshot: " + ", ".join(changed)) self._refresh() return changed def _set_nest_from_screenshot_info(self, info: "ocr.ParsedInfo") -> None: if info.nest_coord is None: self.toast("Nest position not found in screenshot.") return self.board.nest.coord = info.nest_coord self._refresh() self.toast(f"Nest set to {info.nest_coord.label()} from screenshot.") def _set_spotter_from_screenshot_info(self, info: "ocr.ParsedInfo", spotter) -> None: coord = info.spotters.get(spotter.id) if coord is None: self.toast(f"{spotter.name} not found in screenshot.") return spotter.coord = coord self._refresh() self.toast(f"{spotter.name} set to {coord.label()} from screenshot.") def _set_rp_from_screenshot_info(self, info: "ocr.ParsedInfo", rp) -> None: result = info.reference_points.get(rp.rp_name) if result is None: self.toast(f"{rp.name} not found in screenshot.") return raw, clues, coord = result self._merge_parsed_location(rp, raw, clues, coord) self._refresh() self.toast(f"{rp.name} set from screenshot.") def _set_target_from_screenshot_info(self, info: "ocr.ParsedInfo", target) -> None: result = info.targets.get((target.type, target.id)) if result is None: self.toast(f"{target.name} not found in screenshot.") return raw, clues, coord = result self._merge_parsed_location(target, raw, clues, coord) self._refresh() self.toast(f"{target.name} set from screenshot.") def _open_coord_dialog( self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None, initial_location=None, initial_id=None, initial_type=None, ): dialog = CoordDialog( title=title, on_submit=on_submit, show_id=show_id, show_type=show_type, id_placeholder=id_placeholder, initial_location=initial_location, initial_id=initial_id, initial_type=initial_type, ) dialog.present(self) def _refresh(self) -> None: """Single choke point: re-run the solver, then redraw. Every mutation goes through here so newly-unblocked descriptions get a chance to resolve immediately.""" solver.resolve_board(self.board) removed = solver.dedupe_generic_targets(self.board) if removed and isinstance(self.canvas.selected, Target) and self.canvas.selected not in self.board.targets: self._set_selection(None) # the selected generic target just got merged away self.canvas.refresh() self.firing_panel.refresh() def _on_toggle_hide_dead_map(self, value: bool) -> None: self.canvas.hide_dead_from_map = value self._refresh() def _toggle_firing_panel(self) -> None: self.split_view.set_show_sidebar(not self.split_view.get_show_sidebar()) def _set_selection(self, obj, point=None) -> None: """Shared by both the map (click a marker) and the firing panel (click a card) so clicking either keeps the other in sync. `point` disambiguates which candidate of an ambiguous target was clicked.""" self.canvas.set_selected(obj, point) self.firing_panel.set_selected(obj if isinstance(obj, Target) else None, point) if isinstance(obj, Target) and not self.split_view.get_show_sidebar(): self.split_view.set_show_sidebar(True) def _on_map_hover_change(self, obj, point=None) -> None: self.firing_panel.set_hovered(obj if isinstance(obj, Target) else None, point) def _on_cursor_move(self, point_km) -> None: if point_km is None: self.cursor_label.set_label("") return coord = solver.point_to_coord(point_km) coord_text = coord.label() if coord is not None else "off map" nest = self.board.nest if nest.coord is not None: az = ballistics.bearing_deg_point(nest.coord.as_fraction(), point_km) dist = ballistics.distance_km_point(nest.coord.as_fraction(), point_km) self.cursor_label.set_label(f"AZ {az:05.1f}° {dist:5.2f}km {coord_text}") else: self.cursor_label.set_label(coord_text) # -- Nest ----------------------------------------------------------------- def _build_nest_popover(self, rebuild) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) box.set_margin_top(6) box.set_margin_bottom(6) nest = self.board.nest box.append(_row( f"Nest ({_location_status(nest)})", on_input=lambda: self._open_coord_dialog( title="Set Nest coordinates", on_submit=lambda loc, _id, _t: self._apply_and_refresh(nest, loc), initial_location=nest.location, ), on_screenshot=lambda: self._run_ocr_from_clipboard(self._set_nest_from_screenshot_info), hidden=nest.hidden, on_toggle_hidden=lambda: self._toggle_hidden(nest, rebuild), show_geo=nest.show_geo_desc, on_toggle_show_geo=lambda: self._toggle_show_geo(nest, rebuild), )) return box # -- Spotters -------------------------------------------------------------- def _build_spotters_popover(self, rebuild) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box.set_margin_top(6) box.set_margin_bottom(6) load_btn = Gtk.Button(label="Load all from screenshot") load_btn.set_margin_start(8) load_btn.set_margin_end(8) load_btn.set_margin_bottom(4) load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_spotters)) box.append(load_btn) box.append(Gtk.Separator()) for sp in list(self.board.spotters): box.append(_row( f"{sp.name} ({_location_status(sp)})", on_input=lambda sp=sp: self._open_coord_dialog( title=f"Set {sp.name} coordinates", on_submit=lambda loc, _id, _t, sp=sp: self._apply_and_refresh(sp, loc), initial_location=sp.location, ), on_screenshot=lambda sp=sp: self._run_ocr_from_clipboard( lambda info, sp=sp: self._set_spotter_from_screenshot_info(info, sp) ), on_remove=lambda sp=sp: self._remove_spotter(sp, rebuild), hidden=sp.hidden, on_toggle_hidden=lambda sp=sp: self._toggle_hidden(sp, rebuild), show_geo=sp.show_geo_desc, on_toggle_show_geo=lambda sp=sp: self._toggle_show_geo(sp, rebuild), )) box.append(Gtk.Separator()) box.append(_row( "Add spotter", on_input=lambda: self._open_coord_dialog( title="Add spotter", on_submit=lambda loc, id_, _t: self._add_spotter(loc, id_), show_id=True, id_placeholder=f"ID (blank = auto, next: {self.board.next_spotter_id()})", ), on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_spotters), )) return box def _add_spotter(self, location: Location, id_text: str | None) -> None: parsed_id = None if id_text: try: parsed_id = int(id_text) except ValueError: self.toast(f"Spotter ID must be a whole number, got {id_text!r}.") return self.board.add_spotter(location, parsed_id) self._refresh() def _remove_spotter(self, sp, rebuild) -> None: self.board.remove_spotter(sp) self._refresh() rebuild() # -- Reference Points -------------------------------------------------------- def _build_rp_popover(self, rebuild) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box.set_margin_top(6) box.set_margin_bottom(6) load_btn = Gtk.Button(label="Load all from screenshot") load_btn.set_margin_start(8) load_btn.set_margin_end(8) load_btn.set_margin_bottom(4) load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_reference_points)) box.append(load_btn) box.append(Gtk.Separator()) for rp in list(self.board.reference_points): box.append(_row( f"{rp.name} ({_location_status(rp)})", on_input=lambda rp=rp: self._open_coord_dialog( title=f"Set {rp.name} coordinates", on_submit=lambda loc, _id, _t, rp=rp: self._apply_and_refresh(rp, loc), initial_location=rp.location, ), on_screenshot=lambda rp=rp: self._run_ocr_from_clipboard( lambda info, rp=rp: self._set_rp_from_screenshot_info(info, rp) ), on_remove=lambda rp=rp: self._remove_rp(rp, rebuild), hidden=rp.hidden, on_toggle_hidden=lambda rp=rp: self._toggle_hidden(rp, rebuild), show_geo=rp.show_geo_desc, on_toggle_show_geo=lambda rp=rp: self._toggle_show_geo(rp, rebuild), )) box.append(Gtk.Separator()) box.append(_row( "Add RP", on_input=lambda: self._open_coord_dialog( title="Add reference point", on_submit=lambda loc, _id, _t: self._add_rp(loc), ), on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_reference_points), )) return box def _add_rp(self, location: Location) -> None: self.board.add_reference_point(location) self._refresh() def _remove_rp(self, rp, rebuild) -> None: self.board.remove_reference_point(rp) self._refresh() rebuild() # -- Targets ----------------------------------------------------------------- def _build_targets_popover(self, rebuild) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box.set_margin_top(6) box.set_margin_bottom(6) load_btn = Gtk.Button(label="Load all from screenshot") load_btn.set_margin_start(8) load_btn.set_margin_end(8) load_btn.set_margin_bottom(4) load_btn.connect("clicked", lambda _b: self._run_ocr_from_clipboard(self._merge_targets)) box.append(load_btn) box.append(Gtk.Separator()) for t in list(self.board.targets): box.append(_row( f"{t.name} ({_location_status(t)})", on_input=lambda t=t: self._open_coord_dialog( title=f"Set {t.name} coordinates", on_submit=lambda loc, _id, _t2, t=t: self._apply_and_refresh(t, loc), initial_location=t.location, ), on_screenshot=lambda t=t: self._run_ocr_from_clipboard( lambda info, t=t: self._set_target_from_screenshot_info(info, t) ), on_remove=lambda t=t: self._remove_target(t, rebuild), hidden=t.hidden, on_toggle_hidden=lambda t=t: self._toggle_hidden(t, rebuild), show_geo=t.show_geo_desc, on_toggle_show_geo=lambda t=t: self._toggle_show_geo(t, rebuild), alive=t.alive, on_toggle_alive=lambda t=t: self._toggle_alive(t, rebuild), )) box.append(Gtk.Separator()) box.append(_row( "Add target", on_input=lambda: self._open_coord_dialog( title="Add target", on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_), show_id=True, show_type=True, ), on_screenshot=lambda: self._run_ocr_from_clipboard(self._merge_targets), )) return box def _add_target(self, location: Location, id_, type_) -> None: self.board.add_target(type_ or TargetType.UNKNOWN, location, id_) self._refresh() def _add_strike(self) -> None: """Two-step add: pick a shell (for blast radius) first, then click the map to place it, a Strike is just a Target with TargetType.STRIKE and an explicit shell, and its position is set by clicking, not typing in coordinates.""" dialog = Adw.Dialog(title="Add Strike", content_width=340, content_height=200) toolbar_view = Adw.ToolbarView() dialog.set_child(toolbar_view) toolbar_view.add_top_bar(Adw.HeaderBar()) outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16, margin_top=16, margin_bottom=16, margin_start=16, margin_end=16) group = Adw.PreferencesGroup(title="Shell (blast radius)") shells = list(Shell) shell_row = Adw.ComboRow(title="Shell", model=Gtk.StringList.new([s.name for s in shells])) shell_row.set_selected(shells.index(Shell.HCHE)) group.add(shell_row) outer.append(group) next_btn = Gtk.Button(label="Next: click the map to place it") next_btn.add_css_class("suggested-action") next_btn.add_css_class("pill") next_btn.set_halign(Gtk.Align.CENTER) def on_next(_b): chosen_shell = shells[shell_row.get_selected()] dialog.close() self.canvas.start_placement( lambda coord: self._add_strike_at(coord, chosen_shell), preview_radius_km=chosen_shell.blast_radius_km, ) self.toast(f"Click the map to place the strike ({chosen_shell.name}), Esc to cancel.") next_btn.connect("clicked", on_next) outer.append(next_btn) toolbar_view.set_content(outer) dialog.present(self) def _add_strike_at(self, coord, shell: Shell) -> None: target = self.board.add_target(TargetType.STRIKE, coord) target.shell = shell self.board.reorder_target(target, 0) # new strikes go to the front of the list self._refresh() def _start_target_placement(self, target) -> None: """Firing card's "set position on map" button: click-to-place/ reposition, previewing the target's blast radius if its shell has a known one (mirrors the Add Strike flow, generalized to any target).""" self.canvas.start_placement( lambda coord: self._apply_and_refresh(target, Location.from_coord(coord)), preview_radius_km=target.effective_shell.blast_radius_km, ) 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.""" popover = Gtk.Popover() popover.set_parent(self.canvas) popover.set_pointing_to(Gdk.Rectangle(x=int(x), y=int(y), width=1, height=1)) popover.connect("closed", lambda _p: popover.unparent()) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) def add_target(_b): self.board.add_target(TargetType.UNKNOWN, coord) self._refresh() popover.popdown() def add_strike(_b): target = self.board.add_target(TargetType.STRIKE, coord) self.board.reorder_target(target, 0) # new strikes go to the front of the list self._refresh() popover.popdown() target_btn = Gtk.Button(label=f"Add target at {coord.label()}") target_btn.add_css_class("flat") target_btn.connect("clicked", add_target) box.append(target_btn) strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}") strike_btn.add_css_class("flat") strike_btn.connect("clicked", add_strike) box.append(strike_btn) popover.set_child(box) popover.popup() def _remove_target(self, target, rebuild) -> None: self.board.remove_target(target) self._refresh() rebuild() def _remove_target_via_panel(self, target) -> None: """Same as _remove_target, but for the firing panel's own delete button (Strikes), no popover `rebuild` callback to call there.""" self.board.remove_target(target) self._refresh() def _toggle_alive(self, target, rebuild) -> None: target.alive = not target.alive self._refresh() rebuild() # -- shared ------------------------------------------------------------- def _apply_and_refresh(self, obj, location: Location) -> None: _apply_location(obj, location) self._refresh() def _edit_target_position(self, target) -> None: """Firing card's "Edit pos" button: manual coord overrides whatever the solver had (definitive or ambiguous), once set it's sticky, _merge_parsed_location won't touch it on a later re-screenshot. Also lets you change id/type here, prefilled with the current values, blank id means "leave it as-is", not "auto-assign new".""" self._open_coord_dialog( title=f"Edit {target.name}", on_submit=lambda loc, id_, type_: self._apply_target_edit(target, loc, id_, type_), initial_location=target.location, show_id=True, show_type=True, id_placeholder=f"ID (current: {target.id})", initial_id=target.id, initial_type=target.type, ) def _apply_target_edit(self, target, location: Location, id_, type_) -> None: if id_: target.id = id_ if type_ is not None: target.type = type_ self._apply_and_refresh(target, location) def _toggle_hidden(self, obj, rebuild) -> None: obj.hidden = not obj.hidden self._refresh() rebuild() def _toggle_show_geo(self, obj, rebuild) -> None: obj.show_geo_desc = not obj.show_geo_desc self._refresh() rebuild() class FeNigmaApp(Adw.Application): def __init__(self) -> None: super().__init__(application_id=APP_ID) def do_activate(self) -> None: win = self.props.active_window if win is None: win = MainWindow(self) win.present() def main() -> int: app = FeNigmaApp() return app.run(None) if __name__ == "__main__": raise SystemExit(main())