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:
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: