gitstack

grindlemire/go-tui code browser

8.5 KB Go 299 lines 2026-06-19 · 2161ca1 raw
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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
package tui

import (
	"unicode"
	"unicode/utf8"
)

// nextCluster returns the next grapheme cluster at the start of s, its display
// width in terminal cells, and the number of bytes it consumed. The returned
// cluster is a slice of s (no allocation); callers that persist it must clone it.
//
// The cluster profile targets terminal text: combining marks, ZWJ emoji
// sequences, regional-indicator pairs, emoji modifiers (skin tones), and
// variation selectors. It is not full UAX #29: Hangul jamo composition, Prepend,
// Indic conjuncts, and emoji tag sequences (subdivision flags) are not handled.
// Width 0 is never returned: the buffer model reserves 0 for continuation cells,
// and a defective leading combining mark becomes a width-1 cluster.
func nextCluster(s string) (cluster string, width, size int) {
	if len(s) == 0 {
		return "", 0, 0
	}

	// ASCII fast path: only when the next byte is also ASCII (or we are at the
	// end). A non-ASCII next byte (leading byte >= 0x80, e.g. 0xCC starting a
	// combining accent) is NOT a continuation byte but could still attach to
	// this base, so it forces the full decode below.
	if s[0] < 0x80 {
		if len(s) == 1 || s[1] < 0x80 {
			return s[:1], 1, 1
		}
	}

	w, size, _ := clusterAdvance(decodeStringStep(s))
	return s[:size], w, size
}

// nextClusterBytes is the []byte variant for the inline byte scanner. It shares
// the cluster state machine with nextCluster (one implementation, two decode
// steps). It reports the cluster's display width, the number of bytes consumed,
// and the cluster's base rune.
func nextClusterBytes(b []byte) (width, size int, base rune) {
	if len(b) == 0 {
		return 0, 0, 0
	}

	if b[0] < 0x80 {
		if len(b) == 1 || b[1] < 0x80 {
			return 1, 1, rune(b[0])
		}
	}

	return clusterAdvance(decodeBytesStep(b))
}

// decodeStep decodes one rune at a byte offset. Returns the rune, its byte
// size, and the total length of the underlying buffer. A decode at or past the
// end returns size 0.
type decodeStep func(pos int) (r rune, size, length int)

func decodeStringStep(s string) decodeStep {
	return func(pos int) (rune, int, int) {
		if pos >= len(s) {
			return 0, 0, len(s)
		}
		r, size := utf8.DecodeRuneInString(s[pos:])
		return r, size, len(s)
	}
}

func decodeBytesStep(b []byte) decodeStep {
	return func(pos int) (rune, int, int) {
		if pos >= len(b) {
			return 0, 0, len(b)
		}
		r, size := utf8.DecodeRune(b[pos:])
		return r, size, len(b)
	}
}

// clusterAdvance runs the shared grapheme state machine over a decode step,
// starting at offset 0. It returns the cluster's display width, the number of
// bytes consumed, and the base rune.
func clusterAdvance(decode decodeStep) (width, size int, base rune) {
	r0, s0, length := decode(0)
	if s0 == 0 {
		return 0, 0, 0
	}
	pos := s0
	w := baseRuneWidth(r0)

	if regionalIndicator(r0) {
		if pos < length {
			r1, s1, _ := decode(pos)
			if s1 > 0 && regionalIndicator(r1) {
				pos += s1
				return 2, pos, r0
			}
		}
		// Lone RI: w is already 2 (RI is in emojiWideRanges). Fall through so a
		// trailing combining mark or ZWJ sequence still attaches.
	}

	lastWasZWJ := false
	for pos < length {
		r, sz, _ := decode(pos)
		if sz == 0 {
			break
		}
		if graphemeExtend(r) {
			pos += sz
			w = clusterExtendUpdateWidth(r, r0, w)
			lastWasZWJ = isZWJ(r)
			continue
		}
		if lastWasZWJ {
			// Emoji (or any base) after a ZWJ joins the cluster.
			pos += sz
			w = 2
			lastWasZWJ = false
			continue
		}
		break
	}

	return w, pos, r0
}

// clusterExtendUpdateWidth returns the updated width when a grapheme-extend
// rune attaches to the base rune. VS16 forces wide (2), VS15 can narrow an
// emoji base (but not CJK) to 1, and all other extenders preserve the width.
//
// This is shared between clusterAdvance and nextClusterWidth so both
// state machines agree on width after a combining mark, VS16, or VS15.
func clusterExtendUpdateWidth(extendRune, baseRune rune, currentWidth int) int {
	if isVS16(extendRune) {
		return 2
	}
	if extendRune == 0xFE0E && baseRuneWidth(baseRune) == 2 && inRuneRanges(baseRune, emojiWideRanges) {
		return 1
	}
	return currentWidth
}

