Skip to content

Text

A Text is a leaf element that draws text. Layout sizes it by measuring its content, and wraps it only when its style sets a wrap mode: see Text in layout for how a Text takes space, and Text wrapping for the wrap modes.

Styling Parts of the Text

A Text's content is a string, drawn in the Text's text_style, or a tuple of Chunks, each drawn in its own style merged over the Text's text_style, for text whose style changes partway through, as a <span> inside a <p> is. Whatever a chunk's style leaves unset, such as the foreground of the plain chunks below, comes from the Text. A newline in a chunk starts a new line, as it does in a string, and a wrap mode wraps the chunks as one run of text, so a word split across two chunks wraps as one word.

@component
def chunks() -> Div:
    return Div(
        style=col,
        children=(
            Text(content="A plain string, in the Text's text_style", style=text_color("slate", 300)),
            Text(
                style=text_color("slate", 300),
                content=(
                    Chunk(content="error", style=CellStyle(foreground=red_500, bold=True)),
                    Chunk(content=": "),
                    Chunk(content="config.toml", style=CellStyle(underline=True)),
                    Chunk(content=" not found"),
                ),
            ),
        ),
    )

A plain Text and a Text of chunks

A plain string, in the Text's text_style  
error: config.toml not found              

The chunks keep the Text's slate foreground except where they set their own: error is red and bold, and config.toml is underlined in slate.

A chunk's style can be a CellStyle, as above, or a Style, of which it takes the text_style, so the utilities that style a whole Text style a chunk the same way:

WARNING = text_bold | text_color("amber", 400)
ASIDE = text_italic | text_dim


@component
def root() -> Div:
    return Div(
        style=col | gap(1) | pad_x(1),
        children=(
            Text(
                content=(
                    Chunk(content="warning: ", style=WARNING),
                    Chunk(content="3 tests were "),
                    Chunk(content="skipped", style=text_underline | text_color(rose_400)),
                    Chunk(content=" (run with -v to see why)", style=ASIDE),
                ),
                style=text_wrap_pretty,
            ),
            Text(
                content=(
                    Chunk(content="This whole Text is bold and sky blue, "),
                    Chunk(content="except this", style=text_not_bold | text_color("slate", 400)),
                    Chunk(content="."),
                ),
                style=text_bold | text_color("sky", 300) | text_wrap_pretty,
            ),
        ),
    )

Styled chunks

 warning: 3 tests were skipped (run with -v to see why)         

 This whole Text is bold and sky blue, except this.             

The second Text is bold, so the chunk that shouldn't be merges text_not_bold over it.

A chunk ignores the rest of a Style, such as padding or a border, as a browser ignores a <span>'s width, so put those on the Text or a Div around it. Chunk.style holds the CellStyle either way. Syntax highlighting and Markdown build their output from chunks too, and a chunk can be a hyperlink.

Wide Characters

A Text measures, wraps and paints its content by grapheme cluster, the unit a reader sees as one character, so an accented letter written with a combining mark or an emoji joined from several (๐Ÿ‘ฉโ€๐Ÿ’ป) stays whole.

Counterweight measures each cluster with wcwidth's wcswidth. Most clusters take one terminal cell. CJK characters, most emoji and flags take two, and zero-width characters (zero-width spaces and joiners, bidirectional marks) take none and aren't drawn. No cluster takes more than two, which matches terminals that draw a cluster as one unit, such as Ghostty, foot and Windows Terminal. Terminals that add up the widths of a cluster's code points instead draw some clusters wider, such as a joined emoji as its separate parts, and text after one is drawn to the right of where layout put it.

A two-cell cluster is never split across lines or cut in half. When one would cross the right edge of its Text, it moves to the next line if the text wraps, or overflows the box with the rest of its line if it doesn't. Where a clip cuts across one, it is left out, with a space in its place.

WIDE = "ๆ—ฅๆœฌ่ชžใฎๆ–‡็ซ  ๐Ÿ‘ฉโ€๐Ÿ’ป ๐Ÿ‡ฏ๐Ÿ‡ต"


def pane(label: str, text_style: Style, content_width: int) -> Div:
    return Div(
        style=col | width(content_width + 2),
        children=(
            Text(content=label),
            Div(style=col | border, children=(Text(style=text_style, content=WIDE),)),
        ),
    )


@component
def wide() -> Div:
    return Div(
        style=row | align_children_start | gap(2),
        children=(
            pane("wraps", text_wrap_stable, 10),
            pane("clipped", overflow_hidden, 9),
            pane("overflows", default, 9),
        ),
    )

Wide characters wrapped, clipped, and overflowing

