tabwrap

package module
v0.1.4 Latest Latest
Warning

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

Go to latest
Published: Apr 25, 2026 License: MIT Imports: 2 Imported by: 0

README

go-tabwrap

Tab-aware, grapheme-cluster-aware display width utilities for Go.

Provides StringWidth, ExpandTab, Wrap, Truncate, FillLeft, and FillRight — the common building blocks for CLI table renderers and TUI applications.

Features

  • Grapheme-cluster-aware — emoji sequences and combining characters are measured correctly (via displaywidth).
  • Tab-stop expansion — every operation handles \t as an elastic tab stop, not a single character.
  • Line wrappingWrap breaks text to fit a column width. Tabs are indivisible: if a tab does not fit, it moves to the next line.
  • Optional trailing-space trimmingCondition.TrimTrailingSpace removes trailing spaces and tabs from each output line produced by Wrap.
  • ANSI escape sequence aware — optional ControlSequences mode treats 7-bit ECMA-48 escape sequences as zero-width, and optional ControlSequences8Bit mode treats 8-bit C1 ECMA-48 escape sequences as zero-width, allowing correct measurement of styled terminal output. Wrap carries recognized SGR state across line breaks, so each output line is independently styled.
  • East Asian Width — optional treatment of ambiguous characters as double-width.

Width semantics

StringWidth measures terminal display columns by grapheme cluster, not by rune count. That means emoji sequences, combining characters, and other multi-rune graphemes are counted as a single visible unit according to displaywidth.

Tabs expand to tab stops, newlines reset the column, and the width of a multi-line string is the width of its widest line. EastAsianWidth, ControlSequences, and ControlSequences8Bit adjust how individual graphemes are counted, and FillLeft, FillRight, and Wrap all use the same width model.

Install

go get github.com/apstndb/go-tabwrap

Usage

package main

import (
	"fmt"

	"github.com/apstndb/go-tabwrap"
)

func main() {
	// Package-level functions use default settings (TabWidth = 4).
	fmt.Println(tabwrap.StringWidth("hello"))        // 5
	fmt.Println(tabwrap.StringWidth("a\tb"))          // 5 (tab expands to 3 spaces)
	fmt.Println(tabwrap.StringWidth("日本語"))        // 6

	fmt.Println(tabwrap.Truncate("hello world", 8, "...")) // "hello..."
	fmt.Println(tabwrap.FillLeft("42", 5))                  // "   42"
	fmt.Println(tabwrap.FillRight("hi", 5))                 // "hi   "

	// Use Condition for custom tab width or East Asian Width.
	c := &tabwrap.Condition{TabWidth: 8}
	fmt.Println(c.StringWidth("\t"))            // 8
	fmt.Println(c.Wrap("hello world", 5))       // "hello\n world"
	fmt.Println(c.ExpandTab("a\tb"))            // "a       b"

	trimmed := &tabwrap.Condition{TabWidth: 4, TrimTrailingSpace: true}
	fmt.Println(trimmed.Wrap("ab\tcd", 4))      // "ab\ncd"

	// ANSI escape sequences: measure visible width only.
	ansi := &tabwrap.Condition{TabWidth: 4, ControlSequences: true}
	styled := "\x1b[31mhello\x1b[0m"
	fmt.Println(ansi.StringWidth(styled))       // 5 (escape sequences ignored)

	// Wrap carries SGR state across line breaks.
	wrapped := ansi.Wrap("\x1b[31mhelloworld\x1b[0m", 5)
	// Result: "\x1b[31mhello\x1b[0m\n\x1b[31mworld\x1b[0m"
	// Each line is independently styled.
	fmt.Println(wrapped)
}

API

Package-level (default: TabWidth = 4)
Function Description
StringWidth(s) int Display width of s (tab & newline aware)
ExpandTab(s) string Replace tabs with spaces
ExpandTabFunc(s, fn) string Replace tabs using a custom callback
Wrap(s, width) string Wrap to width columns (tabs expanded)
Truncate(s, maxWidth, tail) string Truncate s, append tail if truncated, and for positive maxWidth keep the result within maxWidth (maxWidth <= 0 returns tail as-is)
FillLeft(s, width) string Left-pad with spaces
FillRight(s, width) string Right-pad with spaces
Condition

