Skip to content

xnano.components.image

xnano.components.image

xnano.components.image


Render images and animations directly into terminal cells.

Classes:

  • ImageFrame

    One native-resolution RGB frame and its display duration.

  • ImageData

    Decoded image frames independent of Pillow and the render engine.

  • Image

    A native-resolution terminal image or real-time GIF component.

Attributes:

ImageFit module-attribute

ImageFit: TypeAlias = Literal[
    "crop", "contain", "cover", "stretch", "smart"
]

How source pixels are placed inside the available terminal area.

HorizontalPixelsPerCell module-attribute

HorizontalPixelsPerCell: TypeAlias = Literal[1, 2]

Number of adjacent source pixels sampled into each terminal cell.

ImageSource module-attribute

ImageSource: TypeAlias = (
    str | os.PathLike[str] | bytes | BinaryIO | ImageData
)

A filesystem path, encoded image bytes, stream, or decoded image data.

ImageFrame dataclass

ImageFrame(pixels: bytes, duration_ms: int = 100)

One native-resolution RGB frame and its display duration.

Attributes:

pixels instance-attribute

pixels: bytes

Packed row-major RGB bytes.

duration_ms class-attribute instance-attribute

duration_ms: int = 100

Display duration in milliseconds.

ImageData dataclass

ImageData(
    width: int, height: int, frames: tuple[ImageFrame, ...]
)

Decoded image frames independent of Pillow and the render engine.

Attributes:

Methods:

  • from_bytes

    Decode xnano's compact, Pillow-free animation container.

width instance-attribute

width: int

Native pixel width shared by every frame.

height instance-attribute

height: int

Native pixel height shared by every frame.

frames instance-attribute

frames: tuple[ImageFrame, ...]

RGB frames in playback order.

from_bytes classmethod

from_bytes(data: bytes) -> ImageData

Decode xnano's compact, Pillow-free animation container.

Parameters:

  • data (bytes) –

    Bytes produced by scripts/precompute_demo_image.py.

Returns:

  • ImageData

    Decoded full-resolution RGB image data.

Source code in xnano/components/image.py
@classmethod
def from_bytes(cls, data: bytes) -> ImageData:
    """Decode xnano's compact, Pillow-free animation container.

    Args:
        data: Bytes produced by ``scripts/precompute_demo_image.py``.

    Returns:
        Decoded full-resolution RGB image data.
    """
    if data[:4] != b"XNI1":
        raise ValueError("Image data does not have an XNI1 header.")
    try:
        width, height, frame_count = struct.unpack_from(">HHH", data, 4)
        offset = 10
        frames: list[ImageFrame] = []
        pixel_count = width * height
        for _ in range(frame_count):
            duration_ms, color_count, compressed_size = struct.unpack_from(
                ">HHI", data, offset
            )
            offset += 8
            palette_size = color_count * 3
            palette = data[offset : offset + palette_size]
            offset += palette_size
            compressed = data[offset : offset + compressed_size]
            offset += compressed_size
            indexes = zlib.decompress(compressed)
            if len(indexes) != pixel_count:
                raise ValueError("Image frame has an invalid pixel count.")
            pixels = bytearray(pixel_count * 3)
            for pixel_index, color_index in enumerate(indexes):
                palette_offset = color_index * 3
                pixel_offset = pixel_index * 3
                pixels[pixel_offset : pixel_offset + 3] = palette[
                    palette_offset : palette_offset + 3
                ]
            frames.append(ImageFrame(bytes(pixels), max(10, duration_ms)))
    except (struct.error, zlib.error) as error:
        raise ValueError("Image data is truncated or corrupt.") from error
    if offset != len(data):
        raise ValueError("Image data contains unexpected trailing bytes.")
    return cls(width=width, height=height, frames=tuple(frames))

Image dataclass

Image(
    source: ImageSource = "",
    fit: ImageFit = "crop",
    loop: bool = True,
    speed: float = 1.0,
    background: tuple[int, int, int] = (0, 0, 0),
    horizontal_pixels_per_cell: HorizontalPixelsPerCell = 1,
    correct_terminal_aspect: bool = False,
    playing: bool = True,
    position_ms: float | None = None,
    *,
    visible: bool = True,
    z: int = 0,
    fit_content: bool = False
)