wraps         clipped      overflows            
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          
โ”‚ๆ—ฅๆœฌ่ชžใฎ- โ”‚  โ”‚ๆ—ฅๆœฌ่ชžใฎ โ”‚  โ”‚ๆ—ฅๆœฌ่ชžใฎๆ–‡็ซ  ๐Ÿ‘ฉโ€๐Ÿ’ป ๐Ÿ‡ฏ๐Ÿ‡ต  
โ”‚ๆ–‡็ซ  ๐Ÿ‘ฉโ€๐Ÿ’ป ๐Ÿ‡ฏ๐Ÿ‡ตโ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜          
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    
  • In wraps, ten cells wide, the first word is too long for a line, so it's broken with a hyphen. That leaves nine cells for the word, and ๆ–‡ would take the ninth and tenth, so it starts the next line and the first line ends a cell short.
  • In clipped, nine cells wide, overflow_hidden cuts across ๆ–‡, which is left out, with a space in the ninth cell.
  • In overflows, the unwrapped line runs past the pane's border.

To cut or pad text yourself, measure it as counterweight does, with graphemes and grapheme_width from counterweight.text, so your cuts land where layout's and paint's do.

Control Characters

Newlines (\n and \r\n) start a new line. Every other control character, such as tab or escape, is drawn as a symbol (โ‰, โ›) rather than written to the terminal, where it would run as a command instead of drawing anything.

@component
def control() -> Text:
    return Text(content="name\tsize\nnotes.txt\t\x1b[31mred?\x1b[0m\r\nend\x07")

Control characters drawn as symbols

nameโ‰size               
notes.txtโ‰โ›[31mred?โ›[0m 
endโ‡                    

The tabs, the escape sequences that would have turned the text red, and the bell at the end are each drawn as one symbol, while \n and \r\n break the lines. visible gives the symbol a control character is drawn as. To color text, use chunks instead of escape sequences. To show a program's colored output, run it in a terminal emulator.

API

counterweight.elements.Text dataclass

Text(
    *,
    content: str | tuple[Chunk, ...],
    style: Style = _DEFAULT_STYLE,
    on_input: InputHandler | None = None,
    handle: ElementHandle | None = None,
)

A leaf element that shows text, sized by its text unless its style sizes it, and wrapped as its style says.

content instance-attribute

content: str | tuple[Chunk, ...]

The text, as a string drawn in the style's text_style, or as chunks that each style a run of it.

style class-attribute instance-attribute

style: Style = _DEFAULT_STYLE

How the element is laid out and drawn, and how its text is styled, justified and wrapped.

on_input class-attribute instance-attribute

on_input: InputHandler | None = None

The handler for the input events that reach this element, as their target or as they bubble up from a descendant: key presses and pastes while it is focused, and mouse events over the parts of it that are painted.

handle class-attribute instance-attribute

handle: ElementHandle | None = None

The element handle that names this element for controls such as ScrollTo, and for use_scroll.

children property

children: tuple[Component | AnyElement, ...]

cells property

cells: tuple[StyledGrapheme, ...]

counterweight.elements.Chunk dataclass

Chunk(
    *,
    content: str,
    style: CellStyle | Style = _DEFAULT_CELL_STYLE,
)

A run of a Text's content in one style, as a <span> is in HTML.

style can be a CellStyle, or a Style whose text_style the chunk takes, so the utilities that style a Text's text, such as text_bold | text_color("sky", 300), style a chunk too. A chunk ignores the rest of a Style, as a <span> ignores a width, and Chunk.style is always the CellStyle.

content instance-attribute

content: str

style instance-attribute

style: CellStyle

cells property

cells: tuple[StyledGrapheme, ...]

space classmethod

space() -> Chunk

newline classmethod

newline() -> Chunk

counterweight.text.graphemes

graphemes(text: str) -> list[str]

text split into extended grapheme clusters, the units a reader sees as one character.

counterweight.text.grapheme_width cached

grapheme_width(grapheme: str) -> int

How many terminal cells a grapheme cluster takes, as wcwidth measures it: 0 (zero-width spaces and joiners, bidirectional marks, a combining mark with nothing to combine with), 1, or 2 (CJK, most emoji, flags).

A line break takes none. Any other control character raises ValueError, since terminals don't draw it; pass it through visible first.

counterweight.text.visible cached

visible(grapheme: str) -> str

grapheme, or a symbol standing in for it if it's a control character other than a line break, since writing a control character to the terminal runs it as a command rather than drawing it.

C0 controls (including tab) and delete become their Control Pictures (โ‰, โ›, โก), and C1 controls, which have none, become ๏ฟฝ.