diff --git a/packages/claude-code-plugin/hooks/lib/tiny_actor_grid.py b/packages/claude-code-plugin/hooks/lib/tiny_actor_grid.py new file mode 100644 index 00000000..62aaba09 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/tiny_actor_grid.py @@ -0,0 +1,65 @@ +"""Responsive Tiny Actor Grid layout engine with column fallback (#1268). + +Accepts pre-rendered card lines and assembles them into a responsive grid +that adapts to the available terminal width. +""" +from __future__ import annotations + +from itertools import zip_longest + + +def layout_grid( + cards: list[list[str]], + *, + available_width: int = 80, + card_width: int = 14, + gap: int = 2, +) -> list[str]: + """Arrange pre-rendered cards into a responsive grid. + + Parameters + ---------- + cards: + Each card is a list of strings (one per visual line). + available_width: + Maximum terminal columns for the output. + card_width: + Display width of a single card. + gap: + Number of space characters between adjacent columns. + + Returns + ------- + list[str] + Assembled lines ready for terminal output. + """ + if not cards: + return [] + + max_columns = max(1, (available_width + gap) // (card_width + gap)) + + rows: list[list[list[str]]] = [] + for i in range(0, len(cards), max_columns): + rows.append(cards[i : i + max_columns]) + + separator = " " * gap + row_width = max_columns * card_width + (max_columns - 1) * gap + + output: list[str] = [] + for row in rows: + tallest = max(len(card) for card in row) + + padded_cards: list[list[str]] = [] + for card in row: + padded = list(card) + [" " * card_width] * (tallest - len(card)) + padded_cards.append(padded) + + for line_parts in zip_longest(*padded_cards, fillvalue=" " * card_width): + parts = [part.ljust(card_width) for part in line_parts] + # Pad row to full width when fewer cards than max_columns + while len(parts) < max_columns: + parts.append(" " * card_width) + line = separator.join(parts) + output.append(line) + + return output diff --git a/packages/claude-code-plugin/tests/test_tiny_actor_grid.py b/packages/claude-code-plugin/tests/test_tiny_actor_grid.py new file mode 100644 index 00000000..3c4d4b04 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_tiny_actor_grid.py @@ -0,0 +1,109 @@ +"""Tests for responsive Tiny Actor Grid layout engine (#1268).""" +import os +import sys + +import pytest + +# Ensure hooks/lib is on path +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_lib_dir = os.path.join(os.path.dirname(_tests_dir), "hooks", "lib") +if _lib_dir not in sys.path: + sys.path.insert(0, _lib_dir) + +from tiny_actor_grid import layout_grid + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_CARD_WIDTH = 14 + + +def _make_card(lines: int = 3, width: int = _CARD_WIDTH, label: str = "X") -> list[str]: + """Create a fake pre-rendered card with *lines* rows, each *width* chars.""" + rows: list[str] = [] + for i in range(lines): + text = f"{label}{i}" + rows.append(text.ljust(width)) + return rows + + +# --------------------------------------------------------------------------- +# Column calculation +# --------------------------------------------------------------------------- + + +class TestColumnCalculation: + """layout_grid picks the right number of columns for the available width.""" + + def test_three_cards_fit_one_row_at_80_cols(self): + """3 cards (14 wide, gap 2) need 14+2+14+2+14 = 46 chars => fits in 80.""" + cards = [_make_card(label=c) for c in "ABC"] + lines = layout_grid(cards, available_width=80, card_width=_CARD_WIDTH, gap=2) + # All 3 cards in one row => only 3 visual lines (card height) + assert len(lines) == 3 + + def test_six_cards_wrap_to_two_rows_at_80_cols(self): + """80 cols fits max 5 cards (14+2)*5-2=78. 6 cards => 2 rows.""" + cards = [_make_card(label=c) for c in "ABCDEF"] + lines = layout_grid(cards, available_width=80, card_width=_CARD_WIDTH, gap=2) + # 5 cards first row + 1 card second row => 3 + 3 = 6 visual lines + assert len(lines) == 6 + + def test_single_card_at_40_cols(self): + """1 card at 40 cols => single column, 3 lines.""" + cards = [_make_card()] + lines = layout_grid(cards, available_width=40, card_width=_CARD_WIDTH, gap=2) + assert len(lines) == 3 + + def test_narrow_width_forces_column_reduction(self): + """Width of 20 with card_width 14 and gap 2 => max 1 column.""" + cards = [_make_card(label=c) for c in "AB"] + lines = layout_grid(cards, available_width=20, card_width=_CARD_WIDTH, gap=2) + # 2 cards in 1 column => 2 rows => 6 visual lines + assert len(lines) == 6 + + +# --------------------------------------------------------------------------- +# Output consistency +# --------------------------------------------------------------------------- + + +class TestOutputConsistency: + """All output lines have consistent display width within each row group.""" + + def test_all_lines_consistent_width(self): + """Every line in the output should have the same display width.""" + cards = [_make_card(label=c) for c in "ABCD"] + lines = layout_grid(cards, available_width=80, card_width=_CARD_WIDTH, gap=2) + widths = {len(line) for line in lines} + assert len(widths) == 1, f"Expected uniform width, got {widths}" + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + """Edge cases: empty input, uneven card heights.""" + + def test_empty_input_returns_empty_list(self): + assert layout_grid([], available_width=80, card_width=_CARD_WIDTH) == [] + + def test_cards_with_different_line_counts_are_padded(self): + """Cards with 2 and 4 lines in same row => shorter card padded to 4.""" + short_card = _make_card(lines=2, label="S") + tall_card = _make_card(lines=4, label="T") + lines = layout_grid( + [short_card, tall_card], + available_width=80, + card_width=_CARD_WIDTH, + gap=2, + ) + # Both in same row, padded to tallest (4 lines) + assert len(lines) == 4 + # Every line should have content (no None, no missing) + for line in lines: + assert isinstance(line, str) + assert len(line) > 0