Bases: Component

A native-resolution terminal image or real-time GIF component.

Pass an Image to a runtime render for a zero-delay still/first frame. Animated formats play according to their source frame timings when playing is True.

crop is intentionally the default fit: source pixels are never resized. The source and viewport centers align, with oversized content cropped and undersized content padded around the center.

Example

Image(source="logo.png", fit="contain")

Attributes:

Methods:

  • component_post_init

    Validate configuration and decode the initial source.

  • play

    Resume animation from the current playback position.

  • pause

    Freeze animation at the current playback position.

  • restart

    Restart animation timing at the next render.

  • seek

    Move playback to a source timestamp without delaying a render.

  • get_frame_index

    Return the source frame active at an elapsed playback time.

  • get_size

    Return the native terminal cell dimensions of the source.

  • compose

    Compose the current timed frame for the target terminal area.

  • get_frame

    Optional frame/panel chrome around composed content.

  • before_render

    Called before rendering; returns the effective render area.

  • after_render

    Called after rendering for optional post-paint work.

  • compose_extra_small

    Compose content when the viewport is extra small (< 40 cols).

  • compose_small

    Compose content when the viewport is small (40–79 cols).

  • compose_medium

    Compose content when the viewport is medium (80–119 cols).

  • compose_large

    Compose content when the viewport is large (120–159 cols).

  • compose_extra_large

    Compose content when the viewport is extra large (>= 160 cols).

  • handle_keyboard

    Optional keyboard handler while focused.

  • handle_paste

    Optional paste handler while focused.

source class-attribute instance-attribute

source: ImageSource = ''

Encoded image path, bytes, stream, or decoded image data.

fit class-attribute instance-attribute

fit: ImageFit = 'crop'

Native crop, contain, cover, stretch, or adaptive placement.

loop class-attribute instance-attribute

loop: bool = True

Whether an animation restarts after its final frame.

speed class-attribute instance-attribute

speed: float = 1.0

Playback-rate multiplier.

background class-attribute instance-attribute

background: tuple[int, int, int] = (0, 0, 0)

RGB color behind transparency and centered padding.

horizontal_pixels_per_cell class-attribute instance-attribute

horizontal_pixels_per_cell: HorizontalPixelsPerCell = 1

Adjacent source pixels sampled into each terminal cell.

correct_terminal_aspect class-attribute instance-attribute

correct_terminal_aspect: bool = False

Whether 2x2 sampling preserves physical terminal proportions.

playing class-attribute instance-attribute

playing: bool = True

Whether wall-clock time advances the animation.

position_ms class-attribute instance-attribute

position_ms: float | None = None

Explicit playback position override in milliseconds.

fit_content class-attribute instance-attribute

fit_content: bool = dataclasses.field(
    default=False, kw_only=True
)

Whether layout should use the image's natural cell size.

frame_count property

frame_count: int

Number of decoded still or animation frames.

duration_ms property

duration_ms: int

Total duration of one animation cycle in milliseconds.

finished property

finished: bool

Whether non-looping playback has reached its source duration.

visible class-attribute instance-attribute

visible: bool = dataclasses.field(
    default=True, kw_only=True
)

Whether this component paints at all.

z class-attribute instance-attribute

z: int = dataclasses.field(default=0, kw_only=True)

Stacking order among sibling content.

focused property

focused: bool

Whether this component currently holds field focus.

component_post_init

component_post_init() -> None

Validate configuration and decode the initial source.

Source code in xnano/components/image.py
def component_post_init(self) -> None:
    """Validate configuration and decode the initial source."""
    self._validate_configuration()
    self._decode_source()

play

play() -> None

Resume animation from the current playback position.

Source code in xnano/components/image.py
def play(self) -> None:
    """Resume animation from the current playback position."""
    if self.playing:
        return
    self.playing = True
    # Preserve paused position across resume.
    self._started_at_ns = time.monotonic_ns() - round(
        self._paused_elapsed_ms * 1_000_000
    )

pause

pause() -> None

Freeze animation at the current playback position.

Source code in xnano/components/image.py
def pause(self) -> None:
    """Freeze animation at the current playback position."""
    if not self.playing:
        return
    self._paused_elapsed_ms = self._get_elapsed_ms()
    self.playing = False

restart

restart() -> None

Restart animation timing at the next render.

Source code in xnano/components/image.py
def restart(self) -> None:
    """Restart animation timing at the next render."""
    self._started_at_ns = None
    self._paused_elapsed_ms = 0.0
    self.position_ms = None

seek

seek(position_ms: float) -> None

Move playback to a source timestamp without delaying a render.

Parameters:

  • position_ms (float) –

    Source animation timestamp in milliseconds.

Source code in xnano/components/image.py
def seek(self, position_ms: float) -> None:
    """Move playback to a source timestamp without delaying a render.

    Args:
        position_ms: Source animation timestamp in milliseconds.
    """
    wall_elapsed_ms = max(0.0, position_ms) / self.speed
    self._paused_elapsed_ms = wall_elapsed_ms
    self.position_ms = None
    if self.playing:
        self._started_at_ns = time.monotonic_ns() - round(
            wall_elapsed_ms * 1_000_000
        )
    else:
        self._started_at_ns = None

get_frame_index

get_frame_index(elapsed_ms: float) -> int

Return the source frame active at an elapsed playback time.

Parameters:

  • elapsed_ms (float) –

    Playback time in milliseconds.

Returns:

  • int

    The zero-based active frame index.

Source code in xnano/components/image.py
def get_frame_index(self, elapsed_ms: float) -> int:
    """Return the source frame active at an elapsed playback time.

    Args:
        elapsed_ms: Playback time in milliseconds.

    Returns:
        The zero-based active frame index.
    """
    self._ensure_decoded()
    if len(self._frames) == 1:
        return 0
    position = max(0, int(elapsed_ms * self.speed))
    if self.loop:
        position %= self._duration_ms
    else:
        position = min(position, self._duration_ms - 1)
    for frame_index, frame_end in enumerate(self._frame_ends_ms):
        if position < frame_end:
            return frame_index
    return len(self._frames) - 1

get_size

get_size(ctx: ComponentRenderContext) -> Size

Return the native terminal cell dimensions of the source.

Source code in xnano/components/image.py
def get_size(self, ctx: ComponentRenderContext) -> Size:
    """Return the native terminal cell dimensions of the source."""
    self._ensure_decoded()
    frame = self._frames[0]
    if self.correct_terminal_aspect:
        width = frame.width
    else:
        width = math.ceil(frame.width / self.horizontal_pixels_per_cell)
    return Size(
        width=width,
        height=math.ceil(frame.height / 2),
    )

compose

Compose the current timed frame for the target terminal area.

Source code in xnano/components/image.py
def compose(self, ctx: ComponentRenderContext) -> CellCanvas:
    """Compose the current timed frame for the target terminal area."""
    self._ensure_decoded()
    elapsed_ms = self._get_elapsed_ms()
    if self.playing:
        self._paused_elapsed_ms = elapsed_ms
    frame_index = self.get_frame_index(elapsed_ms)
    source = self._frames[frame_index]
    if self.correct_terminal_aspect:
        native_width = source.width
    else:
        native_width = math.ceil(
            source.width / self.horizontal_pixels_per_cell
        )
    width = max(1, ctx.area.width or native_width)
    height = max(1, ctx.area.height or math.ceil(source.height / 2))
    cache_key = (
        frame_index,
        width,
        height,
        self.fit,
        self.horizontal_pixels_per_cell,
        self.correct_terminal_aspect,
    )
    if cache_key == self._cached_key and self._cached_canvas is not None:
        return self._cached_canvas
    fitted_width = width
    if not self.correct_terminal_aspect:
        fitted_width *= self.horizontal_pixels_per_cell
    fitted = _fit_frame(
        source,
        fitted_width,
        height * 2,
        self.fit,
        self.background,
    )
    if (
        self.correct_terminal_aspect
        and self.horizontal_pixels_per_cell == 2
    ):
        fitted = _resize_frame(
            fitted,
            fitted.width * 2,
            fitted.height,
        )
    canvas = _get_frame_as_canvas(
        fitted,
        self.horizontal_pixels_per_cell,
    )
    result = CellCanvas(
        width=canvas.width,
        height=canvas.height,
        rows=canvas.rows,
        z=self.z,
        visible=self.visible,
    )
    self._cached_key = cache_key
    self._cached_canvas = result
    return result

