Skip to content

Painting a region yourself

turbodesk owns the terminal: the alternate screen, raw mode, and a painter that diffs rows between frames. Anything it does not model has no way onto the screen, and an app that writes its own escape codes will be overwritten or leave debris.

View.passthrough is the sanctioned way through:

View.passthrough(width, height, emit, clear=None)

It reserves width × height blank cells (so layout is unaffected and whatever is behind shows through) and calls emit with the region's settled position each frame. Whatever emit returns is written after the cells beneath it.

clear is written once when the region stops being drawn, and is handed the region it last occupied. It is optional because repainting the cells already undoes an escape that only coloured them.

Image protocols need it, and they need it for two different reasons. Kitty holds the image itself and goes on drawing it over whatever text arrives, so the clear is a delete escape: \x1b_Ga=d,d=a\x1b\\. iTerm2 draws into the character grid, which sounds like it should clean itself up, and does not: the row diff compares blank cells against blank cells and leaves them alone, so the picture stays. Its clear writes spaces over the region, which is why clear receives the region.

def image(renderer, path: Path, width: int, height: int) -> View:
    def emit(region: Region) -> str:
        return renderer.render(path, region.width, region.height)

    return View.passthrough(width, height, emit)

The region travels with the view through hcat, vcat, pad and crop, so emit receives the position layout settled on.

Why the painter has to know

Row diffing cannot see these regions. The cells beneath are blank and compare equal frame to frame, so left alone nothing would ever repaint an image, and nothing would clear one that had gone away.

The painter remembers what each region last emitted and repaints when either the output changes or the rows beneath it were redrawn. An unchanged image is not resent, which matters when the kitty protocol payload for one photo is 3.6 MB.

Regions that vanish are cleared before the surviving ones are repainted, so a protocol whose clear means "forget every placement" cannot wipe an image that was just drawn.

Uses

Images are the first caller. The same hole covers OSC 8 hyperlinks, iTerm2 marks, terminal notifications, and whatever protocol appears next. turbodesk deliberately has no image support: it has a region you can paint, and image protocols are an application's business.

Prezo's 1,602 lines of kitty, sixel, iTerm2 and chafa renderers ported unchanged, because they already produced an escape string for a given cell rectangle, which is exactly what emit returns.

The second caller is examples/fm, and it is the one that found clear missing: a file manager moves off an image constantly, where a slide deck mostly replaces one with another. examples/fm/preview.py is about forty lines covering kitty and iTerm2, which is the size this is meant to be.

The third is examples/browser, which puts OSC 8 hyperlinks on its links. It does not paint the text through the region: the cells are drawn normally and a transparent passthrough is laid on top, writing the same characters again inside the escape. Painting the text through the region works in a terminal and blinds to_text to it, so nothing about the page can be asserted in a test. Where a passthrough can ride over real cells rather than replacing them, it should.

A region cropped out of the view is dropped rather than shifted to a position off screen. emit writes wherever it is handed, and a terminal clamps a negative row to the first line, so a scrolling page of hyperlinks used to leave its links across the top of the window.

Full-screen handover

For something that wants the whole terminal (an image viewer, an editor, a pager), use ui.suspend().