Fix the shell icon picker: real icon size, grid layout, no horizontal scroll

Three real bugs from the first pass, all from actually looking at the
running app instead of just the code:

- Icons rendered tiny regardless of source resolution: Gtk.Image caps
  displayed size to GTK's icon-size classes (built for symbolic
  16/32px icons) no matter what you load into it. Switched to
  Gtk.Picture, which sizes by the image's real dimensions.
- The popover's plain vertical list of rows 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. Replaced with a
  FlowBox grid (icon + blast radius under it, full description as a
  tooltip) with an explicit natural width.
- That grid's column count was pinned to a fixed minimum, so a
  popover that didn't have the room for that many columns overflowed
  sideways and grew a horizontal scrollbar. min_children_per_line
  dropped to 1 (lets it reflow to fewer columns instead) plus
  hscrollbar_policy=NEVER as a hard backstop, not everyone has a
  horizontal scroll wheel.

Also: the firing card's shell button now shows the icon alone (it
already has the shell's short code baked in, a text label next to it
was redundant on an already-tight row), sized to 64px wide/32px tall
to actually be legible, and a new small CSS rule trims the excess
button chrome around an icon-only face so the button isn't visibly
much larger than the icon it holds.
This commit is contained in:
Dominik Moritz Roth 2026-08-09 19:06:56 +02:00
parent 2b73a96c7a
commit a8f77b98cb
2 changed files with 120 additions and 45 deletions

View File

@ -381,8 +381,16 @@ class FiringPanel(Gtk.Box):
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) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
shell_btn = icons.build_shell_button(target.effective_shell, lambda s: self._pick_shell(target, s)) # Icon only, not name-plus-icon: the icon already has the shell's
shell_btn.set_tooltip_text("Change shell (blast radius)") # 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.
shell_btn = icons.build_shell_button(
target.effective_shell, lambda s: self._pick_shell(target, s), show_label=False, icon_width=64
)
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] = []

View File

