Skip to content

Text

Text is shaped with parley: ligatures, kerning, bidirectional text, and fallback between faces are handled for you. Three kinds show it: "text" for labels, "text_input" for editing, and "terminal" for a shell.

Text nodes

title = window.create("text", text="Quarterly report", font_size=22,
                      font_weight=500, fill=(0x1D, 0x1B, 0x20, 0xFF),
                      width=280, height=28)

The styling properties are font_family, font_size (pixels), font_weight (1–1000), font_style ("normal" or "italic"), letter_spacing, line_height (a multiple of the size), and text_align ("start", "center", "end"). Text wraps at word boundaries within the node's width; wrap="none" keeps each paragraph on one line, max_lines caps the lines shown, and overflow="ellipsis" ends a cut line with "…".

Sizing text to its content

"fit-content" (0.5.6.2). Set width="fit-content" and height="fit-content" and a text node is the size of its text, wherever it sits:

label = window.create("text", text="Julia set, drawn by a fill shader",
                      font_size=22, width="fit-content", height="fit-content")

The width is the text on one line, narrowed to the space the parent offers when the text wraps (so a long label wraps instead of overflowing, and height is then the wrapped height). In a box that centres or start-aligns its children it is as wide as the text; a stretched child still fills the box, as in CSS. It is measured by the same shaper as measure_text, rounded up to a whole pixel, and laid out again when the text, font size, family, weight or wrapping changes. Either side can be "fit-content" on its own; get("width") reads it back, and any other value (80, "auto", "50%") takes the side off it. On a box or any other node it means what "auto" does, since those already fit their content.

Without it, a text node has no size of its own — without a width it's zero wide, which is what you get by default. That includes the box an effect shader paints into, so an effect over an unsized text node with padding crops the text to the padding. Size it with "fit-content", or with window.measure_text, which lays the text out exactly as a text node with the same properties paints it:

style = dict(font_size=14, max_lines=1, overflow="ellipsis")
width, height = window.measure_text("A long list item title", max_width=180, **style)
label = window.create("text", text="A long list item title",
                      width=min(width, 180), height=height, **style)

That's how a button hugs its label, or a chip fits its text.

Layout rounds every box to whole pixels, so a text node's explicit width (and min_width, max_width) is laid out rounded up: a width the text fits in still fits. Given the 66.43 that measure_text returned, the node is 67 wide and the text stays on one line; rounded down to 66, it would have wrapped. get("width") still reads back what you set, and layout_width is the whole pixel above it. Percentage and auto widths, and every other kind of node (boxes included), are rounded as before.

Fonts

Tesserae Engine bundles Roboto (regular and medium), Noto Sans Arabic, and Hack Nerd Font Mono, and by default never loads system fonts, so text renders the same on every machine. tre.MONOSPACE_FONT_FAMILY names the monospace face, which terminals use — use it for anything that must line up with one, such as an editor's line numbers.

Any other font is bytes you load and register:

from pathlib import Path
import tre

families = tre.register_font(Path("fonts/Inter-Regular.ttf").read_bytes())
# ["Inter"] -- the name to use as font_family

Registration is for the whole process: every window sees the font, and one already running picks it up on its next frame. Until a family is registered, a font_family naming it falls back to a bundled face.

Other languages and emoji

The bundled fonts cover Latin, Cyrillic, Greek and Arabic. Hebrew, CJK, Indic scripts, Thai, colour emoji and the rest have no bundled font, so text in them draws as missing-glyph boxes or nothing. There are two ways to supply them.

Use the machine's fonts (0.5.4). One switch lets text fall back to whatever is installed, for exactly the glyphs the bundled and registered fonts lack:

tre.set_system_fonts(True)     # for every window in this process, live

A node whose text mixes scripts then needs no special handling: with font_family="Roboto", Latin comes from Roboto and the rest from a system font chosen for its script, so "Hello 漢字 😀 שלום" shows all of it. A right-to-left paragraph (Arabic, Hebrew) lays out right to left and aligns its start on the right. The switch is off by default because it makes text depend on the machine: the same app shows different pixels where different fonts are installed, and where no installed font has a script it still shows boxes. Text the bundled fonts cover is unaffected either way, and turning the switch off again returns to the exact same frames.

Colour emoji (0.5.6.2). Colour emoji draw in colour from a font in the COLR format (Windows' Segoe UI Emoji, a Noto Color Emoji COLR build) and from a bitmap font: the CBDT format of the Noto Color Emoji package most Linux distributions install, and macOS's Apple Color Emoji (sbix). Before 0.5.6.2 a bitmap emoji drew nothing. A bitmap emoji is a picture, so it is drawn from the glyph cache whatever set_glyph_cache says, and it scales from the nearest size the font ships rather than from an outline: a large emoji is a magnified bitmap. The sbix path is the same code as CBDT but has not been checked on a Mac. For the same emoji on every machine, register a COLR emoji font you ship (subset it to the emoji you use, as above).

Ship the fonts with the app. For the same pixels everywhere, register font files you bundle, such as subsets of Noto:

from importlib.resources import files
import tre

for name in ("NotoSansJP-Regular.otf", "NotoSansHebrew-Regular.ttf", "NotoEmoji-Regular.ttf"):
    tre.register_font(files("myapp.fonts").joinpath(name).read_bytes())

Registered fonts take part in fallback too: a glyph that font_family lacks is taken from any registered font that has it, so one call per script is enough and no node needs to name them. Subset a font to the glyphs you need (pyftsubset) to keep an app small; a full CJK font is many megabytes.

Rich text and selectable text

One text node can mix styles. spans styles ranges of its content (0.5.4), given as UTF-8 byte offsets, as a text input's selection and syntax_spans are:

label = window.create("text", text="Sale: $12 $9, ends Friday", font_size=18, width=300)
label.set(spans=[
    (0, 5, {"weight": 500}),                              # "Sale:"
    (6, 9, {"strikethrough": True}),                       # "$12"
    (10, 12, {"color": (0xB3, 0x26, 0x1E, 0xFF), "weight": 500}),
    (20, 26, {"italic": True, "underline": True}),
])

A style may set any of color, weight (an OpenType weight, 100 to 950), italic, underline, strikethrough, font_size (pixels), font_family and link; what it leaves out stays the node's own, and a later span wins where two overlap. Offsets past the text, or inside a multi-byte character, are clamped, so a span survives a shorter text. Spans change the shape of the text (a heavier weight is wider), so the node's width is yours to size, as it is for any text. Underline and strikethrough follow the text's own font metrics and are drawn in the span's colour.

selectable=True lets the user select the text with the pointer and copy it (0.5.4):

help_text.set(selectable=True)          # press and drag selects; Ctrl+C copies
help_text.get("selection")              # (start, end) or None; equal ends are a caret
help_text.set(selection=(0, 5))         # select from code; None clears

Links (0.5.4). A span with a link makes its range clickable. A click (press and release on it, with no selection made between) fires a link event on the text node, which bubbles, and event.href is the span's link string. tre opens nothing and styles nothing: colour and underline the span yourself, and decide in the listener what href means. The pointer shows as a hand over a link (unless the node sets its own cursor), and text with a link takes pointer events, as selectable text does.

label.set(spans=[(10, 14, {"link": "docs", "color": BLUE, "underline": True})])
label.on("link", lambda event: open_page(event.href))

A span's font_size makes its line taller when it is larger than the rest, and font_family picks another face for the range.

Selected text shows the text colour at 30% behind it. Double-click selects a word and triple-click a line. With a selection showing, Ctrl+A selects all of that text, and Shift+Left, Shift+Right, Shift+Home and Shift+End move the end of the selection a character, or to the start or end of the text. Ctrl+Shift+Left and Ctrl+Shift+Right move it a word (a run of letters and digits, a run of punctuation, then the spaces after it), and Shift+Up and Shift+Down a line, keeping the column. Moving past the start or end of a text goes on into the neighbouring selectable text, and Shift+Up on the first line goes to the start of the text, as Shift+Down on the last goes to its end, before they do. While a selection in static text exists these keys belong to it: a scroll view does not also scroll for Shift+Up and Shift+Down, and Ctrl+Shift+Left and Right are consumed by the selection, so no key_down listener hears them (the other Shift keys still reach key_down listeners). The pointer is an I-beam over selectable text, unless the node sets a cursor. A press anywhere else clears the selection.

A drag that starts in one selectable text and moves on into others (0.5.4) selects across them, so a document with a text node per paragraph selects like one text: the start text from the press to its end, every selectable text between whole, and the last text up to the pointer. Copy joins the pieces with a newline, and each node's own range reads back from node.get("selection"). The drag follows the pointer only while it is over selectable text; over anything else it keeps what it had, and it does not scroll the view. Setting selection from code selects within that one text and clears the rest. Ctrl+A and the Shift keys act on the text the selection started in. A selectable text claims the pointer events over its box, which plain text never does (so a label inside a button doesn't swallow the button's clicks): turn it on for text that stands alone. A text input's own selection is separate, and Copy takes the focused input's selection first. A screen reader sees the selection (see Text, selection and links).

Text input

A "text_input" is editable text with a caret, selection, clipboard, and input-method support. Its fill is the text color, so it paints no background of its own — put it in a "box" for a field's background (its own stroke_color, stroke_width, and corner_radius draw and round a border, and its own padding insets its text, 0.5.1). Of the styling properties above it takes font_family, font_size, and font_weight; the rest are for text nodes only:

field_box = window.create("box", width=240, height=40, padding=8,
                          corner_radius=4, stroke_color=(0x79, 0x74, 0x7E, 0xFF),
                          stroke_width=1)
field = window.create("text_input", placeholder="Email", width=224, height=24)
field_box.add_child(field)
field.on("change", lambda e: validate(e.new_value))
Property Does
text the content; setting it fires no change
placeholder, placeholder_fill the hint shown while it's empty
multiline Enter inserts a newline
selection (start, end) as UTF-8 byte offsets; equal ends are a caret
obscured a password field: bullets, and no copy or cut
selection_start, selection_end the ends of the selection in order (0.5.6); set one and the other moves along if it would cross
max_length (0.5.6) the most characters it holds, or None. Typing, paste and an IME commit are cut short at it, and a rejected edit fires no change. Text already longer is kept and can be deleted
read_only (0.5.6) it can be focused, selected and copied but not edited; Enter still submits
input_mode (0.5.6) "text", "numeric", "decimal", "email", "phone", "url" or "search": kept for a keyboard that can adapt. A desktop window has no use for it yet; obscured is passed to the IME as a password
caret_color, selection_fill the caret and selection colors; caret_color animates (0.5.6)
caret_visible, caret_width, caret_shape (0.5.6) draw the caret or leave it to you, its width in pixels (an underline's thickness), and "bar", "block" or "underline"
caret_blink (0.5.6) milliseconds between the caret going off and on, or None for steady. A keystroke or click restarts it, so it is solid while you type, and an idle field wakes only at each edge
syntax_spans [(start, end, color), ...] byte ranges to color
folded_ranges [(start, end), ...] byte ranges drawn as "…"
show_whitespace draw spaces and tabs as marks

Submit, composition and the caret (0.5.6)

  • submit fires on Enter in a single-line input (a multiline one inserts a newline). It doesn't bubble.
  • IME composition fires compose_start, compose_update and compose_end on the input. event.text is the preedit text (not on compose_end) and event.preedit_cursor the IME's cursor range inside it, in bytes. Nothing is in text until the IME commits: the commit arrives as ordinary input and change, after compose_end. Typing over a selection replaces it at the commit. The OS's candidate window is placed at the caret, and a password input tells the IME not to learn from it. A read-only input takes no composition.
  • caret_move fires after a key, click or drag moves the caret or changes the text of an input, once per input event. event.caret is the caret's rectangle (x, y, width, height) in the node's coordinates and event.inserted the (start, end) bytes an edit inserted (None for a plain move). It doesn't fire for set(text=...) or set(selection=...), as change doesn't.
  • node.caret_rect() gives the caret's rectangle at any time, shaped fresh so it is right straight after an edit, and node.text_rects(start, end) the rectangles covering a byte range, one per line. Both are in the node's coordinates, with padding and scrolling in them: put an overlay of your own at the character just typed, or glide a caret of your own (with caret_visible=False) to the next position.
  • A shader on the input still sees only its pixels, not where a character is; the rectangles above can drive its uniforms.
field.set(caret_visible=False)                       # we draw our own caret
def moved(event):
    x, y, w, h = event.caret
    my_caret.animate("translate_x", x, 90)
    if event.inserted:
        flash.set(...)                               # at field.text_rects(*event.inserted)
field.on("caret_move", moved)

In tests, window.simulate("ime_preedit", text="ni", cursor=(0, 2)) sends a composition preview (empty text ends it), then simulate("input", text="你") commits.

A code editor is a multiline input in the monospace face, with syntax spans from your highlighter:

editor = window.create("text_input", text=source, multiline=True,
                       font_family=tre.MONOSPACE_FONT_FAMILY, font_size=13,
                       width=600, height=400)
editor.set(syntax_spans=[(0, 3, (0xB5, 0x3F, 0x8C, 0xFF))])  # "def"

Its events — input and change — are on Events and Input.

Terminal

A "terminal" runs a shell on a pseudo-terminal and emulates its screen:

term = window.create("terminal", shell="/bin/bash", cols=80, rows=24,
                     scrollback_lines=5000, font_size=13)

Its box follows its grid and font. While focused it takes every key: text and named keys go to the shell, and Ctrl+letter sends that control byte, so Ctrl+C interrupts. Ctrl+Shift+C copies its selection, the wheel scrolls its history, and set(cols=, rows=) resizes the grid and tells the shell. get("text") reads the visible grid, one line per row, and selection is (start_row, start_col, end_row, end_col). The palette recolors it — any of ansi (16 colors), foreground, background, cursor, and selection:

term.set(palette={"background": (0x1E, 0x1E, 0x1E, 0xFF),
                  "foreground": (0xD4, 0xD4, 0xD4, 0xFF)})