Character-level string operations and Unicode display width calculation.
Source after ord.sh.
. ./src/ord.sh
. ./src/str.shAll string functions take a variable name (not value) to avoid subshell overhead. Results are stored in output variables.
| Function | Output Variable | Description |
|---|---|---|
tuish_str_len VAR |
TUISH_SLEN |
Length of string in VAR, in characters |
tuish_str_left VAR N |
TUISH_SLEFT |
First N characters |
tuish_str_right VAR N |
TUISH_SRIGHT |
Characters from offset N to end |
tuish_str_char VAR N |
TUISH_SCHAR |
Single character at offset N |
tuish_str_width VAR |
TUISH_SWIDTH |
Display width in terminal columns |
tuish_str_window VAR OFF W |
TUISH_SWINDOW, TUISH_SWINDOW_W |
The slice visible in a W-column window at OFF |
tuish_str_pad VAR W |
TUISH_SPADDED |
VAR fitted to exactly W display columns |
tuish_str_repeat S N |
TUISH_SREPEATED |
S repeated N times |
tuish_str_window also reports TUISH_SWINDOW_W, the slice's display width. That is
not recoverable by measuring the result: the slice may carry SGR runs (arbitrary
bytes, zero columns), and a wide glyph that would have crossed the right edge is
dropped rather than split, leaving the slice a column short of W. A caller padding
the slice out to a field needs the number the slicer already knows.
Offsets are 0-based. These use ${var:off:len} syntax (bash/zsh/ksh93/mksh).
tuish_str_len, tuish_str_left, tuish_str_right and tuish_str_char count
characters. tuish_str_width and tuish_str_window count display columns.
For ASCII the two agree, which is exactly what makes the confusion survive testing:
cjk='日本語'
tuish_str_left cjk 2 # TUISH_SLEFT = '日本' — 2 characters, 4 COLUMNS
tuish_str_window cjk 0 2 # TUISH_SWINDOW = '日' — 2 columnsIf you are clipping to fit a region, a box, or any other geometry, you want columns,
so you want tuish_str_window. Using tuish_str_left for that job is how wide text
used to run straight through a hosted region's right border.
VAR's value fitted to exactly W display columns in TUISH_SPADDED: space-padded
when it is narrower, sliced when it is wider.
name='hi'; tuish_str_pad name 5 # TUISH_SPADDED = 'hi '
name='日本'; tuish_str_pad name 6 # TUISH_SPADDED = '日本 ' — 4 columns + 2It goes through tuish_str_window, so W is columns: a wide glyph that would
straddle the cut is dropped rather than split, and the pad then makes up the missing
column, so the result is W columns either way.
This is the string counterpart of tuish_text ... width=N
(term.md). Use tuish_text when
the field is the whole write; use this when a row is assembled from several pieces
and printed as a unit.
tuish_str_width computes how many terminal columns a string occupies.
ASCII characters are 1 column, CJK ideographs and fullwidth characters are
2 columns, and combining marks / zero-width characters are 0 columns.
text="hello world"
tuish_str_len text # TUISH_SLEN = 11
tuish_str_left text 5 # TUISH_SLEFT = "hello"
tuish_str_right text 6 # TUISH_SRIGHT = "world"
tuish_str_char text 0 # TUISH_SCHAR = "h"
cjk="中文hi"
tuish_str_width cjk # TUISH_SWIDTH = 6 (2+2+1+1)There are two speeds here, and they are three orders of magnitude apart.
Under LC_ALL=C a string of printable ASCII takes a fast path: every character is
one column, so the width is the byte count. That is ~15 µs. Anything else is
decoded byte by byte through _tuish_ord and _tuish_char_width, which is ~4.5 ms
for a 60-column box rule. And the test is per string, not per character: one
non-ASCII byte puts the whole string on the slow path, so a 20-column label with a
single ┤ in it costs ~500 µs.
That matters more than it sounds, because tuish_text runs a width pass on every
draw and the default draw backend is Unicode. A UTF-8 app was paying milliseconds
per label and tens of milliseconds per frame measuring text before writing a byte.
So the decoders sit behind an exact-match memo — a handful of most-recent (string → answer) entries, consulted only on the slow path. The key is compared literally, so it cannot return a wrong answer; a miss just runs the decoder that was going to run anyway. It works because the expensive strings are the repeated ones: rules, borders, box chrome and fixed labels are redrawn verbatim every frame, while the content that genuinely differs row to row is usually prose, which took the ASCII path already.
Measured on a frame of one box, three rules, six Unicode labels and forty rows of
prose: 34 ms → 6.6 ms. Nothing about this is visible in the API — the only way to
notice it is that frames got cheap. tests/bench/bench_paint.sh tracks it, with
paired hot/cold scenarios so a regression that defeats the memo shows up as the two
converging.
These internal functions handle byte-level UTF-8 processing under LC_ALL=C:
| Function | Description |
|---|---|
_tuish_byte_val VAR OFF |
Unsigned byte value at offset |
_tuish_utf8_len |
UTF-8 byte length from lead byte |
_tuish_utf8_decode |
Decode UTF-8 codepoint |
_tuish_char_byte_off VAR N |
Byte offset of character index N |
_tuish_char_width |
Codepoint → display width (0, 1, or 2) |
The tuish_str_* functions require ${var:off:len} parameter expansion,
supported by bash, zsh, ksh93, and mksh. Availability on busybox sh
depends on the build configuration.