text

package
v0.16.0 Latest Latest
Warning

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

Go to latest
Published: Aug 25, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package text lays styled text out in terminal columns: measuring it, wrapping it, truncating it, and drawing it onto a grid.View.

Everything here counts columns rather than bytes or runes. A display atom is never split: ordinary CJK and emoji clusters occupy two columns, while spacing modifiers can make one Unicode grapheme wider still. A combining mark on its own occupies none. Text that is measured one way and drawn another is the source of every misaligned terminal UI, so measuring and drawing live in the same place and agree by construction.

Text that arrives already styled

Decoder is the other direction: the output of a command an interface ran, which comes with the escape sequences that coloured it, read back into the same [Span]s everything here lays out. It is here rather than beside the terminal because what it produces is text — the sequences are how the styling was spelled, and this is the package that knows what styled text is.

Index

Examples

Constants

View Source
const TabStop = 8

TabStop is how far apart tab stops are.

Eight, because that is where a terminal would have put them: the output being rendered was usually formatted by a program writing to a terminal, and lining its columns up means agreeing with the assumption it made.

Variables

This section is empty.

Functions

func Clusters

func Clusters(s string) iter.Seq2[int, string]

Clusters iterates the grapheme clusters of s with the byte offset each starts at.

It is what anything holding a cursor into text needs. A cursor cannot live on a rune boundary: a letter and the accent that modifies it are two runes and one thing on screen, and a cursor between them has no position a terminal could show.

func ColumnOf

func ColumnOf(s string, i int) int

ColumnOf is how many columns of s sit before the byte offset i.

func NextCluster

func NextCluster(s string, i int) int

NextCluster is the byte offset after the cluster at i, or len(s) at the end.

func OffsetAt

func OffsetAt(s string, col int) int

OffsetAt is the byte offset of the cluster boundary nearest to column col, without going past it. It is how a click, or a cursor moving between lines of different lengths, finds where it lands.

func PrefersUnicode added in v0.10.0

func PrefersUnicode(value string) bool

PrefersUnicode reports whether a POSIX locale should select Unicode terminal text. An empty locale keeps the modern Unicode default; C, POSIX, a language without an encoding, and a locale naming another encoding return false.

Resolving which locale belongs to a terminal is a transport concern. Interpreting how that locale constrains text belongs here, beside the text that must be representable in it.

func PrevCluster

func PrevCluster(s string, i int) int

PrevCluster is the byte offset of the cluster ending at i, or zero at the start.

func Printable added in v0.12.0

func Printable(s string) string

Printable returns the terminal-safe text in s.

A tab is kept, because it is laid out rather than obeyed — see TabStop — and every other control character is an instruction for a terminal this package is not asking it to perform. Invalid UTF-8 becomes replacement text. Cleaning occurs before layout rather than at the cell so measuring, cursor offsets and drawing see the same text.

A newline is a control character too. Call Printable on one logical line at a time when line breaks carry meaning of their own.

Example
package main

import (
	"fmt"

	"github.com/Tangerg/oolong/core/text"
)

func main() {
	fmt.Printf("%q\n", text.Printable("name\tvalue\x00\xff"))
}
Output:
"name\tvalue�"
func StampLink(v grid.View, x, y int, s string, start, end int, url string) (col, width int)

StampLink turns the columns occupied by the byte range [start, end) of s into a hyperlink, on text already drawn at (x, y), and reports the columns it covered.

The conversion is the whole point of it existing. Anything that finds links works in bytes, because bytes are what text is; a cell is a column, and the two counts are not the same one. A URL written after an emoji begins at a different column than at its byte offset, one containing a full-width character covers more columns than it has clusters, and a tab before it moves everything by however much was left to the next stop. Getting that wrong underlines the wrong text — visibly, and only for the people whose text is not ASCII.

The returned width is what a caller records so that a click on those columns can be answered later.

func Truncate

func Truncate(s string, width int, ellipsis string) string

