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>
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 23 KiB |
@ -183,9 +183,14 @@ def _location_status(obj) -> str:
|
||||
|
||||
|
||||
_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-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
|
||||
TargetType.STRIKE and an explicit shell, and its position is set
|
||||
by clicking, not typing in coordinates."""
|
||||
dialog = Adw.Dialog(title="Add Strike", content_width=340, content_height=200)
|
||||
dialog = Adw.Dialog(title="Add Strike", content_width=400, content_height=440)
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
dialog.set_child(toolbar_view)
|
||||
toolbar_view.add_top_bar(Adw.HeaderBar())
|
||||
@ -976,10 +981,11 @@ class MainWindow(Adw.ApplicationWindow):
|
||||
label.add_css_class("heading")
|
||||
outer.append(label)
|
||||
|
||||
# Every option shown up front rather than behind a submenu
|
||||
# click, there's room for it here and picking one is the whole
|
||||
# point of this dialog, not an aside.
|
||||
chosen = [Shell.HCHE]
|
||||
shell_btn = icons.build_shell_button(chosen[0], lambda s: chosen.__setitem__(0, s))
|
||||
shell_btn.set_halign(Gtk.Align.START)
|
||||
outer.append(shell_btn)
|
||||
outer.append(icons.build_shell_grid(chosen[0], lambda s: chosen.__setitem__(0, s)))
|
||||
|
||||
next_btn = Gtk.Button(label="Next: click the map to place it")
|
||||
next_btn.add_css_class("suggested-action")
|
||||
|
||||
@ -36,6 +36,51 @@ from .shells import Shell
|
||||
_SELECTED_CSS = "firing-card-selected"
|
||||
_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_ICONS = {
|
||||
"hide": "view-conceal-symbolic",
|
||||
@ -355,6 +400,12 @@ class FiringPanel(Gtk.Box):
|
||||
elev_caption.add_css_class("dim-label")
|
||||
elev_value = Gtk.Label(label=f"{ballistics.elevation_deg(dist, charges):.2f}°", xalign=0)
|
||||
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.add_css_class("caption")
|
||||
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,
|
||||
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
|
||||
# short code baked in (see assets/icons/README.md), a redundant
|
||||
# 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)
|
||||
# is what it actually takes to read the baked-in code at this
|
||||
# size, anything smaller and it blurs into an unreadable smear.
|
||||
# 96px wide (the source art, after its built-in padding got
|
||||
# cropped out, is roughly 2.5:1, so ~39 tall) is what it
|
||||
# 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(
|
||||
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)")
|
||||
row.append(shell_btn)
|
||||
|
||||
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)
|
||||
|
||||
def apply_fill(value: int) -> None:
|
||||
for i, seg in enumerate(segments, start=1):
|
||||
seg.remove_css_class("suggested-action")
|
||||
seg.remove_css_class("flat")
|
||||
seg.add_css_class("suggested-action" if i <= value else "flat")
|
||||
seg.remove_css_class(_CHARGE_SEG_ON)
|
||||
seg.remove_css_class(_CHARGE_SEG_OFF)
|
||||
seg.add_css_class(_CHARGE_SEG_ON if i <= value else _CHARGE_SEG_OFF)
|
||||
count_label.set_label(str(value))
|
||||
|
||||
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}°")
|
||||
|
||||
for n in range(1, ballistics.MAX_POWDER_CHARGE + 1):
|
||||
seg = Gtk.Button(label=" ")
|
||||
seg.set_size_request(14, 14)
|
||||
seg.add_css_class("circular")
|
||||
# valign/halign=CENTER pins these to a real fixed 14x14 circle
|
||||
# (see _ensure_charge_segment_css) no matter how tall the row
|
||||
# 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_tooltip_text(f"{n} charge{'s' if n != 1 else ''}")
|
||||
seg.connect("clicked", lambda _b, n=n: on_pick(n))
|
||||
|
||||
@ -13,8 +13,9 @@ 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, Gtk # noqa: E402
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
|
||||
|
||||
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
|
||||
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."""
|
||||
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 '.<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.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)
|
||||
_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:
|
||||
"""A widget showing a Shell enum member's icon at `width` px wide
|
||||
(the source art is a fixed 2:1 rectangle, height follows). Gtk.Image
|
||||
looked right in code but rendered tiny regardless of the source
|
||||
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
|
||||
into it. Gtk.Picture doesn't do that, it sizes by the image's actual
|
||||
dimensions, which is what a real multi-hundred-pixel icon needs.
|
||||
"""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)
|
||||
height = width // 2
|
||||
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_can_shrink(True)
|
||||
picture.set_size_request(width, height)
|
||||
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(min(width, height))
|
||||
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 = 4
|
||||
_GRID_COLUMNS = 3
|
||||
|
||||
|
||||
def _shell_radius_text(s: Shell) -> str:
|
||||
@ -88,50 +134,96 @@ def _shell_cell(s: Shell) -> Gtk.Widget:
|
||||
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:
|
||||
"""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.
|
||||
A plain vertical box of rows here previously ended up squeezed down
|
||||
to a near-unreadable width, ScrolledWindow sizes to its content's
|
||||
*minimum*, not natural, size unless told otherwise, and a wrapping
|
||||
description label's minimum width can shrink to almost nothing. The
|
||||
grid avoids that by giving the FlowBox a sensible natural width up
|
||||
front (cell width * column count) instead of hoping the content asks
|
||||
for enough space on its own. min_children_per_line is deliberately
|
||||
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 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()
|
||||
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()
|
||||
for s in Shell:
|
||||
|
||||
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)))
|
||||
flow.append(btn)
|
||||
return btn
|
||||
|
||||
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,
|
||||
)
|
||||
scroller.set_min_content_width(_GRID_COLUMNS * (_GRID_ICON_WIDTH + 16))
|
||||
scroller.set_child(flow)
|
||||
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
|
||||
|
||||
@ -22,7 +22,9 @@ class Shell(Enum):
|
||||
FLCH = ("Flechette", 0.60, True)
|
||||
STAR = ("Illumination star", 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)
|
||||
PRPG = ("Agitation leaflet propaganda", None, False)
|
||||
PHGN = ("Phosgene (CG) choking gas", None, False)
|
||||
|
||||