From 06386d3e1e38314173ea1a481136006d89cfbb66 Mon Sep 17 00:00:00 2001 From: beefcarver Date: Fri, 30 Oct 2026 18:43:22 -0400 Subject: [PATCH] fix(art): keep iTerm2 inline images on screen iTerm2 images live in the cell grid, unlike Kitty placements: repainting the mosaic behind them erases the picture. Re-emit the image whenever the mosaic identity (station + track) changes, even if the artwork bytes are the same. Also send doNotMoveCursor=1 to iTerm2 itself so the terminal does not scroll or jump the cursor out from under Textual's compositor; WezTerm keeps the previous payload since it is pickier about parameters. --- somafm_tui/widgets/art.py | 44 ++++++++++++++++++++++++++++++++++----- tests/test_art.py | 14 ++++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/somafm_tui/widgets/art.py b/somafm_tui/widgets/art.py index 9c49643..7d9419e 100644 --- a/somafm_tui/widgets/art.py +++ b/somafm_tui/widgets/art.py @@ -38,6 +38,29 @@ def _tgp(data: str) -> str: return f"\x1b_G{data}\x1b\\" +def _iterm2_native() -> bool: + """Are we talking to iTerm2 itself (rather than a compatible terminal)? + + WezTerm understands the same OSC 1337 payload but is fussier about + parameters it does not know, so the iTerm2-only ones stay gated. + """ + env = os.environ + return env.get("LC_TERMINAL", "").lower() == "iterm2" or env.get( + "TERM_PROGRAM", "" + ).lower() in {"iterm.app", "iterm2"} + + +def _art_key(app) -> str: + """Identity of what the mosaic is drawing (station + current track).""" + station = app.current + if station is None: + return "" + song = station.now_playing + artist = song.artist if song is not None else "" + title = song.title if song is not None else "" + return f"{station.id}|{artist}|{title}" + + def _terminal_wrap(sequence: str) -> str: """Wrap Kitty sequences for tmux DCS passthrough.""" term = os.environ.get("TERM", "") @@ -180,8 +203,8 @@ class ITerm2Image(Widget): """Inline image via OSC 1337, injected through Textual's render_lines(). WezTerm and iTerm2 both implement this natively. ``doNotMoveCursor=1`` - keeps the terminal cursor sane; the widget then restores it to the - bottom-right of the region. + (iTerm2 only) keeps the terminal cursor sane; the widget then restores it + to the bottom-right of the region. """ DEFAULT_CSS = """ @@ -217,8 +240,12 @@ class ITerm2Image(Widget): buffer = BytesIO() self._image.save(buffer, format="PNG") payload = base64.b64encode(buffer.getvalue()).decode("ascii") + # ``doNotMoveCursor`` keeps iTerm2 from scrolling/jumping the cursor + # out from under Textual's compositor; we place the cursor ourselves. + keep_cursor = "doNotMoveCursor=1;" if _iterm2_native() else "" data = ( "\x1b]1337;File=inline=1;" + f"{keep_cursor}" f"width={width};height={height};" f"preserveAspectRatio=0:{payload}\x07" ) @@ -280,6 +307,7 @@ class ArtPane(Widget): self._image_widget: Widget | None = None self._last_data: bytes | None = None self._last_size: tuple[int, int] = (0, 0) + self._mosaic_key = "" # ------------------------------------------------------------------ # # mosaic background @@ -292,9 +320,7 @@ class ArtPane(Widget): return Text("") width = max(1, self.size.width) height = max(1, self.size.height) - song = station.now_playing - key = f"{station.id}|{song.artist if song else ''}|{song.title if song else ''}" - return Group(*art_lines(key, width, height)) + return Group(*art_lines(_art_key(app), width, height)) # ------------------------------------------------------------------ # # image overlay @@ -321,6 +347,14 @@ class ArtPane(Widget): width = max(1, self.size.width) height = max(1, self.size.height) + # Repainting the mosaic overwrites the cells an inline (iTerm2) image + # occupies, so re-emit the image whenever what is drawn underneath + # changes -- even if the artwork bytes happen to be the same. + mosaic_key = _art_key(app) + if mosaic_key != self._mosaic_key: + self._mosaic_key = mosaic_key + force = True + if station is not None and data and artwork.supported(): if ( not force diff --git a/tests/test_art.py b/tests/test_art.py index 5653069..7f3df3e 100644 --- a/tests/test_art.py +++ b/tests/test_art.py @@ -5,6 +5,7 @@ from PIL import Image from somafm_tui import artwork from somafm_tui.app import SomaFMApp +from somafm_tui.widgets import art as art_widgets from somafm_tui.widgets.art import ArtPane, ITerm2Image, KittyImage from somafm_tui.widgets.stage import StagePanel @@ -45,12 +46,23 @@ async def test_art_pane_switches_between_image_and_mosaic(monkeypatch) -> None: assert pane.render() is not None -def test_iterm2_sequence_uses_cell_units() -> None: +def test_iterm2_sequence_uses_cell_units(monkeypatch) -> None: + monkeypatch.setattr(art_widgets, "_iterm2_native", lambda: False) widget = ITerm2Image(Image.new("RGBA", (32, 32), (10, 20, 30, 255))) sequence = widget._sequence(13, 8) assert sequence.startswith( "\x1b]1337;File=inline=1;width=13;height=8;" ) + # WezTerm shares the protocol but not every iTerm2-only parameter. + assert "doNotMoveCursor" not in sequence + + +def test_iterm2_native_keeps_the_cursor_put(monkeypatch) -> None: + monkeypatch.setattr(art_widgets, "_iterm2_native", lambda: True) + widget = ITerm2Image(Image.new("RGBA", (32, 32), (10, 20, 30, 255))) + sequence = widget._sequence(13, 8) + assert sequence.startswith("\x1b]1337;File=inline=1;doNotMoveCursor=1;") + assert "width=13;height=8;" in sequence def test_kitty_direct_placement_sequences() -> None: