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 ¶
- Constants
- func Clusters(s string) iter.Seq2[int, string]
- func ColumnOf(s string, i int) int
- func NextCluster(s string, i int) int
- func OffsetAt(s string, col int) int
- func PrefersUnicode(value string) bool
- func PrevCluster(s string, i int) int
- func Printable(s string) string
- func StampLink(v grid.View, x, y int, s string, start, end int, url string) (col, width int)
- func Truncate(s string, width int, ellipsis string) string
- func Width(s string) int
- func WordAt(s string, at int) (start, end int, ok bool)
- type Class
- type Decoder
- type Edit
- type Line
- type Mark
- type Row
- type Span
- type Wrapped
Examples ¶
Constants ¶
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 ¶
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 NextCluster ¶
NextCluster is the byte offset after the cluster at i, or len(s) at the end.
func OffsetAt ¶
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
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 ¶
PrevCluster is the byte offset of the cluster ending at i, or zero at the start.
func Printable ¶ added in v0.12.0
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 ¶ added in v0.0.2
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 ¶
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 WordAt ¶ added in v0.0.2
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 )
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
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
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
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
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
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
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
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
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 (Line) Clone ¶ added in v0.3.0
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 ¶
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) Truncate ¶
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) Wrap ¶
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
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) Within ¶ added in v0.0.2
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
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.
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.