"""Bundled game-icon lookup and the shared shell picker built from them. Icon files live in the repo's assets/icons/ (see its README.md for where they came from and which source-filename typos got corrected on copy), outside the src/ package, resolved relative to this file rather than the process's cwd so it works no matter where the app was launched from. """ from __future__ import annotations from pathlib import Path import gi gi.require_version("Gdk", "4.0") gi.require_version("GdkPixbuf", "2.0") gi.require_version("Gtk", "4.0") from gi.repository import Gdk, GdkPixbuf, Gtk, Pango # noqa: E402 import cairo from .models import TargetType from .shells import Shell _ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons" _ICON_BUTTON_CSS_CLASS = "fenigma-icon-button" _css_loaded = False NEST_ICON_PATH = _ICONS_DIR / "nest" / "IronNest.png" STRIKE_ICON_PATH = _ICONS_DIR / "misc" / "Crosshair.png" # TargetType -> (enemy_basename, friendly_basename), after 'Enemy_'/ # 'Friendly_'. Either half is None where the game draws no icon for that # type on that side at all -- that's not rare enough on either side to # treat as an exception list bolted onto a shared-name table (the earlier # shape of this code: one basename table plus two separate patch dicts for # "actually the friendly filename differs" and "actually this side has # none at all", which was easy to update inconsistently and silently do # the wrong thing for one side). One explicit pair per type, covering # EVERY TargetType, is the actual shape of the data: a basename shared by # both sides, a basename that differs (a genuine filename mismatch in the # source assets, not a difference in what's drawn, see assets/icons/ # README.md), or a basename that exists on only one side. # # UNKNOWN/ENEMY are deliberately (None, None): generic/ad-hoc, not a unit # the game draws specific art for (see their comments on TargetType). # STRIKE isn't in this table at all: its crosshair isn't an Enemy_/ # Friendly_ file, it's handled as a special case in target_icon_path(). # completeness of this table (every TargetType except STRIKE has a row) is # asserted below, not just hoped for. _TARGET_ICON = { TargetType.UNKNOWN: (None, None), TargetType.ENEMY: (None, None), TargetType.ANTI_AIR: ("AA.png", "AA.png"), TargetType.ANTI_TANK: ("AntiTank.png", "AntiTank.png"), TargetType.ARTILLERY: ("Field Artillery.png", "Field Artillery.png"), TargetType.ARTILLERY_OBSERVER: ("Field Artillery Observer.png", "Field Artillery Observer.png"), TargetType.HEAVY_GUN_TURRET: ("Heavy_Gun_Turret.png", None), TargetType.INFANTRY: ("Infantry.png", "Infantry.png"), TargetType.INFANTRY_MECHANIZED: ("Infantry_mechanized.png", "Infantry_Mechanized.png"), # case differs TargetType.MECH_ANTI_TANK: (None, "Mech_AntiTank.png"), # friendly-only TargetType.MECHANIZED: ("Armor_Mechanized.png", "Armor_Mechanized.png"), TargetType.PILLBOX: ("Heavy_Gun_Bunker.png", None), TargetType.TANK: ("Armor_Mechanized.png", "Armor_Mechanized.png"), # shares MECHANIZED's art, see TargetType TargetType.BASE: ("Base.png", "Military Base.png"), # name differs TargetType.COMMANDER: ("Commander.png", "Commander.png"), TargetType.FDC: ("Fire Direction Center.png", None), TargetType.FORT: (None, "Fort.png"), # friendly-only TargetType.GENERAL: (None, "General.png"), # friendly-only TargetType.KING: (None, "King.png"), # friendly-only TargetType.MARINE_GARRISON: ("Marine.png", "Marine.png"), TargetType.POLICE: (None, "Police.png"), # friendly-only TargetType.SUPPLY_CACHE: ("Ammunition Cache.png", "Ammunition Cache.png"), TargetType.UNDERGROUND_FORT: ("Underground Fort.png", None), TargetType.EMERGENCY_MEDICAL: ("Emergency Medical Operation.png", "Emergency Medical Operation.png"), TargetType.HOSPITAL: (None, "Hospital.png"), # friendly-only TargetType.MEDICAL: ("Medical.png", "Medical.png"), TargetType.MEDICAL_FACILITY: ("Medical Treatment Facility.png", "Medical Treatment Facility.png"), TargetType.CIVIL_MILITARY: (None, "Civil–Military.png"), # friendly-only TargetType.CIVILIAN: ("Civ.png", "Civilian.png"), # name differs TargetType.CIVIL_RIOTING: ("Civil Rioting.png", "Civil Rioting.png"), TargetType.RIOTING: ("Rioting.png", None), TargetType.TV_RADIO_PROPAGANDA: ("TV and Radio Propaganda.png", "TV and Radio Propaganda.png"), TargetType.PORT: ("Port.png", "Port.png"), TargetType.SHIP: ("Ship.png", None), TargetType.SHIP_ENGINE: ("Ship_Engine.png", None), TargetType.SHIP_FDC: ("Ship_FDC.png", None), TargetType.SHIP_STRIPE: ("Ship_Stripe.png", "Ship_stripe.png"), # case differs TargetType.SHIP_TURRET: ("Ship_Turret.png", None), TargetType.TRAIN_LOCOMOTIVE: ("Train_Locomotive.png", None), TargetType.TRAIN_STATION: ("Train_Station.png", "Train_Station.png"), TargetType.TRAIN_TRANSPORT: ("Train_Transport.png", None), TargetType.RECON: ("Recon.png", "Reconnaissance.png"), # name differs TargetType.RECON_LISTENING: ("Recon_Listening.png", "Recon_Listening.png"), } assert {*_TARGET_ICON} | {TargetType.STRIKE, TargetType.STRIKE_REQUEST} == {*TargetType}, ( "every TargetType needs a row in _TARGET_ICON (STRIKE/STRIKE_REQUEST " "are the deliberate exceptions, see the comment above target_icon_path)" ) def _icon_for_side(target_type: TargetType, is_ally: bool) -> Path | None: """This SIDE's own icon for target_type specifically, with no cross-side fallback -- used both by target_icon_path() (which adds the fallback back on top) and by _has_own_icon() (which needs to know whether this side has real art of its own, not whether *some* art is available after falling back).""" entry = _TARGET_ICON.get(target_type) if entry is None: return None basename = entry[1 if is_ally else 0] if basename is None: return None folder, prefix = ("friendly", "Friendly_") if is_ally else ("enemy", "Enemy_") path = _ICONS_DIR / "targets" / folder / f"{prefix}{basename}" return path if path.exists() else None def target_type_from_icon(basename: str | None) -> TargetType | None: """Inverse of _TARGET_ICON, for the map-vision marker classifier, which names what it matched by icon file rather than by TargetType. Not injective: MECHANIZED and TANK share Armor_Mechanized.png, so that one resolves to MECHANIZED and the user retypes it if it was a Tank (map right-click -> Change type). Icons with no TargetType at all give None, which callers treat as UNKNOWN. """ if not basename: return None name = basename if basename.lower().endswith(".png") else f"{basename}.png" for prefix in ("Enemy_", "Friendly_"): if name.startswith(prefix): name = name[len(prefix):] for type_, (enemy_basename, friendly_basename) in _TARGET_ICON.items(): if name in (enemy_basename, friendly_basename): return type_ return None def target_icon_path(target_type: TargetType, is_ally: bool = False) -> Path | None: """Icon file for a Target or Ally's type, or None if there isn't a good one. `is_ally` picks the friendly side of _TARGET_ICON over the enemy one, falling back to the enemy icon if this particular type has no friendly art of its own at all (the two sets aren't the same size, see assets/icons/README.md). STRIKE/STRIKE_REQUEST (a planned impact point, not a unit -- player-placed vs called in by a friendly, see STRIKE_REQUEST's own comment) both get the same crosshair rather than a unit icon, neither fits the Enemy_/Friendly_ naming scheme at all.""" if target_type in (TargetType.STRIKE, TargetType.STRIKE_REQUEST): return STRIKE_ICON_PATH own = _icon_for_side(target_type, is_ally) if own is not None: return own return _icon_for_side(target_type, is_ally=False) if is_ally else None def _ensure_icon_button_css() -> None: """A plain 'flat' Gtk.Button still carries libadwaita's normal button padding/min-size, fine for a text label, way too much empty chrome around a single icon (the button ends up visibly larger than the icon it holds). Loaded lazily (not at import time) and only once, a headless import (e.g. from a test) shouldn't need a live display. The border is reserved at a fixed 2px, transparent, on every one of these buttons all the time, not just the checked one, same reasoning as the firing card's own selection border (see app.py's _FIRING_CARD_CSS): without a border reserved on the unchecked state too, toggling a Gtk.ToggleButton's :checked state would shift its content inward by however wide the border is instead of just changing its color.""" global _css_loaded if _css_loaded: return display = Gdk.Display.get_default() if display is None: return # GTK's CSS has no !important (tried it, GTK's own parser rejects it # outright, 'Junk at end of value'), the only way to beat # libadwaita's own padding/min-size rules is a more specific # selector, not a stronger declaration, a bare '.' wasn't # enough on its own. Both type selectors are needed, not just # 'button': Gtk.Button's and Gtk.ToggleButton's CSS node is actually # named 'button' (so that part did work), but Gtk.MenuButton's is # its own distinct 'menubutton' node, a 'button.' selector # silently never matches it at all, which is why the icon-only # MenuButton face specifically kept its full padding even after # adding the type selector (verified: identical extra width/height # before and after, because the rule was matching zero elements). provider = Gtk.CssProvider() provider.load_from_string(f""" button.{_ICON_BUTTON_CSS_CLASS}, menubutton.{_ICON_BUTTON_CSS_CLASS}, menubutton.{_ICON_BUTTON_CSS_CLASS} > button {{ padding: 2px; min-width: 0; min-height: 0; border: 2px solid transparent; }} button.{_ICON_BUTTON_CSS_CLASS}:checked, menubutton.{_ICON_BUTTON_CSS_CLASS}:checked {{ border-color: @accent_bg_color; }} """) Gtk.StyleContext.add_provider_for_display(display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) _css_loaded = True def shell_icon_path(shell_name: str) -> Path: return _ICONS_DIR / "shells" / f"{shell_name}.png" def shell_icon_image(shell_name: str, width: int = 64) -> Gtk.Widget: """A widget showing a Shell enum member's icon, scaled to `width` px wide (the source art, after cropping out its built-in padding, is roughly 2.5:1, height follows proportionally). Two real bugs got fixed here in turn, both about GTK not sizing the widget the way it looks like it should from the code: - Gtk.Image caps displayed size to GTK's icon-size classes (built for symbolic 16/32px icons), rendering tiny regardless of the source file's actual resolution. - Gtk.Picture avoids that, but loading the full-resolution file and only *hinting* a size via set_size_request() doesn't work either: Picture's own natural-size request is the source image's full native resolution (512x256) no matter what size_request says, so depending on the surrounding layout it could end up either way too large (a container honoring that huge natural request) or inconsistently small (one clamping it back down). Pre-scaling the actual pixel data with GdkPixbuf first, then wrapping *that* already-correctly-sized image, makes the natural size request correct in the first place, nothing left to fight the layout about. Falls back to a generic missing-image icon rather than raising, a gap in the icon set shouldn't crash the shell picker.""" path = shell_icon_path(shell_name) 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 image = Gtk.Image.new_from_icon_name("image-missing-symbolic") image.set_pixel_size(width // 2) return image _GRID_ICON_WIDTH = 88 # per-cell icon in the picker grid, large enough to actually read _GRID_COLUMNS = 3 def _shell_radius_text(s: Shell) -> str: return f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius" def _shell_cell(s: Shell) -> Gtk.Widget: cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, margin_top=4, margin_bottom=4, margin_start=4, margin_end=4) cell.append(shell_icon_image(s.name, width=_GRID_ICON_WIDTH)) radius_label = Gtk.Label(label=_shell_radius_text(s)) radius_label.add_css_class("caption") radius_label.add_css_class("dim-label") cell.append(radius_label) return cell def _build_icon_grid(items, columns, make_button) -> Gtk.Widget: """Shared grid layout: rows of up to `columns` buttons, one per item in `items`, each built by `make_button(item) -> Gtk.Widget`. Used by every icon-grid picker in this module (shells, target types). A plain nested Gtk.Box grid, not a Gtk.FlowBox, on purpose, after two FlowBox attempts both broke in different ways: a ScrolledWindow sizes to its content's *minimum* size unless told otherwise (a first pass squeezed to a near-unreadable width because of that), and separately, FlowBox's own reported natural width (queried with no fixed allocation yet) turned out to mean 'fit every child on one line', ignoring max_children_per_line entirely, so min/max-content- width on a ScrolledWindow around it never actually took effect (verified directly: it kept ballooning out to fit every item in a single row regardless of what those properties were set to). Each of these sets is small and fixed, there's no real need for FlowBox's dynamic reflow-to-fewer-columns behavior here, a manual grid of fixed-size rows has a fully deterministic natural width (columns * cell width, nothing else involved) and sidesteps the whole class of bug.""" items = list(items) grid = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4, margin_top=8, margin_bottom=8, margin_start=8, margin_end=8) for start in range(0, len(items), columns): row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True) for it in items[start:start + columns]: row_box.append(make_button(it)) grid.append(row_box) return grid def _build_shell_grid(make_button) -> Gtk.Widget: return _build_icon_grid(Shell, _GRID_COLUMNS, make_button) def build_shell_popover(on_pick) -> Gtk.Popover: """Popover with a grid of every Shell (icon + blast radius under it, full description as a tooltip), replacing a plain text dropdown/list with something that actually shows what each shell looks like. hscrollbar_policy=NEVER is a backstop against a horizontal scrollbar ever appearing, not everyone has a horizontal scroll wheel. Calls `on_pick(shell)` and closes itself when a cell is clicked. Meant for a context tight on space (a firing card, see build_shell_button below), where hiding the options behind a click is worth it, for a dialog with room to spare, build_shell_grid() below shows them all up front instead.""" popover = Gtk.Popover() _ensure_icon_button_css() def make_button(s: Shell) -> Gtk.Widget: btn = Gtk.Button(child=_shell_cell(s)) btn.add_css_class("flat") btn.add_css_class(_ICON_BUTTON_CSS_CLASS) btn.set_tooltip_text(f"{s.name}: {s.description} ({_shell_radius_text(s)} blast radius)") btn.connect("clicked", lambda _b, s=s: (popover.popdown(), on_pick(s))) return btn scroller = Gtk.ScrolledWindow( max_content_height=440, propagate_natural_height=True, hscrollbar_policy=Gtk.PolicyType.NEVER, ) scroller.set_child(_build_shell_grid(make_button)) popover.set_child(scroller) return popover def build_shell_grid(selected: Shell, on_pick) -> Gtk.Widget: """Inline radio-style grid of every Shell, for a 'pick one before proceeding' context (the Add Strike dialog) with room to just show every option up front rather than hiding them behind a submenu click. Exactly one cell is ever highlighted (Gtk.ToggleButton. set_group() makes them mutually exclusive), `on_pick(shell)` fires whenever the active one changes.""" _ensure_icon_button_css() leader: Gtk.ToggleButton | None = None def make_button(s: Shell) -> Gtk.Widget: nonlocal leader btn = Gtk.ToggleButton(child=_shell_cell(s)) btn.add_css_class("flat") btn.add_css_class(_ICON_BUTTON_CSS_CLASS) btn.set_tooltip_text(f"{s.name}: {s.description} ({_shell_radius_text(s)} blast radius)") if leader is None: leader = btn else: btn.set_group(leader) if s is selected: btn.set_active(True) btn.connect("toggled", lambda b, s=s: on_pick(s) if b.get_active() else None) return btn return _build_shell_grid(make_button) def build_shell_button(selected: Shell, on_pick, *, show_label: bool = True, icon_width: int = 28) -> Gtk.MenuButton: """A flat MenuButton showing the currently selected shell's icon (plus its name, unless `show_label` is False, the icon already has the shell's short code baked in, redundant next to a firing card that's tight on space), opening build_shell_popover() to change it. `on_pick(shell)` fires on selection, after this button's own face has already been updated to match, the caller only needs to react to the new value (persist it, refresh dependents), not maintain the button's display.""" btn = Gtk.MenuButton() btn.add_css_class("flat") if not show_label: _ensure_icon_button_css() btn.add_css_class(_ICON_BUTTON_CSS_CLASS) def render(s: Shell) -> None: if show_label: content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) content.append(shell_icon_image(s.name, width=icon_width)) content.append(Gtk.Label(label=s.name)) else: content = shell_icon_image(s.name, width=icon_width) btn.set_child(content) def handle_pick(s: Shell) -> None: render(s) on_pick(s) render(selected) btn.set_popover(build_shell_popover(handle_pick)) return btn # ---- TargetType icon-grid picker, same idea as the Shell picker above ---- _TYPE_GRID_ICON_WIDTH = 40 # smaller than the shell grid's: ~35 types vs 9 shells, _TYPE_GRID_COLUMNS = 5 # needs to fit a lot more cells in the same dialog width # Mirrors grid_widget.py's CATEGORY_COLOR["target"]/["ally"] (the colors the # map itself draws the plain-dot fallback in). Duplicated rather than # imported: grid_widget.py already imports this module for icon lookups, an # import the other way would be circular. Unlike that module's palette, # these two are not theme-swapped live -- the picker is a modal dialog, not # the persistent map, redrawing it on a theme change isn't worth the wiring. _DOT_COLOR = {False: (0.92, 0.30, 0.28), True: (0.30, 0.85, 0.85)} def _plain_dot(is_ally: bool, width: int) -> Gtk.Widget: """The same 'plain dot' fallback the map itself draws for a type with no dedicated icon (see grid_widget.py's _icon_for), so a type with no icon reads as 'this type has no special marker' rather than as a rendering gap in the picker.""" area = Gtk.DrawingArea() area.set_content_width(width) area.set_content_height(width) def draw(_area, cr, w, h): cr.set_source_rgb(*_DOT_COLOR[is_ally]) cr.arc(w / 2, h / 2, min(w, h) * 0.32, 0, 2 * 3.141592653589793) cr.fill() area.set_draw_func(draw) return area def target_type_icon_image(target_type: "TargetType", is_ally: bool = False, width: int = _TYPE_GRID_ICON_WIDTH) -> Gtk.Widget: """A widget showing target_type's icon (friendly or enemy art per `is_ally`), scaled to `width` px wide. Falls back to the same plain dot the map itself draws for the types with no dedicated icon (UNKNOWN, ENEMY -- see icons.py's _TARGET_ICON_BASENAME comment; STRIKE always has its crosshair), so every cell in the grid stays the same size whether or not it has real art.""" path = target_icon_path(target_type, is_ally=is_ally) if path is not None and 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(is_ally, width) def _has_own_icon(t: "TargetType", is_ally: bool) -> bool: """Whether THIS side specifically has real art for t -- as opposed to target_icon_path() quietly handing back the other side's icon because this side has none of its own (see its own docstring). Used to keep that cross-side fallback out of the picker grids entirely: showing a red diamond as an option for 'Add ally', or offering 'King'/'Police'/ etc. (friendly-only, see _TARGET_ICON) as an enemy type, reads as a real option of the wrong side rather than a missing-icon placeholder. UNKNOWN and ENEMY are the exception: deliberately generic/icon-less on BOTH sides (see their comments on TargetType), always offered regardless.""" if t in (TargetType.UNKNOWN, TargetType.ENEMY): return True return _icon_for_side(t, is_ally) is not None def available_target_types(is_ally: bool = False): """TargetType members worth offering in a picker for this side. STRIKE/STRIKE_REQUEST are never offered: neither is a unit type at all (a planned impact point, not a contact), each is always created through its own path instead -- STRIKE via app.py's dedicated "Add strike" action, STRIKE_REQUEST via ocr.py parsing a fire-support request -- never by picking a type from this generic grid. There's no such thing as a Strike-typed Ally either, offering either one here is just confusing, not merely unlikely. Otherwise: each side only offers types it actually has its own art for (see _has_own_icon / _TARGET_ICON) -- some types are enemy-only and some are friendly-only (King, Police, a friendly hospital, ...), the game simply doesn't draw an installation of every kind on both sides.""" return [ t for t in TargetType if t not in (TargetType.STRIKE, TargetType.STRIKE_REQUEST) and _has_own_icon(t, is_ally) ] def target_type_label(t: "TargetType", is_ally: bool) -> str: """Display text for a picker cell/tooltip, or any other UI spot that would otherwise print obj.type.value directly (map popover headings, "Change type" buttons, toasts, ...). TargetType.ENEMY's own value is literally 'Enemy' (it's the word the game's OCR'd text uses for an ad-hoc *hostile* installation, see TargetType's own comment) -- exactly right in the enemy picker, but confusing in the Ally one, where the very same generic/ad-hoc-named-unit case reads as 'Enemy' is somehow a kind of Ally. Cosmetic only: the underlying TargetType stored on the entity is still ENEMY either way, only the label shown changes -- callers that need an id-safe short form (Ally.name etc.) keep using TargetType.short, not this.""" if is_ally and t is TargetType.ENEMY: return "Ally" return t.value # Old private name, kept as an alias: nothing outside this module should # gain a new dependency on it, but this file's own internal callers below # were written against it. _target_type_label = target_type_label def _target_type_cell(t: "TargetType", is_ally: bool) -> Gtk.Widget: """Icon + name, both a FIXED size regardless of how long the name is -- a real cell size that varies with its label text (three-line names next to one-line ones) makes every row in the grid a different height, which reads as broken/uneven rather than a grid. One line, ellipsized, with the full name in the button's tooltip (see build_target_type_grid) covers the names a single line can't fit.""" cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, margin_top=4, margin_bottom=4, margin_start=2, margin_end=2) cell.append(target_type_icon_image(t, is_ally=is_ally)) name_label = Gtk.Label(label=_target_type_label(t, is_ally), wrap=False, single_line_mode=True, justify=Gtk.Justification.CENTER, width_chars=9, max_width_chars=9, ellipsize=Pango.EllipsizeMode.END) name_label.add_css_class("caption") name_label.add_css_class("dim-label") cell.append(name_label) return cell def build_target_type_grid(selected: "TargetType | None", on_pick, *, is_ally: bool = False) -> Gtk.Widget: """Inline radio-style grid of every available TargetType (icon + name below, same idea as build_shell_grid), replacing the old plain-text dropdown/list. `is_ally` both picks the friendly icon set over the enemy one and restricts the offered types to ones with real friendly art (see available_target_types). Exactly one cell is ever highlighted (`selected`, or none if `selected` is None or not offered on this side). `on_pick(target_type)` fires on every click, including a click on the already-selected cell -- deliberately listening for "clicked", not "toggled": a ToggleButton in a radio group doesn't emit "toggled" when you click the one that's already active (nothing about its state changed), which meant clicking the pre-selected default -- usually exactly the type someone wants, e.g. plain "Target" -- silently did nothing. "clicked" fires every time regardless, so confirming the default now works the same as picking anything else. See _target_type_cell for why the name label is single-line and ellipsized rather than wrapped: an unbounded label size, besides making uneven-height rows, could also (being inside a homogeneous row) stretch every cell in that row wide enough to force the whole dialog into horizontal scrolling -- coord_dialog.py's ScrolledWindow has hscrollbar_policy=NEVER as a backstop against that same failure.""" _ensure_icon_button_css() leader: Gtk.ToggleButton | None = None def make_button(t: "TargetType") -> Gtk.Widget: nonlocal leader btn = Gtk.ToggleButton(child=_target_type_cell(t, is_ally)) btn.add_css_class("flat") btn.add_css_class(_ICON_BUTTON_CSS_CLASS) btn.set_tooltip_text(_target_type_label(t, is_ally)) if leader is None: leader = btn else: btn.set_group(leader) if t is selected: btn.set_active(True) btn.connect("clicked", lambda _b, t=t: on_pick(t)) return btn return _build_icon_grid(available_target_types(is_ally), _TYPE_GRID_COLUMNS, make_button)