"""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) -> 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."""
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: 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 (
("Nest", self._build_nest_popover),
("Spotters", self._build_spotters_popover),
("Reference Points", self._build_rp_popover),
("Targets", self._build_targets_popover),
("Scout Flights", self._build_scout_flights_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)
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) 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 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))
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 _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())
# -- 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,
),
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)
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()})",
)))
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)
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),
)))
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) -> 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,
)))
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}.")
# -- Scout Flights ------------------------------------------------------------
def _build_scout_flights_popover(self, rebuild) -> 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))
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)
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()
# 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())