diff options
| -rw-r--r-- | docs/TEXTUAL_NOTES.md | 9 | ||||
| -rw-r--r-- | idatui/app.py | 47 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 59 |
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. |
