diff --git a/engine/display/__init__.py b/engine/display/__init__.py index e7d09ec..5a29d06 100644 --- a/engine/display/__init__.py +++ b/engine/display/__init__.py @@ -5,102 +5,58 @@ Allows swapping output backends via the Display protocol. Supports auto-discovery of display backends. """ +from enum import Enum, auto from typing import Protocol -from engine.display.backends.kitty import KittyDisplay +# Optional backend - requires moderngl package +try: + from engine.display.backends.moderngl import ModernGLDisplay + + _MODERNGL_AVAILABLE = True +except ImportError: + ModernGLDisplay = None + _MODERNGL_AVAILABLE = False + from engine.display.backends.multi import MultiDisplay from engine.display.backends.null import NullDisplay from engine.display.backends.pygame import PygameDisplay -from engine.display.backends.sixel import SixelDisplay from engine.display.backends.terminal import TerminalDisplay from engine.display.backends.websocket import WebSocketDisplay +class BorderMode(Enum): + """Border rendering modes for displays.""" + + OFF = auto() # No border + SIMPLE = auto() # Traditional border with FPS/frame time + UI = auto() # Right-side UI panel with interactive controls + + class Display(Protocol): """Protocol for display backends. - All display backends must implement: - - width, height: Terminal dimensions - - init(width, height, reuse=False): Initialize the display - - show(buffer): Render buffer to display - - clear(): Clear the display - - cleanup(): Shutdown the display + Required attributes: + - width: int + - height: int - Optional methods for keyboard input: - - is_quit_requested(): Returns True if user pressed Ctrl+C/Q or Escape - - clear_quit_request(): Clears the quit request flag + Required methods (duck typing - actual signatures may vary): + - init(width, height, reuse=False) + - show(buffer, border=False) + - clear() + - cleanup() + - get_dimensions() -> (width, height) - The reuse flag allows attaching to an existing display instance - rather than creating a new window/connection. + Optional attributes (for UI mode): + - ui_panel: UIPanel instance (set by app when border=UI) - Keyboard input support by backend: - - terminal: No native input (relies on signal handler for Ctrl+C) - - pygame: Supports Ctrl+C, Ctrl+Q, Escape for graceful shutdown - - websocket: No native input (relies on signal handler for Ctrl+C) - - sixel: No native input (relies on signal handler for Ctrl+C) - - null: No native input - - kitty: Supports Ctrl+C, Ctrl+Q, Escape (via pygame-like handling) + Optional methods: + - is_quit_requested() -> bool + - clear_quit_request() -> None """ width: int height: int - def init(self, width: int, height: int, reuse: bool = False) -> None: - """Initialize display with dimensions. - - Args: - width: Terminal width in characters - height: Terminal height in rows - reuse: If True, attach to existing display instead of creating new - """ - ... - - def show(self, buffer: list[str], border: bool = False) -> None: - """Show buffer on display. - - Args: - buffer: Buffer to display - border: If True, render border around buffer (default False) - """ - ... - - def clear(self) -> None: - """Clear display.""" - ... - - def cleanup(self) -> None: - """Shutdown display.""" - ... - - def get_dimensions(self) -> tuple[int, int]: - """Get current terminal dimensions. - - Returns: - (width, height) in character cells - - This method is called after show() to check if the display - was resized. The main loop should compare this to the current - viewport dimensions and update accordingly. - """ - ... - - def is_quit_requested(self) -> bool: - """Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape). - - Returns: - True if quit was requested, False otherwise - - Optional method - only implemented by backends that support keyboard input. - """ - ... - - def clear_quit_request(self) -> None: - """Clear the quit request flag. - - Optional method - only implemented by backends that support keyboard input. - """ - ... - class DisplayRegistry: """Registry for display backends with auto-discovery.""" @@ -110,22 +66,18 @@ class DisplayRegistry: @classmethod def register(cls, name: str, backend_class: type[Display]) -> None: - """Register a display backend.""" cls._backends[name.lower()] = backend_class @classmethod def get(cls, name: str) -> type[Display] | None: - """Get a display backend class by name.""" return cls._backends.get(name.lower()) @classmethod def list_backends(cls) -> list[str]: - """List all available display backend names.""" return list(cls._backends.keys()) @classmethod def create(cls, name: str, **kwargs) -> Display | None: - """Create a display instance by name.""" cls.initialize() backend_class = cls.get(name) if backend_class: @@ -134,31 +86,18 @@ class DisplayRegistry: @classmethod def initialize(cls) -> None: - """Initialize and register all built-in backends.""" if cls._initialized: return - cls.register("terminal", TerminalDisplay) cls.register("null", NullDisplay) cls.register("websocket", WebSocketDisplay) - cls.register("sixel", SixelDisplay) - cls.register("kitty", KittyDisplay) cls.register("pygame", PygameDisplay) - + if _MODERNGL_AVAILABLE: + cls.register("moderngl", ModernGLDisplay) # type: ignore[arg-type] cls._initialized = True @classmethod - def create_multi(cls, names: list[str]) -> "Display | None": - """Create a MultiDisplay from a list of backend names. - - Args: - names: List of display backend names (e.g., ["terminal", "pygame"]) - - Returns: - MultiDisplay instance or None if any backend fails - """ - from engine.display.backends.multi import MultiDisplay - + def create_multi(cls, names: list[str]) -> MultiDisplay | None: displays = [] for name in names: backend = cls.create(name) @@ -166,10 +105,8 @@ class DisplayRegistry: displays.append(backend) else: return None - if not displays: return None - return MultiDisplay(displays) @@ -190,44 +127,28 @@ def _strip_ansi(s: str) -> str: return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s) -def render_border( +def _render_simple_border( buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0 ) -> list[str]: - """Render a border around the buffer. - - Args: - buf: Input buffer (list of strings) - width: Display width in characters - height: Display height in rows - fps: Current FPS to display in top border (optional) - frame_time: Frame time in ms to display in bottom border (optional) - - Returns: - Buffer with border applied - """ + """Render a traditional border around the buffer.""" if not buf or width < 3 or height < 3: return buf inner_w = width - 2 inner_h = height - 2 - # Crop buffer to fit inside border cropped = [] for i in range(min(inner_h, len(buf))): line = buf[i] - # Calculate visible width (excluding ANSI codes) visible_len = len(_strip_ansi(line)) if visible_len > inner_w: - # Truncate carefully - this is approximate for ANSI text cropped.append(line[:inner_w]) else: cropped.append(line + " " * (inner_w - visible_len)) - # Pad with empty lines if needed while len(cropped) < inner_h: cropped.append(" " * inner_w) - # Build borders if fps > 0: fps_str = f" FPS:{fps:.0f}" if len(fps_str) < inner_w: @@ -248,10 +169,8 @@ def render_border( else: bottom_border = "└" + "─" * inner_w + "┘" - # Build result with left/right borders result = [top_border] for line in cropped: - # Ensure exactly inner_w characters before adding right border if len(line) < inner_w: line = line + " " * (inner_w - len(line)) elif len(line) > inner_w: @@ -262,14 +181,107 @@ def render_border( return result +def render_ui_panel( + buf: list[str], + width: int, + height: int, + ui_panel, + fps: float = 0.0, + frame_time: float = 0.0, +) -> list[str]: + """Render buffer with a right-side UI panel.""" + from engine.pipeline.ui import UIPanel + + if not isinstance(ui_panel, UIPanel): + return _render_simple_border(buf, width, height, fps, frame_time) + + panel_width = min(ui_panel.config.panel_width, width - 4) + main_width = width - panel_width - 1 + + panel_lines = ui_panel.render(panel_width, height) + + main_buf = buf[: height - 2] + main_result = _render_simple_border( + main_buf, main_width + 2, height, fps, frame_time + ) + + combined = [] + for i in range(height): + if i < len(main_result): + main_line = main_result[i] + if len(main_line) >= 2: + main_content = ( + main_line[1:-1] if main_line[-1] in "│┌┐└┘" else main_line[1:] + ) + main_content = main_content.ljust(main_width)[:main_width] + else: + main_content = " " * main_width + else: + main_content = " " * main_width + + panel_idx = i + panel_line = ( + panel_lines[panel_idx][:panel_width].ljust(panel_width) + if panel_idx < len(panel_lines) + else " " * panel_width + ) + + separator = "│" if 0 < i < height - 1 else "┼" if i == 0 else "┴" + combined.append(main_content + separator + panel_line) + + return combined + + +def render_border( + buf: list[str], + width: int, + height: int, + fps: float = 0.0, + frame_time: float = 0.0, + border_mode: BorderMode | bool = BorderMode.SIMPLE, +) -> list[str]: + """Render a border or UI panel around the buffer. + + Args: + buf: Input buffer + width: Display width + height: Display height + fps: FPS for top border + frame_time: Frame time for bottom border + border_mode: Border rendering mode + + Returns: + Buffer with border/panel applied + """ + # Normalize border_mode to BorderMode enum + if isinstance(border_mode, bool): + border_mode = BorderMode.SIMPLE if border_mode else BorderMode.OFF + + if border_mode == BorderMode.UI: + # UI panel requires a UIPanel instance (injected separately) + # For now, this will be called by displays that have a ui_panel attribute + # This function signature doesn't include ui_panel, so we'll handle it in render_ui_panel + # Fall back to simple border if no panel available + return _render_simple_border(buf, width, height, fps, frame_time) + elif border_mode == BorderMode.SIMPLE: + return _render_simple_border(buf, width, height, fps, frame_time) + else: + return buf + + __all__ = [ "Display", "DisplayRegistry", "get_monitor", "render_border", + "render_ui_panel", + "BorderMode", "TerminalDisplay", "NullDisplay", "WebSocketDisplay", - "SixelDisplay", "MultiDisplay", + "PygameDisplay", ] + +if _MODERNGL_AVAILABLE: + __all__.append("ModernGLDisplay") diff --git a/engine/display/backends/kitty.py b/engine/display/backends/kitty.py deleted file mode 100644 index 9174a3d..0000000 --- a/engine/display/backends/kitty.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -Kitty graphics display backend - renders using kitty's native graphics protocol. -""" - -import time - -from engine.display.renderer import get_default_font_path, parse_ansi - - -def _encode_kitty_graphic(image_data: bytes, width: int, height: int) -> bytes: - """Encode image data using kitty's graphics protocol.""" - import base64 - - encoded = base64.b64encode(image_data).decode("ascii") - - chunks = [] - for i in range(0, len(encoded), 4096): - chunk = encoded[i : i + 4096] - if i == 0: - chunks.append(f"\x1b_Gf=100,t=d,s={width},v={height},c=1,r=1;{chunk}\x1b\\") - else: - chunks.append(f"\x1b_Gm={height};{chunk}\x1b\\") - - return "".join(chunks).encode("utf-8") - - -class KittyDisplay: - """Kitty graphics display backend using kitty's native protocol.""" - - width: int = 80 - height: int = 24 - - def __init__(self, cell_width: int = 9, cell_height: int = 16): - self.width = 80 - self.height = 24 - self.cell_width = cell_width - self.cell_height = cell_height - self._initialized = False - self._font_path = None - - def init(self, width: int, height: int, reuse: bool = False) -> None: - """Initialize display with dimensions. - - Args: - width: Terminal width in characters - height: Terminal height in rows - reuse: Ignored for KittyDisplay (protocol doesn't support reuse) - """ - self.width = width - self.height = height - self._initialized = True - - def _get_font_path(self) -> str | None: - """Get font path from env or detect common locations.""" - import os - - if self._font_path: - return self._font_path - - env_font = os.environ.get("MAINLINE_KITTY_FONT") - if env_font and os.path.exists(env_font): - self._font_path = env_font - return env_font - - font_path = get_default_font_path() - if font_path: - self._font_path = font_path - - return self._font_path - - def show(self, buffer: list[str], border: bool = False) -> None: - import sys - - t0 = time.perf_counter() - - # Get metrics for border display - fps = 0.0 - frame_time = 0.0 - from engine.display import get_monitor - - monitor = get_monitor() - if monitor: - stats = monitor.get_stats() - avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0 - frame_count = stats.get("frame_count", 0) if stats else 0 - if avg_ms and frame_count > 0: - fps = 1000.0 / avg_ms - frame_time = avg_ms - - # Apply border if requested - if border: - from engine.display import render_border - - buffer = render_border(buffer, self.width, self.height, fps, frame_time) - - img_width = self.width * self.cell_width - img_height = self.height * self.cell_height - - try: - from PIL import Image, ImageDraw, ImageFont - except ImportError: - return - - img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255)) - draw = ImageDraw.Draw(img) - - font_path = self._get_font_path() - font = None - if font_path: - try: - font = ImageFont.truetype(font_path, self.cell_height - 2) - except Exception: - font = None - - if font is None: - try: - font = ImageFont.load_default() - except Exception: - font = None - - for row_idx, line in enumerate(buffer[: self.height]): - if row_idx >= self.height: - break - - tokens = parse_ansi(line) - x_pos = 0 - y_pos = row_idx * self.cell_height - - for text, fg, bg, bold in tokens: - if not text: - continue - - if bg != (0, 0, 0): - bbox = draw.textbbox((x_pos, y_pos), text, font=font) - draw.rectangle(bbox, fill=(*bg, 255)) - - if bold and font: - draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font) - - draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font) - - if font: - x_pos += draw.textlength(text, font=font) - - from io import BytesIO - - output = BytesIO() - img.save(output, format="PNG") - png_data = output.getvalue() - - graphic = _encode_kitty_graphic(png_data, img_width, img_height) - - sys.stdout.buffer.write(graphic) - sys.stdout.flush() - - elapsed_ms = (time.perf_counter() - t0) * 1000 - - from engine.display import get_monitor - - monitor = get_monitor() - if monitor: - chars_in = sum(len(line) for line in buffer) - monitor.record_effect("kitty_display", elapsed_ms, chars_in, chars_in) - - def clear(self) -> None: - import sys - - sys.stdout.buffer.write(b"\x1b_Ga=d\x1b\\") - sys.stdout.flush() - - def cleanup(self) -> None: - self.clear() - - def get_dimensions(self) -> tuple[int, int]: - """Get current dimensions. - - Returns: - (width, height) in character cells - """ - return (self.width, self.height) diff --git a/engine/display/backends/sixel.py b/engine/display/backends/sixel.py deleted file mode 100644 index 52dfc2b..0000000 --- a/engine/display/backends/sixel.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Sixel graphics display backend - renders to sixel graphics in terminal. -""" - -import time - -from engine.display.renderer import get_default_font_path, parse_ansi - - -def _encode_sixel(image) -> str: - """Encode a PIL Image to sixel format (pure Python).""" - img = image.convert("RGBA") - width, height = img.size - pixels = img.load() - - palette = [] - pixel_palette_idx = {} - - def get_color_idx(r, g, b, a): - if a < 128: - return -1 - key = (r // 32, g // 32, b // 32) - if key not in pixel_palette_idx: - idx = len(palette) - if idx < 256: - palette.append((r, g, b)) - pixel_palette_idx[key] = idx - return pixel_palette_idx.get(key, 0) - - for y in range(height): - for x in range(width): - r, g, b, a = pixels[x, y] - get_color_idx(r, g, b, a) - - if not palette: - return "" - - if len(palette) == 1: - palette = [palette[0], (0, 0, 0)] - - sixel_data = [] - sixel_data.append( - f'"{"".join(f"#{i};2;{r};{g};{b}" for i, (r, g, b) in enumerate(palette))}' - ) - - for x in range(width): - col_data = [] - for y in range(0, height, 6): - bits = 0 - color_idx = -1 - for dy in range(6): - if y + dy < height: - r, g, b, a = pixels[x, y + dy] - if a >= 128: - bits |= 1 << dy - idx = get_color_idx(r, g, b, a) - if color_idx == -1: - color_idx = idx - elif color_idx != idx: - color_idx = -2 - - if color_idx >= 0: - col_data.append( - chr(63 + color_idx) + chr(63 + bits) - if bits - else chr(63 + color_idx) + "?" - ) - elif color_idx == -2: - pass - - if col_data: - sixel_data.append("".join(col_data) + "$") - else: - sixel_data.append("-" if x < width - 1 else "$") - - sixel_data.append("\x1b\\") - - return "\x1bPq" + "".join(sixel_data) - - -class SixelDisplay: - """Sixel graphics display backend - renders to sixel graphics in terminal.""" - - width: int = 80 - height: int = 24 - - def __init__(self, cell_width: int = 9, cell_height: int = 16): - self.width = 80 - self.height = 24 - self.cell_width = cell_width - self.cell_height = cell_height - self._initialized = False - self._font_path = None - - def _get_font_path(self) -> str | None: - """Get font path from env or detect common locations.""" - import os - - if self._font_path: - return self._font_path - - env_font = os.environ.get("MAINLINE_SIXEL_FONT") - if env_font and os.path.exists(env_font): - self._font_path = env_font - return env_font - - font_path = get_default_font_path() - if font_path: - self._font_path = font_path - - return self._font_path - - def init(self, width: int, height: int, reuse: bool = False) -> None: - """Initialize display with dimensions. - - Args: - width: Terminal width in characters - height: Terminal height in rows - reuse: Ignored for SixelDisplay - """ - self.width = width - self.height = height - self._initialized = True - - def show(self, buffer: list[str], border: bool = False) -> None: - import sys - - t0 = time.perf_counter() - - # Get metrics for border display - fps = 0.0 - frame_time = 0.0 - from engine.display import get_monitor - - monitor = get_monitor() - if monitor: - stats = monitor.get_stats() - avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0 - frame_count = stats.get("frame_count", 0) if stats else 0 - if avg_ms and frame_count > 0: - fps = 1000.0 / avg_ms - frame_time = avg_ms - - # Apply border if requested - if border: - from engine.display import render_border - - buffer = render_border(buffer, self.width, self.height, fps, frame_time) - - img_width = self.width * self.cell_width - img_height = self.height * self.cell_height - - try: - from PIL import Image, ImageDraw, ImageFont - except ImportError: - return - - img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255)) - draw = ImageDraw.Draw(img) - - font_path = self._get_font_path() - font = None - if font_path: - try: - font = ImageFont.truetype(font_path, self.cell_height - 2) - except Exception: - font = None - - if font is None: - try: - font = ImageFont.load_default() - except Exception: - font = None - - for row_idx, line in enumerate(buffer[: self.height]): - if row_idx >= self.height: - break - - tokens = parse_ansi(line) - x_pos = 0 - y_pos = row_idx * self.cell_height - - for text, fg, bg, bold in tokens: - if not text: - continue - - if bg != (0, 0, 0): - bbox = draw.textbbox((x_pos, y_pos), text, font=font) - draw.rectangle(bbox, fill=(*bg, 255)) - - if bold and font: - draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font) - - draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font) - - if font: - x_pos += draw.textlength(text, font=font) - - sixel = _encode_sixel(img) - - sys.stdout.buffer.write(sixel.encode("utf-8")) - sys.stdout.flush() - - elapsed_ms = (time.perf_counter() - t0) * 1000 - - from engine.display import get_monitor - - monitor = get_monitor() - if monitor: - chars_in = sum(len(line) for line in buffer) - monitor.record_effect("sixel_display", elapsed_ms, chars_in, chars_in) - - def clear(self) -> None: - import sys - - sys.stdout.buffer.write(b"\x1b[2J\x1b[H") - sys.stdout.flush() - - def cleanup(self) -> None: - pass - - def get_dimensions(self) -> tuple[int, int]: - """Get current dimensions. - - Returns: - (width, height) in character cells - """ - return (self.width, self.height) diff --git a/engine/display/backends/websocket.py b/engine/display/backends/websocket.py index 00b9289..062dc87 100644 --- a/engine/display/backends/websocket.py +++ b/engine/display/backends/websocket.py @@ -1,5 +1,14 @@ """ WebSocket display backend - broadcasts frame buffer to connected web clients. + +TODO: Transform to a true streaming backend with: +- Proper WebSocket message streaming (currently sends full buffer each frame) +- Connection pooling and backpressure handling +- Binary protocol for efficiency (instead of JSON) +- Client management with proper async handling +- Mark for deprecation if replaced by a new streaming implementation + +Current implementation: Simple broadcast of text frames to all connected clients. """ import asyncio diff --git a/tests/test_display.py b/tests/test_display.py index a9980ce..5adc678 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -77,11 +77,13 @@ class TestDisplayRegistry: DisplayRegistry.initialize() assert DisplayRegistry.get("terminal") == TerminalDisplay assert DisplayRegistry.get("null") == NullDisplay - from engine.display.backends.sixel import SixelDisplay + from engine.display.backends.pygame import PygameDisplay from engine.display.backends.websocket import WebSocketDisplay assert DisplayRegistry.get("websocket") == WebSocketDisplay - assert DisplayRegistry.get("sixel") == SixelDisplay + assert DisplayRegistry.get("pygame") == PygameDisplay + # Removed backends (sixel, kitty) should not be present + assert DisplayRegistry.get("sixel") is None def test_initialize_idempotent(self): """initialize can be called multiple times safely.""" diff --git a/tests/test_sixel.py b/tests/test_sixel.py deleted file mode 100644 index 677c74d..0000000 --- a/tests/test_sixel.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Tests for engine.display.backends.sixel module. -""" - -from unittest.mock import MagicMock, patch - - -class TestSixelDisplay: - """Tests for SixelDisplay class.""" - - def test_init_stores_dimensions(self): - """init stores dimensions.""" - from engine.display.backends.sixel import SixelDisplay - - display = SixelDisplay() - display.init(80, 24) - assert display.width == 80 - assert display.height == 24 - - def test_init_custom_cell_size(self): - """init accepts custom cell size.""" - from engine.display.backends.sixel import SixelDisplay - - display = SixelDisplay(cell_width=12, cell_height=18) - assert display.cell_width == 12 - assert display.cell_height == 18 - - def test_show_handles_empty_buffer(self): - """show handles empty buffer gracefully.""" - from engine.display.backends.sixel import SixelDisplay - - display = SixelDisplay() - display.init(80, 24) - - with patch("engine.display.backends.sixel._encode_sixel") as mock_encode: - mock_encode.return_value = "" - display.show([]) - - def test_show_handles_pil_import_error(self): - """show gracefully handles missing PIL.""" - from engine.display.backends.sixel import SixelDisplay - - display = SixelDisplay() - display.init(80, 24) - - with patch.dict("sys.modules", {"PIL": None}): - display.show(["test line"]) - - def test_clear_sends_escape_sequence(self): - """clear sends clear screen escape sequence.""" - from engine.display.backends.sixel import SixelDisplay - - display = SixelDisplay() - - with patch("sys.stdout") as mock_stdout: - display.clear() - mock_stdout.buffer.write.assert_called() - - def test_cleanup_does_nothing(self): - """cleanup does nothing.""" - from engine.display.backends.sixel import SixelDisplay - - display = SixelDisplay() - display.cleanup() - - -class TestSixelAnsiParsing: - """Tests for ANSI parsing in SixelDisplay.""" - - def test_parse_empty_string(self): - """handles empty string.""" - from engine.display.renderer import parse_ansi - - result = parse_ansi("") - assert len(result) > 0 - - def test_parse_plain_text(self): - """parses plain text without ANSI codes.""" - from engine.display.renderer import parse_ansi - - result = parse_ansi("hello world") - assert len(result) == 1 - text, fg, bg, bold = result[0] - assert text == "hello world" - - def test_parse_with_color_codes(self): - """parses ANSI color codes.""" - from engine.display.renderer import parse_ansi - - result = parse_ansi("\033[31mred\033[0m") - assert len(result) == 1 - assert result[0][0] == "red" - assert result[0][1] == (205, 49, 49) - - def test_parse_with_bold(self): - """parses bold codes.""" - from engine.display.renderer import parse_ansi - - result = parse_ansi("\033[1mbold\033[0m") - assert len(result) == 1 - assert result[0][0] == "bold" - assert result[0][3] is True - - def test_parse_256_color(self): - """parses 256 color codes.""" - from engine.display.renderer import parse_ansi - - result = parse_ansi("\033[38;5;196mred\033[0m") - assert len(result) == 1 - assert result[0][0] == "red" - - -class TestSixelEncoding: - """Tests for Sixel encoding.""" - - def test_encode_empty_image(self): - """handles empty image.""" - from engine.display.backends.sixel import _encode_sixel - - with patch("PIL.Image.Image") as mock_image: - mock_img_instance = MagicMock() - mock_img_instance.convert.return_value = mock_img_instance - mock_img_instance.size = (0, 0) - mock_img_instance.load.return_value = {} - mock_image.return_value = mock_img_instance - - result = _encode_sixel(mock_img_instance) - assert result == ""