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>
882 lines
36 KiB
Python
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())
|