Gdk.Rectangle(x=..., y=..., width=..., height=...) silently ignores every constructor keyword argument in this PyGObject version (verified directly: it always built a zeroed rect regardless of what was passed in), so popover.set_pointing_to() was always pointing at the canvas's top-left corner. Fixed by constructing the rect and assigning its fields afterward, which does work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1169 lines
49 KiB
Python
1169 lines
49 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, 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"<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)
|
|
|
|
if on_toggle_show_geo is not None:
|
|
geo_btn = Gtk.Button(
|
|
icon_name="starred-symbolic" if show_geo else "non-starred-symbolic",
|
|
tooltip_text="Always show bearing/distance overlay" if not show_geo
|
|
else "Only show overlay on hover",
|
|
)
|
|
geo_btn.add_css_class("flat")
|
|
geo_btn.connect("clicked", lambda _b: on_toggle_show_geo())
|
|
box.append(geo_btn)
|
|
|
|
if on_toggle_alive is not None:
|
|
alive_btn = Gtk.Button(
|
|
icon_name="object-select-symbolic" if alive else "action-unavailable-symbolic",
|
|
tooltip_text="Mark destroyed" if alive else "Mark alive",
|
|
)
|
|
alive_btn.add_css_class("flat")
|
|
alive_btn.connect("clicked", lambda _b: on_toggle_alive())
|
|
box.append(alive_btn)
|
|
|
|
if on_toggle_hidden is not None:
|
|
hide_btn = Gtk.Button(
|
|
icon_name="view-reveal-symbolic" if hidden else "view-conceal-symbolic",
|
|
tooltip_text="Show on map" if hidden else "Hide from map",
|
|
)
|
|
hide_btn.add_css_class("flat")
|
|
hide_btn.connect("clicked", lambda _b: on_toggle_hidden())
|
|
box.append(hide_btn)
|
|
|
|
if on_convert is not None:
|
|
convert_btn = Gtk.Button(
|
|
icon_name="object-flip-horizontal-symbolic",
|
|
tooltip_text="Wrong bucket? Convert to a Target/Reference Point",
|
|
)
|
|
convert_btn.add_css_class("flat")
|
|
convert_btn.connect("clicked", lambda _b: on_convert())
|
|
box.append(convert_btn)
|
|
|
|
if on_remove is not None:
|
|
rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove")
|
|
rm_btn.add_css_class("flat")
|
|
rm_btn.connect("clicked", lambda _b: on_remove())
|
|
box.append(rm_btn)
|
|
|
|
return box
|
|
|
|
|
|
def _add_row(label: str, on_click, close) -> Gtk.Widget:
|
|
"""The trailing 'Add <thing>' row in a popover: just one button, not
|
|
the full _row() layout (which always pairs a manual-input action
|
|
with a per-item screenshot action). There's nothing to screenshot
|
|
*into* yet for something that doesn't exist, and the universal
|
|
clipboard button in the header already covers 'load everything from
|
|
a screenshot' for every category, a second, category-specific
|
|
version of that here was redundant.
|
|
|
|
Closes the popover before calling on_click(): every on_click here
|
|
either opens its own modal dialog or arms map-click placement, and
|
|
the popover staying open on top of that just holds onto focus the
|
|
dialog/map should have instead."""
|
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6,
|
|
margin_top=4, margin_bottom=4, margin_start=8, margin_end=8)
|
|
btn = Gtk.Button(label=label, hexpand=True)
|
|
btn.connect("clicked", lambda _b: (close(), on_click()))
|
|
box.append(btn)
|
|
return box
|
|
|
|
|
|
def _scout_flight_row(sf, *, on_replot, on_remove, on_toggle_hidden) -> Gtk.Widget:
|
|
"""One scout-flight list entry: unlike _row(), there's no coordinate
|
|
input/screenshot/geo-overlay, just where it's plotted, a hide toggle,
|
|
and remove."""
|
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
box.set_margin_top(4)
|
|
box.set_margin_bottom(4)
|
|
box.set_margin_start(8)
|
|
box.set_margin_end(8)
|
|
|
|
start_coord = solver.point_to_coord(sf.center)
|
|
grid_name = f"{start_coord.X}{start_coord.Y}" if start_coord is not None else "?"
|
|
label = Gtk.Label(
|
|
xalign=0, hexpand=True,
|
|
label=f"{sf.name} ({grid_name}, bearing {sf.bearing_deg:05.1f}°)",
|
|
)
|
|
if sf.hidden:
|
|
label.add_css_class("dim-label")
|
|
box.append(label)
|
|
|
|
replot_btn = Gtk.Button(icon_name="find-location-symbolic", tooltip_text="Replot on map")
|
|
replot_btn.connect("clicked", lambda _b: on_replot())
|
|
box.append(replot_btn)
|
|
|
|
hide_btn = Gtk.Button(
|
|
icon_name="view-reveal-symbolic" if sf.hidden else "view-conceal-symbolic",
|
|
tooltip_text="Show on map" if sf.hidden else "Hide from map",
|
|
)
|
|
hide_btn.add_css_class("flat")
|
|
hide_btn.connect("clicked", lambda _b: on_toggle_hidden())
|
|
box.append(hide_btn)
|
|
|
|
rm_btn = Gtk.Button(icon_name="user-trash-symbolic", tooltip_text="Remove")
|
|
rm_btn.add_css_class("flat")
|
|
rm_btn.connect("clicked", lambda _b: on_remove())
|
|
box.append(rm_btn)
|
|
|
|
return box
|
|
|
|
|
|
def _location_status(obj) -> str:
|
|
if obj.coord is not None:
|
|
suffix = " approximate" if obj.location.note else ""
|
|
return f"{obj.coord.label()}{suffix}"
|
|
if obj.location.potential_coords:
|
|
return f"ambiguous ({len(obj.location.potential_coords)} candidates)"
|
|
if obj.location.desc_raw:
|
|
return "from description"
|
|
return "not set"
|
|
|
|
|
|
_FIRING_CARD_CSS = """
|
|
/* border is reserved at the same 2px on every card, always, selection
|
|
only changes its color (transparent -> accent). Without a border
|
|
here too, an unselected card has no border at all, and picking one
|
|
up shifts everything inside it inward by however wide the border is,
|
|
which reads as the icons/labels visibly jumping on selection. */
|
|
.firing-card { border: 2px solid transparent; transition: background-color 150ms ease, border-color 150ms ease; }
|
|
.firing-card-hovered { background-color: alpha(@accent_color, 0.12); }
|
|
.firing-card-selected { border-color: @accent_color; }
|
|
"""
|
|
|
|
|
|
def _install_css() -> None:
|
|
provider = Gtk.CssProvider()
|
|
provider.load_from_string(_FIRING_CARD_CSS)
|
|
Gtk.StyleContext.add_provider_for_display(
|
|
Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|
)
|
|
|
|
|
|
class MainWindow(Adw.ApplicationWindow):
|
|
def __init__(self, app: Adw.Application) -> None:
|
|
super().__init__(application=app, title="FeNigma")
|
|
self.set_default_size(1100, 750)
|
|
_install_css()
|
|
|
|
self.board = Board()
|
|
self._clipboard_watch_handler = None
|
|
|
|
self.toast_overlay = Adw.ToastOverlay()
|
|
self.set_content(self.toast_overlay)
|
|
|
|
toolbar_view = Adw.ToolbarView()
|
|
self.toast_overlay.set_child(toolbar_view)
|
|
|
|
header = Adw.HeaderBar()
|
|
header.set_title_widget(Gtk.Box()) # drop the window-title label, header's crowded
|
|
toolbar_view.add_top_bar(header)
|
|
|
|
save_btn = Gtk.Button(icon_name="document-save-symbolic")
|
|
save_btn.set_tooltip_text("Save board to file")
|
|
save_btn.connect("clicked", lambda _b: self._save_to_file())
|
|
header.pack_start(save_btn)
|
|
|
|
load_btn = Gtk.Button(icon_name="document-open-symbolic")
|
|
load_btn.set_tooltip_text("Load board from file")
|
|
load_btn.connect("clicked", lambda _b: self._load_from_file())
|
|
header.pack_start(load_btn)
|
|
|
|
clear_btn = Gtk.Button(icon_name="edit-clear-all-symbolic")
|
|
clear_btn.set_tooltip_text("Clear board (drop everything)")
|
|
clear_btn.connect("clicked", lambda _b: self._clear_board())
|
|
header.pack_start(clear_btn)
|
|
|
|
clip_btn = Gtk.Button(icon_name="edit-paste-symbolic")
|
|
clip_btn.set_tooltip_text("Fetch screenshot or text from clipboard (Ctrl+P)")
|
|
clip_btn.connect("clicked", lambda _b: self._fetch_clipboard())
|
|
header.pack_start(clip_btn)
|
|
|
|
self._watch_btn = Gtk.ToggleButton(icon_name="media-playback-start-symbolic")
|
|
self._watch_btn.set_tooltip_text(
|
|
"Auto-watch clipboard: apply new screenshots or pasted text as soon as they're copied"
|
|
)
|
|
self._watch_btn.connect("toggled", self._on_toggle_clipboard_watch)
|
|
header.pack_start(self._watch_btn)
|
|
|
|
square_cells_btn = Gtk.ToggleButton(icon_name="view-grid-symbolic")
|
|
square_cells_btn.set_tooltip_text("Force square grid cells (letterbox instead of stretch)")
|
|
square_cells_btn.connect("toggled", lambda b: self.canvas.set_square_cells(b.get_active()))
|
|
header.pack_start(square_cells_btn)
|
|
|
|
header.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL))
|
|
|
|
for label, popover_builder in (
|
|
("Units", self._build_units_popover),
|
|
("Spotters", self._build_spotters_popover),
|
|
("Reference Points", self._build_rp_popover),
|
|
("Scout Flights", self._build_scout_flights_popover),
|
|
("Targets", self._build_targets_popover),
|
|
):
|
|
header.pack_start(self._make_menu_button(label, popover_builder))
|
|
|
|
header.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL))
|
|
|
|
strike_btn = Gtk.Button(icon_name="find-location-symbolic")
|
|
strike_btn.set_tooltip_text("Add strike")
|
|
strike_btn.connect("clicked", lambda _b: self._add_strike())
|
|
header.pack_start(strike_btn)
|
|
|
|
scout_btn = Gtk.Button(icon_name="airplane-mode-symbolic")
|
|
scout_btn.set_tooltip_text("Plan scout flight")
|
|
scout_btn.connect("clicked", lambda _b: self._add_scout_flight())
|
|
header.pack_start(scout_btn)
|
|
|
|
firing_btn = Gtk.Button(icon_name="sidebar-show-right-symbolic")
|
|
firing_btn.set_tooltip_text("Firing commands")
|
|
firing_btn.connect("clicked", lambda _b: self._toggle_firing_panel())
|
|
header.pack_end(firing_btn)
|
|
|
|
self.cursor_label = Gtk.Label(xalign=1)
|
|
self.cursor_label.add_css_class("dim-label")
|
|
self.cursor_label.add_css_class("numeric")
|
|
# Monospace so the constantly-changing digits (azimuth/distance/
|
|
# coord under the cursor) don't jitter the label's width on every
|
|
# mouse-move redraw the way a proportional font would.
|
|
self.cursor_label.add_css_class("monospace")
|
|
header.pack_end(self.cursor_label) # packed after firing_btn -> sits to its left
|
|
|
|
self.canvas = GridCanvas(self.board)
|
|
self.firing_panel = FiringPanel(
|
|
self.board,
|
|
on_change=self._refresh,
|
|
on_select=self._set_selection,
|
|
on_edit_position=self._edit_target_position,
|
|
on_set_position=self._start_target_placement,
|
|
on_remove=self._remove_target_via_panel,
|
|
on_toggle_hide_dead_map=self._on_toggle_hide_dead_map,
|
|
)
|
|
self.canvas.on_select = self._set_selection
|
|
self.canvas.on_hover_change = self._on_map_hover_change
|
|
self.canvas.on_cursor_move = self._on_cursor_move
|
|
self.canvas.on_right_click = self._on_map_right_click
|
|
|
|
self.split_view = Adw.OverlaySplitView()
|
|
self.split_view.set_content(self.canvas)
|
|
self.split_view.set_sidebar(self.firing_panel)
|
|
self.split_view.set_sidebar_position(Gtk.PackType.END)
|
|
self.split_view.set_min_sidebar_width(280)
|
|
self.split_view.set_max_sidebar_width(360)
|
|
self.split_view.set_show_sidebar(False)
|
|
toolbar_view.set_content(self.split_view)
|
|
|
|
controller = Gtk.ShortcutController()
|
|
controller.add_shortcut(
|
|
Gtk.Shortcut.new(
|
|
Gtk.ShortcutTrigger.parse_string("<Control>p"),
|
|
Gtk.CallbackAction.new(lambda *_a: self._fetch_clipboard() or True),
|
|
)
|
|
)
|
|
self.add_controller(controller)
|
|
self.connect("destroy", self._on_window_destroy)
|
|
|
|
# -- generic helpers -----------------------------------------------------
|
|
def _make_menu_button(self, label: str, build_popover) -> Gtk.MenuButton:
|
|
"""build_popover(rebuild, close) returns the popover's content
|
|
widget; rebuild() lets a row's own callback refresh the popover
|
|
in place (e.g. after toggling hidden, or removing an item)
|
|
instead of only refreshing next time it's reopened. close()
|
|
closes the popover outright, for an action that opens its own
|
|
modal dialog or arms map-click placement, where the popover
|
|
staying open on top just holds onto focus that dialog/the map
|
|
should have instead (see _add_row's use of it)."""
|
|
button = Gtk.MenuButton(label=label)
|
|
popover = Gtk.Popover()
|
|
popover.set_size_request(280, -1)
|
|
button.set_popover(popover)
|
|
|
|
def rebuild():
|
|
popover.set_child(build_popover(rebuild, popover.popdown))
|
|
|
|
popover.connect("show", lambda _p: rebuild())
|
|
return button
|
|
|
|
def toast(self, message: str) -> None:
|
|
self.toast_overlay.add_toast(Adw.Toast(title=message, timeout=3))
|
|
|
|
# -- save / load -----------------------------------------------------------
|
|
def _save_to_file(self) -> None:
|
|
dialog = Gtk.FileDialog(initial_name="board.json")
|
|
dialog.save(self, None, self._on_save_finish)
|
|
|
|
def _on_save_finish(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
|
try:
|
|
gfile = dialog.save_finish(result)
|
|
except GLib.Error as exc:
|
|
if exc.matches(Gtk.dialog_error_quark(), Gtk.DialogError.DISMISSED):
|
|
return
|
|
self.toast(f"Save failed: {exc.message}")
|
|
return
|
|
|
|
data = json.dumps(self.board.to_dict(), indent=2)
|
|
try:
|
|
gfile.replace_contents(
|
|
data.encode("utf-8"), None, False, Gio.FileCreateFlags.NONE, None
|
|
)
|
|
except GLib.Error as exc:
|
|
self.toast(f"Save failed: {exc.message}")
|
|
return
|
|
self.toast(f"Saved to {gfile.get_path()}")
|
|
|
|
def _load_from_file(self) -> None:
|
|
dialog = Gtk.FileDialog()
|
|
dialog.open(self, None, self._on_load_finish)
|
|
|
|
def _on_load_finish(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
|
try:
|
|
gfile = dialog.open_finish(result)
|
|
except GLib.Error as exc:
|
|
if exc.matches(Gtk.dialog_error_quark(), Gtk.DialogError.DISMISSED):
|
|
return
|
|
self.toast(f"Load failed: {exc.message}")
|
|
return
|
|
|
|
try:
|
|
ok, contents, _etag = gfile.load_contents(None)
|
|
data = json.loads(contents.decode("utf-8"))
|
|
self.board.load_from_dict(data)
|
|
except (GLib.Error, json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
self.toast(f"Load failed: {exc}")
|
|
return
|
|
|
|
self._refresh()
|
|
self.toast(f"Loaded {gfile.get_path()}")
|
|
|
|
# -- OCR plumbing -----------------------------------------------------------
|
|
def _fetch_clipboard(self) -> None:
|
|
"""Header button / Ctrl+P: universal fetch, run OCR and merge everything found."""
|
|
self._run_ocr_from_clipboard(self._merge_all)
|
|
|
|
def _run_ocr_from_clipboard(self, on_parsed) -> None:
|
|
"""Read the clipboard and call on_parsed(ParsedInfo). Prefers plain/rich
|
|
text (pasted intel, no screenshot needed) when the clipboard has any;
|
|
falls back to reading an image and running it through OCR otherwise."""
|
|
clipboard = Gdk.Display.get_default().get_clipboard()
|
|
formats = clipboard.get_formats()
|
|
if formats is not None and formats.contain_gtype(str):
|
|
clipboard.read_text_async(None, lambda cb, res: self._on_clipboard_text_ready(res, on_parsed))
|
|
return
|
|
clipboard.read_texture_async(None, lambda cb, res: self._on_ocr_texture_ready(res, on_parsed))
|
|
|
|
def _on_clipboard_text_ready(self, result: Gio.AsyncResult, on_parsed) -> None:
|
|
clipboard = Gdk.Display.get_default().get_clipboard()
|
|
try:
|
|
text = clipboard.read_text_finish(result)
|
|
except GLib.Error as exc:
|
|
self.toast(f"Clipboard read failed ({exc.message}).")
|
|
return
|
|
if not text or not text.strip():
|
|
self.toast("Clipboard text is empty. Copy some intel text or a screenshot first.")
|
|
return
|
|
|
|
try:
|
|
info = ocr.parse_text(text)
|
|
except Exception as exc: # parsing hiccups shouldn't crash the app
|
|
self.toast(f"Parsing failed: {exc}")
|
|
return
|
|
|
|
on_parsed(info)
|
|
|
|
def _on_ocr_texture_ready(self, result: Gio.AsyncResult, on_parsed) -> None:
|
|
clipboard = Gdk.Display.get_default().get_clipboard()
|
|
try:
|
|
texture = clipboard.read_texture_finish(result)
|
|
except GLib.Error as exc:
|
|
self.toast(f"No image on clipboard ({exc.message}).")
|
|
return
|
|
if texture is None:
|
|
self.toast("Clipboard has no image. Copy a screenshot first.")
|
|
return
|
|
|
|
try:
|
|
pil_image = Image.open(io.BytesIO(texture.save_to_png_bytes().get_data()))
|
|
info = ocr.run(pil_image)
|
|
except Exception as exc: # OCR/parsing hiccups shouldn't crash the app
|
|
self.toast(f"OCR failed: {exc}")
|
|
return
|
|
|
|
on_parsed(info)
|
|
|
|
def _on_toggle_clipboard_watch(self, btn: Gtk.ToggleButton) -> None:
|
|
"""Auto-watch toggle: while on, every clipboard change that looks
|
|
like an image (not e.g. text copied elsewhere) is OCR'd and merged
|
|
automatically, no manual fetch needed between screenshots."""
|
|
clipboard = Gdk.Display.get_default().get_clipboard()
|
|
if btn.get_active():
|
|
self._clipboard_watch_handler = clipboard.connect("changed", self._on_clipboard_changed)
|
|
btn.set_icon_name("media-playback-stop-symbolic")
|
|
btn.set_tooltip_text("Auto-watch clipboard: on, click to stop")
|
|
self.toast("Watching clipboard, new screenshots apply automatically.")
|
|
else:
|
|
if self._clipboard_watch_handler is not None:
|
|
clipboard.disconnect(self._clipboard_watch_handler)
|
|
self._clipboard_watch_handler = None
|
|
btn.set_icon_name("media-playback-start-symbolic")
|
|
btn.set_tooltip_text(
|
|
"Auto-watch clipboard: apply new screenshots or pasted text as soon as they're copied"
|
|
)
|
|
|
|
def _on_clipboard_changed(self, clipboard: Gdk.Clipboard) -> None:
|
|
formats = clipboard.get_formats()
|
|
if formats is None or not (formats.contain_gtype(Gdk.Texture) or formats.contain_gtype(str)):
|
|
return # neither an image nor text, ignore quietly
|
|
self._run_ocr_from_clipboard(self._merge_all)
|
|
|
|
def _on_window_destroy(self, *_a) -> None:
|
|
if self._clipboard_watch_handler is not None:
|
|
Gdk.Display.get_default().get_clipboard().disconnect(self._clipboard_watch_handler)
|
|
self._clipboard_watch_handler = None
|
|
|
|
def _clear_board(self) -> None:
|
|
board = self.board
|
|
if (board.nest.coord is None and not board.spotters and not board.reference_points
|
|
and not board.targets and not board.scout_flights):
|
|
return # nothing to clear
|
|
dialog = Adw.AlertDialog(
|
|
heading="Clear board?",
|
|
body="Drops the Nest position and every spotter, reference point, target, and scout flight. "
|
|
"This can't be undone.",
|
|
)
|
|
dialog.add_response("cancel", "Cancel")
|
|
dialog.add_response("clear", "Clear")
|
|
dialog.set_response_appearance("clear", Adw.ResponseAppearance.DESTRUCTIVE)
|
|
dialog.set_default_response("cancel")
|
|
dialog.set_close_response("cancel")
|
|
dialog.connect("response", self._on_clear_board_response)
|
|
dialog.present(self)
|
|
|
|
def _on_clear_board_response(self, _dialog, response: str) -> None:
|
|
if response != "clear":
|
|
return
|
|
self.board.clear()
|
|
self._set_selection(None)
|
|
self._refresh()
|
|
self.toast("Board cleared.")
|
|
|
|
def _merge_all(self, info: "ocr.ParsedInfo") -> None:
|
|
changed = []
|
|
if info.nest_coord is not None:
|
|
self.board.nest.coord = info.nest_coord
|
|
changed.append("Nest")
|
|
changed.extend(self._merge_spotters(info, toast=False))
|
|
changed.extend(self._merge_reference_points(info, toast=False))
|
|
changed.extend(self._merge_targets(info, toast=False))
|
|
changed.extend(self._merge_allies(info, toast=False))
|
|
|
|
if not changed:
|
|
self.toast("No relevant info found in screenshot.")
|
|
else:
|
|
self.toast("Merged from screenshot: " + ", ".join(changed))
|
|
self._refresh()
|
|
|
|
def _merge_spotters(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
|
changed = []
|
|
for spotter_id, coord in info.spotters.items():
|
|
existing = next((s for s in self.board.spotters if s.id == spotter_id), None)
|
|
if existing is not None:
|
|
existing.coord = coord
|
|
else:
|
|
existing = self.board.add_spotter(coord, spotter_id)
|
|
changed.append(existing.name)
|
|
|
|
if toast:
|
|
self.toast("No spotters found in screenshot." if not changed
|
|
else "Loaded from screenshot: " + ", ".join(changed))
|
|
self._refresh()
|
|
return changed
|
|
|
|
def _merge_parsed_location(self, existing, raw: str, clues, coord) -> None:
|
|
"""Refresh desc/clues from a fresh OCR read, but never clobber a
|
|
coord the entity already has, from a prior resolve, an earlier
|
|
screenshot's Grid ref, or a manual Edit Pos override. Re-reading
|
|
the same intel later shouldn't undo that; only apply the new coord
|
|
if there wasn't one already."""
|
|
existing.location.desc_raw = raw
|
|
existing.location.clues = clues
|
|
if existing.coord is None:
|
|
existing.coord = coord
|
|
|
|
def _merge_reference_points(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
|
changed = []
|
|
for name, (raw, clues, coord) in info.reference_points.items():
|
|
existing = next((rp for rp in self.board.reference_points if rp.rp_name == name), None)
|
|
if existing is not None:
|
|
self._merge_parsed_location(existing, raw, clues, coord)
|
|
else:
|
|
existing = self.board.add_reference_point(
|
|
Location(coord=coord, desc_raw=raw, clues=clues), name=name
|
|
)
|
|
changed.append(existing.name)
|
|
|
|
if toast:
|
|
self.toast("No reference points found in screenshot." if not changed
|
|
else "Loaded from screenshot: " + ", ".join(changed))
|
|
self._refresh()
|
|
return changed
|
|
|
|
def _merge_targets(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
|
changed = []
|
|
for (target_type, target_id), (raw, clues, coord, shell, requested_time) in info.targets.items():
|
|
existing = next(
|
|
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None
|
|
)
|
|
if existing is not None:
|
|
self._merge_parsed_location(existing, raw, clues, coord)
|
|
else:
|
|
existing = self.board.add_target(
|
|
target_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=target_id
|
|
)
|
|
# A fire-support request's shell/deadline are current-state
|
|
# facts from THIS report, not provenance to preserve like
|
|
# desc_raw/clues, a later re-read should just overwrite them.
|
|
if shell is not None:
|
|
existing.shell = shell
|
|
if requested_time is not None:
|
|
existing.requested_time = requested_time
|
|
changed.append(existing.name)
|
|
|
|
# "SupplyCache#1 Destroyed." etc. Mark it dead if we already know
|
|
# it; if this destruction report is the *first* we've heard of it
|
|
# (never separately spotted with a position), still record it as a
|
|
# dead, position-unknown target rather than losing the report,
|
|
# better a target with no coord than no record it existed at all.
|
|
for target_type, target_id in info.destroyed:
|
|
existing = next(
|
|
(t for t in self.board.targets if t.type == target_type and t.id == target_id), None
|
|
)
|
|
is_new = existing is None
|
|
if existing is None:
|
|
existing = self.board.add_target(target_type, id_=target_id)
|
|
if existing.alive:
|
|
existing.alive = False
|
|
suffix = " (destroyed, position unknown)" if is_new else " (destroyed)"
|
|
changed.append(f"{existing.name}{suffix}")
|
|
|
|
if toast:
|
|
self.toast("No targets found in screenshot." if not changed
|
|
else "Loaded from screenshot: " + ", ".join(changed))
|
|
self._refresh()
|
|
return changed
|
|
|
|
def _merge_allies(self, info: "ocr.ParsedInfo", *, toast: bool = True) -> list[str]:
|
|
changed = []
|
|
for (ally_type, ally_id), (raw, clues, coord) in info.allies.items():
|
|
existing = next(
|
|
(a for a in self.board.allies if a.type == ally_type and a.id == ally_id), None
|
|
)
|
|
if existing is not None:
|
|
self._merge_parsed_location(existing, raw, clues, coord)
|
|
else:
|
|
existing = self.board.add_ally(
|
|
ally_type, Location(coord=coord, desc_raw=raw, clues=clues), id_=ally_id
|
|
)
|
|
changed.append(existing.name)
|
|
|
|
if toast:
|
|
self.toast("No allies found in screenshot." if not changed
|
|
else "Loaded from screenshot: " + ", ".join(changed))
|
|
self._refresh()
|
|
return changed
|
|
|
|
def _open_coord_dialog(
|
|
self, *, title, on_submit, show_id=False, show_type=False, id_placeholder=None,
|
|
initial_location=None, initial_id=None, initial_type=None,
|
|
):
|
|
dialog = CoordDialog(
|
|
title=title,
|
|
on_submit=on_submit,
|
|
show_id=show_id,
|
|
show_type=show_type,
|
|
id_placeholder=id_placeholder,
|
|
initial_location=initial_location,
|
|
initial_id=initial_id,
|
|
initial_type=initial_type,
|
|
)
|
|
dialog.present(self)
|
|
|
|
def _refresh(self) -> None:
|
|
"""Single choke point: re-run the solver, then redraw. Every
|
|
mutation goes through here so newly-unblocked descriptions get a
|
|
chance to resolve immediately."""
|
|
solver.resolve_board(self.board)
|
|
removed = solver.dedupe_generic_targets(self.board)
|
|
if removed and isinstance(self.canvas.selected, Target) and self.canvas.selected not in self.board.targets:
|
|
self._set_selection(None) # the selected generic target just got merged away
|
|
self.canvas.refresh()
|
|
self.firing_panel.refresh()
|
|
|
|
def _on_toggle_hide_dead_map(self, value: bool) -> None:
|
|
self.canvas.hide_dead_from_map = value
|
|
self._refresh()
|
|
|
|
def _toggle_firing_panel(self) -> None:
|
|
self.split_view.set_show_sidebar(not self.split_view.get_show_sidebar())
|
|
|
|
def _set_selection(self, obj, point=None) -> None:
|
|
"""Shared by both the map (click a marker) and the firing panel
|
|
(click a card) so clicking either keeps the other in sync. `point`
|
|
disambiguates which candidate of an ambiguous target was clicked."""
|
|
self.canvas.set_selected(obj, point)
|
|
self.firing_panel.set_selected(obj if isinstance(obj, Target) else None, point)
|
|
if isinstance(obj, Target) and not self.split_view.get_show_sidebar():
|
|
self.split_view.set_show_sidebar(True)
|
|
|
|
def _on_map_hover_change(self, obj, point=None) -> None:
|
|
self.firing_panel.set_hovered(obj if isinstance(obj, Target) else None, point)
|
|
|
|
def _on_cursor_move(self, point_km) -> None:
|
|
if point_km is None:
|
|
self.cursor_label.set_label("")
|
|
return
|
|
col, row = point_km
|
|
# A strict bounds check, not solver.point_to_coord()'s own: that
|
|
# one deliberately tolerates up to 0.5km past an edge (rounding
|
|
# slop from noisy OCR'd coordinates), which is the right call
|
|
# for parsing text but not for 'is the mouse actually over the
|
|
# map', a cursor genuinely off the drawn grid still resolved to
|
|
# a real-looking coord within that slop margin.
|
|
if not (0 <= col <= COLS and 0 <= row <= ROWS):
|
|
self.cursor_label.set_label("off map")
|
|
return
|
|
coord = solver.point_to_coord(point_km)
|
|
if coord is None:
|
|
self.cursor_label.set_label("off map")
|
|
return
|
|
nest = self.board.nest
|
|
if nest.coord is not None:
|
|
az = ballistics.bearing_deg_point(nest.coord.as_fraction(), point_km)
|
|
dist = ballistics.distance_km_point(nest.coord.as_fraction(), point_km)
|
|
self.cursor_label.set_label(f"AZ {az:05.1f}° {dist:5.2f}km {coord.label()}")
|
|
else:
|
|
self.cursor_label.set_label(coord.label())
|
|
|
|
# -- Spotters --------------------------------------------------------------
|
|
def _build_spotters_popover(self, rebuild, close) -> Gtk.Widget:
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
box.set_margin_top(6)
|
|
box.set_margin_bottom(6)
|
|
|
|
for sp in list(self.board.spotters):
|
|
box.append(_row(
|
|
f"{sp.name} ({_location_status(sp)})",
|
|
on_input=lambda sp=sp: self._open_coord_dialog(
|
|
title=f"Set {sp.name} coordinates",
|
|
on_submit=lambda loc, _id, _t, sp=sp: self._apply_and_refresh(sp, loc),
|
|
initial_location=sp.location,
|
|
),
|
|
on_remove=lambda sp=sp: self._remove_spotter(sp, rebuild),
|
|
hidden=sp.hidden,
|
|
on_toggle_hidden=lambda sp=sp: self._toggle_hidden(sp, rebuild),
|
|
show_geo=sp.show_geo_desc,
|
|
on_toggle_show_geo=lambda sp=sp: self._toggle_show_geo(sp, rebuild),
|
|
))
|
|
|
|
box.append(Gtk.Separator())
|
|
box.append(_add_row("Add spotter", lambda: self._open_coord_dialog(
|
|
title="Add spotter",
|
|
on_submit=lambda loc, id_, _t: self._add_spotter(loc, id_),
|
|
show_id=True,
|
|
id_placeholder=f"ID (blank = auto, next: {self.board.next_spotter_id()})",
|
|
), close))
|
|
return box
|
|
|
|
def _add_spotter(self, location: Location, id_text: str | None) -> None:
|
|
parsed_id = None
|
|
if id_text:
|
|
try:
|
|
parsed_id = int(id_text)
|
|
except ValueError:
|
|
self.toast(f"Spotter ID must be a whole number, got {id_text!r}.")
|
|
return
|
|
self.board.add_spotter(location, parsed_id)
|
|
self._refresh()
|
|
|
|
def _remove_spotter(self, sp, rebuild) -> None:
|
|
self.board.remove_spotter(sp)
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
# -- Reference Points --------------------------------------------------------
|
|
def _build_rp_popover(self, rebuild, close) -> Gtk.Widget:
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
box.set_margin_top(6)
|
|
box.set_margin_bottom(6)
|
|
|
|
for rp in list(self.board.reference_points):
|
|
box.append(_row(
|
|
f"{rp.name} ({_location_status(rp)})",
|
|
on_input=lambda rp=rp: self._open_coord_dialog(
|
|
title=f"Set {rp.name} coordinates",
|
|
on_submit=lambda loc, _id, _t, rp=rp: self._apply_and_refresh(rp, loc),
|
|
initial_location=rp.location,
|
|
),
|
|
on_remove=lambda rp=rp: self._remove_rp(rp, rebuild),
|
|
hidden=rp.hidden,
|
|
on_toggle_hidden=lambda rp=rp: self._toggle_hidden(rp, rebuild),
|
|
show_geo=rp.show_geo_desc,
|
|
on_toggle_show_geo=lambda rp=rp: self._toggle_show_geo(rp, rebuild),
|
|
on_convert=lambda rp=rp: self._convert_rp_to_target(rp, rebuild),
|
|
))
|
|
|
|
box.append(Gtk.Separator())
|
|
box.append(_add_row("Add RP", lambda: self._open_coord_dialog(
|
|
title="Add reference point",
|
|
on_submit=lambda loc, _id, _t: self._add_rp(loc),
|
|
), close))
|
|
return box
|
|
|
|
def _add_rp(self, location: Location) -> None:
|
|
self.board.add_reference_point(location)
|
|
self._refresh()
|
|
|
|
def _remove_rp(self, rp, rebuild) -> None:
|
|
self.board.remove_reference_point(rp)
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
def _convert_rp_to_target(self, rp, rebuild) -> None:
|
|
"""OCR guesses which bucket a named thing belongs in (a fixed
|
|
landmark vs. an actual contact), sometimes it guesses wrong,
|
|
this fixes it without losing the position/clues already worked
|
|
out, rather than deleting and re-typing it from scratch."""
|
|
if self.canvas.selected is rp:
|
|
self._set_selection(None)
|
|
self.board.remove_reference_point(rp)
|
|
new_target = self.board.add_target(TargetType.UNKNOWN, rp.location, id_=rp.rp_name)
|
|
new_target.hidden = rp.hidden
|
|
new_target.show_geo_desc = rp.show_geo_desc
|
|
self._refresh()
|
|
rebuild()
|
|
self.toast(f"{rp.name} converted to {new_target.name}.")
|
|
|
|
# -- Targets -----------------------------------------------------------------
|
|
def _build_targets_popover(self, rebuild, close) -> Gtk.Widget:
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
box.set_margin_top(6)
|
|
box.set_margin_bottom(6)
|
|
|
|
for t in list(self.board.targets):
|
|
box.append(_row(
|
|
f"{t.name} ({_location_status(t)})",
|
|
on_input=lambda t=t: self._open_coord_dialog(
|
|
title=f"Set {t.name} coordinates",
|
|
on_submit=lambda loc, _id, _t2, t=t: self._apply_and_refresh(t, loc),
|
|
initial_location=t.location,
|
|
),
|
|
on_remove=lambda t=t: self._remove_target(t, rebuild),
|
|
hidden=t.hidden,
|
|
on_toggle_hidden=lambda t=t: self._toggle_hidden(t, rebuild),
|
|
show_geo=t.show_geo_desc,
|
|
on_toggle_show_geo=lambda t=t: self._toggle_show_geo(t, rebuild),
|
|
alive=t.alive,
|
|
on_toggle_alive=lambda t=t: self._toggle_alive(t, rebuild),
|
|
on_convert=lambda t=t: self._convert_target_to_rp(t, rebuild),
|
|
))
|
|
|
|
box.append(Gtk.Separator())
|
|
box.append(_add_row("Add target", lambda: self._open_coord_dialog(
|
|
title="Add target",
|
|
on_submit=lambda loc, id_, type_: self._add_target(loc, id_, type_),
|
|
show_id=True,
|
|
show_type=True,
|
|
), close))
|
|
return box
|
|
|
|
def _add_target(self, location: Location, id_, type_) -> None:
|
|
self.board.add_target(type_ or TargetType.UNKNOWN, location, id_)
|
|
self._refresh()
|
|
|
|
def _convert_target_to_rp(self, target, rebuild) -> None:
|
|
"""The other direction of _convert_rp_to_target: an actual
|
|
contact that was actually a fixed landmark. Reuses the target's
|
|
own name as the new RP's name, so it stays recognizable."""
|
|
if self.canvas.selected is target:
|
|
self._set_selection(None)
|
|
self.board.remove_target(target)
|
|
new_rp = self.board.add_reference_point(target.location, name=target.name)
|
|
new_rp.hidden = target.hidden
|
|
new_rp.show_geo_desc = target.show_geo_desc
|
|
self._refresh()
|
|
rebuild()
|
|
self.toast(f"{target.name} converted to {new_rp.name}.")
|
|
|
|
# -- Units (Nest + Allies) -------------------------------------------------
|
|
def _build_units_popover(self, rebuild, close) -> Gtk.Widget:
|
|
"""Your own side of the map: the Nest (always first, there's
|
|
only ever one) plus every friendly contact ('FriendlyTank#1:',
|
|
tracked entirely separately from Targets, see models.py's
|
|
Ally/Board.allies). Allies are never fired on, so no shell/
|
|
charge/assignment/alive controls here, just position/
|
|
visibility, same as a Reference Point."""
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
box.set_margin_top(6)
|
|
box.set_margin_bottom(6)
|
|
|
|
nest = self.board.nest
|
|
box.append(_row(
|
|
f"Nest ({_location_status(nest)})",
|
|
on_input=lambda: self._open_coord_dialog(
|
|
title="Set Nest coordinates",
|
|
on_submit=lambda loc, _id, _t: self._apply_and_refresh(nest, loc),
|
|
initial_location=nest.location,
|
|
),
|
|
hidden=nest.hidden,
|
|
on_toggle_hidden=lambda: self._toggle_hidden(nest, rebuild),
|
|
show_geo=nest.show_geo_desc,
|
|
on_toggle_show_geo=lambda: self._toggle_show_geo(nest, rebuild),
|
|
))
|
|
box.append(Gtk.Separator())
|
|
|
|
for a in list(self.board.allies):
|
|
box.append(_row(
|
|
f"{a.name} ({_location_status(a)})",
|
|
on_input=lambda a=a: self._open_coord_dialog(
|
|
title=f"Set {a.name} coordinates",
|
|
on_submit=lambda loc, _id, _t, a=a: self._apply_and_refresh(a, loc),
|
|
initial_location=a.location,
|
|
),
|
|
on_remove=lambda a=a: self._remove_ally(a, rebuild),
|
|
hidden=a.hidden,
|
|
on_toggle_hidden=lambda a=a: self._toggle_hidden(a, rebuild),
|
|
show_geo=a.show_geo_desc,
|
|
on_toggle_show_geo=lambda a=a: self._toggle_show_geo(a, rebuild),
|
|
))
|
|
|
|
box.append(Gtk.Separator())
|
|
box.append(_add_row("Add ally", lambda: self._open_coord_dialog(
|
|
title="Add ally",
|
|
on_submit=lambda loc, id_, type_: self._add_ally(loc, id_, type_),
|
|
show_id=True,
|
|
show_type=True,
|
|
), close))
|
|
return box
|
|
|
|
def _add_ally(self, location: Location, id_, type_) -> None:
|
|
self.board.add_ally(type_ or TargetType.UNKNOWN, location, id_)
|
|
self._refresh()
|
|
|
|
def _remove_ally(self, ally, rebuild) -> None:
|
|
self.board.remove_ally(ally)
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
# -- Scout Flights ------------------------------------------------------------
|
|
def _build_scout_flights_popover(self, rebuild, close) -> Gtk.Widget:
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
box.set_margin_top(6)
|
|
box.set_margin_bottom(6)
|
|
|
|
for sf in list(self.board.scout_flights):
|
|
box.append(_scout_flight_row(
|
|
sf,
|
|
on_replot=lambda sf=sf: self._replot_scout_flight(sf),
|
|
on_remove=lambda sf=sf: self._remove_scout_flight(sf, rebuild),
|
|
on_toggle_hidden=lambda sf=sf: self._toggle_hidden(sf, rebuild),
|
|
))
|
|
|
|
box.append(Gtk.Separator())
|
|
box.append(_add_row("Add scout flight", self._add_scout_flight, close))
|
|
return box
|
|
|
|
def _add_scout_flight(self) -> None:
|
|
"""Click-to-place, like Add Strike: the anchor snaps to the center
|
|
of whatever large grid square the cursor is in, the bearing is
|
|
read off where in that square the cursor actually sits (see
|
|
GridCanvas._scout_flight_anchor)."""
|
|
self.canvas.start_scout_flight_placement(self._commit_scout_flight)
|
|
self.toast("Click the map to plot the scout flight, Esc to cancel.")
|
|
|
|
def _commit_scout_flight(self, center_km, bearing_deg) -> None:
|
|
self.board.add_scout_flight(center_km, bearing_deg)
|
|
self._refresh()
|
|
|
|
def _replot_scout_flight(self, sf) -> None:
|
|
self.canvas.start_scout_flight_placement(
|
|
lambda center, bearing, sf=sf: self._apply_scout_flight_replot(sf, center, bearing)
|
|
)
|
|
self.toast(f"Click the map to replot {sf.name}, Esc to cancel.")
|
|
|
|
def _apply_scout_flight_replot(self, sf, center_km, bearing_deg) -> None:
|
|
sf.center = center_km
|
|
sf.bearing_deg = bearing_deg
|
|
self._refresh()
|
|
|
|
def _remove_scout_flight(self, sf, rebuild) -> None:
|
|
self.board.remove_scout_flight(sf)
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
def _add_strike(self) -> None:
|
|
"""Two-step add: pick a shell (for blast radius) first, then click
|
|
the map to place it, a Strike is just a Target with
|
|
TargetType.STRIKE and an explicit shell, and its position is set
|
|
by clicking, not typing in coordinates."""
|
|
dialog = Adw.Dialog(title="Add Strike", content_width=400, content_height=440)
|
|
toolbar_view = Adw.ToolbarView()
|
|
dialog.set_child(toolbar_view)
|
|
toolbar_view.add_top_bar(Adw.HeaderBar())
|
|
|
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16,
|
|
margin_top=16, margin_bottom=16, margin_start=16, margin_end=16)
|
|
label = Gtk.Label(label="Shell (blast radius)", xalign=0)
|
|
label.add_css_class("heading")
|
|
outer.append(label)
|
|
|
|
# Every option shown up front rather than behind a submenu
|
|
# click, there's room for it here and picking one is the whole
|
|
# point of this dialog, not an aside.
|
|
chosen = [Shell.HCHE]
|
|
outer.append(icons.build_shell_grid(chosen[0], lambda s: chosen.__setitem__(0, s)))
|
|
|
|
next_btn = Gtk.Button(label="Next: click the map to place it")
|
|
next_btn.add_css_class("suggested-action")
|
|
next_btn.add_css_class("pill")
|
|
next_btn.set_halign(Gtk.Align.CENTER)
|
|
|
|
def on_next(_b):
|
|
chosen_shell = chosen[0]
|
|
dialog.close()
|
|
self.canvas.start_placement(
|
|
lambda coord: self._add_strike_at(coord, chosen_shell),
|
|
preview_radius_km=chosen_shell.blast_radius_km,
|
|
)
|
|
self.toast(f"Click the map to place the strike ({chosen_shell.name}), Esc to cancel.")
|
|
|
|
next_btn.connect("clicked", on_next)
|
|
outer.append(next_btn)
|
|
toolbar_view.set_content(outer)
|
|
dialog.present(self)
|
|
|
|
def _add_strike_at(self, coord, shell: Shell) -> None:
|
|
target = self.board.add_target(TargetType.STRIKE, coord)
|
|
target.shell = shell
|
|
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
|
self._refresh()
|
|
|
|
def _start_target_placement(self, target) -> None:
|
|
"""Firing card's "set position on map" button: click-to-place/
|
|
reposition, previewing the target's blast radius if its shell has
|
|
a known one (mirrors the Add Strike flow, generalized to any
|
|
target)."""
|
|
self.canvas.start_placement(
|
|
lambda coord: self._apply_and_refresh(target, Location.from_coord(coord)),
|
|
preview_radius_km=target.effective_shell.blast_radius_km,
|
|
)
|
|
self.toast(f"Click the map to place {target.name}, Esc to cancel.")
|
|
|
|
def _on_map_right_click(self, coord, x: float, y: float) -> None:
|
|
"""Right-click anywhere on the map: quick-add a Target or Strike
|
|
right there, no dialog, for when you already know exactly where
|
|
you're pointing and don't need to type coordinates."""
|
|
popover = Gtk.Popover()
|
|
popover.set_parent(self.canvas)
|
|
# NOT Gdk.Rectangle(x=..., y=..., ...): verified directly that this
|
|
# PyGObject version silently ignores every constructor keyword arg on
|
|
# boxed types like GdkRectangle (a real bug, not a style preference,
|
|
# it built a zeroed rect every time), which is exactly why this popover
|
|
# always opened pinned to the canvas's top-left corner instead of the
|
|
# actual click position. Assigning the fields after construction works.
|
|
rect = Gdk.Rectangle()
|
|
rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
|
|
popover.set_pointing_to(rect)
|
|
popover.connect("closed", lambda _p: popover.unparent())
|
|
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
|
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
|
|
|
def add_target(_b):
|
|
self.board.add_target(TargetType.UNKNOWN, coord)
|
|
self._refresh()
|
|
popover.popdown()
|
|
|
|
def add_strike(_b):
|
|
target = self.board.add_target(TargetType.STRIKE, coord)
|
|
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
|
self._refresh()
|
|
popover.popdown()
|
|
|
|
target_btn = Gtk.Button(label=f"Add target at {coord.label()}")
|
|
target_btn.add_css_class("flat")
|
|
target_btn.connect("clicked", add_target)
|
|
box.append(target_btn)
|
|
|
|
strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}")
|
|
strike_btn.add_css_class("flat")
|
|
strike_btn.connect("clicked", add_strike)
|
|
box.append(strike_btn)
|
|
|
|
popover.set_child(box)
|
|
popover.popup()
|
|
|
|
def _remove_target(self, target, rebuild) -> None:
|
|
self.board.remove_target(target)
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
def _remove_target_via_panel(self, target) -> None:
|
|
"""Same as _remove_target, but for the firing panel's own delete
|
|
button (Strikes), no popover `rebuild` callback to call there."""
|
|
self.board.remove_target(target)
|
|
self._refresh()
|
|
|
|
def _toggle_alive(self, target, rebuild) -> None:
|
|
target.alive = not target.alive
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
# -- shared -------------------------------------------------------------
|
|
def _apply_and_refresh(self, obj, location: Location) -> None:
|
|
_apply_location(obj, location)
|
|
self._refresh()
|
|
# A manual clue edit (typically the Description tab) can go in and
|
|
# come out unresolved, or resolved only approximately, with no
|
|
# other feedback, tell the user which instead of leaving it
|
|
# looking like nothing happened / a clean fix.
|
|
if obj.location.note:
|
|
self.toast(f"{obj.name}: {obj.location.note}.")
|
|
return
|
|
reason = solver.explain_unresolved(obj.location, self.board)
|
|
if reason is not None:
|
|
self.toast(f"{obj.name} not resolved: {reason}.")
|
|
|
|
def _edit_target_position(self, target) -> None:
|
|
"""Firing card's "Edit pos" button: manual coord overrides whatever
|
|
the solver had (definitive or ambiguous), once set it's sticky,
|
|
_merge_parsed_location won't touch it on a later re-screenshot.
|
|
Also lets you change id/type here, prefilled with the current
|
|
values, blank id means "leave it as-is", not "auto-assign new"."""
|
|
self._open_coord_dialog(
|
|
title=f"Edit {target.name}",
|
|
on_submit=lambda loc, id_, type_: self._apply_target_edit(target, loc, id_, type_),
|
|
initial_location=target.location,
|
|
show_id=True,
|
|
show_type=True,
|
|
id_placeholder=f"ID (current: {target.id})",
|
|
initial_id=target.id,
|
|
initial_type=target.type,
|
|
)
|
|
|
|
def _apply_target_edit(self, target, location: Location, id_, type_) -> None:
|
|
if id_:
|
|
target.id = id_
|
|
if type_ is not None:
|
|
target.type = type_
|
|
self._apply_and_refresh(target, location)
|
|
|
|
def _toggle_hidden(self, obj, rebuild) -> None:
|
|
obj.hidden = not obj.hidden
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
def _toggle_show_geo(self, obj, rebuild) -> None:
|
|
obj.show_geo_desc = not obj.show_geo_desc
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
|
|
class FeNigmaApp(Adw.Application):
|
|
def __init__(self) -> None:
|
|
super().__init__(application_id=APP_ID)
|
|
|
|
def do_activate(self) -> None:
|
|
win = self.props.active_window
|
|
if win is None:
|
|
win = MainWindow(self)
|
|
win.present()
|
|
|
|
|
|
def main() -> int:
|
|
app = FeNigmaApp()
|
|
return app.run(None)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|