Truncate cuts plain text to at most width columns, ending it with ellipsis when anything was cut. When a cut occurs, retained source tabs are expanded as described by Line.Truncate; an uncut string is returned unchanged.

func Width

func Width(s string) int

Width is how many columns s would occupy, with tabs expanded from column zero.

func WordAt added in v0.0.2

func WordAt(s string, at int) (start, end int, ok bool)

WordAt is the byte range of the word containing an offset, and whether there is one.

A word is the run of clusters sharing the class of the one under the offset — see Class. Whitespace is not a word and reports false, so a double-click in the margin selects nothing rather than selecting the gap. Punctuation is its own word of one cluster, because a run of it is not something anybody means to have selected.

The offset is pulled to a cluster boundary first, so a caller working in columns cannot land inside a character and get half of it.

Types

type Class added in v0.0.2

type Class uint8

Class is the family of characters a cluster belongs to, for deciding where a word begins and ends.

const (
	// Space is whitespace, which is not part of any word.
	Space Class = iota
	// Word is a letter, a digit or an underscore — the run a double-click takes in
	// text written in an alphabet.
	Word
	// Han is Chinese, and the first of the three scripts written without spaces.
	//
	// In those, a run of letters is not a word and the script itself is the only
	// boundary left. Double-clicking inside 中文词组 takes the whole run: taking the
	// alphabetic rule instead would swallow the Latin beside it, and taking one
	// character would select less than anybody meant.
	Han
	// Kana is Japanese hiragana and katakana, which are one boundary between them
	// because a word switches from one to the other inside itself.
	Kana
	// Hangul is Korean.
	Hangul
	// Punct is everything else. It selects only itself, because a run of punctuation
	// is not a thing anybody means to have selected.
	Punct
)

func ClassOf added in v0.0.2

func ClassOf(cluster string) Class

ClassOf is the family the first character of a cluster belongs to.

type Decoder added in v0.0.2

type Decoder struct {

	// Base is what text arrives in before any sequence says otherwise, and what a
	// reset goes back to. It is how output is drawn in the interface's own body
	// style while still being recoloured by whatever wrote it.
	//
	// The zero value is the terminal's own appearance, which is right for output
	// shown on its own and wrong inside a themed pane.
	Base grid.Style
	// contains filtered or unexported fields
}

Decoder turns terminal output back into styled text: the escape sequences a program wrote to colour its output become [Span]s, and everything else is dropped.

This is the one direction that was missing. An interface that runs commands is handed their output, and their output is coloured; a cell refuses control characters at the boundary, on purpose, so without this every caller has either to strip the colour and lose it or to write this again.

Reading a stream

Output arrives in whatever pieces a read produced, and neither a line nor a sequence respects those boundaries. Decoder.Feed answers with the lines a newline has finished, holds the rest, and carries the style in force from one piece to the next — so a colour opened in one chunk still applies in the next, and a sequence split down the middle is not read as text. Decoder.Open is the line still being written, which is what a live interface draws while the rest of it is still coming.

A decoder belongs to one goroutine, like everything else here. It is deliberately not an io.Writer: something wired to a command's standard output is written to from whatever goroutine is waiting on that command, and this library has exactly one that may touch what is on screen. Read the pipe there, post the chunk, decode it here.

What is read and what is not

Colour and the six attributes a cell can carry, which is all a cell has — and the hyperlink a terminal was told about, which is the one thing in the stream that says where a piece of text points. Every other sequence is consumed and dropped, and dropped is the point: it neither reaches a cell, where it would be obeyed on the next repaint, nor shows up as its own text.

A carriage return is dropped rather than obeyed. Obeying it — and the cursor movement and erasure beside it, which is what a progress bar rewriting its line is made of — is a terminal emulator, which is another product and not this one. What that costs is visible and bounded: output that redrew a line in place reads as the several versions of it, one after another.

The sixteen colours a terminal names rather than numbers are resolved through grid.PaletteRGB, because a grid.Color is either a number or the terminal's own and there is nothing in between to hold "the user's idea of red". The values are xterm's, which is what a terminal that was never themed shows.

A Decoder must not be copied after its first use.

Example
package main

import (
	"fmt"

	"github.com/Tangerg/oolong/core/text"
)

func main() {
	// Output arrives in whatever pieces a read produced — here with a colour opened
	// in one and closed in another, and a sequence split down the middle.
	var d text.Decoder
	for _, chunk := range []string{"building \x1b[3", "3mtwo\x1b[0m targets\nlin", "king\n"} {
		for _, line := range d.Feed(chunk) {
			fmt.Printf("%q\n", line.String())
		}
	}
	fmt.Printf("still open: %q\n", d.Open().String())

}
Output:
"building two targets"
"linking"
still open: ""

func (*Decoder) Feed added in v0.0.2

func (d *Decoder) Feed(chunk string) []Line

Feed takes another piece of the output and returns the lines a newline finished.

What is left over stays in the decoder: the line no newline has ended yet — see Decoder.Open — and a sequence that arrived only in part. Nothing is lost by stopping in the middle of either.

Feed follows the ordinary streaming-decoder shape: hand over the next piece, take back what is now decidable, and let Decoder.Flush settle what only the end of the stream can.

func (*Decoder) Flush added in v0.0.2

func (d *Decoder) Flush() []Line

Flush ends the stream: the open line, if there is one, and nothing else.

A sequence that never finished is dropped rather than shown, for the reason a cell drops a control character — half of a sequence is not text, and printing it would print the introducer.

func (*Decoder) Open added in v0.0.2

func (d *Decoder) Open() Line

Open is the line still being written: everything decoded since the last newline.

It is what an interface draws while the rest is still arriving. The returned Line is a read-only view owned by the decoder and may be replaced by a later decoder call. A caller that will retain or modify it uses Line.Clone.

func (*Decoder) Reset added in v0.0.2

func (d *Decoder) Reset()

Reset returns the decoder to where it started, keeping Decoder.Base. It is what a component reuses one for a second command rather than allocating another.

type Edit added in v0.0.2

type Edit struct {
	// Start and End are the range replaced, the end exclusive.
	Start, End int
	// Text is what goes there.
	Text string
}

Edit is a range of a document replaced by other text.

It is the only shape a change to text has. An insertion is an empty range with text, a deletion is a range with none, and a replacement is both — so anything that has to react to a change reacts to one thing rather than to three.

Offsets are bytes from the start of the whole document. Not lines and columns: how a document is stored is the business of whatever stores it, and a change described in line numbers could only be applied to something that had them.

func (Edit) Apply added in v0.0.2

func (e Edit) Apply(document string) string

Apply is the document with the edit made.

A range outside the document is clamped to it, and one the wrong way round is taken the right way round, so an edit worked out from a position that has since moved cannot panic.

func (Edit) Delta added in v0.0.2

func (e Edit) Delta(n int) int

Delta is how much longer a document of length n gets, which is negative when it shrinks. The edit is clamped to that document before the difference is measured, exactly as it is by Edit.Apply and Edit.Shift.

func (Edit) Shift added in v0.4.0

func (e Edit) Shift(marks []Mark, n int) []Mark

Shift moves marks over the edit in a document of length n, in order, dropping the ones it destroyed.

The edit is clamped to the document first, by the same rule as Edit.Apply. The length is therefore part of the operation rather than an optional validation hint: without it an edit before byte zero would replace one range in the text and move its metadata as though it had replaced a different one.

Which way a mark moves at the edges

Text inserted exactly where a mark begins goes before it, and text inserted exactly where a mark ends goes after it. So typing on either side of a chip in a prompt leaves the chip the length it was, which is the only answer that lets a user type up against one — the other would swallow the next thing they wrote.

What happens to a mark the edit reached into

An empty edit changes neither text nor marks. Its position may be inside a mark, but a position on its own did not reach into anything.

An atomic mark is dropped: half of a thing that stood for something is not a smaller thing, it is a fragment that still looks like the thing and no longer is. Any other mark stretches to cover what replaced the part the edit took — and is dropped too if the edit took all of it, because a range covering nothing says nothing about the text and a caller keying a record off it would keep it for ever.

