From 1391122de95dbf9fe5936510a0fc8ce2a058bea5 Mon Sep 17 00:00:00 2001 From: Mateusz Cholewka Date: Sun, 16 Aug 2026 00:19:18 +0200 Subject: [PATCH] Improve details view --- AGENTS.md | 3 +- src/Console/Components/Details.php | 193 ++++++++++++++++++++++---- src/Console/Components/LogItem.php | 42 +----- src/Console/State/AppState.php | 2 + src/Console/Support/Scroll.php | 29 ++++ src/Console/Support/SpanTruncator.php | 112 +++++++++++++++ src/Console/Windows/Popup.php | 1 + tests/Unit/ScrollTest.php | 34 +++++ tests/Unit/SpanTruncatorTest.php | 68 +++++++++ 9 files changed, 415 insertions(+), 69 deletions(-) create mode 100644 src/Console/Support/SpanTruncator.php create mode 100644 tests/Unit/SpanTruncatorTest.php diff --git a/AGENTS.md b/AGENTS.md index cd5a7a3..ee2ba04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,10 @@ src/ State/AppState.php Central observable state store (magic __get/__set) State/LogItem.php Value object for a single log entry Support/Scroll.php Cursor/offset scrolling math — the best-tested module in the repo + Support/SpanTruncator.php Clips/windows Span[] to a fixed width for fixed-width panes (Details, LogItem) docs/ Deeper documentation — see docs/README.md examples/BasicExample.php Runnable demo of the tp() API -tests/Unit/ ScrollTest.php, ScrollbarRenderTest.php — only test suites that currently exist +tests/Unit/ ScrollTest.php, ScrollbarRenderTest.php, SpanTruncatorTest.php — only test suites that currently exist ``` ## Running things diff --git a/src/Console/Components/Details.php b/src/Console/Components/Details.php index 76aeebc..8c84131 100644 --- a/src/Console/Components/Details.php +++ b/src/Console/Components/Details.php @@ -9,18 +9,25 @@ use PhpTui\Term\KeyModifiers; use PhpTui\Term\MouseButton; use PhpTui\Term\MouseEventKind; +use PhpTui\Tui\Color\AnsiColor; use PhpTui\Tui\Color\RgbColor; use PhpTui\Tui\Display\Area; +use PhpTui\Tui\Extension\Core\Widget\BlockWidget; +use PhpTui\Tui\Extension\Core\Widget\Buffer\BufferContext; +use PhpTui\Tui\Extension\Core\Widget\BufferWidget; use PhpTui\Tui\Extension\Core\Widget\CompositeWidget; use PhpTui\Tui\Extension\Core\Widget\List\ListItem; use PhpTui\Tui\Extension\Core\Widget\ListWidget; -use PhpTui\Tui\Extension\Core\Widget\Scrollbar\ScrollbarOrientation; -use PhpTui\Tui\Extension\Core\Widget\Scrollbar\ScrollbarSymbols; -use PhpTui\Tui\Extension\Core\Widget\ScrollbarWidget; +use PhpTui\Tui\Position\Position; +use PhpTui\Tui\Style\Modifier; use PhpTui\Tui\Style\Style; use PhpTui\Tui\Text\Line; use PhpTui\Tui\Text\Span; use PhpTui\Tui\Text\Text; +use PhpTui\Tui\Text\Title; +use PhpTui\Tui\Widget\Borders; +use PhpTui\Tui\Widget\BorderType; +use PhpTui\Tui\Widget\HorizontalAlignment; use PhpTui\Tui\Widget\Widget; use Tapper\Console\CommandAttributes\KeyPressed; use Tapper\Console\CommandAttributes\Mouse; @@ -29,11 +36,57 @@ use Tapper\Console\Palette; use Tapper\Console\PhpHighlighter; use Tapper\Console\Support\Scroll; +use Tapper\Console\Support\SpanTruncator; class Details extends Component { + // The whole pane is wrapped in a bordered Block (see view()): 1 column each side, + // 1 row top/bottom. Every width/height used to lay out content has to account for + // that, since BlockRenderer only insets the *rendering*, not values computed here + // beforehand (truncation budgets, paging math). + private const int BORDER_SIZE = 2; + + private const int SCROLLBAR_GUTTER = 1; + + private const int H_STEP = 4; + private int $count = 0; + // How far scrollRight() can still usefully move — the largest (rawWidth - budget) + // across every line on screen, each measured against its own budget (code lines + // reserve a prefix, payload lines a 2-space indent, so budgets differ per line + // type) — computed fresh each view() pass. Must NOT be derived from a single + // combined "widest line" width compared against a single flat contentWidth(): + // that undercounts by whatever prefix/indent that widest line reserved, clamping + // scrollRight() a few columns short of the true end and leaving a misleading + // trailing "…" that implies there's more when there isn't. + private int $maxHOffset = 0; + + private function trackScrollBound(int $rawWidth, int $budget): void + { + if ($rawWidth <= $budget) { + return; + } + + // Once hOffset > 0, SpanTruncator::window() always reserves one column for + // the leading "…" it now has to show — so the *effective* content budget once + // scrolling is `budget - 1`, not `budget`. Landing exactly on the last + // character therefore needs one more step than a naive (rawWidth - budget) + // would suggest, or the clamp stops one short and a trailing "…" lingers even + // though nothing more is actually hidden. + $this->maxHOffset = max($this->maxHOffset, $rawWidth - $budget + 1); + } + + private function contentWidth(): int + { + return max(0, $this->area->width - self::BORDER_SIZE); + } + + private function contentHeight(): int + { + return max(0, $this->area->height - self::BORDER_SIZE); + } + #[KeyPressed(KeyCode::Backspace)] #[KeyPressed(KeyCode::Esc)] #[Mouse(MouseEventKind::Down, MouseButton::Right)] @@ -41,6 +94,7 @@ public function close(): void { $this->appState->previewLog = null; $this->appState->detailsOffset = 0; + $this->appState->detailsHOffset = 0; } #[KeyPressed(KeyCode::Up)] @@ -58,7 +112,7 @@ public function up(): void #[Mouse(MouseEventKind::ScrollDown)] public function down(): void { - if ($this->appState->detailsOffset < $this->count - $this->area->height) { + if ($this->appState->detailsOffset < $this->count - $this->contentHeight()) { $this->appState->detailsOffset++; } } @@ -66,7 +120,7 @@ public function down(): void #[KeyPressed('u', KeyModifiers::CONTROL)] public function pageUp(): void { - $halfPage = (int) floor($this->area->height / 2); + $halfPage = (int) floor($this->contentHeight() / 2); $this->appState->detailsOffset = max(0, $this->appState->detailsOffset - $halfPage); } @@ -74,14 +128,30 @@ public function pageUp(): void #[KeyPressed('d', KeyModifiers::CONTROL)] public function pageDown(): void { - $halfPage = (int) floor($this->area->height / 2); + $halfPage = (int) floor($this->contentHeight() / 2); $this->appState->detailsOffset = min( - $this->count - $this->area->height, + $this->count - $this->contentHeight(), $this->appState->detailsOffset + $halfPage, ); } + #[KeyPressed(KeyCode::Left)] + #[KeyPressed('h')] + #[Mouse(MouseEventKind::ScrollLeft)] + public function scrollLeft(): void + { + $this->appState->detailsHOffset = max(0, $this->appState->detailsHOffset - self::H_STEP); + } + + #[KeyPressed(KeyCode::Right)] + #[KeyPressed('l')] + #[Mouse(MouseEventKind::ScrollRight)] + public function scrollRight(): void + { + $this->appState->detailsHOffset = min($this->maxHOffset, $this->appState->detailsHOffset + self::H_STEP); + } + private function parseCode(array $code): array { $numberWidth = array_reduce( @@ -102,7 +172,13 @@ private function parseCode(array $code): array $prefixStyle, ); - $lineSpans = [$prefix, ...PhpHighlighter::highlightLine($line['line'])]; + $rawCodeSpans = PhpHighlighter::highlightLine($line['line']); + $budget = max(0, $this->contentWidth() - $prefix->width() - self::SCROLLBAR_GUTTER); + $this->trackScrollBound(array_sum(array_map(fn (Span $s): int => $s->width(), $rawCodeSpans)), $budget); + + $codeSpans = SpanTruncator::window($rawCodeSpans, $this->appState->detailsHOffset, $budget, Style::default()->darkGray()); + + $lineSpans = [$prefix, ...$codeSpans]; $item = ListItem::new(Text::fromLine(new Line($lineSpans))); if ($active) { @@ -113,50 +189,78 @@ private function parseCode(array $code): array }, $code); } + private function sectionLabel(string $label): ListItem + { + return ListItem::new(Text::fromLine(Line::fromSpan(Span::styled( + ' '.$label, + Style::default()->fg(RgbColor::fromHex(Palette::ACCENT))->addModifier(Modifier::BOLD), + )))); + } + private function parseStackTrace(string $rootDir, array $trace, array $code): array { - $stack = array_map(function ($item) use ($rootDir) { - $file = sprintf( + $budget = max(0, $this->contentWidth() - self::SCROLLBAR_GUTTER); + + $stack = array_map(function ($item) use ($rootDir, $budget) { + $file = ' '.sprintf( '%s:%s', str_replace($rootDir, '', $item['file']), $item['line'], ); - return ListItem::fromString($file)->style(Style::default()->darkGray()); + $this->trackScrollBound(mb_strlen($file), $budget); + $spans = SpanTruncator::window([Span::fromString($file)], $this->appState->detailsHOffset, $budget, Style::default()->darkGray()); + + return ListItem::new(Text::fromLine(new Line($spans)))->style(Style::default()->darkGray()); }, $trace); $stack[0]->style(Style::default()->yellow()); return [ - ListItem::fromString('──────────────────── Context ────────────────────'), + $this->sectionLabel('Context'), ListItem::fromString(''), ...$this->parseCode($code), ListItem::fromString(''), + $this->sectionLabel('Stacktrace'), ...$stack, ]; } protected function view(Area $area): Widget { + $this->maxHOffset = 0; + $log = $this->appState->previewLog; $datetime = DateTime::createFromFormat('U.u', sprintf('%.6f', $log->timestamp)); - $formatted = $datetime->format('H:i:s.u'); - - $info = [ - Line::fromString(sprintf('Log #%s | %s', $log->id, $log->caller)), - Line::fromString($formatted), - Line::fromstring(''), - Line::fromString('──────────────────── Payload ────────────────────'), - Line::fromString(''), + $time = $datetime->format('H:i:s.u'); + + $timeLine = Line::fromSpan(Span::styled(' '.$time, Style::default()->darkGray())); + + $infoListItems = [ + ListItem::new(Text::fromLine($timeLine)), + ListItem::fromString(''), + $this->sectionLabel('Payload'), ]; - $infoListItems = array_map(fn ($line) => ListItem::new(Text::fromLine($line)), $info); $formattedMessage = match ($log->kind) { 'wait' => [Line::fromSpan(Span::styled($log->message, Style::default()->yellow()))], 'error' => [Line::fromSpan(Span::styled($log->message, Style::default()->fg(RgbColor::fromHex(Palette::ERROR))))], default => MessageFormatter::colorizeFormattedJson($log->message), }; - $formattedListItems = array_map(fn ($line) => ListItem::new(Text::fromLine($line)), $formattedMessage); + + $payloadBudget = max(0, $this->contentWidth() - 2 - self::SCROLLBAR_GUTTER); + + foreach ($formattedMessage as $line) { + $this->trackScrollBound($line->width(), $payloadBudget); + } + + $formattedListItems = array_map( + fn (Line $line): ListItem => ListItem::new(Text::fromLine(new Line([ + Span::fromString(' '), + ...SpanTruncator::window($line->spans, $this->appState->detailsHOffset, $payloadBudget, Style::default()->darkGray()), + ]))), + $formattedMessage, + ); $allItems = [ ...$infoListItems, @@ -167,16 +271,47 @@ protected function view(Area $area): Widget $this->count = count($allItems); - return CompositeWidget::fromWidgets( + $thumbBounds = Scroll::proportionalThumb( + $this->count, + $this->contentHeight(), + $this->appState->detailsOffset, + $this->contentHeight(), + ); + + $scrollbar = BufferWidget::new(function (BufferContext $context) use ($thumbBounds): void { + $trackArea = $context->area; + $x = max(0, $trackArea->right() - 1); + + // List rows paint their background across the *full* row width (including + // this column, reserved as blank via SCROLLBAR_GUTTER) — an active/highlighted + // row would otherwise bleed its background tint through here. setChar() alone + // doesn't touch style (and Style::default() can't clear an inherited color via + // patchStyle's null-means-keep-existing semantics), so the color has to be + // reset explicitly with AnsiColor::Reset. + $neutralStyle = Style::default()->fg(AnsiColor::Reset)->bg(AnsiColor::Reset); + + for ($y = $trackArea->top(); $y < $trackArea->bottom(); $y++) { + $isThumb = $thumbBounds !== null + && ($y - $trackArea->top()) >= $thumbBounds[0] + && ($y - $trackArea->top()) < $thumbBounds[1]; + + $context->buffer->get(Position::at($x, $y)) + ->setChar($isThumb ? '█' : ($thumbBounds === null ? ' ' : '│')) + ->setStyle($neutralStyle); + } + }); + + $content = CompositeWidget::fromWidgets( ListWidget::default() ->items(...$allItems) ->offset($this->appState->detailsOffset), - ScrollbarWidget::default() - ->state(Scroll::scrollbarState($this->count, $this->area->height, $this->appState->detailsOffset)) - ->orientation(ScrollbarOrientation::VerticalRight) - ->symbols(new ScrollbarSymbols('│', '█', '', '')) - ->endSymbol(null) - ->beginSymbol(null), + $scrollbar, ); + + return BlockWidget::default() + ->borders(Borders::ALL) + ->borderType(BorderType::Rounded) + ->titles(Title::fromString(' Details ')->horizontalAlignment(HorizontalAlignment::Center)) + ->widget($content); } } diff --git a/src/Console/Components/LogItem.php b/src/Console/Components/LogItem.php index 5f80055..f982b42 100644 --- a/src/Console/Components/LogItem.php +++ b/src/Console/Components/LogItem.php @@ -21,6 +21,7 @@ use Tapper\Console\MessageFormatter; use Tapper\Console\Palette; use Tapper\Console\State\LogItem as LogItemState; +use Tapper\Console\Support\SpanTruncator; class LogItem extends Component { @@ -76,43 +77,6 @@ public function click(): void } } - /** - * @param Span[] $spans - * @return Span[] - */ - private function truncateSpans(array $spans, int $maxWidth, Style $ellipsisStyle): array - { - $totalWidth = array_sum(array_map(fn (Span $span): int => $span->width(), $spans)); - - if ($totalWidth <= $maxWidth) { - return $spans; - } - - $budget = max(0, $maxWidth - 1); - $truncated = []; - - foreach ($spans as $span) { - if ($budget <= 0) { - break; - } - - if ($span->width() <= $budget) { - $truncated[] = $span; - $budget -= $span->width(); - - continue; - } - - $chars = array_slice(mb_str_split($span->content), 0, $budget); - $truncated[] = Span::styled(implode('', $chars), $span->style); - $budget = 0; - } - - $truncated[] = Span::styled('…', $ellipsisStyle); - - return $truncated; - } - protected function view(Area $area): Widget { if (! $this->log) { @@ -145,7 +109,7 @@ protected function view(Area $area): Widget ? Span::styled(sprintf(' ×%d', $this->log->repeatCount), Style::default()->fg(RgbColor::fromHex(Palette::ACCENT))) : null; - $messageSpans = $this->truncateSpans($messageSpans, $messageWidth - ($repeatBadge?->width() ?? 0), $darkGray); + $messageSpans = SpanTruncator::truncate($messageSpans, $messageWidth - ($repeatBadge?->width() ?? 0), $darkGray); if ($repeatBadge) { $messageSpans[] = $repeatBadge; @@ -153,7 +117,7 @@ protected function view(Area $area): Widget $wMess = ParagraphWidget::fromSpans(...$messageSpans); $wFile = ParagraphWidget::fromSpans( - ...$this->truncateSpans([Span::styled(sprintf('↪ %s', $this->log->caller), $darkGray)], $messageWidth, $darkGray), + ...SpanTruncator::truncate([Span::styled(sprintf('↪ %s', $this->log->caller), $darkGray)], $messageWidth, $darkGray), ); if ($mark) { diff --git a/src/Console/State/AppState.php b/src/Console/State/AppState.php index 6bd7dc2..0ecda6f 100644 --- a/src/Console/State/AppState.php +++ b/src/Console/State/AppState.php @@ -19,6 +19,7 @@ * @property array $logs * @property int $detailsCursor * @property int $detailsOffset + * @property int $detailsHOffset * @property int $pendingWaits * @property bool $popupOpen * @property ?string $errorNotice @@ -51,6 +52,7 @@ public function __construct( private array $logs = [], private int $detailsCursor = 0, private int $detailsOffset = 0, + private int $detailsHOffset = 0, private int $pendingWaits = 0, private bool $popupOpen = false, private ?string $errorNotice = null, diff --git a/src/Console/Support/Scroll.php b/src/Console/Support/Scroll.php index 6568052..84164be 100644 --- a/src/Console/Support/Scroll.php +++ b/src/Console/Support/Scroll.php @@ -16,6 +16,35 @@ public static function scrollbarState(int $count, int $visible, int $offset): Sc return new ScrollbarState(max(0, $count - $visible), $offset, $visible); } + /** + * php-tui's ScrollbarState only has one `contentLength` field, which + * ScrollbarRenderer uses as the denominator for *both* the thumb's position + * ratio (correct: it has to be `count - visible`, the range `offset` moves + * over, so the thumb reaches the very end at max offset) and its size ratio + * (wrong for that same reason: sizing needs `count`, not `count - visible`, + * or a short list barely taller than the viewport renders a thumb that fills + * the whole track). The two ratios need different denominators, which the + * built-in widget can't express — so this computes thumb bounds directly + * against the true `visible / count` proportion instead of going through + * ScrollbarState/ScrollbarRenderer. + * + * @return array{0: int, 1: int}|null null when everything already fits (no thumb needed) + */ + public static function proportionalThumb(int $count, int $visible, int $offset, int $trackHeight): ?array + { + if ($count <= $visible || $trackHeight <= 0) { + return null; + } + + $thumbSize = min($trackHeight, max(1, (int) round(($visible / $count) * $trackHeight))); + $maxThumbStart = $trackHeight - $thumbSize; + $maxOffset = max(1, $count - $visible); + $ratio = min(1.0, $offset / $maxOffset); + $thumbStart = (int) round($ratio * $maxThumbStart); + + return [$thumbStart, $thumbStart + $thumbSize]; + } + public function cursorDown(int $count, int $visible): void { if ($this->appState->cursor < $count - 1) { diff --git a/src/Console/Support/SpanTruncator.php b/src/Console/Support/SpanTruncator.php new file mode 100644 index 0000000..47c20fa --- /dev/null +++ b/src/Console/Support/SpanTruncator.php @@ -0,0 +1,112 @@ + $span->width(), $spans)); + + if ($totalWidth <= $maxWidth) { + return $spans; + } + + $budget = max(0, $maxWidth - 1); + $truncated = []; + + foreach ($spans as $span) { + if ($budget <= 0) { + break; + } + + if ($span->width() <= $budget) { + $truncated[] = $span; + $budget -= $span->width(); + + continue; + } + + $chars = array_slice(mb_str_split($span->content), 0, $budget); + $truncated[] = Span::styled(implode('', $chars), $span->style); + $budget = 0; + } + + $truncated[] = Span::styled('…', $ellipsisStyle); + + return $truncated; + } + + /** + * Like truncate(), but starts the visible window `$offset` characters in — for + * horizontal scrolling. Adds a leading `…` when characters are hidden to the + * left, in addition to the trailing one when characters are hidden to the right. + * + * @param Span[] $spans + * @return Span[] + */ + public static function window(array $spans, int $offset, int $maxWidth, Style $ellipsisStyle): array + { + $chars = []; + foreach ($spans as $span) { + foreach (mb_str_split($span->content) as $char) { + $chars[] = [$char, $span->style]; + } + } + + $total = count($chars); + $hasLeft = $offset > 0; + $windowBudget = max(0, $maxWidth - ($hasLeft ? 1 : 0)); + $window = array_slice($chars, $offset, $windowBudget); + $hasRight = ($offset + count($window)) < $total; + + if ($hasRight) { + $window = array_slice($window, 0, max(0, count($window) - 1)); + } + + $result = []; + + if ($hasLeft) { + $result[] = Span::styled('…', $ellipsisStyle); + } + + $buffer = ''; + $bufferStyle = null; + + foreach ($window as [$char, $style]) { + if ($bufferStyle !== null && $style !== $bufferStyle) { + $result[] = Span::styled($buffer, $bufferStyle); + $buffer = ''; + } + + $buffer .= $char; + $bufferStyle = $style; + } + + if ($buffer !== '') { + $result[] = Span::styled($buffer, $bufferStyle); + } + + if ($hasRight) { + $result[] = Span::styled('…', $ellipsisStyle); + } + + return $result; + } +} diff --git a/src/Console/Windows/Popup.php b/src/Console/Windows/Popup.php index bf95c43..55d41d0 100644 --- a/src/Console/Windows/Popup.php +++ b/src/Console/Windows/Popup.php @@ -49,6 +49,7 @@ protected function view(Area $area): Widget ['g', 'G', 'Jump to top / bottom'], ['Ctrl+u', 'Ctrl+d', 'Page up / down'], ['space', 'enter', 'Open details'], + ['h / ←', 'l / →', 'Scroll details horizontally'], ['enter', '', 'Continue a paused wait'], ['backspace', 'esc', 'Close details / back to live'], ['ctrl+l', '', 'Clear all logs'], diff --git a/tests/Unit/ScrollTest.php b/tests/Unit/ScrollTest.php index 7085d67..c86a976 100644 --- a/tests/Unit/ScrollTest.php +++ b/tests/Unit/ScrollTest.php @@ -212,3 +212,37 @@ expect($state->position)->toBe($state->contentLength); }); }); + +describe('proportional thumb', function () { + it('returns null when everything already fits on screen', function () { + expect(Scroll::proportionalThumb(count: 5, visible: 20, offset: 0, trackHeight: 20))->toBeNull(); + }); + + it('sizes the thumb against the true visible/count ratio, not scrollbarState\'s count-visible one', function () { + // Regression: with count=13, visible=8, scrollbarState()'s contentLength is only + // 5 (count - visible), so feeding that into ScrollbarRenderer's size formula + // (viewport / contentLength) gives 8/5 > 1 — a thumb that fills the whole + // 8-row track even though barely more than half the content is visible. + [$start, $end] = Scroll::proportionalThumb(count: 13, visible: 8, offset: 0, trackHeight: 8); + + expect($end - $start)->toBe((int) round((8 / 13) * 8)) + ->and($end - $start)->toBeLessThan(8); + }); + + it('starts the thumb at the top when offset is 0', function () { + [$start] = Scroll::proportionalThumb(count: 13, visible: 8, offset: 0, trackHeight: 8); + + expect($start)->toBe(0); + }); + + it('reaches the very bottom of the track at the max offset', function () { + $count = 13; + $visible = 8; + $trackHeight = 8; + $maxOffset = $count - $visible; + + [, $end] = Scroll::proportionalThumb($count, $visible, $maxOffset, $trackHeight); + + expect($end)->toBe($trackHeight); + }); +}); diff --git a/tests/Unit/SpanTruncatorTest.php b/tests/Unit/SpanTruncatorTest.php new file mode 100644 index 0000000..33cc3fc --- /dev/null +++ b/tests/Unit/SpanTruncatorTest.php @@ -0,0 +1,68 @@ + $s->content, $spans)); +} + +describe('window', function () { + it('returns the spans untouched when everything already fits', function () { + $spans = [Span::fromString('hello')]; + + $result = SpanTruncator::window($spans, offset: 0, maxWidth: 20, ellipsisStyle: Style::default()); + + expect(joined($result))->toBe('hello'); + }); + + it('adds a trailing ellipsis when content overflows to the right', function () { + $spans = [Span::fromString('hello world')]; + + $result = SpanTruncator::window($spans, offset: 0, maxWidth: 6, ellipsisStyle: Style::default()); + + expect(joined($result))->toBe('hello…'); + }); + + it('adds a leading ellipsis when scrolled past the start', function () { + $spans = [Span::fromString('hello world')]; + + $result = SpanTruncator::window($spans, offset: 6, maxWidth: 20, ellipsisStyle: Style::default()); + + expect(joined($result))->toBe('…world'); + }); + + it('adds both ellipses when scrolled into the middle', function () { + $spans = [Span::fromString('hello world foo bar')]; + + $result = SpanTruncator::window($spans, offset: 4, maxWidth: 6, ellipsisStyle: Style::default()); + + expect(joined($result))->toBe('…o wo…'); + }); + + it('shows just a leading ellipsis once scrolled past this line\'s own length', function () { + $spans = [Span::fromString('short')]; + + $result = SpanTruncator::window($spans, offset: 20, maxWidth: 10, ellipsisStyle: Style::default()); + + expect(joined($result))->toBe('…'); + }); + + it('preserves per-span styles across the window', function () { + $red = Style::default()->red(); + $blue = Style::default()->blue(); + $spans = [Span::styled('foo', $red), Span::styled('bar', $blue)]; + + $result = SpanTruncator::window($spans, offset: 0, maxWidth: 20, ellipsisStyle: Style::default()); + + expect($result)->toHaveCount(2) + ->and($result[0]->content)->toBe('foo') + ->and($result[0]->style)->toBe($red) + ->and($result[1]->content)->toBe('bar') + ->and($result[1]->style)->toBe($blue); + }); +});