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.
This commit is contained in:
1 parent
6eb0c6b6fe
commit
06386d3e1e
2 files changed
+52
-6
No files matched your search
@@ -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
|
||||
|
||||
+13
-1
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user