Commit Graph

23 Commits

Author SHA1 Message Date
a92aff5e06 Fix right-click map menu opening at (0,0) instead of the cursor position
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>
2026-08-09 21:18:47 +02:00
68d312934b Close the popover when 'Add X' opens a dialog or arms map placement
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.
2026-08-09 20:32:06 +02:00
77fe6a2948 Header: swap Targets/Scout Flights order, separator before the strike button
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:27:55 +02:00
c74a8eefea Merge Nest into a renamed 'Units' menu (first in the header), Nest listed first
Nest had its own single-entry popover for no strong reason, and Allies
was its own separate one right next to it, both are 'your own side of
the map'. Merged into one 'Units' popover, Nest always first (there's
only ever one), Allies listed below it, and moved to the first spot in
the header's row of menu buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:26:42 +02:00
3955fa42c7 Drop TargetType.HOSTILE_*, track allies as their own Ally collection
Two related changes:

1. TargetType.HOSTILE_ARTILLERY/HOSTILE_TANK renamed to ARTILLERY/TANK,
   dropping the baked-in hostility assumption from the type name
   itself (a type describes the unit kind now, not an allegiance).
   Migration entries added for both, plus the already-existing
   COASTAL_BATTERY one, so old save files still load.

2. A friendly contact ('FriendlyTank#1:', detected by stripping a
   leading 'Friendly'/'Hostile' word off the type word before matching
   it, see ocr.py's _resolve_target_type()) is NOT a Target with a flag
   flipped, it's tracked as a new, entirely separate Ally
   (Board.allies), with its own id namespace: an ally Tank#1 and a
   hostile Target Tank#1 are two unrelated things that happen to share
   an id, not a collision (verified directly, see the rendered
   screenshot both coexisting). Ally intentionally has none of Target's
   firing-relevant fields (shell/powder_charges/assignment/alive),
   allies are never fired on. 'Hostile' and no prefix at all both mean
   a regular (non-ally) Target, not-ally is the default.

   Wired through: Board.add_ally/remove_ally, placed_entities_all()/
   ambiguous_entities_all() (new 'ally' category, cyan on the map,
   distinct from every other category's color), solver.resolve_board()
   (allies' own clues resolve too), find_by_name() (an ally can be a
   clue reference target), save/load round-trip, a new 'Allies' header
   popover mirroring Targets' (position/hide/geo-overlay/remove, no
   shell/charge/alive controls), and ParsedInfo.allies as a same-shaped
   but separate dict from ParsedInfo.targets, merged by a new
   _merge_allies() alongside _merge_targets() in _merge_all().

Verified: full test suite (added a dedicated OCR test for the Friendly/
Hostile/bare-prefix routing), a GTK smoke test round-tripping an ally
through save/load and the popover build, and a rendered screenshot
showing an ally Tank#1 and a hostile Target Tank#1 both on the map at
once with the same id, distinct colors, no collision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:23:42 +02:00
57e73c58ea Fix real zoom+square-cells bugs, add drag-to-pan, simplify popover buttons
Every fix here was verified directly (numeric checks against the view
math, rendered screenshots, GTK smoke tests), not just reasoned about,
after the previous zoom/square pass shipped with real problems still
in it.

Map fixes:
- Off-viewport entities bled through onto the canvas when zoomed in:
  _km_to_px() has no inherent bound, an entity actually elsewhere on
  the map still got projected and drawn if the result happened to land
  within the canvas's pixel bounds. Clipped all marker/overlay/arrow/
  scout-flight drawing to the grid's own visible rectangle.
- Gridlines themselves bled into the label margin above/left of the
  grid: the line-drawing loop deliberately over-generated a couple of
  lines past the true viewport edge (meant to cover a trailing partial
  cell, which doesn't need its own line, just the whole-integer lines
  already bounding it). Switched to ceil/floor bounds that only ever
  produce in-range lines, verified directly against the view math for
  a fractional zoom/pan (no clip needed for this half, the bug was
  generating the wrong lines in the first place, not failing to hide
  them).
- Square-cell letterbox padding stayed pixel-identical at every zoom
  level (verified: same padding at 1x/2x/5x), which meant the fixed
  bands ate a bigger and bigger share of an already-zoomed-in view.
  Once actually zoomed in with square_cells on, the viewport's own
  aspect now follows the canvas shape instead of staying locked to the
  full map's 20:10 ratio, so cells come out square with zero
  letterboxing rather than fixed padding. The whole-map view (zoom=1)
  is unchanged, that's the one case that has a real reason to keep the
  fixed 20:10 shape.
- Added drag-to-pan, active once actually zoomed in (at 1x the whole
  map's already on screen, nothing to pan to). Guards against a drag's
  release also firing as a click-select via a small pixel threshold.

Popover cleanup (Nest/Spotters/Reference Points/Targets):
- Removed each popover's own 'Load all from screenshot' bulk button
  and every row's individual 'set this one from screenshot' button:
  the universal clipboard button in the header already re-parses a
  fresh screenshot/paste and merges it into everything it recognizes
  by name, these were redundant category- and item-specific ways to
  trigger the exact same merge. Deleted the now-dead
  _set_*_from_screenshot_info() methods along with them.
- The trailing 'Add spotter'/'Add RP'/'Add target'/'Add scout flight'
  rows are now a single full-width button (new _add_row() helper)
  instead of the full two-button _row() layout, there's nothing to
  screenshot into for something that doesn't exist yet.

Verified: full pytest suite, direct numeric check that no gridline
lands outside the grid rectangle at a fractional zoom/pan, a rendered
screenshot at 4x zoom confirming off-viewport entities no longer
appear and gridlines terminate cleanly at the edges, and a full-app
smoke test exercising drag-pan end to end including the post-drag
click-suppression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:04:55 +02:00
9fa2f30835 Move the square-cell toggle next to the clipboard auto-watch button
Groups it with the other map-display-affecting toggle rather than
sitting among the entity-adding buttons, per feedback.
2026-08-09 19:45:56 +02:00
e585fc5428 Map: square-cell toggle and scroll-wheel zoom
Refactored GridCanvas's geometry around a single _View namedtuple
(cell_w/cell_h/grid_w/grid_h/pad_x/pad_y/viewport origin/visible
extent) instead of threading cell_w/cell_h/grid_h separately through
every draw and hit-test method, that's what makes the two new features
below tractable without a parameter explosion.

- Square-cell toggle (header button, 'view-grid-symbolic'): forces
  cell_w == cell_h, letterboxing (padding) whichever axis has leftover
  space instead of stretching cells to fill the widget. Off by default,
  recovers the exact previous stretch-to-fill behavior.
- Scroll-wheel zoom: 1x (the whole 20x10 map, the old fixed behavior)
  up to 10x, anchored at the cursor's last known position so the km
  point under it stays under it as the zoom level changes, panned/
  clamped so the viewport never hangs off the grid's edge. Grid lines,
  column/row labels, and every marker only draw for the visible
  viewport, not always the full 20x10 grid.

Also two bugs found and fixed along the way:
- The header's cursor-location readout showed AZ/distance-from-nest
  numbers even when the cursor was off the map entirely:
  bearing_deg_point()/distance_km_point() are happy to compute on any
  raw km point, on- map or not, only the coord label itself checked
  bounds. Now the whole readout is just 'off map' whenever the cursor
  genuinely isn't over the grid.
- That bounds check initially reused solver.point_to_coord()'s own
  tolerance, which deliberately forgives up to 0.5km past an edge
  (rounding slop for noisy OCR'd coordinates), the wrong call for 'is
  the mouse over the map', a cursor visibly off the drawn grid still
  passed it. The cursor readout now uses a strict 0<=col<=COLS/
  0<=row<=ROWS check instead.

Verified with GTK smoke tests: zoom in/out and pan-anchoring math,
square-cell letterboxing padding, hover/hit-testing, the toggle wired
end-to-end through the real header button, and the full app launching
and surviving the exact scenario that crashed it earlier in this same
session (an incomplete mid-refactor commit referenced _on_scroll before
it was defined, caught immediately by re-running the app, fixed by
finishing the refactor properly instead of patching around it).

Also, on the firing card: swapped the shell icon to come after the
powder-charge segments instead of before (per feedback), and gave the
assignment cycle button (L/R/-) the same 'image-button' style class its
icon-only siblings get automatically, it was visibly wider than them
for carrying a text label instead of an icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:43:35 +02:00
08d046536e Fix cursor readout showing AZ/dist for points that are actually off the map
_on_cursor_move() only checked map bounds for the coord label itself
('off map' vs a real grid ref), the AZ/distance-from-nest numbers next
to it were computed and shown regardless, bearing_deg_point()/
distance_km_point() are happy to run on any raw km point, negative or
past the 20x10 grid included, so a cursor genuinely off the map could
still show what looked like a real reading. Now the whole label is
just 'off map', no numbers, whenever point_to_coord() says there's no
real grid position there.
2026-08-09 19:32:39 +02:00
bcfa995417 Bump charge-segment size now that its CSS rule actually applies
The previous 20px min-width/height looked like a regression (smaller
than before) once the button.<class> selector fix actually started
taking effect: before that fix, the rule was being silently ignored
entirely and the theme's own (bigger) default button min-size was what
users were actually seeing by accident. Now that the rule genuinely
applies, bumped to 28px, verified directly against the real allocated
widget size (28x28), not just the requested CSS value.
2026-08-09 19:31:03 +02:00
3b209e743e Shell icon picker: fix real sizing bugs, crop icon padding, inline grid for Add Strike
Several rounds of actually looking at the running app instead of just
the code, in order:

- The icon-only shell button on a firing card, and the popover-based
  picker, both fought a genuine GTK sizing quirk: Gtk.Picture's natural
  size is the source image's full native resolution (512x256) no
  matter what set_size_request() says, so the button's own requested
  size swung unpredictably depending on layout context (sometimes
  comically wide, sometimes squeezed tiny). Fixed by pre-scaling the
  actual pixel data with GdkPixbuf first (preserving real aspect ratio)
  and wrapping the already-correctly-sized result, nothing left for
  the surrounding layout to guess about.
- The shell icon PNGs themselves carry a lot of built-in transparent
  padding around the actual artwork. Cropped all of them (except MSTD,
  see below) to the same shared bounding box, computed as the union of
  every file's real content so nothing gets clipped and every icon
  stays aligned the same way.
- MSTD (mustard/YX blister agent) removed from the Shell enum entirely:
  its own game icon is stamped 'UNAVAILABLE IN DEMO', it isn't actually
  a real selectable shell in the game.
- Extra empty chrome around icon-only buttons turned out to be GTK CSS
  specificity, not a missing property: GTK's CSS has no !important at
  all (confirmed, its parser rejects it outright), and separately,
  Gtk.MenuButton's CSS node is literally named 'menubutton', not
  'button', so a 'button.<class>' selector silently matched zero
  elements on it. The actual leftover padding lives on MenuButton's
  internal child button node, reached with a child-combinator selector
  ('menubutton.<class> > button').
- The Add Strike dialog's shell picker is now an inline radio-style
  grid (build_shell_grid(), Gtk.ToggleButton.set_group()) instead of a
  button that opens a submenu popover: it's a 'pick one before
  proceeding' dialog with room to just show every option up front,
  hiding them behind an extra click didn't earn anything. The firing
  card and its tighter row still use the popover version.
- Grid is 3 columns (was 4), sized bigger now that padding is real
  content, not just empty canvas.

Also two unrelated but real bugs found and fixed while chasing this,
from actually reproducing what screenshots showed instead of taking
them as pure cosmetic complaints:
- Selecting a firing card shifted every icon/label inside it inward by
  2px: only .firing-card-selected had a border, unselected cards had
  none at all, so gaining a border on selection shifted the box's
  content instead of just changing a color. Border is now reserved at
  the same width on every card, always, selection only changes its
  color from transparent.
- Changing a target's powder charge visibly shifted the whole card:
  the charge segment buttons had no valign, so once the row got taller
  (the bigger shell icon next to them), they silently inherited the
  default FILL alignment and stretched into ovals instead of staying a
  fixed circle. They also relied on plain 'flat'/'suggested-action' for
  their off/on look, which draws no border at all when off, making an
  unselected segment invisible against the card background. Both fixed
  with a dedicated fixed-size, always-outlined CSS class and explicit
  valign/halign=CENTER.
- The AZ column's screen position depended on the ELEV value's digit
  count (elevation recalculates per charge picked), fixed with a
  reserved width_chars on the elevation label.

Verified at each step by actually rendering the affected widget/dialog/
card to a PNG and looking at it (including a real FiringPanel card
before/after a charge change, pixel-diffed to confirm nothing but the
intended fill state moved), not by reasoning about the code alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:28:41 +02:00
2b73a96c7a Replace the plain-text shell dropdown/list with an icon picker
Both places the app asks for a shell (the Add Strike dialog's
Adw.ComboRow, and the firing panel's per-target shell popover) used to
show just the bare enum name in a plain list, nothing conveying what
the shell actually is. New icons.py builds a shared MenuButton +
popover from the game's own shell icon set (assets/icons/shells/,
already named to match Shell.name exactly): each row shows the shell's
icon, name, description, and blast radius, and the button face updates
to match whichever one gets picked.

Verified via a GTK smoke test that the button and its popover build and
open without crashing.
2026-08-09 18:59:50 +02:00
c273f57435 OCR: multi-word RP names, bare-name targets, FO-report triangulation
Three related fixes/additions, found together while working through a
batch of new intel formats:

1. squash_span_content() only handled the 'Enemy X Y' and 'Type#N'
   shapes, so a plain multi-word bold name ('The Mole', "Dockmaster's
   House") passed through untouched and got silently truncated at the
   first space by every downstream single-token assumption
   (_RP_HEADER_RE, the (\S+) clue-reference capture). Added a generic
   fallback: collapse any multi-word bold span into one alphanumeric
   token, UNLESS any word contains a digit, that's very likely a
   coordinate span ('C9 7:9') instead of a name, and squashing THAT the
   same way corrupted it into garbage ('C979') rather than a name, a
   real regression caught immediately by testing against nest/spotter
   parsing before committing.

2. A bare '<Name>:' header (no 'Reference Point'/'Enemy' keyword, no
   digit id, e.g. 'HMS Rockingham:') was previously dropped entirely,
   nothing recognized it at all. Added _BARE_NAME_HEADER_RE as the
   last-resort header check (colon required, not optional like every
   other header regex, nothing else anchors this match). Resolves to a
   Target (TargetType.UNKNOWN), not a Reference Point, a named thing
   giving its own clues is being spotted, not a fixed landmark.

3. Forward-observer reports ('FO#5 Audio report on HMS Rockingham:
   2.24km From I8 6:9'): each FO's position is a literal one-off
   coordinate, not a name referencing some known entity, and isn't
   meant to be tracked as a real board entity. parse_fo_reports()
   triangulates immediately using a throwaway scratch Board (reusing
   solve_location()'s exact geometry/priority) and keeps only the
   resulting coordinate, discarding every ephemeral FO position
   afterward, nothing leaks into the real board.

Also added a 'convert to Target/Reference Point' action (RP and
Target rows both), since bare-name-header classification is a guess
that can land in the wrong bucket, this fixes it without losing the
position/clues already worked out.

Verified against the exact reported examples plus the full existing
regression sweep (standard blocks, Enemy names, Listening
Post/Coastal Battery, Marine Garrison, nest/spotter parsing) and
through the real GTK merge flow.
2026-08-09 16:51:30 +02:00
202219abf2 OCR: parse fire-support requests ('Marine Garrison#1 pinned!')
New shape: '<Name>#<id> pinned!' followed by '<Shell> Shells requested
on <coord>' and 'Requested before - <T-time> -'. Different from every
existing coord shape (no 'Grid' keyword), so it needed its own
extractor (_extract_requested_on_coord), plus new ones for the shell
code and the deadline string. New TargetType.MARINE_GARRISON, its
multi-word name + real digit id already works through the existing
squash_multiword_ids() pipeline (same as Coastal Battery/Listening
Post) with no new header regex needed.

Target gained a requested_time field (raw string, this app doesn't
track a game clock to compare it against), persisted through
save/load and shown on the map below the coord label. The requested
shell sets target.shell directly rather than staying a suggestion,
matching how a manually-picked shell already works.

info.targets' value tuple grew from 3 to 5 elements (raw, clues,
coord, shell, requested_time); updated both call sites in app.py
that unpack it. Verified end to end: parse -> merge -> save/load
round trip -> map draw.
2026-08-09 16:20:55 +02:00
40387d64b0 OCR: parse ad-hoc 'Enemy X Y' installations as a new target type
'Enemy Signal Station:' and its ilk are named in plain English rather
than the usual Type#N shape, and get referenced the same way
elsewhere ('Bearing 034 from Enemy Signal Station'), breaking two
assumptions everywhere else in this module: headers/references are a
single whitespace-free token, and an id is digit-shaped.

squash_enemy_names() collapses 'Enemy' + up to 4 Title Case words that
follow it into one token in our own id shape ('Enemy#SignalStation')
before anything else parses the text, so every existing from-<ref>
clue pattern and the named-header matcher keep working unmodified.
Wired into both parse_text() (OCR/clipboard) and
parse_clues_from_text() (manual description tab).

Two follow-on fixes this surfaced: _clean_reference() previously
assumed a named reference's id is always digit-shaped and would
truncate 'Enemy#SignalStation' down to 'Enemy#Sig' via
_fix_id_digits's letter-to-digit mapping; and the letter-id header
check needed to run *before* _NAMED_HEADER_RE, whose digit class
overlaps plain letters (S/B/Z/G/O/I/L) and would otherwise
partial-match and mangle the id first.

New TargetType.ENEMY carries these. Verified end to end (including
the solver resolving the cross-references between them) through both
parse_text() and the real _merge_targets() app flow.
2026-08-09 12:32:49 +02:00
980be86908 Fall back to a compromise point when two distance circles don't cross
Previously two distance-only clues whose circles didn't actually
intersect (real typewriter data isn't perfectly consistent, a
misplaced spotter or an off-by-a-bit distance reading is enough) just
gave up entirely, even when they were nearly touching.

solver.closest_compromise_point() picks the midpoint between each
circle's point facing the other, the standard notion of the closest
approach between two circles. It stays well-behaved even when the
centers are nearly coincident, unlike projecting along the center
line the way a real intersection's formula does, which diverges as
the centers get close while the radii stay far apart, exactly the
near-coincident case this is for.

The result is flagged rather than treated as a clean fix: Location
gained a  field, set whenever solve_location() had to use this
fallback, cleared by any subsequent coord.setter call (manual or
solver), shown in the entity's status label and toasted after a
manual clue edit. Persisted through save/load.
2026-08-08 23:56:24 +02:00
8c73442c03 Surface why a manual clue edit didn't resolve, instead of silence
Circle-circle intersection (and the other solvable shapes) already
worked correctly, verified with a direct test: two distance-only
clues resolve to potential_coords whenever the two circles actually
cross. What was missing was feedback when they don't, e.g. two
distances that put one circle entirely inside the other given the
references' real positions looks identical, from the dialog's
perspective, to a reference that's just not placed yet.

solver.explain_unresolved() distinguishes 'waiting on <ref> for a
known position' from 'these two readings are geometrically
inconsistent', wired into _apply_and_refresh's toast after a manual
Description-tab edit.
2026-08-08 23:50:14 +02:00
d37c109cda Show the anchor grid square's name in the scout flight row, not just bearing 2026-08-08 23:42:25 +02:00
2ac133ae3e Move clear-board button to third icon on the header's left side 2026-08-08 23:41:20 +02:00
186e2c4d6d Add scout flight planning: click-to-place rectangle, new dropdown category
A ScoutFlight anchors to the center of whatever large grid square the
cursor is in, with its bearing read off where in that square the
cursor actually sits, decoupling a clean anchor point from fine
direction control. The plotted rectangle extends 0.92km back and
13.04km forward along that bearing, 1.21km to each side
(solver.scout_flight_corners), previewed live while placing (see
GridCanvas.start_scout_flight_placement) and drawn as a filled
rectangle once committed.

New 'Scout Flights' header dropdown lists them with replot/hide/remove
per entry, plus a header button to plan a new one. Wired into
Board.clear() and save/load (SAVE_FORMAT_VERSION bumped to 3, old
saves load fine via the new key's default).
2026-08-08 23:32:54 +02:00
419674b5af Add clear-board button and clipboard auto-watch toggle
Board.clear() drops the Nest position, spotters, reference points, and
targets; wired to a header button behind a confirm dialog since it's
not reversible.

The clipboard-watch toggle connects to Gdk.Clipboard's 'changed'
signal and re-runs the merge-all OCR flow automatically whenever the
clipboard gains new image content, skipping non-image changes (e.g.
text copied elsewhere) so it doesn't spam toasts. Off by default.
Disconnected on window destroy.
2026-08-08 23:15:40 +02:00
ba6b07a476 Sweep em-dashes out of code, docstrings, and requirements.txt
Same style fix applied to the README earlier, extended everywhere:
replaced " -- " with commas/colons/periods (picking whichever reads
right per occurrence, splitting into two sentences where the clauses
were independent), fixed a few user-facing strings along the way
(entity list rows, placement/strike toasts, ambiguous-candidate tag,
shell picker button label). Left three intentional non-prose uses
alone: the "unassigned" dash glyph in firing_panel.py (and its
docstring diagram), and ocr.py's dash-variant regex character class,
which needs to literally match em/en-dashes in OCR'd text.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 20:27:53 +02:00
7ecda4ea3b Rename package/app to FeNigma consistently
- src/ironnest_assist/ -> src/fenigma/
- run.sh invokes -m fenigma.app
- APP_ID: eu.dominik-roth.IronNestAssist -> eu.dominik-roth.FeNigma
- window title, IronNestApp class -> FeNigmaApp
- __init__.py docstring, README pgrep hint updated

IRON NEST (the game's own name, e.g. NEST_KEYWORD in ocr.py) is left
untouched, only our own project/app naming changed. Verified: clean
import under the new module path, install.sh still builds the venv
correctly, and the OCR regression sweep (9 known screenshots) still
passes.

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