get_frame

get_frame() -> Any | None

Optional frame/panel chrome around composed content.

Source code in xnano/components/component.py
def get_frame(self) -> Any | None:
    """Optional frame/panel chrome around composed content."""
    return None

before_render

before_render(
    ctx: ComponentRenderContext[StateT], area: "Area"
) -> "Area"

Called before rendering; returns the effective render area.

Source code in xnano/components/component.py
def before_render(
    self,
    ctx: ComponentRenderContext[StateT],
    area: "Area",
) -> "Area":
    """Called before rendering; returns the effective render area."""
    return area

after_render

after_render(
    ctx: ComponentRenderContext[StateT], area: "Area"
) -> None

Called after rendering for optional post-paint work.

Source code in xnano/components/component.py
def after_render(
    self,
    ctx: ComponentRenderContext[StateT],
    area: "Area",
) -> None:
    """Called after rendering for optional post-paint work."""
    return None

compose_extra_small

compose_extra_small(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is extra small (< 40 cols).

Optional responsive counterpart to :meth:compose. When overridden, it is used instead of compose while the window is in this size tier. Overriding any compose_* variant opts the component into breakpoint dispatch; a component that overrides none pays no per-frame cost.

Source code in xnano/components/component.py
@responsive_noop
def compose_extra_small(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is extra small (< 40 cols).

    Optional responsive counterpart to :meth:`compose`. When
    overridden, it is used *instead of* ``compose`` while the window
    is in this size tier. Overriding any ``compose_*`` variant opts the
    component into breakpoint dispatch; a component that overrides none
    pays no per-frame cost.
    """
    return None

compose_small

compose_small(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is small (40–79 cols).

See :meth:compose_extra_small.

Source code in xnano/components/component.py
@responsive_noop
def compose_small(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is small (40–79 cols).

    See :meth:`compose_extra_small`.
    """
    return None

compose_medium

compose_medium(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is medium (80–119 cols).

See :meth:compose_extra_small.

Source code in xnano/components/component.py
@responsive_noop
def compose_medium(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is medium (80–119 cols).

    See :meth:`compose_extra_small`.
    """
    return None

compose_large

compose_large(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is large (120–159 cols).

See :meth:compose_extra_small.

Source code in xnano/components/component.py
@responsive_noop
def compose_large(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is large (120–159 cols).

    See :meth:`compose_extra_small`.
    """
    return None

compose_extra_large

compose_extra_large(
    ctx: ComponentRenderContext[StateT],
) -> Content | None

Compose content when the viewport is extra large (>= 160 cols).

See :meth:compose_extra_small.

Source code in xnano/components/component.py
@responsive_noop
def compose_extra_large(
    self, ctx: ComponentRenderContext[StateT]
) -> Content | None:
    """Compose content when the viewport is extra large (>= 160 cols).

    See :meth:`compose_extra_small`.
    """
    return None

handle_keyboard

handle_keyboard(keyboard: 'KeyboardEventData') -> bool

Optional keyboard handler while focused.

Returns:

  • bool

    True when the event was consumed.

Source code in xnano/components/component.py
def handle_keyboard(self, keyboard: "KeyboardEventData") -> bool:
    """Optional keyboard handler while focused.

    Returns:
        ``True`` when the event was consumed.
    """
    return False

handle_paste

handle_paste(text: str) -> bool

Optional paste handler while focused.

Returns:

  • bool

    True when the paste was consumed.

Source code in xnano/components/component.py
def handle_paste(self, text: str) -> bool:
    """Optional paste handler while focused.

    Returns:
        ``True`` when the paste was consumed.
    """
    return False