Condition provides all the above functions as methods, with configurable fields:

Field Default Description
TabWidth 4 Columns per tab stop
EastAsianWidth false Treat ambiguous EA chars as width 2
ControlSequences false Treat 7-bit ANSI escapes as zero-width
ControlSequences8Bit false Treat 8-bit ECMA-48 escapes as zero-width
TrimTrailingSpace false Trim trailing spaces and tabs from each Wrap output line

ControlSequences8Bit affects width calculation and wrapping. Truncate follows displaywidth and ignores ControlSequences8Bit, even when it is enabled for StringWidth and Wrap. That means 8-bit C1 sequences may measure as zero-width or wrap correctly, but still count during truncation. go-tabwrap keeps that behavior because parsing raw 8-bit C1 bytes during truncation can conflict with UTF-8 byte boundaries.

Additional methods:

Method Description
ExpandTab(s) string Replace tabs with spaces
ExpandTabFunc(s, fn) string Replace tabs using a custom callback
Wrap(s, width) string Wrap to width columns (tabs expanded)

Acknowledgements

This package stands on the shoulders of:

  • mattn/go-runewidth — the long-standing standard for terminal string width in Go. go-tabwrap provides a similar API shape while adding tab-awareness and grapheme-cluster support.
  • clipperhouse/displaywidth — the underlying grapheme-cluster-aware width engine that powers go-tabwrap. go-tabwrap adds tab-stop handling, wrapping, truncation, and padding on top.

License

MIT

Documentation

Overview

Package tabwrap provides tab-aware, grapheme-cluster-aware display width operations for terminal/fixed-width output.

It wraps clipperhouse/displaywidth to add tab-stop handling, line wrapping, truncation, and padding — the common building blocks for CLI table renderers and TUI applications.

Width is measured in terminal display columns, by grapheme cluster rather than rune. Tabs expand to tab stops, newlines reset the column, and the width of a multi-line string is the width of its widest line. The handling of East Asian ambiguous width and ECMA-48 control sequences follows the active Condition options.

Key differences from mattn/go-runewidth:

  • Grapheme-cluster-aware (emoji, combining characters) via displaywidth.
  • Built-in tab-stop expansion in every operation.

Key additions over clipperhouse/displaywidth:

  • Tab-aware StringWidth, ExpandTab, Wrap, Truncate, FillLeft, FillRight.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ExpandTab added in v0.1.3

func ExpandTab(s string) string

ExpandTab replaces every tab with spaces using default settings.

func ExpandTabFunc added in v0.1.3

func ExpandTabFunc(s string, fn func(nSpaces int) string) string

ExpandTabFunc replaces every tab using a custom callback with default settings.

ExpandTabFunc panics if fn is nil and s contains a tab, because fn is only called when a tab is encountered.

func FillLeft

func FillLeft(s string, width int) string

FillLeft pads s on the left using default settings.

func FillRight

func FillRight(s string, width int) string

FillRight pads s on the right using default settings.

func StringWidth

func StringWidth(s string) int

StringWidth returns the display width of s using default settings. See Condition.StringWidth for the width model.

func Truncate

func Truncate(s string, maxWidth int, tail string) string

Truncate truncates s using default settings.

func Wrap added in v0.1.3

func Wrap(s string, width int) string

Wrap wraps s to fit within width display columns using default settings.

Types

type Condition

