-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwidth.go
More file actions
162 lines (150 loc) · 4.92 KB
/
width.go
File metadata and controls
162 lines (150 loc) · 4.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
// SPDX-FileCopyrightText: Copyright 2026 Carabiner Systems, Inc
// SPDX-License-Identifier: Apache-2.0
package termtable
import (
"strings"
"unicode"
"github.com/rivo/uniseg"
)
// GraphemeRun carries a single grapheme cluster's text, its display
// width, and any ANSI escape bytes that immediately preceded it in the
// source (so a wrap-aware renderer can re-emit them). Cluster text
// never contains ANSI escapes and never contains '\n' — NaturalLines
// splits hard breaks before segmentation.
type GraphemeRun struct {
Text string
Width int
EscPrefix string
}
// DisplayWidth returns the number of terminal columns s would occupy
// when rendered, ignoring ANSI escape sequences. Grapheme clusters are
// counted by their East Asian Width: most characters are 1 column,
// CJK and emoji are 2, combining marks are 0.
//
// This reports the Unicode-standard width. Table rendering may pick
// a wider "conservative" value to survive terminals without emoji
// ligature support — see EmojiWidthMode.
func DisplayWidth(s string) int {
return uniseg.StringWidth(StripANSI(s))
}
// displayWidthFor returns the display width of s under mode. Used
// inside the renderer to measure column alignment after the
// Table's emoji width mode has been resolved. Pass
// EmojiWidthGrapheme for Unicode-collapsed widths (equivalent to
// DisplayWidth); pass EmojiWidthConservative to sum constituent
// parts of composite emoji.
func displayWidthFor(s string, mode EmojiWidthMode) int {
stripped := StripANSI(s)
if mode == EmojiWidthGrapheme {
return uniseg.StringWidth(stripped)
}
var w int
state := -1
rest := stripped
for rest != "" {
var cluster string
cluster, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state)
w += clusterWidth(cluster, mode)
}
return w
}
// MinUnbreakableWidth returns the display width of the widest run of
// consecutive non-whitespace grapheme clusters in s (ANSI ignored).
// It is the smallest column width at which s can render without hard-
// breaking a word. Widths follow Unicode standard semantics; see
// DisplayWidth.
func MinUnbreakableWidth(s string) int {
return minUnbreakableWidthFor(s, EmojiWidthGrapheme)
}
// minUnbreakableWidthFor is the mode-aware variant used by Measure.
func minUnbreakableWidthFor(s string, mode EmojiWidthMode) int {
stripped := StripANSI(s)
var maxW, curW int
state := -1
rest := stripped
for rest != "" {
var cluster string
cluster, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state)
if isWhitespaceCluster(cluster) {
if curW > maxW {
maxW = curW
}
curW = 0
continue
}
curW += clusterWidth(cluster, mode)
}
if curW > maxW {
maxW = curW
}
return maxW
}
// NaturalLines splits s on '\n' (hard breaks) and returns each line as
// a sequence of grapheme runs with preserved ANSI escape prefixes. A
// trailing '\r' on each split line is removed so CRLF inputs behave
// the same as LF inputs.
//
// An input of "" produces a single empty line. An input of "\n"
// produces two empty lines.
func NaturalLines(s string) [][]GraphemeRun {
return naturalLinesFor(s, EmojiWidthGrapheme)
}
// naturalLinesFor is the mode-aware variant used by the render
// pipeline. Each grapheme run's Width field reflects the requested
// EmojiWidthMode, so downstream wrap / layout math operates on
// consistent values.
func naturalLinesFor(s string, mode EmojiWidthMode) [][]GraphemeRun {
rawLines := strings.Split(s, "\n")
out := make([][]GraphemeRun, 0, len(rawLines))
for _, line := range rawLines {
out = append(out, graphemeRunsOf(strings.TrimSuffix(line, "\r"), mode))
}
return out
}
// graphemeRunsOf segments a single line (containing no '\n') into
// grapheme runs, attaching any intervening ANSI escape sequences as
// EscPrefix of the next visible cluster. Each run's Width is
// computed via clusterWidth under the supplied mode. Trailing
// escapes with no following cluster are dropped.
func graphemeRunsOf(line string, mode EmojiWidthMode) []GraphemeRun {
if line == "" {
return nil
}
segs := scanANSI(line)
var runs []GraphemeRun
var pendingEsc strings.Builder
state := -1
for _, seg := range segs {
if seg.kind == segEsc {
pendingEsc.WriteString(line[seg.start:seg.end])
continue
}
rest := line[seg.start:seg.end]
for rest != "" {
var cluster string
cluster, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state)
runs = append(runs, GraphemeRun{
Text: cluster,
Width: clusterWidth(cluster, mode),
EscPrefix: pendingEsc.String(),
})
pendingEsc.Reset()
}
}
return runs
}
// isWhitespaceCluster reports whether every rune in the cluster is
// Unicode whitespace. Zero-width modifiers combined with whitespace
// keep the cluster whitespace-like; any non-whitespace rune makes it
// non-whitespace overall.
func isWhitespaceCluster(cluster string) bool {
if cluster == "" {
return false
}
for _, r := range cluster {
if !unicode.IsSpace(r) {
return false
}
}
return true
}