The marks are shifted in place, which is what a caller that keeps them in a slice wants. The result is the slice with the destroyed ones removed, so a caller that held a mark by value has to find it again by identity.

type Line

type Line []Span

Line is one logical line of styled text — logical in that it has no width yet. Wrapping turns it into however many rows it needs.

func CloneLines added in v0.4.0

func CloneLines(lines []Line) []Line

CloneLines returns an independently owned copy of lines and every line in it. It is the collection counterpart to Line.Clone; a caller taking ownership of a rendered document should not have to rebuild that deep-copy boundary itself.

func Decode added in v0.0.2

func Decode(s string, base grid.Style) []Line

Decode is one string of output, whole, in one call.

The base style is what it arrives in before any sequence says otherwise — see Decoder.Base. This is the form for output that has already finished; anything still arriving wants a Decoder, which is the same reading with the state kept between pieces.

Example
package main

import (
	"fmt"

	"github.com/Tangerg/oolong/core/grid"
	"github.com/Tangerg/oolong/core/text"
)

func main() {
	// What a command wrote, colour and all. The style an interface chose is what it
	// arrives in, and the sequences in it say the rest.
	body := grid.Style{FG: grid.RGBColor(0xE2, 0xE6, 0xEF)}
	for _, line := range text.Decode("ok \x1b[32mpassed\x1b[0m\nnext", body) {
		for _, span := range line {
			fmt.Printf("%q %v\n", span.Text, span.Style.FG.RGB())
		}
	}

}
Output:
"ok " {226 230 239}
"passed" {0 128 0}
"next" {226 230 239}

func Of

func Of(s string, style grid.Style) Line

Of is the one-span line for a piece of plain styled text.

func (Line) Clone added in v0.3.0

func (l Line) Clone() Line

Clone returns an independently owned copy of the line.

It copies the span storage and detaches text and link strings from their source allocations. The latter matters at long-lived ownership boundaries: a short span sliced from a command's output must not keep the command's complete output alive.

func (Line) Draw

func (l Line) Draw(v grid.View, x, y int) int

Draw writes the line onto v at (x, y) and returns how many columns it advanced. Tabs are expanded from the line's own start, not from the view's, so a line drawn at an indent keeps the column relationships it was written with.

A span that points somewhere is stamped onto the columns it took, so the text a terminal shows is the text a terminal will open — and a link that a wrap broke in two is stamped on both halves, which is how one hyperlink covers two rows.

func (Line) String

func (l Line) String() string

String is the line's text with the styling dropped.

func (Line) Truncate

func (l Line) Truncate(width int, ellipsis string) Line

Truncate cuts the line to at most width columns, ending it with ellipsis when anything was cut. The ellipsis takes the style of the last text that survived, so it reads as part of the sentence it is ending.

The result can fall a column short of width: a cut never splits a wide cluster. When a cut occurs, each retained source tab is returned as the spaces it occupied from its original column. An uncut line is returned unchanged. Expanding tabs in a truncated result keeps its measured width stable when the result is placed elsewhere.

func (Line) Width

func (l Line) Width() int

Width is how many columns the line would occupy unwrapped, with tabs expanded.

func (Line) Wrap

func (l Line) Wrap(width int) []Wrapped

Wrap breaks the line into rows of at most width columns.

Breaks are preferred at spaces, and a word longer than the width is broken between grapheme clusters instead. The run of spaces at a break is consumed: it hangs off neither the end of one row nor the start of the next. Styles survive every break.

A width of zero or less returns the line whole: a caller with no width to lay out in is better served by text it can measure than by text silently thrown away.

type Mark added in v0.0.2

