"""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, icons, ocr, solver # noqa: E402 from .coord_dialog import CoordDialog # noqa: E402 from .firing_panel import FiringPanel # noqa: E402 from .grid_widget import COLS, ROWS, 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_remove=None, hidden=False, on_toggle_hidden=None, show_geo=False, on_toggle_show_geo=None, alive=None, on_toggle_alive=None, on_convert=None, ) -> Gtk.Widget: """One list entry: a label plus input/geo/hide[/alive][/convert] [/remove] action buttons. There used to also be a per-item 'set from screenshot' button here, dropped: the universal clipboard button in the header already re-parses a fresh screenshot/paste and merges it into whatever it recognizes (including this exact item by name), a second, item-specific way to trigger that same merge was redundant.""" 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) 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_convert is not None: convert_btn = Gtk.Button( icon_name="object-flip-horizontal-symbolic", tooltip_text="Wrong bucket? Convert to a Target/Reference Point", ) convert_btn.add_css_class("flat") convert_btn.connect("clicked", lambda _b: on_convert()) box.append(convert_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 _add_row(label: str, on_click, close) -> Gtk.Widget: """The trailing 'Add ' row in a popover: just one button, not the full _row() layout (which always pairs a manual-input action with a per-item screenshot action). There's nothing to screenshot *into* yet for something that doesn't exist, and the universal clipboard button in the header already covers 'load everything from a screenshot' for every category, a second, category-specific version of that here was redundant. Closes the popover before calling on_click(): every on_click here either opens its own modal dialog or arms map-click placement, and the popover staying open on top of that just holds onto focus the dialog/map should have instead.""" box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, margin_top=4, margin_bottom=4, margin_start=8, margin_end=8) btn = Gtk.Button(label=label, hexpand=True) btn.connect("clicked", lambda _b: (close(), on_click())) box.append(btn) return box def _scout_flight_row(sf, *, on_replot, on_remove, on_toggle_hidden) -> Gtk.Widget: """One scout-flight list entry: unlike _row(), there's no coordinate input/screenshot/geo-overlay, just where it's plotted, a hide toggle, and remove.""" 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) start_coord = solver.point_to_coord(sf.center) grid_name = f"{start_coord.X}{start_coord.Y}" if start_coord is not None else "?" label = Gtk.Label( xalign=0, hexpand=True, label=f"{sf.name} ({grid_name}, bearing {sf.bearing_deg:05.1f}°)", ) if sf.hidden: label.add_css_class("dim-label") box.append(label) replot_btn = Gtk.Button(icon_name="find-location-symbolic", tooltip_text="Replot on map") replot_btn.connect("clicked", lambda _b: on_replot()) box.append(replot_btn) hide_btn = Gtk.Button( icon_name="view-reveal-symbolic" if sf.hidden else "view-conceal-symbolic", tooltip_text="Show on map" if sf.hidden else "Hide from map", ) hide_btn.add_css_class("flat") hide_btn.connect("clicked", lambda _b: on_toggle_hidden()) box.append(hide_btn) 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: suffix = " approximate" if obj.location.note else "" return f"{obj.coord.label()}{suffix}" 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 = """ /* border is reserved at the same 2px on every card, always, selection only changes its color (transparent -> accent). Without a border here too, an unselected card has no border at all, and picking one up shifts everything inside it inward by however wide the border is, which reads as the icons/labels visibly jumping on selection. */ .firing-card { border: 2px solid transparent; transition: background-color 150ms ease, border-color 150ms ease; } .firing-card-hovered { background-color: alpha(@accent_color, 0.12); } .firing-card-selected { border-color: @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._clipboard_watch_handler = None 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) clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic") clear_btn.set_tooltip_text("Clear board (drop everything)") 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._watch_btn = Gtk.ToggleButton(icon_name="media-playback-start-symbolic") self._watch_btn.set_tooltip_text( "Auto-watch clipboard: apply new screenshots or pasted text as soon as they're copied" ) self._watch_btn.connect("toggled", self._on_toggle_clipboard_watch) header.pack_start(self._watch_btn) square_cells_btn = Gtk.ToggleButton(icon_name="view-grid-symbolic") square_cells_btn.set_tooltip_text("Force square grid cells (letterbox instead of stretch)") square_cells_btn.connect("toggled", lambda b: self.canvas.set_square_cells(b.get_active())) header.pack_start(square_cells_btn) header.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) for label, popover_builder in ( ("Units", self._build_units_popover), ("Spotters", self._build_spotters_popover), ("Reference Points", self._build_rp_popover), ("Scout Flights", self._build_scout_flights_popover), ("Targets", self._build_targets_popover), ): header.pack_start(self._make_menu_button(label, popover_builder)) header.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) 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) scout_btn = Gtk.Button(icon_name="airplane-mode-symbolic") scout_btn.set_tooltip_text("Plan scout flight") scout_btn.connect("clicked", lambda _b: self._add_scout_flight()) header.pack_start(scout_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") # Monospace so the constantly-changing digits (azimuth/distance/ # coord under the cursor) don't jitter the label's width on every # mouse-move redraw the way a proportional font would. self.cursor_label.add_css_class("monospace") 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) self.connect("destroy", self._on_window_destroy) # -- generic helpers ----------------------------------------------------- def _make_menu_button(self, label: str, build_popover) -> Gtk.MenuButton: """build_popover(rebuild, close) 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. close() closes the popover outright, for an action that opens its own modal dialog or arms map-click placement, where the popover staying open on top just holds onto focus that dialog/the map should have instead (see _add_row's use of it).""" 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.popdown)) 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 and call on_parsed(ParsedInfo). Prefers plain/rich text (pasted intel, no screenshot needed) when the clipboard has any; falls back to reading an image and running it through OCR otherwise.""" clipboard = Gdk.Display.get_default().get_clipboard() formats = clipboard.get_formats() if formats is not None and formats.contain_gtype(str): clipboard.read_text_async(None, lambda cb, res: self._on_clipboard_text_ready(res, on_parsed)) return clipboard.read_texture_async(None, lambda cb, res: self._on_ocr_texture_ready(res, on_parsed)) def _on_clipboard_text_ready(self, result: Gio.AsyncResult, on_parsed) -> None: clipboard = Gdk.Display.get_default().get_clipboard() try: text = clipboard.read_text_finish(result) except GLib.Error as exc: self.toast(f"Clipboard read failed ({exc.message}).") return if not text or not text.strip(): self.toast("Clipboard text is empty. Copy some intel text or a screenshot first.") return try: info = ocr.parse_text(text) except Exception as exc: # parsing hiccups shouldn't crash the app self.toast(f"Parsing failed: {exc}") return on_parsed(info) 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 _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 automatically, no manual fetch needed between screenshots.""" clipboard = Gdk.Display.get_default().get_clipboard() if btn.get_active(): self._clipboard_watch_handler = clipboard.connect("changed", self._on_clipboard_changed) btn.set_icon_name("media-playback-stop-symbolic") btn.set_tooltip_text("Auto-watch clipboard: on, click to stop") self.toast("Watching clipboard, new screenshots apply automatically.") else: if self._clipboard_watch_handler is not None: clipboard.disconnect(self._clipboard_watch_handler) self._clipboard_watch_handler = None btn.set_icon_name("media-playback-start-symbolic") btn.set_tooltip_text( "Auto-watch clipboard: apply new screenshots or pasted text as soon as they're copied" ) def _on_clipboard_changed(self, clipboard: Gdk.Clipboard) -> None: formats = clipboard.get_formats() if formats is None or not (formats.contain_gtype(Gdk.Texture) or formats.contain_gtype(str)): return # neither an image nor text, ignore quietly self._run_ocr_from_clipboard(self._merge_all) def _on_window_destroy(self, *_a) -> None: if self._clipboard_watch_handler is not None: Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler) self._clipboard_watch_handler = None def _clear_board(self) -> None: board = self.board if (board.nest.coord is None and not board.spotters and not board.reference_points and not board.targets and not board.scout_flights): return # nothing to clear dialog = Adw.AlertDialog( heading="Clear board?", body="Drops the Nest position and every spotter, reference point, target, and scout flight. " "This can't be undone.", ) dialog.add_response("cancel", "Cancel") dialog.add_response("clear", "Clear") dialog.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_default_response("cancel") dialog.set_close_response("cancel") dialog.connect("response", self._on_clear_board_response) dialog.present(self) def _on_clear_board_response(self, _dialog, response: str) -> None: if response != "clear": return self.board.clear() self._set_selection(None) self._refresh() self.toast("Board cleared.") 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)) changed.extend(self._merge_allies(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, shell, requested_time) 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 ) # A fire-support request's shell/deadline are current-state # facts from THIS report, not provenance to preserve like # desc_raw/clues, a later re-read should just overwrite them. if shell is not None: existing.shell = shell if requested_time is not None: existing.requested_time = requested_time 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 _merge_allies(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]: changed = [] for (ally_type, ally_id), (raw, clues, coord) in info.allies.items(): existing = next( (a for a in self.board.allies if a.type == ally_type and a.id == ally_id), None ) if existing is not None: self._merge_parsed_location(existing, raw, clues, coord) else: existing = self.board.add_ally( ally_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=ally_id ) changed.append(existing.name) if toast: self.toast("No allies found in screenshot." if not changed else "Loaded from screenshot: " + ", ".join(changed)) self._refresh() return changed 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 col, row = point_km # A strict bounds check, not solver.point_to_coord()'s own: that # one deliberately tolerates up to 0.5km past an edge (rounding # slop from noisy OCR'd coordinates), which is the right call # for parsing text but not for 'is the mouse actually over the # map', a cursor genuinely off the drawn grid still resolved to # a real-looking coord within that slop margin. if not (0 <= col <= COLS and 0 <= row <= ROWS): self.cursor_label.set_label("off map") return coord = solver.point_to_coord(point_km) if coord is None: self.cursor_label.set_label("off map") return 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.label()}") else: self.cursor_label.set_label(coord.label()) # -- Spotters -------------------------------------------------------------- def _build_spotters_popover(self, rebuild, close) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box.set_margin_top(6) box.set_margin_bottom(6) 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_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(_add_row("Add spotter", 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()})", ), close)) 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, close) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box.set_margin_top(6) box.set_margin_bottom(6) 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_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), on_convert=lambda rp=rp: self._convert_rp_to_target(rp, rebuild), )) box.append(Gtk.Separator()) box.append(_add_row("Add RP", lambda: self._open_coord_dialog( title="Add reference point", on_submit=lambda loc, _id, _t: self._add_rp(loc), ), close)) 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() def _convert_rp_to_target(self, rp, rebuild) -> None: """OCR guesses which bucket a named thing belongs in (a fixed landmark vs. an actual contact), sometimes it guesses wrong, this fixes it without losing the position/clues already worked out, rather than deleting and re-typing it from scratch.""" if self.canvas.selected is rp: self._set_selection(None) self.board.remove_reference_point(rp) new_target = self.board.add_target(TargetType.UNKNOWN, rp.location, id_=rp.rp_name) new_target.hidden = rp.hidden new_target.show_geo_desc = rp.show_geo_desc self._refresh() rebuild() self.toast(f"{rp.name} converted to {new_target.name}.") # -- Targets ----------------------------------------------------------------- def _build_targets_popover(self, rebuild, close) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box.set_margin_top(6) box.set_margin_bottom(6) 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_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), on_convert=lambda t=t: self._convert_target_to_rp(t, rebuild), )) box.append(Gtk.Separator()) box.append(_add_row("Add target", 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, ), close)) return box def _add_target(self, location: Location, id_, type_) -> None: self.board.add_target(type_ or TargetType.UNKNOWN, location, id_) self._refresh() def _convert_target_to_rp(self, target, rebuild) -> None: """The other direction of _convert_rp_to_target: an actual contact that was actually a fixed landmark. Reuses the target's own name as the new RP's name, so it stays recognizable.""" if self.canvas.selected is target: self._set_selection(None) self.board.remove_target(target) new_rp = self.board.add_reference_point(target.location, name=target.name) new_rp.hidden = target.hidden new_rp.show_geo_desc = target.show_geo_desc self._refresh() rebuild() self.toast(f"{target.name} converted to {new_rp.name}.") # -- Units (Nest + Allies) ------------------------------------------------- def _build_units_popover(self, rebuild, close) -> Gtk.Widget: """Your own side of the map: the Nest (always first, there's only ever one) plus every friendly contact ('FriendlyTank#1:', tracked entirely separately from Targets, see models.py's Ally/Board.allies). Allies are never fired on, so no shell/ charge/assignment/alive controls here, just position/ visibility, same as a Reference Point.""" box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) 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, ), 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), )) box.append(Gtk.Separator()) for a in list(self.board.allies): box.append(_row( f"{a.name} ({_location_status(a)})", on_input=lambda a=a: self._open_coord_dialog( title=f"Set {a.name} coordinates", on_submit=lambda loc, _id, _t, a=a: self._apply_and_refresh(a, loc), initial_location=a.location, ), on_remove=lambda a=a: self._remove_ally(a, rebuild), hidden=a.hidden, on_toggle_hidden=lambda a=a: self._toggle_hidden(a, rebuild), show_geo=a.show_geo_desc, on_toggle_show_geo=lambda a=a: self._toggle_show_geo(a, rebuild), )) box.append(Gtk.Separator()) box.append(_add_row("Add ally", lambda: self._open_coord_dialog( title="Add ally", on_submit=lambda loc, id_, type_: self._add_ally(loc, id_, type_), show_id=True, show_type=True, ), close)) return box def _add_ally(self, location: Location, id_, type_) -> None: self.board.add_ally(type_ or TargetType.UNKNOWN, location, id_) self._refresh() def _remove_ally(self, ally, rebuild) -> None: self.board.remove_ally(ally) self._refresh() rebuild() # -- Scout Flights ------------------------------------------------------------ def _build_scout_flights_popover(self, rebuild, close) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) box.set_margin_top(6) box.set_margin_bottom(6) for sf in list(self.board.scout_flights): box.append(_scout_flight_row( sf, on_replot=lambda sf=sf: self._replot_scout_flight(sf), on_remove=lambda sf=sf: self._remove_scout_flight(sf, rebuild), on_toggle_hidden=lambda sf=sf: self._toggle_hidden(sf, rebuild), )) box.append(Gtk.Separator()) box.append(_add_row("Add scout flight", self._add_scout_flight, close)) return box def _add_scout_flight(self) -> None: """Click-to-place, like Add Strike: the anchor snaps to the center of whatever large grid square the cursor is in, the bearing is read off where in that square the cursor actually sits (see GridCanvas._scout_flight_anchor).""" self.canvas.start_scout_flight_placement(self._commit_scout_flight) self.toast("Click the map to plot the scout flight, Esc to cancel.") def _commit_scout_flight(self, center_km, bearing_deg) -> None: self.board.add_scout_flight(center_km, bearing_deg) self._refresh() def _replot_scout_flight(self, sf) -> None: self.canvas.start_scout_flight_placement( lambda center, bearing, sf=sf: self._apply_scout_flight_replot(sf, center, bearing) ) self.toast(f"Click the map to replot {sf.name}, Esc to cancel.") def _apply_scout_flight_replot(self, sf, center_km, bearing_deg) -> None: sf.center = center_km sf.bearing_deg = bearing_deg self._refresh() def _remove_scout_flight(self, sf, rebuild) -> None: self.board.remove_scout_flight(sf) self._refresh() rebuild() 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=400, content_height=440) 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) label = Gtk.Label(label="Shell (blast radius)", xalign=0) label.add_css_class("heading") outer.append(label) # Every option shown up front rather than behind a submenu # click, there's room for it here and picking one is the whole # point of this dialog, not an aside. chosen = [Shell.HCHE] outer.append(icons.build_shell_grid(chosen[0], lambda s: chosen.__setitem__(0, s))) 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 = chosen[0] 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) # NOT Gdk.Rectangle(x=..., y=..., ...): verified directly that this # PyGObject version silently ignores every constructor keyword arg on # boxed types like GdkRectangle (a real bug, not a style preference, # it built a zeroed rect every time), which is exactly why this popover # always opened pinned to the canvas's top-left corner instead of the # actual click position. Assigning the fields after construction works. rect = Gdk.Rectangle() 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()) 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() # A manual clue edit (typically the Description tab) can go in and # come out unresolved, or resolved only approximately, with no # other feedback, tell the user which instead of leaving it # looking like nothing happened / a clean fix. if obj.location.note: self.toast(f"{obj.name}: {obj.location.note}.") return reason = solver.explain_unresolved(obj.location, self.board) if reason is not None: self.toast(f"{obj.name} not resolved: {reason}.") 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())