type Condition struct {
	// TabWidth is the number of columns per tab stop. Zero or negative defaults to 4.
	TabWidth int
	// EastAsianWidth treats ambiguous East Asian characters as width 2 when true.
	EastAsianWidth bool
	// ControlSequences treats 7-bit ANSI escape sequences (CSI, OSC, etc.)
	// as zero-width when true. This allows correct width measurement of
	// strings containing terminal color codes and other SGR sequences.
	ControlSequences bool
	// ControlSequences8Bit treats 8-bit C1 ECMA-48 escape sequences as zero-width
	// when true. It can be enabled independently of ControlSequences; enabling
	// both covers both the 7-bit and 8-bit forms. Truncate follows displaywidth
	// and ignores this option.
	ControlSequences8Bit bool
	// TrimTrailingSpace removes trailing spaces and tabs from each output line
	// produced by Wrap when true. This applies after wrapping, while preserving
	// trailing zero-width graphemes on the line (for example, ANSI control
	// sequences when ControlSequences or ControlSequences8Bit are enabled).
	TrimTrailingSpace bool
}

Condition configures display width behaviour.

func NewCondition

func NewCondition() *Condition

NewCondition returns a Condition with default settings (TabWidth = 4).

func (*Condition) ExpandTab

func (c *Condition) ExpandTab(s string) string

ExpandTab replaces every tab with spaces according to tab stops. Columns reset at each newline.

func (*Condition) ExpandTabFunc added in v0.1.2

func (c *Condition) ExpandTabFunc(s string, fn func(nSpaces int) string) string

ExpandTabFunc replaces every tab by calling fn with the number of spaces the tab would normally expand to (based on the current column and tab width). The column advances by nSpaces regardless of what fn returns, so the caller is responsible for returning a string whose display width equals nSpaces if alignment matters. Columns reset at each newline.

ExpandTabFunc panics if fn is nil and s contains a tab, because fn is only called when a tab is encountered.

func (*Condition) FillLeft

func (c *Condition) FillLeft(s string, width int) string

FillLeft pads s on the left with spaces to reach width display columns. For multi-line strings, padding is added only at the start of the full string, so only the first line changes. If another line is already at least width columns wide, s is returned unchanged. Width is measured using the same rules as Condition.StringWidth. When left padding is needed, tabs in the first line are expanded first so the added spaces do not shift later tab stops there.

func (*Condition) FillRight

func (c *Condition) FillRight(s string, width int) string

FillRight pads s on the right with spaces to reach width display columns. For multi-line strings, padding is computed from the widest line but is added only at the end of the full string, so only the last line changes. Width is measured using the same rules as Condition.StringWidth. If s is already at least width columns wide it is returned unchanged.

func (*Condition) StringWidth

func (c *Condition) StringWidth(s string) int

StringWidth returns the display width of s in terminal columns.

Width is measured by grapheme cluster, not rune. Tabs expand to tab stops, newlines reset the column, and for multi-line strings the result is the width of the widest line. EastAsianWidth, ControlSequences, and ControlSequences8Bit affect how individual graphemes are counted.

func (*Condition) Truncate

func (c *Condition) Truncate(s string, maxWidth int, tail string) string

Truncate truncates s to fit within positive maxWidth display columns, appending tail if truncation occurs. Tabs are expanded before measuring. If tail itself is too wide to fit, it is truncated first so the result still fits maxWidth. When maxWidth <= 0, tail is returned as-is.

ControlSequences8Bit follows displaywidth and is ignored here, even when it is enabled for StringWidth and Wrap. This can make 8-bit C1 sequences count as zero-width for measurement but not for truncation; go-tabwrap keeps that behavior to avoid mis-parsing UTF-8 byte sequences as standalone C1 controls.

func (*Condition) Wrap

func (c *Condition) Wrap(s string, width int) string

Wrap wraps s to fit within width display columns.

Tabs are indivisible tokens: if a tab does not fit on the current line the entire tab moves to the next line. Tabs in the output are expanded to spaces so the result is render-ready.

Existing newlines are preserved. When width <= 0 the string is returned with tabs expanded but no wrapping applied.

When control-sequence handling is enabled, Wrap carries across line breaks only those SGR (Select Graphic Rendition) sequences that are recognized as zero-width under the active options: 7-bit sequences when ControlSequences is true, and 8-bit sequences when ControlSequences8Bit is true. For those sequences, a reset is emitted before each newline and the active SGR sequences are replayed after it so each output line remains independently styled.

Jump to

Keyboard shortcuts

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