FeNigma/src/fenigma/app.py
Dominik Roth ba6b07a476 Sweep em-dashes out of code, docstrings, and requirements.txt
Same style fix applied to the README earlier, extended everywhere:
replaced " -- " with commas/colons/periods (picking whichever reads
right per occurrence, splitting into two sentences where the clauses
were independent), fixed a few user-facing strings along the way
(entity list rows, placement/strike toasts, ambiguous-candidate tag,
shell picker button label). Left three intentional non-prose uses
alone: the "unassigned" dash glyph in firing_panel.py (and its
docstring diagram), and ocr.py's dash-variant regex character class,
which needs to literally match em/en-dashes in OCR'd text.

Also caught and fixed a stale models.py docstring claiming "no solver
yet" (solver.py has existed for a while) while touching that
paragraph anyway, and a formatting artifact in coord_dialog.py's
docstring left by the sed pass (misaligned comma from a since-removed
alignment gap).

Verified: py_compile across all files, the 9-screenshot OCR
regression sweep, and a GTK smoke test exercising the edited
toast/placement code paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 20:27:53 +02:00

882 lines
36 KiB
Python

"""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"<s>{GLib.markup_escape_text(name)}</s>")
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("<Control>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())