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>
This commit is contained in:
Dominik Moritz Roth 2026-08-09 19:28:41 +02:00
parent a8f77b98cb
commit 3b209e743e
20 changed files with 224 additions and 63 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -183,9 +183,14 @@ def _location_status(obj) -> str:
_FIRING_CARD_CSS = """ _FIRING_CARD_CSS = """
.firing-card { transition: background-color 150ms ease, border-color 150ms ease; } /* 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-hovered { background-color: alpha(@accent_color, 0.12); }
.firing-card-selected { border: 2px solid @accent_color; } .firing-card-selected { border-color: @accent_color; }
""" """
@ -965,7 +970,7 @@ class MainWindow(Adw.ApplicationWindow):
the map to place it, a Strike is just a Target with the map to place it, a Strike is just a Target with
TargetType.STRIKE and an explicit shell, and its position is set TargetType.STRIKE and an explicit shell, and its position is set
by clicking, not typing in coordinates.""" by clicking, not typing in coordinates."""
dialog = Adw.Dialog(title="Add Strike", content_width=340, content_height=200) dialog = Adw.Dialog(title="Add Strike", content_width=400, content_height=440)
toolbar_view = Adw.ToolbarView() toolbar_view = Adw.ToolbarView()
dialog.set_child(toolbar_view) dialog.set_child(toolbar_view)
toolbar_view.add_top_bar(Adw.HeaderBar()) toolbar_view.add_top_bar(Adw.HeaderBar())
@ -976,10 +981,11 @@ class MainWindow(Adw.ApplicationWindow):
label.add_css_class("heading") label.add_css_class("heading")
outer.append(label) 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] chosen = [Shell.HCHE]
shell_btn = icons.build_shell_button(chosen[0], lambda s: chosen.__setitem__(0, s)) outer.append(icons.build_shell_grid(chosen[0], lambda s: chosen.__setitem__(0, s)))
shell_btn.set_halign(Gtk.Align.START)
outer.append(shell_btn)
next_btn = Gtk.Button(label="Next: click the map to place it") next_btn = Gtk.Button(label="Next: click the map to place it")
next_btn.add_css_class("suggested-action") next_btn.add_css_class("suggested-action")

View File

@ -36,6 +36,51 @@ from .shells import Shell
_SELECTED_CSS = "firing-card-selected" _SELECTED_CSS = "firing-card-selected"
_HOVERED_CSS = "firing-card-hovered" _HOVERED_CSS = "firing-card-hovered"
_CHARGE_SEG_CSS_CLASS = "firing-charge-segment"
_CHARGE_SEG_ON = "charge-seg-on"
_CHARGE_SEG_OFF = "charge-seg-off"
_charge_seg_css_loaded = False
def _ensure_charge_segment_css() -> None:
"""Powder-charge segment buttons used to rely on plain 'flat'/
'suggested-action' for their off/on look: flat draws no background
or border at all, so once the shell icon next to them made the row
taller (and the segments, with no valign set, stretched to fill
that height instead of staying a fixed 14px circle), an unselected
segment became an invisible blank area and a selected one an
elongated blue oval instead of a small circle. This gives them
their own fixed size (immune to row height) and a visible outline
when off, not just 'not colored in yet'."""
global _charge_seg_css_loaded
if _charge_seg_css_loaded:
return
display = Gdk.Display.get_default()
if display is None:
return
# button.<class> (type + class), not just .<class>: GTK CSS has no
# !important, a more specific selector is the only way to actually
# beat libadwaita's own button padding/min-size rules.
provider = Gtk.CssProvider()
provider.load_from_string(f"""
button.{_CHARGE_SEG_CSS_CLASS} {{
min-width: 20px;
min-height: 20px;
padding: 0;
border-radius: 9999px;
}}
button.{_CHARGE_SEG_CSS_CLASS}.{_CHARGE_SEG_OFF} {{
background-color: transparent;
border: 1.5px solid alpha(currentColor, 0.45);
}}
button.{_CHARGE_SEG_CSS_CLASS}.{_CHARGE_SEG_ON} {{
background-color: @accent_bg_color;
border: 1.5px solid @accent_bg_color;
}}
""")
Gtk.StyleContext.add_provider_for_display(display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
_charge_seg_css_loaded = True
_SHOW_DEAD_STATES = ["hide", "show", "sort_later"] _SHOW_DEAD_STATES = ["hide", "show", "sort_later"]
_SHOW_DEAD_ICONS = { _SHOW_DEAD_ICONS = {
"hide": "view-conceal-symbolic", "hide": "view-conceal-symbolic",
@ -355,6 +400,12 @@ class FiringPanel(Gtk.Box):
elev_caption.add_css_class("dim-label") elev_caption.add_css_class("dim-label")
elev_value = Gtk.Label(label=f"{ballistics.elevation_deg(dist, charges):.2f}°", xalign=0) elev_value = Gtk.Label(label=f"{ballistics.elevation_deg(dist, charges):.2f}°", xalign=0)
elev_value.add_css_class("title-3") elev_value.add_css_class("title-3")
# Reserves a fixed character width regardless of the actual
# digit count ('9.33°' vs '49.33°' vs '180.00°'), otherwise the
# AZ column right after it visibly shifts sideways every time
# picking a different powder charge changes the elevation's
# digit count.
elev_value.set_width_chars(7)
dist_label = Gtk.Label(label=f"{dist:.2f}km", xalign=0) dist_label = Gtk.Label(label=f"{dist:.2f}km", xalign=0)
dist_label.add_css_class("caption") dist_label.add_css_class("caption")
dist_label.add_css_class("dim-label") dist_label.add_css_class("dim-label")
@ -379,29 +430,34 @@ class FiringPanel(Gtk.Box):
def _build_charge_row(self, target: Target, dist_km: float, min_charge: int, def _build_charge_row(self, target: Target, dist_km: float, min_charge: int,
charges: int, elev_value_label: Gtk.Label) -> Gtk.Widget: charges: int, elev_value_label: Gtk.Label) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) _ensure_charge_segment_css()
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, valign=Gtk.Align.CENTER)
# Icon only, not name-plus-icon: the icon already has the shell's # Icon only, not name-plus-icon: the icon already has the shell's
# short code baked in (see assets/icons/README.md), a redundant # short code baked in (see assets/icons/README.md), a redundant
# text label would just eat width on an already-tight card row. # text label would just eat width on an already-tight card row.
# 64px wide (the source art is a fixed 2:1 rectangle, so 32 tall) # 96px wide (the source art, after its built-in padding got
# is what it actually takes to read the baked-in code at this # cropped out, is roughly 2.5:1, so ~39 tall) is what it
# size, anything smaller and it blurs into an unreadable smear. # actually takes to read the baked-in code at a glance.
# valign=CENTER matters here specifically: it's the tallest thing
# in this row now, everything else needs to center against it
# rather than stretch to match its height (see the segments below).
shell_btn = icons.build_shell_button( shell_btn = icons.build_shell_button(
target.effective_shell, lambda s: self._pick_shell(target, s), show_label=False, icon_width=64 target.effective_shell, lambda s: self._pick_shell(target, s), show_label=False, icon_width=96
) )
shell_btn.set_valign(Gtk.Align.CENTER)
shell_btn.set_tooltip_text(f"{target.effective_shell.name}: change shell (blast radius)") shell_btn.set_tooltip_text(f"{target.effective_shell.name}: change shell (blast radius)")
row.append(shell_btn) row.append(shell_btn)
segments: list[Gtk.Button] = [] segments: list[Gtk.Button] = []
count_label = Gtk.Label(label=str(charges)) count_label = Gtk.Label(label=str(charges), valign=Gtk.Align.CENTER)
count_label.set_margin_start(4) count_label.set_margin_start(4)
def apply_fill(value: int) -> None: def apply_fill(value: int) -> None:
for i, seg in enumerate(segments, start=1): for i, seg in enumerate(segments, start=1):
seg.remove_css_class("suggested-action") seg.remove_css_class(_CHARGE_SEG_ON)
seg.remove_css_class("flat") seg.remove_css_class(_CHARGE_SEG_OFF)
seg.add_css_class("suggested-action" if i <= value else "flat") seg.add_css_class(_CHARGE_SEG_ON if i <= value else _CHARGE_SEG_OFF)
count_label.set_label(str(value)) count_label.set_label(str(value))
def on_pick(n: int) -> None: def on_pick(n: int) -> None:
@ -410,9 +466,14 @@ class FiringPanel(Gtk.Box):
elev_value_label.set_label(f"{ballistics.elevation_deg(dist_km, n):.2f}°") elev_value_label.set_label(f"{ballistics.elevation_deg(dist_km, n):.2f}°")
for n in range(1, ballistics.MAX_POWDER_CHARGE + 1): for n in range(1, ballistics.MAX_POWDER_CHARGE + 1):
seg = Gtk.Button(label=" ") # valign/halign=CENTER pins these to a real fixed 14x14 circle
seg.set_size_request(14, 14) # (see _ensure_charge_segment_css) no matter how tall the row
seg.add_css_class("circular") # around them gets, without it they silently inherit the
# default FILL alignment and stretch to match the row's
# height, which is what turned them into a giant blue oval
# once the shell icon next to them got taller than plain text.
seg = Gtk.Button(label="", valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
seg.add_css_class(_CHARGE_SEG_CSS_CLASS)
seg.set_sensitive(n >= min_charge) seg.set_sensitive(n >= min_charge)
seg.set_tooltip_text(f"{n} charge{'s' if n != 1 else ''}") seg.set_tooltip_text(f"{n} charge{'s' if n != 1 else ''}")
seg.connect("clicked", lambda _b, n=n: on_pick(n)) seg.connect("clicked", lambda _b, n=n: on_pick(n))

View File

@ -13,8 +13,9 @@ from pathlib import Path
import gi import gi
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402 from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
from .shells import Shell from .shells import Shell
@ -29,15 +30,46 @@ def _ensure_icon_button_css() -> None:
padding/min-size, fine for a text label, way too much empty chrome 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 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 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.""" 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 global _css_loaded
if _css_loaded: if _css_loaded:
return return
display = Gdk.Display.get_default() display = Gdk.Display.get_default()
if display is None: if display is None:
return 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 '.<class>' 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.<class>' 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 = Gtk.CssProvider()
provider.load_from_string(f".{_ICON_BUTTON_CSS_CLASS} {{ padding: 2px; min-width: 0; min-height: 0; }}") 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) Gtk.StyleContext.add_provider_for_display(display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
_css_loaded = True _css_loaded = True
@ -47,30 +79,44 @@ def shell_icon_path(shell_name: str) -> Path:
def shell_icon_image(shell_name: str, width: int = 64) -> Gtk.Widget: def shell_icon_image(shell_name: str, width: int = 64) -> Gtk.Widget:
"""A widget showing a Shell enum member's icon at `width` px wide """A widget showing a Shell enum member's icon, scaled to `width` px
(the source art is a fixed 2:1 rectangle, height follows). Gtk.Image wide (the source art, after cropping out its built-in padding, is
looked right in code but rendered tiny regardless of the source roughly 2.5:1, height follows proportionally).
file's real resolution, it caps displayed size to GTK's icon-size
classes (meant for symbolic 16/32px icons) no matter what you load Two real bugs got fixed here in turn, both about GTK not sizing the
into it. Gtk.Picture doesn't do that, it sizes by the image's actual widget the way it looks like it should from the code:
dimensions, which is what a real multi-hundred-pixel icon needs. - 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 Falls back to a generic missing-image icon rather than raising, a
gap in the icon set shouldn't crash the shell picker.""" gap in the icon set shouldn't crash the shell picker."""
path = shell_icon_path(shell_name) path = shell_icon_path(shell_name)
height = width // 2
if path.exists(): if path.exists():
picture = Gtk.Picture.new_for_filename(str(path)) 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_content_fit(Gtk.ContentFit.CONTAIN)
picture.set_can_shrink(True) picture.set_can_shrink(True)
picture.set_size_request(width, height) picture.set_size_request(pixbuf.get_width(), pixbuf.get_height())
return picture return picture
image = Gtk.Image.new_from_icon_name("image-missing-symbolic") image = Gtk.Image.new_from_icon_name("image-missing-symbolic")
image.set_pixel_size(min(width, height)) image.set_pixel_size(width // 2)
return image return image
_GRID_ICON_WIDTH = 88 # per-cell icon in the picker grid, large enough to actually read _GRID_ICON_WIDTH = 88 # per-cell icon in the picker grid, large enough to actually read
_GRID_COLUMNS = 4 _GRID_COLUMNS = 3
def _shell_radius_text(s: Shell) -> str: def _shell_radius_text(s: Shell) -> str:
@ -88,50 +134,96 @@ def _shell_cell(s: Shell) -> Gtk.Widget:
return cell return cell
def _build_shell_grid(make_button) -> Gtk.Widget:
"""Shared grid layout: rows of up to _GRID_COLUMNS buttons, one per
Shell, each built by `make_button(shell) -> Gtk.Widget`. Used by
both the popover picker and the inline radio-style grid below.
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 shell in a
single row regardless of what those properties were set to). Shell
is a small, fixed, known set, 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."""
shells = list(Shell)
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(shells), _GRID_COLUMNS):
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, homogeneous=True)
for s in shells[start:start + _GRID_COLUMNS]:
row_box.append(make_button(s))
grid.append(row_box)
return grid
def build_shell_popover(on_pick) -> Gtk.Popover: def build_shell_popover(on_pick) -> Gtk.Popover:
"""Popover with a grid of every Shell (icon + blast radius under """Popover with a grid of every Shell (icon + blast radius under it,
it, full description as a tooltip), replacing a plain text dropdown/ full description as a tooltip), replacing a plain text dropdown/list
list with something that actually shows what each shell looks like. with something that actually shows what each shell looks like.
A plain vertical box of rows here previously ended up squeezed down hscrollbar_policy=NEVER is a backstop against a horizontal
to a near-unreadable width, ScrolledWindow sizes to its content's scrollbar ever appearing, not everyone has a horizontal scroll
*minimum*, not natural, size unless told otherwise, and a wrapping wheel. Calls `on_pick(shell)` and closes itself when a cell is
description label's minimum width can shrink to almost nothing. The clicked. Meant for a context tight on space (a firing card, see
grid avoids that by giving the FlowBox a sensible natural width up build_shell_button below), where hiding the options behind a click
front (cell width * column count) instead of hoping the content asks is worth it, for a dialog with room to spare, build_shell_grid()
for enough space on its own. min_children_per_line is deliberately below shows them all up front instead."""
NOT pinned to the same column count: a fixed minimum forces that
many columns even when the popover doesn't actually have the room
(near a screen edge, a narrow window), which used to overflow
sideways and need a horizontal scrollbar to reach. Letting it reflow
down to fewer columns instead, plus hscrollbar_policy=NEVER as a
hard backstop, means scrolling here is always vertical only, no
horizontal scroll wheel required (plenty of mice don't have one).
Calls `on_pick(shell)` and closes itself when a cell is clicked."""
popover = Gtk.Popover() popover = Gtk.Popover()
flow = Gtk.FlowBox(
min_children_per_line=1, max_children_per_line=_GRID_COLUMNS,
row_spacing=4, column_spacing=4, homogeneous=True,
selection_mode=Gtk.SelectionMode.NONE,
margin_top=8, margin_bottom=8, margin_start=8, margin_end=8,
)
_ensure_icon_button_css() _ensure_icon_button_css()
for s in Shell:
def make_button(s: Shell) -> Gtk.Widget:
btn = Gtk.Button(child=_shell_cell(s)) btn = Gtk.Button(child=_shell_cell(s))
btn.add_css_class("flat") btn.add_css_class("flat")
btn.add_css_class(_ICON_BUTTON_CSS_CLASS) btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
btn.set_tooltip_text(f"{s.name}: {s.description} ({_shell_radius_text(s)} blast radius)") 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))) btn.connect("clicked", lambda _b, s=s: (popover.popdown(), on_pick(s)))
flow.append(btn) return btn
scroller = Gtk.ScrolledWindow( scroller = Gtk.ScrolledWindow(
max_content_height=440, propagate_natural_height=True, propagate_natural_width=True, max_content_height=440, propagate_natural_height=True,
hscrollbar_policy=Gtk.PolicyType.NEVER, hscrollbar_policy=Gtk.PolicyType.NEVER,
) )
scroller.set_min_content_width(_GRID_COLUMNS * (_GRID_ICON_WIDTH + 16)) scroller.set_child(_build_shell_grid(make_button))
scroller.set_child(flow)
popover.set_child(scroller) popover.set_child(scroller)
return popover 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: 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 """A flat MenuButton showing the currently selected shell's icon
(plus its name, unless `show_label` is False, the icon already has (plus its name, unless `show_label` is False, the icon already has

View File

@ -22,7 +22,9 @@ class Shell(Enum):
FLCH = ("Flechette", 0.60, True) FLCH = ("Flechette", 0.60, True)
STAR = ("Illumination star", None, True) STAR = ("Illumination star", None, True)
SMK = ("White phosphorus smoke", None, True) SMK = ("White phosphorus smoke", None, True)
MSTD = ("Mustard (YX) blister agent", None, False) # MSTD (mustard/YX blister agent) intentionally left out: its own
# game icon is stamped 'UNAVAILABLE IN DEMO', it isn't actually
# implemented, not a real selectable shell to offer.
TEAR = ("Lachrymatory (CN) irritant", 0.85, False) TEAR = ("Lachrymatory (CN) irritant", 0.85, False)
PRPG = ("Agitation leaflet propaganda", None, False) PRPG = ("Agitation leaflet propaganda", None, False)
PHGN = ("Phosgene (CG) choking gas", None, False) PHGN = ("Phosgene (CG) choking gas", None, False)