Skip to content

Render Primitives

Edit this page

Tui applies the padding, the border and the background of a widget after its render() method returns, so render() can't change them. When a widget needs to rewrite its whole box (paint a layer under the border, dim it, mask part of it), override postRender() and use the AnsiUtils helpers to walk and cut the ANSI lines of the box.

Post-Processing a Render

The renderer calls postRender() with the finished lines of the widget (content, padding, border and background included) and passes its result to the parent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use Symfony\Component\Tui\Render\RenderContext;
use Symfony\Component\Tui\Widget\AbstractWidget;

final class SpoilerPanel extends AbstractWidget
{
    private bool $revealed = false;

    public function render(RenderContext $context): array
    {
        // ...
    }

    public function postRender(array $lines, RenderContext $context): array
    {
        if ($this->revealed) {
            return $lines;
        }

        // $context->getColumns() is the width of the whole box, border included
        return array_map(
            static fn (): string => str_repeat('░', $context->getColumns()),
            $lines,
        );
    }

    public function reveal(): void
    {
        $this->revealed = true;
        // the result of postRender() is cached along with the render
        $this->invalidate();
    }
}

Each returned line is one terminal row. The renderer throws a RenderException when any of them is wider than $context->getColumns().

Because the result is cached, a widget that animates its post-processing must call invalidate() for every new frame. See Tick Loop to drive such a widget over time.

Walking the Cells of a Line

AnsiUtils::walkCells() walks a rendered line in a single pass and yields one token per escape sequence and one per grapheme cluster. Concatenating the text of all tokens gives back the original line, so you can rebuild a line while changing only some of its cells (mask a region, recolor a column, overlay another line cell by cell):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\Tui\Ansi\AnsiUtils;

// each token is an array with these keys:
//   'text':  the grapheme, the escape sequence, or a single byte
//            when the line is not valid UTF-8
//   'col':   the column where the token starts
//   'width': the display width (0 for escape sequences)
//   'bg':    whether an SGR background is active at this point
$maskedLine = '';
foreach (AnsiUtils::walkCells($line) as $token) {
    $isMasked = $token['width'] > 0
        && $token['col'] >= 10 && $token['col'] < 20;
    $maskedLine .= $isMasked ? str_repeat('*', $token['width']) : $token['text'];
}

Escape sequences other than SGR (cursor moves, erases, OSC hyperlinks, APC markers) are yielded as zero-width tokens and never change bg. When overlaying one line on another, use bg to keep the cells that set their own background on top, as transparent layers do.

Slicing a Line to an Exact Width

AnsiUtils::sliceToWidth() extracts a range of columns from a line and always returns exactly the requested number of columns:

1
2
$left = AnsiUtils::sliceToWidth($line, 0, 20);
$right = AnsiUtils::sliceToWidth($line, 20, 20);

A cell wider than one column (a wide character or a tab) that crosses a boundary of the range becomes spaces for the columns inside the range, and a line shorter than the range is padded with spaces. This way, adjacent slices of the same line cover every column exactly once, which is needed to compose fixed-width regions out of slices.

The slice keeps the escape sequences found before the range, so its content renders with the style in force at that point, and drops the ones found after the range.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version