/
githubmirror
/
symfony
Обзор
Документация
Войти
/
githubmirror
/
symfony
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
8.2
src/Symfony/Component/Tui/Render/Renderer.php
348 строк
14 KB
Fabien Potencier
[Tui] Fix descendant position tracking in horizontal layouts
11 авг 2026, 15:57
11 авг 2026, 15:57
8e6de60
Код
Авторство
О чём код?
<?php /* * This file is part of the Symfony package. * * (c) Fabien Potencier <fabien@symfony.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\Tui\Render; use Symfony\Component\Tui\Ansi\AnsiUtils; use Symfony\Component\Tui\Exception\RenderException; use Symfony\Component\Tui\Style\Align; use Symfony\Component\Tui\Style\DefaultStyleSheet; use Symfony\Component\Tui\Style\Direction; use Symfony\Component\Tui\Style\Style; use Symfony\Component\Tui\Style\StyleSheet; use Symfony\Component\Tui\Widget\AbstractWidget; use Symfony\Component\Tui\Widget\ContainerWidget; use Symfony\Component\Tui\Widget\Figlet\FontRegistry; /** * Renders the widget tree with style resolution, layout, and chrome. * * The Renderer: * 1. Resolves styles through cascade (* → FQCN → CSS class → state → instance) * 2. Computes layout (vertical/horizontal with gap and fill children) * 3. Calls widget->render() with enriched context * 4. Applies chrome (padding, border, background) around widget content * * All widget types are rendered through the Renderer: containers via * renderContainer(), and leaf widgets by delegating to widget->render(). * * @experimental * * @internal * * @author Fabien Potencier <fabien@symfony.com> */ final class Renderer implements WidgetRendererInterface { private StyleSheet $styleSheet; private FontRegistry $fontRegistry; private PositionTracker $positionTracker; private LayoutEngine $layoutEngine; private ChromeApplier $chromeApplier; /** Current terminal columns, set during render() for breakpoint resolution */ private ?int $currentColumns = null; public function __construct(?StyleSheet $styleSheet = null, ?FontRegistry $fontRegistry = null) { $this->fontRegistry = $fontRegistry ?? new FontRegistry(); $this->positionTracker = new PositionTracker(); $this->layoutEngine = new LayoutEngine($this, $this->positionTracker, $this->fontRegistry); $this->chromeApplier = new ChromeApplier($this); if (null !== $styleSheet) { // Clone the user stylesheet to preserve its runtime type // (e.g. TailwindStylesheet) so that its resolve() override // is used. Merge the defaults underneath: default rules are // added only for selectors the user hasn't already defined. $this->styleSheet = clone $styleSheet; $this->styleSheet->mergeDefaults(DefaultStyleSheet::create()); } else { $this->styleSheet = DefaultStyleSheet::create(); } } /** * Add a stylesheet. */ public function addStyleSheet(StyleSheet $styleSheet): void { $this->styleSheet->merge($styleSheet); } /** * Get the stylesheet. */ public function getStyleSheet(): StyleSheet { return $this->styleSheet; } /** * Get the tracked position of a widget from the last render pass. * * Returns null if the widget was not rendered or is not being tracked. */ public function getWidgetRect(AbstractWidget $widget): ?WidgetRect { return $this->positionTracker->getWidgetRect($widget); } /** * Render the widget tree starting from root. * * @return string[] Array of rendered lines */ public function render(ContainerWidget $root, int $columns, int $rows): array { $context = new RenderContext($columns, $rows, null, $this->fontRegistry, fillRows: true); $this->currentColumns = $columns; $this->positionTracker->reset(); $result = $this->renderWidget($root, $context); // Track root widget position $this->positionTracker->setWidgetRect($root, new WidgetRect(0, 0, $columns, \count($result))); return $result; } public function renderWidget(AbstractWidget $widget, RenderContext $context): array { // Allow widget to sync state before rendering $widget->beforeRender(); // Check render cache: if the widget hasn't been invalidated and // the available dimensions are unchanged, reuse the previous output. // This skips style resolution, layout, chrome, and content rendering. $cacheColumns = $context->getColumns(); $cacheRows = $context->getRows(); $cached = $widget->getRenderCache($cacheColumns, $cacheRows); if (null !== $cached) { return $cached; } // 1. Resolve style by merging: global → FQCN → state → instance $resolvedStyle = $this->resolveStyle($widget); // Hidden widgets produce no output and take no space if (true === $resolvedStyle->getHidden()) { $widget->setRenderCache([], $cacheColumns, $cacheRows); return []; } // 2. Apply maxColumns constraint if set $maxColumns = $resolvedStyle->getMaxColumns(); if (null !== $maxColumns && $context->getColumns() > $maxColumns) { $context = $context->withColumns($maxColumns); } // 3. Create enriched context with resolved style $styledContext = $context->withStyle($resolvedStyle); // 4. For ContainerWidget, use the layout engine if ($widget instanceof ContainerWidget) { $lines = $this->renderContainer($widget, $styledContext, $resolvedStyle); } else { // 5. For all other widgets (leaf widgets + ParentInterface), // render content with inner dimensions, then apply chrome $innerContext = $this->chromeApplier->computeInnerContext($styledContext, $resolvedStyle); $lines = $widget->render($innerContext); $lines = $this->chromeApplier->apply($lines, $context->getColumns(), $resolvedStyle, $widget); } // Validate that no line exceeds the available width. // This catches widget bugs early, at the source, rather than // letting over-wide lines flow to ScreenWriter where the widget // context is lost. Image lines (Kitty/iTerm2 protocol) are // excluded because their visible width is not meaningful. $availableColumns = $context->getColumns(); foreach ($lines as $i => $line) { if ('' === $line || AnsiUtils::containsImage($line)) { continue; } $lineWidth = AnsiUtils::visibleWidth($line); if ($lineWidth > $availableColumns) { throw new RenderException(\sprintf("Widget \"%s\" rendered line %d with width %d, exceeding the available %d columns.\nLine preview: \"%s\".", $widget::class, $i, $lineWidth, $availableColumns, mb_substr(AnsiUtils::stripAnsiCodes($line), 0, 100)), $i, $lineWidth, $availableColumns); } } $widget->setRenderCache($lines, $cacheColumns, $cacheRows); return $lines; } public function resolveStyle(AbstractWidget $widget): Style { return $this->styleSheet->resolve($widget, $this->currentColumns); } public function measureIntrinsicWidth(AbstractWidget $widget, int $maxColumns, int $rows): int { $resolvedStyle = $this->resolveStyle($widget); // Apply maxColumns from the widget's own style $styleMaxColumns = $resolvedStyle->getMaxColumns(); if (null !== $styleMaxColumns) { $maxColumns = min($maxColumns, $styleMaxColumns); } // Compute chrome (border + padding) [$innerColumns] = $this->chromeApplier->computeInnerDimensions($maxColumns, $rows, $resolvedStyle); if ($widget instanceof ContainerWidget) { // For containers, render children within inner dimensions and measure $children = array_values(array_filter( $widget->all(), fn (AbstractWidget $child) => true !== $this->resolveStyle($child)->getHidden(), )); $direction = $resolvedStyle->getDirection() ?? Direction::Vertical; $gap = $resolvedStyle->getGap() ?? 0; if (Direction::Horizontal === $direction) { // Horizontal container: sum of children's intrinsic widths + gaps $totalWidth = $gap * max(0, \count($children) - 1); foreach ($children as $child) { $totalWidth += $this->measureIntrinsicWidth($child, $innerColumns, $rows); } $contentWidth = $totalWidth; } else { // Vertical container: widest child $contentWidth = 0; foreach ($children as $child) { $contentWidth = max($contentWidth, $this->measureIntrinsicWidth($child, $innerColumns, $rows)); } } } else { // For leaf widgets, render content at inner dimensions and measure widest line $innerContext = $this->chromeApplier->computeInnerContext( new RenderContext($maxColumns, $rows, null, $this->fontRegistry)->withStyle($resolvedStyle), $resolvedStyle, ); $widget->beforeRender(); $contentLines = $widget->render($innerContext); $widget->clearRenderCache(); $contentWidth = 0; foreach ($contentLines as $line) { $contentWidth = max($contentWidth, AnsiUtils::visibleWidth($line)); } } $chromeWidth = $maxColumns - $innerColumns; return min(max(1, $contentWidth + $chromeWidth), $maxColumns); } /** * Render a container widget with its children. * * @return string[] */ private function renderContainer(ContainerWidget $widget, RenderContext $context, Style $resolvedStyle): array { // Filter out hidden children so they don't take up layout space $children = array_values(array_filter( $widget->all(), fn (AbstractWidget $child) => true !== $this->resolveStyle($child)->getHidden(), )); $columns = $context->getColumns(); $rows = $context->getRows(); if (!$children) { return $this->chromeApplier->apply([], $columns, $resolvedStyle, $widget); } // Calculate inner dimensions (content area after border/padding) [$innerColumns, $innerRows] = $this->chromeApplier->computeInnerDimensions($columns, $rows, $resolvedStyle); // Get direction and gap from resolved style $direction = $resolvedStyle->getDirection() ?? Direction::Vertical; $gap = $resolvedStyle->getGap() ?? 0; // Compute styled gap line matching what a child widget would render as blank // This ensures gap lines inherit the container's resolved style (e.g. bold from * rule) $gapLine = null; if ($gap > 0) { $gapContent = str_repeat(' ', max(1, $innerColumns)); $gapLine = $resolvedStyle->isPlain() ? $gapContent : $resolvedStyle->apply($gapContent); } // Push the content area's absolute offset onto the position stack if ($this->positionTracker->isActive()) { [$parentRow, $parentCol] = $this->positionTracker->currentOffset(); [$chromeTop, $chromeLeft] = $this->chromeApplier->computeChromeOffset($resolvedStyle); $this->positionTracker->push($parentRow + $chromeTop, $parentCol + $chromeLeft); } $align = $resolvedStyle->getAlign(); $hasAlign = null !== $align && Align::Left !== $align; $verticalAlign = $resolvedStyle->getVerticalAlign(); $hasVerticalAlign = null !== $verticalAlign; // Render children using layout engine. // For horizontal containers, pass verticalAlign so layoutHorizontal can // offset shorter children (align-items). For vertical containers it is unused. $childLines = $this->layoutEngine->layout( $children, $innerColumns, $innerRows, $gap, $direction, $gapLine, Direction::Horizontal === $direction ? $verticalAlign : null, ); // Pop position stack $this->positionTracker->pop(); // Apply vertical alignment offset when VerticalAlign is set and the widget owns its rows. if ($hasVerticalAlign && \count($childLines) < $innerRows && ($context->shouldFillRows() || $widget->isVerticallyExpanded()) ) { if (0 < $verticalOffset = $this->layoutEngine->computeVerticalAlignOffset(\count($childLines), $innerRows, $verticalAlign)) { $topPad = array_fill(0, $verticalOffset, ''); array_unshift($childLines, ...$topPad); $this->positionTracker->shiftContentPositions($children, 0, $verticalOffset); } } // Pad to fill allocated rows so that chrome (e.g. background-color) covers the full height. // // A fill child is detected by isVerticallyExpanded()=true AND shouldFillRows()=false: // layoutVertical creates a fresh RenderContext (fillRows=false) with exactly $childFillRows, // so the child must fill those rows itself before chromeApplier runs — otherwise layoutVertical // pads with bare empty strings that bypass chromeApplier and leave background incomplete. // // Root renders (shouldFillRows=true) pad only when VerticalAlign is explicitly set; // without it the root returns its natural content height. $shouldPad = ($widget->isVerticallyExpanded() && !$context->shouldFillRows()) || ($context->shouldFillRows() && $hasVerticalAlign); if ($shouldPad && \count($childLines) < $innerRows) { while (\count($childLines) < $innerRows) { $childLines[] = ''; } } // Apply horizontal alignment for child widgets and adjust tracked positions if ($hasAlign && 0 < $alignOffset = $this->layoutEngine->computeAlignOffset($childLines, $innerColumns, $align)) { $childLines = $this->layoutEngine->shiftLines($childLines, $alignOffset); $this->positionTracker->shiftContentPositions($children, $alignOffset); } // Apply chrome (padding, border, background) return $this->chromeApplier->apply($childLines, $columns, $resolvedStyle, $widget); } }