type Mark struct {
	// ID is the caller's handle on the mark. Nothing here reads it.
	ID uint64
	// Kind is the caller's own label, for telling one family of marks from another.
	Kind int
	// Start and End are the byte range, the end exclusive.
	Start, End int
	// Atomic says the mark stands for one thing, so an edit reaching inside it
	// destroys it rather than stretching it.
	//
	// A chip in a prompt naming a file is atomic: text typed into the middle of it
	// names something else, and a chip that still looks like a file and points at
	// half of one is worse than no chip. A highlight is not: it is about the text it
	// covers, and text inserted in the middle of it is still covered.
	Atomic bool
}

Mark is a range of a document that moves as the document is edited.

It is how anything can be said about a piece of text without being said in the text: which run of it stands for a file the user picked, which run is a search result, which run somebody spelled wrong. Every one of those is a range that has to still be over the same words after something is typed somewhere else, and keeping them in step is the same problem each time — see Edit.Shift.

The identity and the kind mean nothing here. A caller keeps whatever the mark stands for beside it, keyed by the identity, and this only promises that the identity survives as long as the mark does.

func (Mark) Covers added in v0.0.2

func (m Mark) Covers(at int) bool

Covers reports whether a byte offset is inside the mark. The end is exclusive, so the offset just after a mark is outside it.

func (Mark) Empty added in v0.0.2

func (m Mark) Empty() bool

Empty reports whether the mark covers nothing.

func (Mark) Within added in v0.0.2

func (m Mark) Within(at int) bool

Within reports whether an offset is strictly inside the mark.

Strictly, unlike Mark.Covers: a mark's two ends are places a cursor may sit, and only what is between them is not. They are different questions and the difference matters — treating the start as inside would mean a cursor arriving from the left skipped straight past the mark, and nothing could be typed in front of one.

type Row added in v0.1.0

type Row struct {
	Text   string
	Offset int
	Line   int
	Joined bool
	Gap    string
}

Row is the meaningful text of one visual row and where that text begins.

Text excludes gutters, markers and other decoration. Offset keeps its columns aligned with the rendered row without putting decoration into copied or searched content. Line is the one-based logical source line, when the producer has one; zero means unspecified. Joined and Gap make a width-induced break reversible: a copy can rejoin a wrapped paragraph while preserving a consumed space and not inventing one inside a long word.

func (Row) Separator added in v0.1.0

func (r Row) Separator() string

Separator is what goes between this row and the one above it in copied or searched logical text.

type Span

type Span struct {
	Text  string
	Style grid.Style
	// Link is where the run points: a URL, a file, whatever a terminal will open.
	// Empty is text that points nowhere, which is nearly all text.
	//
	// It is carried here rather than stamped onto cells afterwards because by then
	// the columns are gone: a line is wrapped, truncated and drawn wherever it fits,
	// and something holding byte offsets into the text it was made from cannot say
	// which cells the third word ended up on. A span survives all three, so the
	// address survives with it — see [Line.Draw], which is where it reaches the
	// cells, and [github.com/Tangerg/oolong/core/grid.Cell.Link], which is what a
	// terminal is told.
	Link string
}

Span is a run of text sharing one style, and — where it points at something — one address.

type Wrapped

type Wrapped struct {
	Line Line
	// Joined marks a row that continues the line above it rather than starting a
	// line of its own. Anything rejoining rows — copying a selection, say — needs
	// to know which line breaks were the text's and which were the width's.
	Joined bool
	// From and To are the byte range of the line this row came from, as [Line.String]
	// numbers it. They make the wrap invertible: anything that found something in the
	// text before it was wrapped — a URL, a search match, the ends of a selection —
	// can work out which rows it landed on and where along them.
	//
	// The range is not the same text as the row: the spaces a break consumed are
	// outside it at either end, a tab inside it became the spaces it stands for, and a
	// control character inside it was dropped. It is provenance, not content.
	From, To int
}

Wrapped is one physical row produced by wrapping a Line.

func (Wrapped) Draw

func (w Wrapped) Draw(v grid.View, x, y int) int

Draw writes the row onto v at (x, y).

func (Wrapped) Width

func (w Wrapped) Width() int

Width is how many columns the row occupies.

Jump to

Keyboard shortcuts

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