// baseRuneWidth returns the display width of a base rune ignoring the
// zero-width override: 2 for East Asian wide / emoji-wide ranges, else 1.
func baseRuneWidth(r rune) int {
	if r < 0 || r > unicode.MaxRune {
		return 1
	}
	// C0 and C1 controls render narrow.
	if r < 0x20 || (r >= 0x7F && r < 0xA0) {
		return 1
	}
	if inRuneRanges(r, eastAsianWideRanges) || inRuneRanges(r, emojiWideRanges) {
		return 2
	}
	return 1
}

// graphemeExtend reports whether r attaches to the preceding base, contributing
// zero width. This covers combining marks (Mn/Me/Mc), the join controls (ZWNJ
// and ZWJ, exactly U+200C and U+200D), variation selectors, and the emoji
// modifier range U+1F3FB..U+1F3FF (skin tones).
//
// It deliberately does NOT include the whole unicode.Cf category: most format
// characters (bidi controls, ZWSP U+200B) have UAX #29 GCB=Control and must
// break, not glue. The cost is that emoji tag sequences (Cf tag chars) do not
// cluster.
//
// Keep this deliberately narrower than isZeroWidthRune in cell.go (which uses the
// broad unicode.Cf, fine for per-rune width). Do not unify the two predicates, or
// format controls would wrongly glue into grapheme clusters.
func graphemeExtend(r rune) bool {
	if r >= 0x1F3FB && r <= 0x1F3FF {
		return true
	}
	return unicode.In(r, unicode.Mn, unicode.Me, unicode.Mc, unicode.Join_Control, unicode.Variation_Selector)
}

// isZWJ reports whether r is the ZERO WIDTH JOINER.
func isZWJ(r rune) bool {
	return r == 0x200D
}

// isVS16 reports whether r is VARIATION SELECTOR-16 (emoji presentation).
func isVS16(r rune) bool {
	return r == 0xFE0F
}

// regionalIndicator reports whether r is a regional indicator symbol (used in
// pairs to form flag emoji).
func regionalIndicator(r rune) bool {
	return r >= 0x1F1E6 && r <= 0x1F1FF
}

// clusterCount returns the number of grapheme clusters in s.
func clusterCount(s string) int {
	n := 0
	for len(s) > 0 {
		_, _, size := nextCluster(s)
		if size == 0 {
			break
		}
		n++
		s = s[size:]
	}
	return n
}

// clusterRuneStarts returns the rune index where each grapheme cluster begins,
// followed by the total rune count. For "ab" it returns [0,1,2]; for the family
// emoji (7 runes, one cluster) it returns [0,7]. Used by editable widgets to
// snap a cursor rune index onto a cluster boundary.
func clusterRuneStarts(s string) []int {
	starts := []int{0}
	runeIdx := 0
	for len(s) > 0 {
		_, _, size := nextCluster(s)
		if size == 0 {
			break
		}
		// Count runes in this cluster.
		for i := 0; i < size; {
			_, rs := utf8.DecodeRuneInString(s[i:])
			if rs == 0 {
				break
			}
			i += rs
			runeIdx++
		}
		starts = append(starts, runeIdx)
		s = s[size:]
	}
	return starts
}

// clusterEndAfterInsert returns the rune index at the end of the cluster that
// contains the rune at insertedRuneIdx in s. It re-segments from the cluster
// start at/under insertedRuneIdx, so a combining mark inserted after a base lands
// the cursor after the combined cluster, and a base char lands one cluster on.
func clusterEndAfterInsert(s string, insertedRuneIdx int) int {
	starts := clusterRuneStarts(s)
	// Find the cluster whose [start, end) range contains insertedRuneIdx.
	for i := 0; i+1 < len(starts); i++ {
		if insertedRuneIdx >= starts[i] && insertedRuneIdx < starts[i+1] {
			return starts[i+1]
		}
	}
	// At or past the end (e.g. appended at the very end): cursor at total.
	return starts[len(starts)-1]
}

// snapRuneToClusterStart snaps a rune index down to the nearest cluster start at
// or before it (clamped to [0, total]).
func snapRuneToClusterStart(s string, runeIdx int) int {
	starts := clusterRuneStarts(s)
	if runeIdx <= 0 {
		return 0
	}
	last := starts[len(starts)-1]
	if runeIdx >= last {
		return last
	}
	snapped := 0
	for _, st := range starts {
		if st <= runeIdx {
			snapped = st
		} else {
			break
		}
	}
	return snapped
}

// runeIndexToDisplayCol returns the display column at the given rune index,
// summing cluster widths from the start of s. The index is snapped to a cluster
// boundary first so a position inside a cluster reports that cluster's start col.
func runeIndexToDisplayCol(s string, runeIdx int) int {
	col := 0
	runeAt := 0
	for len(s) > 0 {
		if runeAt >= runeIdx {
			break
		}
		_, w, size := nextCluster(s)
		if size == 0 {
			break
		}
		clusterRunes := utf8.RuneCountInString(s[:size])
		if runeAt+clusterRunes > runeIdx {
			// Inside this cluster: report its starting column (snap behavior).
			break
		}
		col += w
		runeAt += clusterRunes
		s = s[size:]
	}
	return col
}