diff --git a/assets/icons/shells/AP.png b/assets/icons/shells/AP.png index d9f1d35..6089814 100644 Binary files a/assets/icons/shells/AP.png and b/assets/icons/shells/AP.png differ diff --git a/assets/icons/shells/APHE.png b/assets/icons/shells/APHE.png index e11ff0d..33b6865 100644 Binary files a/assets/icons/shells/APHE.png and b/assets/icons/shells/APHE.png differ diff --git a/assets/icons/shells/ATMC.png b/assets/icons/shells/ATMC.png index 1d1fc47..81222aa 100644 Binary files a/assets/icons/shells/ATMC.png and b/assets/icons/shells/ATMC.png differ diff --git a/assets/icons/shells/CLMN.png b/assets/icons/shells/CLMN.png index 989f768..f8f3f67 100644 Binary files a/assets/icons/shells/CLMN.png and b/assets/icons/shells/CLMN.png differ diff --git a/assets/icons/shells/EQKE.png b/assets/icons/shells/EQKE.png index 3d3dfa6..6bee0d1 100644 Binary files a/assets/icons/shells/EQKE.png and b/assets/icons/shells/EQKE.png differ diff --git a/assets/icons/shells/FLCH.png b/assets/icons/shells/FLCH.png index 0b4a136..8874300 100644 Binary files a/assets/icons/shells/FLCH.png and b/assets/icons/shells/FLCH.png differ diff --git a/assets/icons/shells/HCHE.png b/assets/icons/shells/HCHE.png index fc03112..b7f8317 100644 Binary files a/assets/icons/shells/HCHE.png and b/assets/icons/shells/HCHE.png differ diff --git a/assets/icons/shells/HE.png b/assets/icons/shells/HE.png index d9f6697..e833ff0 100644 Binary files a/assets/icons/shells/HE.png and b/assets/icons/shells/HE.png differ diff --git a/assets/icons/shells/INCN.png b/assets/icons/shells/INCN.png index 6f459db..f679d6e 100644 Binary files a/assets/icons/shells/INCN.png and b/assets/icons/shells/INCN.png differ diff --git a/assets/icons/shells/LE.png b/assets/icons/shells/LE.png index ae1246e..d2d4e4d 100644 Binary files a/assets/icons/shells/LE.png and b/assets/icons/shells/LE.png differ diff --git a/assets/icons/shells/MSTD.png b/assets/icons/shells/MSTD.png deleted file mode 100644 index 1a894c3..0000000 Binary files a/assets/icons/shells/MSTD.png and /dev/null differ diff --git a/assets/icons/shells/PHGN.png b/assets/icons/shells/PHGN.png index e646864..075eb5e 100644 Binary files a/assets/icons/shells/PHGN.png and b/assets/icons/shells/PHGN.png differ diff --git a/assets/icons/shells/PRPG.png b/assets/icons/shells/PRPG.png index 5c04f8d..1c25376 100644 Binary files a/assets/icons/shells/PRPG.png and b/assets/icons/shells/PRPG.png differ diff --git a/assets/icons/shells/SMK.png b/assets/icons/shells/SMK.png index e6faa69..afc94be 100644 Binary files a/assets/icons/shells/SMK.png and b/assets/icons/shells/SMK.png differ diff --git a/assets/icons/shells/STAR.png b/assets/icons/shells/STAR.png index 7984587..38f186b 100644 Binary files a/assets/icons/shells/STAR.png and b/assets/icons/shells/STAR.png differ diff --git a/assets/icons/shells/TEAR.png b/assets/icons/shells/TEAR.png index 8fc7bc0..fa3e2d7 100644 Binary files a/assets/icons/shells/TEAR.png and b/assets/icons/shells/TEAR.png differ diff --git a/src/fenigma/app.py b/src/fenigma/app.py index 1dbbaaa..bcd7232 100644 --- a/src/fenigma/app.py +++ b/src/fenigma/app.py @@ -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") diff --git a/src/fenigma/firing_panel.py b/src/fenigma/firing_panel.py index d84ed4f..0734961 100644 --- a/src/fenigma/firing_panel.py +++ b/src/fenigma/firing_panel.py @@ -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. (type + class), not just .: 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)) diff --git a/src/fenigma/icons.py b/src/fenigma/icons.py index 9ca03af..c302ff6 100644 --- a/src/fenigma/icons.py +++ b/src/fenigma/icons.py @@ -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 '.' 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".{_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 diff --git a/src/fenigma/shells.py b/src/fenigma/shells.py index 8499165..10d9634 100644 --- a/src/fenigma/shells.py +++ b/src/fenigma/shells.py @@ -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)