Fix silent proposal-popover repaint bug; add underground target marker

"Accept as..." (proposal type-picker) and "Change type" (entity-edit)
opened to a visibly empty/unchanged popover with no traceback: swapping
an already-open Popover's child and re-popup()ing it reported the right
size internally but the compositor never repainted the reused surface.
Confirmed live via temporary debug instrumentation, not guessed. Fixed
by popping the old popover down and opening a genuinely new one at the
same anchor point instead of resizing in place.

Also adds a Target.underground_tier (1-3) marker: a "Mark underground"
entry in the entity-edit popover, rendered as the game's own Armor-tier
additive badge stacked directly on the unit icon. The badge is
scaled/positioned off its real opaque content (PIL bbox), not its PNG
canvas, since the additive art carries a lot of off-center transparent
padding; and overlaps down into the icon by a fixed pixel amount, since
both shapes taper to a point at the seam and exact bbox-touching still
read as a visible gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dominik Moritz Roth 2026-08-13 18:44:36 +02:00
parent e43c3478c9
commit 896c7dc36a
13 changed files with 304 additions and 6 deletions

29
TODO.md
View File

@ -119,6 +119,35 @@ Status legend: [x] fixed+tested, [~] partially addressed, [ ] open/needs input
accept attempt after that silently died before the ally/target accept attempt after that silently died before the ally/target
ever got added, popover already closed by the time it happened. ever got added, popover already closed by the time it happened.
Fixed there; not a separate bug. Fixed there; not a separate bug.
- [x] "Accept as…" (the type-picker submenu on a proposal, and "Change
type" on an already-placed entity) opening to a visibly empty/
unchanged popover. This one left no traceback at all -- confirmed
live with temporary debug prints that the button's `clicked` signal
fires, the icon grid builds successfully (all N types), and
`Popover.set_child()` on the already-open outer popover reports the
right `visible=True`/width/height afterward... but the compositor
never actually repaints that reused surface, so nothing new ever
appeared on screen. Fixed by not resizing the existing open
popover at all: popping it down and opening a genuinely new one
(fresh native surface) at the same anchor point instead. Same fix
applied to both call sites (`_open_proposal_menu`'s `show_type`,
`_open_entity_menu`'s `show_type`, the latter refactored to share
the same `_reopen_with()` helper).
- [x] New: mark a Target as underground, at a hardening tier (1-3),
rendered as the game's own Armor-tier additive badge stacked on
the icon. `Target.underground_tier: int | None`, a "Mark
underground" entry in the entity-edit popover (tier picker reusing
the same fresh-popover fix above), and `GridCanvas` draws the
badge above the marker's icon, overlapping down into it by
`_ADDITIVE_OVERLAP_PX` -- both the diamond icon's top corner and
the badge's bottom are tapered to a near-point, not a flat edge,
so bbox-exact touching still read as a gap; a real pixel overlap
is what actually looks contiguous (confirmed against the game's
own stacked-badge screenshots). Badge is scaled/positioned off the
art's real opaque content (PIL `getbbox()`), not its PNG canvas --
the additive files carry a lot of off-center transparent padding
that made the badge look tiny and floating if sized off the raw
canvas.
## Needs more scope / your input before I keep going ## Needs more scope / your input before I keep going

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -14,6 +14,8 @@ from __future__ import annotations
import io import io
import json import json
import pickle
import signal
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@ -272,6 +274,15 @@ class MainWindow(Adw.ApplicationWindow):
self._import_job = None # in-flight map_import.ImportJob, if any self._import_job = None # in-flight map_import.ImportJob, if any
self.screenshot_import = None # the map screenshot currently on the board self.screenshot_import = None # the map screenshot currently on the board
# Dev-only: SIGUSR1 pickles {board, screenshot_import} to a fixed
# path so a `kill -USR1` + relaunch (e.g. while bisecting a live
# bug) can restore the in-progress board/screenshot/proposals
# instead of losing them. One-shot: the restore consumes and
# deletes the file. Not wired to any UI -- debugging aid only.
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1,
self._dev_dump_session_for_restart)
self._dev_maybe_restore_session()
self.toast_overlay = Adw.ToastOverlay() self.toast_overlay = Adw.ToastOverlay()
self.set_content(self.toast_overlay) self.set_content(self.toast_overlay)
@ -685,6 +696,48 @@ class MainWindow(Adw.ApplicationWindow):
self._drop_shot_btn.set_visible(imp is not None) self._drop_shot_btn.set_visible(imp is not None)
self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending())) self._accept_all_btn.set_sensitive(bool(imp is not None and imp.pending()))
_DEV_SESSION_PATH = Path(tempfile.gettempdir()) / "fenigma_dev_session.pkl"
def _dev_dump_session_for_restart(self, *_a) -> bool:
"""SIGUSR1 handler: pickle {board, screenshot_import} so a
following relaunch can pick this session right back up. See the
SIGUSR1 registration in __init__ for why this exists."""
try:
with open(self._DEV_SESSION_PATH, "wb") as f:
pickle.dump({"board": self.board, "screenshot_import": self.screenshot_import}, f)
print(f"fenigma: dev session dumped to {self._DEV_SESSION_PATH}", flush=True)
except Exception as exc:
print(f"fenigma: dev session dump failed: {exc!r}", flush=True)
return GLib.SOURCE_CONTINUE
def _dev_maybe_restore_session(self) -> None:
"""Counterpart to `_dev_dump_session_for_restart`: one-shot restore
on startup if a dump is sitting there. Sets `self.board`/
`self.screenshot_import` directly (before the rest of __init__
builds the widgets that reference them) but defers the actual
redraw to an idle callback, since `self.canvas` doesn't exist yet
at this point in __init__."""
if not self._DEV_SESSION_PATH.exists():
return
try:
with open(self._DEV_SESSION_PATH, "rb") as f:
data = pickle.load(f)
self.board = data["board"]
self.screenshot_import = data["screenshot_import"]
self._DEV_SESSION_PATH.unlink()
print("fenigma: dev session restored", flush=True)
except Exception as exc:
print(f"fenigma: dev session restore failed: {exc!r}", flush=True)
return
GLib.idle_add(self._dev_finish_session_restore)
def _dev_finish_session_restore(self) -> bool:
self._refresh()
self._refresh_proposals()
if self.screenshot_import is not None:
self.canvas.set_screenshot(self.screenshot_import.overlay, self.screenshot_import.px_per_km)
return GLib.SOURCE_REMOVE
def _accept_proposal(self, proposal, type_=None) -> None: def _accept_proposal(self, proposal, type_=None) -> None:
coord = _coord_from_proposal(proposal) coord = _coord_from_proposal(proposal)
if coord is None: if coord is None:
@ -794,15 +847,29 @@ class MainWindow(Adw.ApplicationWindow):
# real pickable unit types (see their own comments in # real pickable unit types (see their own comments in
# models.py) and shouldn't have been offered as "what this # models.py) and shouldn't have been offered as "what this
# detected marker actually is". # detected marker actually is".
grid = icons.build_target_type_grid(
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
)
scroller = Gtk.ScrolledWindow(propagate_natural_height=True, scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
propagate_natural_width=True, propagate_natural_width=True,
max_content_height=340, max_content_height=340,
hscrollbar_policy=Gtk.PolicyType.NEVER) hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(icons.build_target_type_grid( scroller.set_child(grid)
detected, lambda t: accept(t), is_ally=(proposal.side == "friendly"),
))
box.append(scroller) box.append(scroller)
# Swapping the child of an ALREADY-open Popover and re-popup()ing
# it reports the right size internally (visible=True, sane
# width/height) but the compositor never actually repaints the
# reused surface -- confirmed live: nothing appears on screen no
# matter how many times it's reopened. Popping the OLD popover
# down and opening a genuinely NEW one (fresh native surface,
# same anchor point) instead of resizing the existing one
# sidesteps that.
nonlocal popover
old_popover = popover
popover = self._popover_at(x, y)
popover.set_child(box) popover.set_child(box)
old_popover.popdown()
popover.popup()
show_main() show_main()
popover.popup() popover.popup()
@ -1521,10 +1588,31 @@ class MainWindow(Adw.ApplicationWindow):
# panel's own alive button offers, just reachable from the # panel's own alive button offers, just reachable from the
# map too rather than only from the sidebar. # map too rather than only from the sidebar.
button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive) button(box, "Mark destroyed" if obj.alive else "Mark alive", toggle_alive)
# Underground is Target-only too, see Target.underground_tier's
# own comment -- no such thing as an underground Ally.
ug_label = ("Mark underground" if obj.underground_tier is None
else f"Underground (tier {obj.underground_tier})")
button(box, ug_label, show_underground)
if not isinstance(obj, Nest): if not isinstance(obj, Nest):
button(box, "Delete", delete, css="destructive-action") button(box, "Delete", delete, css="destructive-action")
popover.set_child(box) popover.set_child(box)
def _reopen_with(box) -> None:
"""Swapping an ALREADY-open Popover's child via set_child()
alone reports the right size (visible=True, sane width/height)
but the compositor never actually repaints the reused surface
on some setups -- confirmed live, nothing appears on screen no
matter how many times it's reopened. Popping the OLD popover
down and opening a genuinely NEW one at the same anchor point
(fresh native surface, not an in-place resize) sidesteps it.
Shared by every page past show_main() in this menu."""
nonlocal popover
old_popover = popover
popover = self._popover_at(x, y)
popover.set_child(box)
old_popover.popdown()
popover.popup()
def show_type(): def show_type():
box = page() box = page()
heading(box, "Type") heading(box, "Type")
@ -1542,7 +1630,7 @@ class MainWindow(Adw.ApplicationWindow):
obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally), obj.type, lambda t: set_type(t), is_ally=isinstance(obj, Ally),
)) ))
box.append(scroller) box.append(scroller)
popover.set_child(box) _reopen_with(box)
def set_type(t): def set_type(t):
obj.type = t obj.type = t
@ -1551,6 +1639,26 @@ class MainWindow(Adw.ApplicationWindow):
self.toast(f"{_display_name(obj)} is now a " self.toast(f"{_display_name(obj)} is now a "
f"{icons.target_type_label(t, isinstance(obj, Ally))}.") f"{icons.target_type_label(t, isinstance(obj, Ally))}.")
def show_underground():
box = page()
heading(box, "Underground")
scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
propagate_natural_width=True,
max_content_height=340,
hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(icons.build_underground_tier_grid(
obj.underground_tier, lambda tier: set_underground(tier),
))
box.append(scroller)
_reopen_with(box)
def set_underground(tier):
obj.underground_tier = tier
self._refresh()
popover.popdown()
self.toast(f"{_display_name(obj)} is no longer underground." if tier is None
else f"{_display_name(obj)} is now underground (tier {tier}).")
def show_id(): def show_id():
box = page() box = page()
heading(box, "ID") heading(box, "ID")

View File

@ -12,6 +12,7 @@ from collections import namedtuple
import cairo import cairo
import gi import gi
import numpy as np import numpy as np
from PIL import Image as PILImage
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
@ -195,6 +196,44 @@ def _icon_for(category: str, obj) -> cairo.ImageSurface | None:
return None return None
# path -> (surface, content_bbox) for additive badges specifically.
# Separate from _ICON_SURFACE_CACHE because these also need their real
# opaque content's bounding box: unlike the unit icons (already ~edge to
# edge in their own canvas, see _draw_icon_marker), the additive art
# (assets/icons/targets/additives/) sits inside a lot of transparent
# padding that isn't even centered -- scaling/positioning off the full
# 256x256 canvas made the badge look tiny and float with a visible gap
# above the icon it's supposed to touch. bbox is None for a path that
# failed to load, or (l, t, r, b) of its actual opaque pixels.
_ADDITIVE_CACHE: dict = {}
def _additive_surface(path) -> tuple:
if path not in _ADDITIVE_CACHE:
surface, bbox = None, None
try:
surface = cairo.ImageSurface.create_from_png(str(path))
bbox = PILImage.open(str(path)).getbbox()
except Exception:
pass
_ADDITIVE_CACHE[path] = (surface, bbox)
return _ADDITIVE_CACHE[path]
def _additive_for(category: str, obj) -> tuple | None:
"""The underground-tier badge overlaid on top of a Target's own icon,
or None. Target-only (see Target.underground_tier's own comment)."""
if category != "target":
return None
tier = getattr(obj, "underground_tier", None)
if tier is None:
return None
surface, bbox = _additive_surface(icons.underground_icon_path(tier))
if surface is None:
return None
return (surface, bbox)
class GridCanvas(Gtk.DrawingArea): class GridCanvas(Gtk.DrawingArea):
def __init__(self, board: Board) -> None: def __init__(self, board: Board) -> None:
super().__init__() super().__init__()
@ -817,7 +856,8 @@ class GridCanvas(Gtk.DrawingArea):
dim=(category == "target" and not obj.alive) or obj.hidden, dim=(category == "target" and not obj.alive) or obj.hidden,
selected=(obj is self.selected), coord=obj.coord, selected=(obj is self.selected), coord=obj.coord,
extra_line=getattr(obj, "requested_time", None), extra_line=getattr(obj, "requested_time", None),
icon_surface=_icon_for(category, obj)) icon_surface=_icon_for(category, obj),
additive=_additive_for(category, obj))
for category, obj in self.board.ambiguous_entities_all(): for category, obj in self.board.ambiguous_entities_all():
if self._excluded_from_map(obj): if self._excluded_from_map(obj):
@ -878,7 +918,8 @@ class GridCanvas(Gtk.DrawingArea):
def _draw_marker(self, cr, view, point_km, color, label, def _draw_marker(self, cr, view, point_km, color, label,
canvas_width, canvas_height, *, hollow=False, dim=False, canvas_width, canvas_height, *, hollow=False, dim=False,
selected=False, coord=None, extra_line=None, icon_surface=None) -> None: selected=False, coord=None, extra_line=None, icon_surface=None,
additive=None) -> None:
x, y = self._km_to_px(view, point_km) x, y = self._km_to_px(view, point_km)
r, g, b = color r, g, b = color
alpha = 0.45 if dim else 1.0 alpha = 0.45 if dim else 1.0
@ -898,6 +939,8 @@ class GridCanvas(Gtk.DrawingArea):
# plain filled dot is more honest about the current zoom level. # plain filled dot is more honest about the current zoom level.
if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX: if not hollow and icon_surface is not None and view.cell_w >= ICON_MIN_CELL_PX:
self._draw_icon_marker(cr, x, y, icon_surface, alpha) self._draw_icon_marker(cr, x, y, icon_surface, alpha)
if additive is not None:
self._draw_additive_badge(cr, x, y, additive, alpha)
elif hollow: elif hollow:
cr.new_path() # cairo's arc() draws a line from any stale current cr.new_path() # cairo's arc() draws a line from any stale current
cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's cr.set_source_rgba(r, g, b, alpha) # point (e.g. the last label's
@ -962,6 +1005,48 @@ class GridCanvas(Gtk.DrawingArea):
cr.paint_with_alpha(alpha) cr.paint_with_alpha(alpha)
cr.restore() cr.restore()
# How far the badge's content bbox sinks into the icon's, in the
# icon's own 32px box units. Both the diamond's top corner and the
# Armor badge's bottom are tapered to a near-point, not a flat edge
# (see assets/icons/targets/enemy/Enemy_Infantry.png and the Armor
# additives) -- lining up their bboxes exactly *touching* leaves them
# meeting at a single pixel with no visual mass on either side of it,
# which still reads as a gap. A real pixel overlap is what actually
# looks contiguous, confirmed against the game's own stacked-badge
# screenshots (stars/helmet/diamond all overlapping, not edge-to-edge).
_ADDITIVE_OVERLAP_PX = 10.0
def _draw_additive_badge(self, cr, x, y, additive, alpha) -> None:
"""A badge (underground tier, currently the only additive) drawn
directly north of the icon marker, overlapping down into it by
`_ADDITIVE_OVERLAP_PX`, at the same full size as the marker
itself -- stacked above it rather than shrunk into a corner, so
it reads as its own clearly-legible symbol, not a tiny decoration
obscuring the unit icon it modifies.
Scaled/positioned off the source art's actual opaque content
(`bbox`), not its full canvas: the additive PNGs carry a lot of
transparent padding that isn't even centered (see _ADDITIVE_CACHE's
comment), so sizing/placing off the raw canvas made the badge look
tiny and float with a visible gap above the icon -- using bbox
instead makes what's actually drawn sit right against it."""
surface, bbox = additive
sw, sh = surface.get_width(), surface.get_height()
left, top, right, bottom = bbox if bbox is not None else (0, 0, sw, sh)
content_w, content_h = right - left, bottom - top
if content_w <= 0 or content_h <= 0:
return
box = 32.0 # same visual size as the icon marker's own box
scale = box / max(content_w, content_h)
icon_top = y - 16 # _draw_icon_marker's own box=32, centered on y
ty = icon_top - bottom * scale + self._ADDITIVE_OVERLAP_PX
cr.save()
cr.translate(x - (left + right) / 2 * scale, ty)
cr.scale(scale, scale)
cr.set_source_surface(surface, 0, 0)
cr.paint_with_alpha(alpha)
cr.restore()
def _draw_firing_arrows(self, cr, view) -> None: def _draw_firing_arrows(self, cr, view) -> None:
"""Red arrow(s) Nest -> Target, for whatever's hovered or selected. """Red arrow(s) Nest -> Target, for whatever's hovered or selected.
Points at exactly the hovered/selected candidate when one is known Points at exactly the hovered/selected candidate when one is known

