cell

package
v0.0.11 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 4, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package cell is the single source of terminal geometry: measuring text in display cells and cutting or padding it to an exact width.

Every layout decision in the UI must go through this package. The rule it enforces is that a terminal's unit is the *cell*, which is neither a byte, nor a rune, nor a grapheme:

  • "四字" is 2 runes and 4 cells.
  • A ZWJ family emoji is 7 runes, 1 grapheme, and 2 cells — but summing runewidth.RuneWidth over its runes says 8. That mistake is what produced the one defect found when the TUI 2.0 goldens were first reviewed (docs/tui-2.0.md, decision 11).
  • A styled string carries escape sequences that occupy no cells at all.

Width handles all three. Measuring with len(), len([]rune(s)), or a per-rune width sum does not, and each failure mode shears a panel in a way that is invisible in ASCII-only testing.

Rune indexing is still correct for things that are not geometry — cursor positions in a text buffer, Telegram's rune-indexed entity offsets, a parser's cursor. This package is about how wide something *draws*, not where it sits in a string.

Index

Constants

View Source
const Ellipsis = "…"

Ellipsis is the marker appended by Truncate when it had to cut.

View Source
const LinkClose = "\x1b]8;;\x1b\\"

LinkClose ends an OSC 8 hyperlink: the same sequence with an empty URI.

Variables

This section is empty.

Functions

func Clamp

func Clamp(s string, maxWidth int) string

Clamp is Truncate without the ellipsis: a hard cap for cases where the cut is a safety net rather than something to show the reader, or where the caller has already added its own marker.

func ClampLeft

func ClampLeft(s string, width int) string

ClampLeft removes width cells from the *start* of s, keeping the tail.

This is what a single-line input field needs when the cursor has run past the right edge: the visible window slides right by dropping leading cells, so the cursor stays in view.

func Fill

func Fill(colour lipgloss.Color, s string, width int) string

Fill fits s to exactly width cells and paints colour behind every one of them — including the cells covered by s's own styled spans.

This exists because the obvious spelling silently does not work:

lipgloss.NewStyle().Background(r.Panel).Render(cell.Fit(line, width))

