aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-07 23:56:34 +0200
committerblasty <blasty@local>2026-08-07 23:56:34 +0200
commit64a8e28f0ad314a1f9a1ae58ab5e42f36bb4787a (patch)
tree4425f07c877d4eb5f2e68115fd6c262c22c89a3c
parentCentre modals with a rule about modals, not a list of them (diff)
downloadida-tui-64a8e28f0ad314a1f9a1ae58ab5e42f36bb4787a.tar.gz
ida-tui-64a8e28f0ad314a1f9a1ae58ab5e42f36bb4787a.tar.xz
ida-tui-64a8e28f0ad314a1f9a1ae58ab5e42f36bb4787a.zip
splash: scale the logo to the pane instead of dropping itHEADmain
Reported as "the splash logo stopped rendering". It had not stopped: the splash asks for the artwork's NATURAL size and shows nothing when that does not fit, and the artwork needs 31 rows plus 10 of box chrome. A pane in a split zellij window is 31 rows — one row short of the 41 it wanted — so the logo silently disappeared. Traced with $IDATUI_KITTY_LOG in the real session: compose: supported=True app.size=Size(width=159, height=31) cells=60x23 fits=False The terminal scales an image into whatever cell box it is placed in (`c=`/`r=` on the placement), so there was never a reason for all-or-nothing. `logo_cells(max_rows)` now fits the art to the room left after the box's furniture, and the same number reserves the cells and sizes the placement, so a resize needs no relayout. In that same 31-row pane it now draws 55x21 instead of nothing. Two things fixed on the way: * The chrome constant was one row optimistic (`rows + 9` where the box measures 10: border 2, padding 2, art margin 1, title 1, note 1+1, help 1+1). At exactly the old threshold the help line was clipped off the bottom. * `_fits` conflated "is the terminal big enough" with "is the artwork the right size", which is what made the image path inherit the block art's all-or-nothing behaviour. The block art genuinely cannot scale (it is half-block cells, 26 rows) and still falls back to the text splash; the image no longer does. `splash_scaling` pins it at 31, 30 and 44 rows: the logo is drawn, it is scaled to the room, the box is never clipped, and a big pane still gets the natural size. 905 passed, 0 failed.
-rw-r--r--docs/TEXTUAL_NOTES.md9
-rw-r--r--idatui/app.py47
-rw-r--r--tests/test_scenarios.py59
3 files changed, 105 insertions, 10 deletions
diff --git a/docs/TEXTUAL_NOTES.md b/docs/TEXTUAL_NOTES.md
index 10df669..aba84ae 100644
--- a/docs/TEXTUAL_NOTES.md
+++ b/docs/TEXTUAL_NOTES.md
@@ -53,6 +53,15 @@ Hard-won Textual behaviour and the patterns this app relies on. Pairs with
`build_byte_to_codepoint_dict`, so character offsets smear on non-ASCII), and
a `TextAreaTheme` that sets `base_style` overrides the widget's CSS colours —
ours sets only `syntax_styles` so the editor keeps the app's background.
+- **Fixed-size splash art disappears instead of shrinking.** The kitty image is
+ scaled by the terminal into whatever cell box you place it in (`c=`/`r=`), so
+ sizing it to the artwork's natural height and then asking "is there room?" is
+ all-or-nothing — a 31-row zellij pane was ONE row short of the 41 the splash
+ wanted, and the logo silently vanished. Size the art to the room instead
+ (`logo_cells(max_rows)`), and keep the chrome constant honest:
+ `LOGO_CHROME_ROWS = 10` is border 2 + padding 2 + the art's margin 1 + title 1
+ + note 1+1 + help 1+1, which the old `rows + 9` under-counted by one, so at
+ exactly the threshold the help line was clipped off the bottom.
- **Centre modals with a rule, not a list.** `ModalScreen { align: center middle; }`
matches subclasses, so every dialog inherits it and the next one is centred
for free. Naming the screens instead (`SymbolPalette, StringsPalette, …`) is
diff --git a/idatui/app.py b/idatui/app.py
index bbf8e4e..8baff74 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -4270,22 +4270,37 @@ _LOGO_PATH = os.path.join(_REPO_ROOT, "logo.ans")
#: is half-blocks (two pixels per cell); this is a transparent PNG at 768px.
LOGO_PNG = os.path.join(_REPO_ROOT, "logo.png")
_LOGO_BOX = (60, 33) # the most room the splash will give the art
+#: Rows the loading box spends on everything that is not the artwork: border 2,
+#: padding 2, the art's margin 1, title 1, note 1 + margin 1, help 1 + margin 1.
+LOGO_CHROME_ROWS = 10
+#: Below this the image is a postage stamp; show the text splash instead.
+LOGO_MIN_ROWS = 8
_logo_cells: tuple[int, int] | None = None
-def logo_cells() -> tuple[int, int]:
+def logo_cells(max_rows: int | None = None) -> tuple[int, int]:
"""Cell footprint for the image, derived from the artwork and the terminal's
real cell size rather than hardcoded.
Cells are nowhere near square (9x22 px here, 1:2.44), so a fixed box picked
for one aspect ratio stretches any other. Recomputing means the art can be
replaced without anyone remembering to edit a constant.
+
+ ``max_rows`` shrinks it to the room actually available. The terminal scales
+ the image into whatever cell box we place it in, so there is no reason for
+ the splash to be all-or-nothing -- and it WAS all-or-nothing: a 31-row pane
+ is one row short of the natural size, so the logo silently disappeared
+ rather than being drawn a little smaller.
"""
global _logo_cells
if _logo_cells is None:
px = kittygfx.png_size(LOGO_PNG)
_logo_cells = kittygfx.fit(px, *_LOGO_BOX) if px else _LOGO_BOX
- return _logo_cells
+ if max_rows is None or max_rows >= _logo_cells[1]:
+ return _logo_cells
+ px = kittygfx.png_size(LOGO_PNG)
+ return (kittygfx.fit(px, _LOGO_BOX[0], max(max_rows, 1)) if px
+ else (_LOGO_BOX[0], max(max_rows, 1)))
_logo_cache: object = False # False == not yet loaded (None == absent/unreadable)
@@ -4317,12 +4332,16 @@ class LoadingScreen(ModalScreen):
self._title = title
self._note = note
self._image = False # drawing the real image, not the block art
+ self._cells: tuple[int, int] | None = None # image size, in cells
self._last_place = 0.0 # throttles re-anchoring after a repaint
+ def _room(self) -> int:
+ """Rows left for artwork once the box's own furniture is paid for."""
+ return self.app.size.height - LOGO_CHROME_ROWS
+
def _fits(self, rows: int) -> bool:
- """Room for the art plus the title/note/help lines and box chrome."""
- sz = self.app.size
- return sz.height >= rows + 9 and sz.width >= 64
+ """Room for art of exactly ``rows`` (the block art cannot be resized)."""
+ return self._room() >= rows and self.app.size.width >= 64
def compose(self) -> ComposeResult:
with Vertical(id="loading-box"):
@@ -4331,11 +4350,16 @@ class LoadingScreen(ModalScreen):
# The image is anchored to screen cells rather than composited by
# Textual (no unicode-placeholder support here), so the widget is
# only reserved blank space -- see _place_logo.
- cols, rows = logo_cells()
+ # Scale the image to the room there is, rather than demanding its
+ # natural size and vanishing when one row is missing.
+ room = self._room()
+ cols, rows = logo_cells(room)
+ self._cells = (cols, rows)
kittygfx.log(f"compose: supported={kittygfx.supported()} "
- f"app.size={self.app.size} cells={cols}x{rows} "
- f"fits={self._fits(rows)}")
- if kittygfx.supported() and self._fits(rows):
+ f"app.size={self.app.size} room={room} "
+ f"cells={cols}x{rows} natural={logo_cells()}")
+ if (kittygfx.supported() and self.app.size.width >= 64
+ and room >= LOGO_MIN_ROWS):
self._image = True
blank = Static("\n" * (rows - 1), id="loading-image")
blank.styles.height = rows
@@ -4386,7 +4410,10 @@ class LoadingScreen(ModalScreen):
kittygfx.log(f"place_logo: region={region}")
if not region.width or not region.height:
return
- cols, rows = logo_cells()
+ # The reserved region is the truth about how much room there is; the
+ # image is scaled into exactly it, so a resize needs no relayout.
+ cols, rows = self._cells or logo_cells()
+ rows = min(rows, region.height)
col = region.x + max((region.width - cols) // 2, 0) # centre it
kittygfx.place(region.y, col, min(cols, region.width), rows)
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 37ec017..5297f0c 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -1094,6 +1094,65 @@ async def s_structs(c: Ctx):
c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+@scenario("splash_scaling")
+async def s_splash_scaling(c: Ctx):
+ """The splash scales the logo to the pane instead of dropping it.
+
+ The bug this pins: the artwork's natural size is ~31 rows plus 10 of box
+ chrome, and the check was "do you have 41 rows?". A 31-row pane -- what a
+ split zellij window actually gives you -- was one row short, so the logo
+ silently disappeared. The terminal scales an image into whatever cell box
+ it is placed in, so there was never a reason for all-or-nothing.
+ """
+ from idatui import kittygfx
+ from idatui.app import (LOGO_CHROME_ROWS, LOGO_MIN_ROWS, LoadingScreen,
+ logo_cells)
+
+ app = c.app
+ placed: list[tuple] = []
+ real_supported, real_upload, real_place = (
+ kittygfx.supported, kittygfx.upload, kittygfx.place)
+ kittygfx.supported = lambda: True
+ kittygfx.upload = lambda *a, **k: True
+ kittygfx.place = lambda *a, **k: (placed.append(a), True)[1]
+ try:
+ for width, height in ((159, 31), (100, 30), (140, 44)):
+ await c.pilot.resize_terminal(width, height)
+ await c.pause(0.05)
+ app.push_screen(LoadingScreen("echo"))
+ await c.wait(lambda: isinstance(app.screen, LoadingScreen), 5)
+ scr = app.screen
+ # push_screen returns before compose has mounted the children.
+ await c.wait(lambda: scr._cells is not None, 5)
+ room = height - LOGO_CHROME_ROWS
+ has_image = bool(scr.query("#loading-image"))
+ c.check(f"{width}x{height}: the logo is drawn, not dropped",
+ has_image and room >= LOGO_MIN_ROWS,
+ f"image={has_image} room={room}")
+ if has_image:
+ cols, rows = scr._cells
+ c.check(f"{width}x{height}: scaled to the room available",
+ rows <= room and rows == min(room, logo_cells()[1]),
+ f"cells={scr._cells} room={room} natural={logo_cells()}")
+ await c.wait(lambda: scr.query_one("#loading-box").region.height > 0, 5)
+ box = scr.query_one("#loading-box").region
+ c.check(f"{width}x{height}: the box is not clipped",
+ box.y >= 0 and box.y + box.height <= height,
+ f"box={box} screen={height}")
+ app.pop_screen()
+ await c.pause(0.05)
+ c.check("a full-size pane still gets the artwork's natural size",
+ logo_cells(999) == logo_cells(), f"{logo_cells(999)}")
+ c.check("and the image was actually placed each time", len(placed) >= 3,
+ f"{placed}")
+ finally:
+ kittygfx.supported, kittygfx.upload, kittygfx.place = (
+ real_supported, real_upload, real_place)
+ # Every later scenario assumes the suite's own geometry.
+ await c.pilot.resize_terminal(140, 44)
+ await c.pause(0.05)
+
+
@scenario("modal_centering")
async def s_modal_centering(c: Ctx):
"""Every dialog we define is centred, without anyone maintaining a list.