Skip to content

Views

A View is an immutable grid of styled cells. It is the only thing your app returns, and everything in termopy either produces one or combines them.

Making one

from termopy import Size, Style, View

View.text("hello")                       # one row
View.text("hello", Style(bold=True))     # with a style
View.rect(20, 5, Style(bg=theme.base))   # a filled block
View.blank(20, 5)                        # transparent: what is behind shows through
View.EMPTY                               # nothing, 0x0

View.text handles the awkward parts of terminal text for you. Control characters paint nothing. Wide characters (CJK, emoji) take two cells. Combining marks ride along with the character they modify, and a zero-width joiner welds a whole emoji sequence into one glyph:

assert View.text("ๆ—ฅๆœฌ่ชž").width == 6      # three wide characters
assert View.text("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ").width == 2      # one glyph, not four

Combining

Three combinators, and they are the whole layout system:

View.hcat([left, right])     # side by side
View.vcat([top, bottom])     # stacked
View.zcat([front, back])     # layered; the first is on top

hcat and vcat pad the shorter view so the result is a rectangle. zcat is how you put something over a background:

View.zcat([
    content,
    View.rect(*ui.size, Style(bg=ui.theme.base)),   # fills what content leaves blank
])

Positioning

view.pad(left=2, top=1)              # add space around it
view.crop(right=3, bottom=1)         # take space off it
view.center(within=ui.size)          # centre inside a box

There is no layout engine. A two-column split is arithmetic:

left_width = size.width // 2
View.hcat([
    body(Size(left_width, size.height)),
    sidebar(Size(size.width - left_width, size.height)),
])

That is deliberate, and it has a cost. The comparison says what.

Styles

Style is a NamedTuple: fg, bg, bold, italic, underline, blink, invert.

Style(fg=Color.hex("#ff8000"), bold=True)
Style(fg=ui.theme.mauve)                     # a theme role
base | Style(bold=True)                      # merge; the right-hand side wins
view.colored(fg=Color(255, 0, 0))            # fill in colours cells left unset

Colours degrade automatically: truecolor where the terminal supports it, xterm-256 or the 16-colour palette where it does not.

Themes

ui.theme is one of eighteen flavours, with twenty-six named roles from Catppuccin: base, text, subtext0, surface0, overlay1, mauve, red, green, and so on. Pick one with TERMOPY_THEME=gruvbox_dark, or pass theme= to run.

Use role names and your app follows whatever flavour the user chose.

Immutability

Every method returns a new View; none mutate. Try it:

view._rows = ()      # AttributeError: View is immutable; cannot set '_rows'

That matters because View.width reads the first row's length, so a view whose rows had different lengths would report a width that was a lie, and every crop, pad and hcat after it would use that number. Rows are squared up in the constructor and the object is closed afterwards.

Reading a view back

view.size                            # Size(width, height)
view.to_grid(size)                   # rows of (char, Style), cropped or padded to size

For tests, termopy.testing.to_text(view) gives you the characters without the styling.

What else a view carries

Besides cells, a View carries three things that travel with it through hcat, vcat, pad and crop, arriving with correct absolute coordinates:

  • handlers: click regions, from on_click
  • tags: named regions you can look up after layout, from tagged / find
  • passthroughs: regions the app paints itself, see Passthrough

tagged is how a widget finds out where it ended up:

view = body.tagged("cursor")
region = view.find("cursor")     # Region(x, y, width, height) | None