Every menu popover (Units/Spotters/Reference Points/Targets/Scout Flights) stayed open on top of the modal dialog or map-click placement its own 'Add <thing>' button just triggered, holding onto focus that should have gone to the new dialog or the map instead. _add_row() now takes a close() callback (threaded through from _make_menu_button, which already owns the Gtk.Popover) and pops the popover down right before calling on_click(). Verified end to end with a GTK smoke test: opens the real 'Scout Flights' popover, clicks its real 'Add scout flight' button, confirms the popover is no longer visible afterward.
1161 lines
49 KiB
Python
1161 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)
|
|
popover.set_pointing_to(Gdk.Rectangle(x=int(x), y=int(y), width=1, height=1))
|
|
popover.connect("closed", lambda _p: popover.unparent())
|
|
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
|
|
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
|
|
|
|
def add_target(_b):
|
|
self.board.add_target(TargetType.UNKNOWN, coord)
|
|
self._refresh()
|
|
popover.popdown()
|
|
|
|
def add_strike(_b):
|
|
target = self.board.add_target(TargetType.STRIKE, coord)
|
|
self.board.reorder_target(target, 0) # new strikes go to the front of the list
|
|
self._refresh()
|
|
popover.popdown()
|
|
|
|
target_btn = Gtk.Button(label=f"Add target at {coord.label()}")
|
|
target_btn.add_css_class("flat")
|
|
target_btn.connect("clicked", add_target)
|
|
box.append(target_btn)
|
|
|
|
strike_btn = Gtk.Button(label=f"Add strike at {coord.label()}")
|
|
strike_btn.add_css_class("flat")
|
|
strike_btn.connect("clicked", add_strike)
|
|
box.append(strike_btn)
|
|
|
|
popover.set_child(box)
|
|
popover.popup()
|
|
|
|
def _remove_target(self, target, rebuild) -> None:
|
|
self.board.remove_target(target)
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
def _remove_target_via_panel(self, target) -> None:
|
|
"""Same as _remove_target, but for the firing panel's own delete
|
|
button (Strikes), no popover `rebuild` callback to call there."""
|
|
self.board.remove_target(target)
|
|
self._refresh()
|
|
|
|
def _toggle_alive(self, target, rebuild) -> None:
|
|
target.alive = not target.alive
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
# -- shared -------------------------------------------------------------
|
|
def _apply_and_refresh(self, obj, location: Location) -> None:
|
|
_apply_location(obj, location)
|
|
self._refresh()
|
|
# A manual clue edit (typically the Description tab) can go in and
|
|
# come out unresolved, or resolved only approximately, with no
|
|
# other feedback, tell the user which instead of leaving it
|
|
# looking like nothing happened / a clean fix.
|
|
if obj.location.note:
|
|
self.toast(f"{obj.name}: {obj.location.note}.")
|
|
return
|
|
reason = solver.explain_unresolved(obj.location, self.board)
|
|
if reason is not None:
|
|
self.toast(f"{obj.name} not resolved: {reason}.")
|
|
|
|
def _edit_target_position(self, target) -> None:
|
|
"""Firing card's "Edit pos" button: manual coord overrides whatever
|
|
the solver had (definitive or ambiguous), once set it's sticky,
|
|
_merge_parsed_location won't touch it on a later re-screenshot.
|
|
Also lets you change id/type here, prefilled with the current
|
|
values, blank id means "leave it as-is", not "auto-assign new"."""
|
|
self._open_coord_dialog(
|
|
title=f"Edit {target.name}",
|
|
on_submit=lambda loc, id_, type_: self._apply_target_edit(target, loc, id_, type_),
|
|
initial_location=target.location,
|
|
show_id=True,
|
|
show_type=True,
|
|
id_placeholder=f"ID (current: {target.id})",
|
|
initial_id=target.id,
|
|
initial_type=target.type,
|
|
)
|
|
|
|
def _apply_target_edit(self, target, location: Location, id_, type_) -> None:
|
|
if id_:
|
|
target.id = id_
|
|
if type_ is not None:
|
|
target.type = type_
|
|
self._apply_and_refresh(target, location)
|
|
|
|
def _toggle_hidden(self, obj, rebuild) -> None:
|
|
obj.hidden = not obj.hidden
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
def _toggle_show_geo(self, obj, rebuild) -> None:
|
|
obj.show_geo_desc = not obj.show_geo_desc
|
|
self._refresh()
|
|
rebuild()
|
|
|
|
|
|
class FeNigmaApp(Adw.Application):
|
|
def __init__(self) -> None:
|
|
super().__init__(application_id=APP_ID)
|
|
|
|
def do_activate(self) -> None:
|
|
win = self.props.active_window
|
|
if win is None:
|
|
win = MainWindow(self)
|
|
win.present()
|
|
|
|
|
|
def main() -> int:
|
|
app = FeNigmaApp()
|
|
return app.run(None)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|