Skip to content

Views

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

Making one

from turbodesk 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
])

A cell is opaque or absent, with one exception. Style(dim=0.4) means darken what is underneath rather than replace it: zcat keeps the character below and shades its colour toward black. That is a shadow, and it is why a window's shadow falling across another window darkens its text instead of swallowing it.

dark = View.rect(view.width, view.height, Style(dim=0.4)).pad(left=2, top=1)
View.zcat([view, dark])                      # `widgets.frame.shadowed` is this

dim is compositor-only: the renderer never sees one, because zcat has already resolved it into a concrete shaded colour. A dim cell over nothing is just an ordinary cell.

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.

There is one helper, and it is not an engine: no constraints, no solver, no second pass. It does the one piece of arithmetic that every widget in the library was doing by hand.

Filling a fixed width

The shape worth a helper is a run of views across a known width, with the slack going wherever you mark it:

from turbodesk import layout

layout.row([titles, None], width)                        # pad the right
layout.row([pairs, None, hint], width)                   # push the hint right
layout.row([None, title, None], width, fill="โ•")         # centre in a run of โ•

None is a gap that expands; two of them share the slack evenly. Where you put it is the alignment, which is why there is no align= argument. fill is the character the gaps are made of and style paints them.

Content that does not fit is cropped from the right, so the leftmost cell survives a narrow terminal โ€” a status line loses its last pair rather than its first. If your right-hand end matters more, shorten that cell yourself before handing it over.

That is the whole module. There is no column, because a vertical run is View.vcat plus a height and nothing in the library needed the arithmetic twice.

Styles

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

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 nineteen flavours, with twenty-six named roles from Catppuccin: base, text, subtext0, surface0, overlay1, mauve, red, green, and so on. Pick one with TURBODESK_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
view.find(tag)                       # Region | None, after layout
view.hit(pos, "click")               # the topmost handler of a kind at a point
view.handlers_at(pos)                # every handler there, innermost and topmost first

handlers_at is the order composition produced: a nested handler comes before the one wrapping it, and a view stacked on top before the ones under it. hit is that with a kind filter and only the first answer. The runtime uses both โ€” hit to route a click, the full walk to decide whether a drag is the thing under the pointer or merely the thing beneath whatever is.

Region carries x, y, width, height, plus contains(pos) and covers(other) for asking whether one region is entirely inside another. Equal regions cover.

For tests, turbodesk.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