@ -12,81 +12,148 @@ from pathlib import Path
import gi import gi
gi.require_version("GdkPixbuf", "2.0") gi.require_version("Gdk", "4.0")
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
from gi.repository import GdkPixbuf, Gtk # noqa: E402 from gi.repository import Gdk, Gtk # noqa: E402
from .shells import Shell from .shells import Shell
_ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons" _ICONS_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "icons"
_ICON_BUTTON_CSS_CLASS = "fenigma-icon-button"
_css_loaded = False
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."""
global _css_loaded
if _css_loaded:
return
display = Gdk.Display.get_default()
if display is None:
return
provider = Gtk.CssProvider()
provider.load_from_string(f".{_ICON_BUTTON_CSS_CLASS} {{ padding: 2px; min-width: 0; min-height: 0; }}")
Gtk.StyleContext.add_provider_for_display(display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
_css_loaded = True
def shell_icon_path(shell_name: str) -> Path: def shell_icon_path(shell_name: str) -> Path:
return _ICONS_DIR / "shells" / f"{shell_name}.png" return _ICONS_DIR / "shells" / f"{shell_name}.png"
def shell_icon_image(shell_name: str, width: int = 64) -> Gtk.Image: def shell_icon_image(shell_name: str, width: int = 64) -> Gtk.Widget:
"""A Gtk.Image for a Shell enum member's icon, scaled to `width` px """A widget showing a Shell enum member's icon at `width` px wide
wide (the source art is a fixed 2:1 rectangle, height follows). (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.
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():
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), width, width // 2, False) picture = Gtk.Picture.new_for_filename(str(path))
return Gtk.Image.new_from_pixbuf(pixbuf) picture.set_content_fit(Gtk.ContentFit.CONTAIN)
picture.set_can_shrink(True)
picture.set_size_request(width, height)
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(width // 2) image.set_pixel_size(min(width, height))
return image return image
def _shell_row(s: Shell) -> Gtk.Widget: _GRID_ICON_WIDTH = 88 # per-cell icon in the picker grid, large enough to actually read
radius = f"{s.blast_radius_km}km blast radius" if s.blast_radius_km is not None else "blast radius unknown" _GRID_COLUMNS = 4
content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
content.append(shell_icon_image(s.name, width=56))
label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0, hexpand=True) def _shell_radius_text(s: Shell) -> str:
name_label = Gtk.Label(label=s.name, xalign=0) return f"{s.blast_radius_km}km" if s.blast_radius_km is not None else "unknown radius"
name_label.add_css_class("heading")
desc_label = Gtk.Label(label=f"{s.description} · {radius}", xalign=0, wrap=True)
desc_label.add_css_class("caption") def _shell_cell(s: Shell) -> Gtk.Widget:
desc_label.add_css_class("dim-label") cell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
label_box.append(name_label) margin_top=4, margin_bottom=4, margin_start=4, margin_end=4)
label_box.append(desc_label) cell.append(shell_icon_image(s.name, width=_GRID_ICON_WIDTH))
content.append(label_box) radius_label = Gtk.Label(label=_shell_radius_text(s))
return content radius_label.add_css_class("caption")
radius_label.add_css_class("dim-label")
cell.append(radius_label)
return cell
def build_shell_popover(on_pick) -> Gtk.Popover: def build_shell_popover(on_pick) -> Gtk.Popover:
"""Popover listing every Shell with its icon, name, description, and """Popover with a grid of every Shell (icon + blast radius under
blast radius, replacing a plain text dropdown/list with something it, full description as a tooltip), replacing a plain text dropdown/
that actually shows what each shell looks like. Calls `on_pick(shell)` list with something that actually shows what each shell looks like.
and closes itself when a row is clicked.""" 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 = Gtk.Popover() popover = Gtk.Popover()
scroller = Gtk.ScrolledWindow(max_content_height=420, propagate_natural_height=True) flow = Gtk.FlowBox(
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, min_children_per_line=1, max_children_per_line=_GRID_COLUMNS,
margin_top=6, margin_bottom=6, margin_start=6, margin_end=6) 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: for s in Shell:
row = Gtk.Button(child=_shell_row(s)) btn = Gtk.Button(child=_shell_cell(s))
row.add_css_class("flat") btn.add_css_class("flat")
row.connect("clicked", lambda _b, s=s: (popover.popdown(), on_pick(s))) btn.add_css_class(_ICON_BUTTON_CSS_CLASS)
box.append(row) btn.set_tooltip_text(f"{s.name}: {s.description} ({_shell_radius_text(s)} blast radius)")
scroller.set_child(box) btn.connect("clicked", lambda _b, s=s: (popover.popdown(), on_pick(s)))
flow.append(btn)
scroller = Gtk.ScrolledWindow(
max_content_height=440, propagate_natural_height=True, propagate_natural_width=True,
hscrollbar_policy=Gtk.PolicyType.NEVER,
)
scroller.set_min_content_width(_GRID_COLUMNS * (_GRID_ICON_WIDTH + 16))
scroller.set_child(flow)
popover.set_child(scroller) popover.set_child(scroller)
return popover return popover
def build_shell_button(selected: Shell, on_pick) -> 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 and """A flat MenuButton showing the currently selected shell's icon
name, opening build_shell_popover() to change it. `on_pick(shell)` (plus its name, unless `show_label` is False, the icon already has
fires on selection, after this button's own face has already been the shell's short code baked in, redundant next to a firing card
updated to match, the caller only needs to react to the new value that's tight on space), opening build_shell_popover() to change it.
(persist it, refresh dependents), not maintain the button's display.""" `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 = Gtk.MenuButton()
btn.add_css_class("flat") 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: def render(s: Shell) -> None:
content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) if show_label:
content.append(shell_icon_image(s.name, width=28)) content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
content.append(Gtk.Label(label=s.name)) 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) btn.set_child(content)
def handle_pick(s: Shell) -> None: def handle_pick(s: Shell) -> None: