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)¶
submitfires on Enter in a single-line input (a multiline one inserts a newline). It doesn't bubble.- IME composition fires
compose_start,compose_updateandcompose_endon the input.event.textis the preedit text (not oncompose_end) andevent.preedit_cursorthe IME's cursor range inside it, in bytes. Nothing is intextuntil the IME commits: the commit arrives as ordinaryinputandchange, aftercompose_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_movefires after a key, click or drag moves the caret or changes the text of an input, once per input event.event.caretis the caret's rectangle(x, y, width, height)in the node's coordinates andevent.insertedthe(start, end)bytes an edit inserted (Nonefor a plain move). It doesn't fire forset(text=...)orset(selection=...), aschangedoesn't.node.caret_rect()gives the caret's rectangle at any time, shaped fresh so it is right straight after an edit, andnode.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 (withcaret_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)})