View File

@ -449,6 +449,76 @@ def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, wid
return _plain_dot(is_ally, width) return _plain_dot(is_ally, width)
_ADDITIVES_DIR = _ICONS_DIR / "targets" / "additives"
UNDERGROUND_TIERS = (1, 2, 3)
def underground_icon_path(tier: int) -> Path:
"""The badge overlaid on a Target's own icon when it's marked
underground at this tier (1..3, harder to hit = higher). Reuses the
game's own Armor-tier additive art (assets/icons/targets/additives/
Additive_Armor{1,2,3}.png) rather than inventing bespoke "underground"
art of our own -- there's nothing else in the game's icon set for
"buried/fortified", and Armor's own visual (a plate) already reads
right for that."""
return _ADDITIVES_DIR / f"Additive_Armor{tier}.png"
def underground_tier_image(tier: int | None, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget:
"""A widget for one cell of the underground-tier picker: the additive
badge itself for a real tier, or a plain dot (this module's usual
'nothing chosen' placeholder) for the "not underground" cell."""
if tier is None:
return _plain_dot(False, width)
path = underground_icon_path(tier)
if path.exists():
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, -1, True)
picture = Gtk.Picture.new_for_pixbuf(pixbuf)
picture.set_content_fit(Gtk.ContentFit.CONTAIN)
picture.set_can_shrink(True)
picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
return picture
return _plain_dot(False, width)
def build_underground_tier_grid(selected: int | None, on_pick) -> Gtk.Widget:
"""Same radio-style grid idea as build_target_type_grid, just over
(None, 1, 2, 3) instead of TargetType -- None first, as "not
underground" (clearing an existing tier) is exactly as valid a pick
as any real tier, not a separate "remove" action bolted on
afterward."""
_ensure_icon_button_css()
leader: Gtk.ToggleButton | None = None
items = [None, *UNDERGROUND_TIERS]
def make_button(tier: int | None) -> Gtk.Widget:
nonlocal leader
cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
margin_top=4, margin_bottom=4, margin_start=2, margin_end=2)
cell.append(underground_tier_image(tier))
label = Gtk.Label(label="None" if tier is None else f"Tier {tier}",
wrap=False, single_line_mode=True,
justify=Gtk.Justification.CENTER, width_chars=9,
max_width_chars=9, ellipsize=Pango.EllipsizeMode.END)
label.add_css_class("caption")
label.add_css_class("dim-label")
cell.append(label)
btn = Gtk.ToggleButton(child=cell)
btn.add_css_class("flat")
btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
btn.set_tooltip_text("Not underground" if tier is None else f"Underground, tier {tier}")
if leader is None:
leader = btn
else:
btn.set_group(leader)
if tier is selected:
btn.set_active(True)
btn.connect("clicked", lambda _b, tier=tier: on_pick(tier))
return btn
return _build_icon_grid(items, _TYPE_GRID_COLUMNS, make_button)
def _has_own_icon(t: "TargetType", is_ally: bool) -> bool: def _has_own_icon(t: "TargetType", is_ally: bool) -> bool:
"""Whether THIS side specifically has real art for t -- as opposed to """Whether THIS side specifically has real art for t -- as opposed to
target_icon_path() quietly handing back the other side's icon because target_icon_path() quietly handing back the other side's icon because

View File

@ -411,6 +411,10 @@ class Target:
# raw string as printed, this app doesn't track a game clock to compare # raw string as printed, this app doesn't track a game clock to compare
# it against, it's shown as-is for the player's own reference. # it against, it's shown as-is for the player's own reference.
requested_time: str | None = None requested_time: str | None = None
# None = not underground. 1..3 = underground, at that hardening tier
# (see icons.UNDERGROUND_TIERS) -- higher survives more. Target-only:
# there's no such thing as an underground Ally in this game.
underground_tier: int | None = None
@property @property
def name(self) -> str: def name(self) -> str:
@ -770,6 +774,7 @@ class Board:
"shell": t.shell.name if t.shell is not None else None, "shell": t.shell.name if t.shell is not None else None,
"assignment": t.assignment, "assignment": t.assignment,
"requested_time": t.requested_time, "requested_time": t.requested_time,
"underground_tier": t.underground_tier,
} }
for t in self.targets for t in self.targets
], ],
@ -837,6 +842,7 @@ class Board:
shell=Shell[t["shell"]] if t.get("shell") else None, shell=Shell[t["shell"]] if t.get("shell") else None,
assignment=t.get("assignment", "unassigned"), assignment=t.get("assignment", "unassigned"),
requested_time=t.get("requested_time"), requested_time=t.get("requested_time"),
underground_tier=t.get("underground_tier"),
) )
for t in data.get("targets", []) for t in data.get("targets", [])
] ]