emits the background once, at the front. Every styled span inside the line closes itself with ESC[0m, and a reset clears the background along with the foreground, so the panel colour survives only as far as the first span and the rest of the row is drawn on whatever the terminal's default happens to be. In a two-line chat row that shows up as a title line with no fill at all and a preview line whose fill stops where the text does.

It is the same family of defect as WrapLines: SGR is a mode, and a reset is not a scope. Fill therefore re-opens the background after every reset in s, which also means a span may set its own colours — a selection bar, an unread badge on cyan — and the line still returns to the surface rather than to nothing. Because the surface is re-opened BEFORE each span's own sequences, a nested Fill wins over an outer one: a row that painted itself sel keeps sel when the frame fills its column with panel.

func FillRows

func FillRows(colour lipgloss.Color, rows []string, width int) string

FillRows fits each row to width, paints colour behind every cell of it, and joins them.

It exists because Fill kept being applied one row at a time and then forgotten on the next surface. Every overlay assembles a row out of styled spans and hands it to a background style, and every styled span closes itself with ESC[0m — which clears the background along with the foreground. So the surface survives as far as the first span and the rest of the row shows the terminal through it. That is divergence 19, and it arrived a second time in the overlays, which were migrated to the palette after the panels were fixed and did not get the fix that went with it.

One function rather than a call site per surface, so the next one written gets it by using it.

func Fit

func Fit(s string, width int) string

Fit clamps s to exactly width cells: truncated without an ellipsis if wider, space-padded if narrower.

This is the final guarantee for an assembled row whose individual field budgets are each best-effort — it is what makes "every line is exactly the frame width" true rather than aspirational.

func FitLine

func FitLine(style lipgloss.Style, s string, totalWidth int) string

FitLine renders s through style, clamped to exactly one line and at most totalWidth display cells — padded with the style's own background/whitespace if s is shorter.

This exists to close a whole class of bug: lipgloss's Style.Width(w) treats w as the TOTAL rendered width, INCLUDING the style's own padding, and when content-plus-padding would exceed w, lipgloss WORD-WRAPS the content onto additional lines rather than truncating or overflowing (see lipgloss Style.Render: "if !inline && width > 0 { wrapAt := width - leftPadding - rightPadding; str = ansi.Wrap(str, wrapAt, "") }", applied before the padding is even added). Style.MaxWidth doesn't help either — it truncates each already-wrapped line individually, so it produces more short lines instead of fewer.

Concretely: content built to be exactly totalWidth cells wide and then handed to a *padded* style's .Width(totalWidth) silently wraps instead of rendering as one line. That single misunderstanding produced four independent-looking bugs across this codebase: chat list rows growing past their fixed 2-line stride (desyncing row-index math), folder tab backgrounds wrapping so a "visible" tab was never actually painted, and the status bar hints line splitting mid-word. FitLine is the fix for all of them: it truncates s to totalWidth - style.GetHorizontalFrameSize() display cells BEFORE handing it to style.Width, so the internal word-wrap never has anything to wrap.

s is assumed to be single-line content (no embedded "\n"); render each line of a multi-line row through FitLine separately and join with "\n" rather than passing multi-line input in.

The style is expected to be a SINGLE-ROW one: padding, colours, bold. A border or a vertical margin adds rows of its own, which no amount of horizontal budgeting can collapse back to one, and a horizontal margin is laid outside the width lipgloss is given rather than inside it. No row style in this app has any of the three; a caller that wants a box wants theme.OverlayFrame, which is not fitted to a line.

Note: if totalWidth is smaller than style.GetHorizontalFrameSize(), lipgloss cannot shrink the style's padding below its natural size, so the result can exceed totalWidth in that degenerate case. No panel in this app is ever that narrow.

func Link(uri, text string) string

Link wraps text in an OSC 8 hyperlink. A caller that has no URI, or is drawing into a terminal that was not asked for hyperlinks, should not call it rather than passing "" — an empty URI is the CLOSING sequence, and emitting one that closes nothing is how a link leaks in the other direction.

func MaxWidth

func MaxWidth(s string) int

MaxWidth returns the width of the widest line in possibly-multi-line text — the width of the box that would contain it.

func OpenLink(s string) string

OpenLink returns the OSC 8 opening sequence left active at the end of s, verbatim so it can be re-emitted, or "" when s closes what it opened.

It is OpenStyle's counterpart for hyperlinks, and it exists for the same reason. `ansi.Wrap` breaks a line between a link's opening and closing sequences without repairing either, so the first line ends with the link still open and the second carries a close that opens nothing. Every terminal that understands OSC 8 then treats the rest of that first row — its trailing padding, the panel rule, and the column beside it — as part of the link.

The design record concluded this could not be fixed the way the SGR leak was, because reopening a hyperlink means knowing which runes belong to it after wrapping. That is true of a wrapper that has to infer the URI, and not true here: the URI is carried in the opening sequence, so the sequence is its own answer. See docs/tui-2.0.md, divergence 14.

Unlike OpenStyle this does not accumulate. A hyperlink is not a mode that composes — a second OSC 8 replaces the first, and an empty URI ends it.

func OpenStyle

func OpenStyle(s string) string

OpenStyle returns the SGR state left active at the end of s: the concatenation of the sequences that have not been cancelled by a reset, and "" when s closes everything it opens.

WrapLines uses it to reopen a run on the next line. It is exported because "this row leaves no style open" is an invariant every component that draws into a column has to hold, and asserting it is the only way to catch a leak that is invisible in a single-column dump.

Accumulating rather than keeping only the last sequence, because a styled run can be opened in pieces — lipgloss emits one combined sequence, but nested renders produce several — and dropping the earlier ones would reopen a continuation line in half its original style.

func Pad

func Pad(s string, width int) string

Pad right-pads s with spaces to width cells. A string already at or over width is returned unchanged — callers wanting a hard cap should Clamp first, or use Fit, which does both.

func PadLeft

func PadLeft(s string, width int) string

PadLeft left-pads s with spaces to width cells, i.e. right-aligns it in a field of that width. A string already at or over width is returned unchanged — Truncate first if it must also be capped.

func PaintedWidth

func PaintedWidth(s string) int

PaintedWidth returns how many leading display cells of s are drawn with some background colour in effect, stopping at the first cell that is not.

It is the assertion that makes Fill testable, and it is a count rather than a boolean because the interesting failure is a row whose fill dies two thirds of the way along — the column it died at is the thing worth putting in the message. It deliberately does not care WHICH background: a row is correct when no cell falls through to the terminal's default, and the badge, the selection bar and the surface are all legitimate answers.

A colour profile with no colour renders no sequences at all, so this returns 0 for every string under the default `go test` profile. That is the point: a package asserting on it must pin a profile in TestMain, which turns a vacuous styling test into a failing one.

func Reserve

func Reserve(s string) int

Reserve is how much room to keep for s.

In a declared mode it is Width: the user has said which way their terminal draws, so there is nothing to hedge. In EmojiAuto it is the WIDER of the two renderings — what the tables say, and what a terminal that composes nothing would draw. That is the tight upper bound over the possibilities, which is what a reservation wants: it can only ever be too generous, and being too generous costs a gap.

Counting a cell per composition rune, which is what this did first, is not that bound. A three-person family is two ZWJs, so it reserved four — and a terminal that composes nothing draws six, straight over whatever was beside it. The bug is the one this code exists to prevent, in the code meant to prevent it.

Callers laying out a row against a budget want this; callers measuring what was actually drawn want Width.

func SafeLinkURI added in v0.0.8

func SafeLinkURI(uri string) (string, bool)

SafeLinkURI is the URI to put in an OSC 8 sequence, and whether to emit one at all.

The URI in a message comes from whoever sent it. sanitizeTerminal already strips the C0/C1 bytes that would break OUT of the sequence, so this is not about escaping the terminal; it is about what the terminal is asked to do once the sequence parses. Three rules:

  • the scheme must be one a message plausibly means (see linkSchemes)
  • every byte must be printable ASCII, percent-encoded if it is not, since OSC 8 has no other way to carry one
  • the whole thing must fit maxLinkURI

Anything else returns false, and Link then draws the text without the wrapper. That is the failure-safe direction: the reader still sees a link and can still read where it claims to go, but a click cannot hand an arbitrary scheme to the platform opener.

This is the same lesson as the notification sequences (docs/tui-2.0.md, divergence 41): content that goes INSIDE a control sequence needs a different filter from content that goes on the screen, and having sanitised one is no reason to think the other is covered.

func SetEmojiMode

func SetEmojiMode(m EmojiMode)

SetEmojiMode declares how this terminal draws composed emoji. Call it once, before the first render.

func Truncate

func Truncate(s string, maxWidth int) string

Truncate clamps s to at most maxWidth display cells, appending an ellipsis when it actually had to cut something. It is ANSI-safe, so an already-styled string is not corrupted mid-escape.

This is the primary truncation used for user-supplied text — chat titles, message previews, folder labels — where the reader benefits from seeing that something was elided.

func Width

func Width(s string) int

Width returns the display width of a single line in terminal cells. ANSI escape sequences are ignored and grapheme clusters are measured as the terminal draws them.

"As the terminal draws them" is a claim the Unicode tables cannot make on their own for composed emoji, which is what EmojiMode is about; in EmojiSeparate this measures the pieces instead. For a row being laid out against a budget rather than measured after the fact, use Reserve.

For multi-line text use MaxWidth; this counts a newline as nothing and so would under-report a block.

func Wrap

func Wrap(s string, width int) string

Wrap breaks s onto lines of at most width cells, preferring word boundaries. An unbroken token longer than width is hard-broken rather than allowed to overflow, since a row wider than its column shears the frame.

Wrapping is ANSI-aware in the sense that escape sequences are not counted as cells and are not cut in half. It does NOT make each line independently styled — see WrapLines, which is what a caller composing rows out of the result almost always wants.

func WrapLines

func WrapLines(s string, width int) []string

WrapLines wraps s to width and returns lines that are each self-contained: a style spanning a break is closed at the end of one line and reopened at the start of the next.

Wrap alone is not enough for anything drawn into a column. It leaves the opening sequence on the first line and the reset on the last, so the lines between carry no styling of their own — and a terminal does not reset at a newline. In a multi-column frame the rows of one panel are not adjacent on screen: whatever a body line leaves open bleeds through its own trailing padding, across the panel rule, and into the next column, for as many rows as it takes to reach the reset.

This is the ONLY correct way to wrap styled text into rows. There is no version of the bug that shows up in a single-column layout, which is why it survives review so easily.

Types

type EmojiMode

type EmojiMode int32

Emoji width is not a property of the string, and this package cannot ask.

A terminal decides how wide an emoji sequence is drawn, and terminals disagree — not slightly, and not in one direction. Three classes of sequence have a *composition* rule, and a terminal that applies it and one that does not produce different widths:

  • A base character followed by U+FE0F. "❤️" is U+2764 plus the emoji presentation selector. A terminal that honours the selector draws a double-width picture; one that ignores it draws the narrow text heart the base character already was. Tables say 2, that terminal draws 1 — the row comes out SHORT.
  • A ZWJ sequence. "👨‍👩‍👧" is three emoji joined by U+200D. A terminal that composes draws one family in two cells; one that does not draws three emoji in six. Tables say 2 — the row comes out LONG.
  • A regional-indicator pair. "🇷🇸" is two letters that compose into a flag in two cells, or stay two letter-boxes in four. Tables say 2 — LONG again.

So "does this terminal draw emoji narrow or wide" is the wrong question: the same terminal is narrow on the first class and wide on the other two. The right question is whether it COMPOSES, which is what the modes below name. There is no environment variable that answers it, and the runtime query that would was removed in wave 5 — its response bytes leaked into the composer. So it is declared, or it is guessed at pessimistically.

const (
	// EmojiAuto measures with the Unicode tables and, where a caller asks
	// for a RESERVATION rather than a measurement, keeps room for whichever
	// of the two renderings is wider.
	//
	// The pessimism goes in one direction only. Over-reserving costs a gap
	// or a tab dropped early — visible, harmless and self-evident.
	// Under-reserving lets a row run past its budget and overwrite what is
	// beside it, which is what put "nnected" on somebody's top bar. Given
	// the choice between a gap and a corrupted row, this takes the gap.
	//
	// The default, and the only mode that is a guess.
	EmojiAuto EmojiMode = iota

	// EmojiComposed declares that this terminal applies every composition
	// rule: a selector is honoured, a ZWJ sequence is one glyph, a flag is
	// a flag. The tables are then exactly right and nothing is reserved on
	// top of them.
	EmojiComposed

	// EmojiSeparate declares that this terminal applies none of them: a
	// selector is ignored and the base drawn as text, and a joined or
	// paired sequence is drawn as its separate parts. Widths are then the
	// sum of the pieces — narrower than the tables for the first class,
	// wider for the other two.
	EmojiSeparate
)

func CurrentEmojiMode

func CurrentEmojiMode() EmojiMode

CurrentEmojiMode reports the declared mode.

func ParseEmojiMode

func ParseEmojiMode(v string) EmojiMode

ParseEmojiMode reads a configured value. Unrecognised falls back to EmojiAuto rather than failing: a typo in a cosmetic setting should cost the user the setting, not the client.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL