gitstack

grindlemire/go-tui code browser

11.4 KB Go 375 lines 2026-06-25 · 64b8b59 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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
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.
//
// NextCluster is the exported wrapper. It behaves identically.
func NextCluster(s string) (cluster string, width, size int) { return nextCluster(s) }

// NextClusterRunes is like NextCluster but also returns the number of Unicode
// code points (runes) in the cluster. This avoids a separate utf8.RuneCountInString
// call for callers that need both the cluster text and its rune length.
func NextClusterRunes(s string) (cluster string, width, size, runes int) {
	cluster, width, size = nextCluster(s)
	if size > 0 {
		runes = utf8.RuneCountInString(cluster)
	}
	return
}

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 or continuation byte >= 0x80)
	// forces the full clusterAdvance path. For a bare ASCII byte followed by a
	// raw continuation byte (malformed UTF-8), clusterAdvance correctly returns
	// just the ASCII byte — the continuation byte starts its own cluster.
	// Examples that skip the fast path:
	//   'a' + 0xCC (leading byte of a combining mark) — attaches via graphemeExtend
	//   'a' + 0x80 (raw continuation byte, malformed) — breaks, 'a' is its own cluster
	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 user-perceived characters (grapheme
// clusters) in s. This accounts for multi-rune clusters — flags, ZWJ emoji
// families, and decomposed accents each count as one cluster.
func ClusterCount(s string) int {
	n := 0
	for len(s) > 0 {
		_, _, size := NextCluster(s)
		if size == 0 {
			break
		}
		n++
		s = s[size:]
	}
	return n
}

// clusterCount is the internal equivalent for use inside the package.
func clusterCount(s string) int { return ClusterCount(s) }

// ClusterRuneCount returns the number of Unicode code points (runes) in s.
// It walks grapheme clusters via NextCluster and sums the rune count of each
// cluster. This is equivalent to utf8.RuneCountInString but avoids the
// unicode/utf8 import for consumers that already use this package.
func ClusterRuneCount(s string) int {
	n := 0
	for len(s) > 0 {
		_, _, size := NextCluster(s)
		if size == 0 {
			break
		}
		n += utf8.RuneCountInString(s[:size])
		s = s[size:]
	}
	return n
}

// clusterEnd returns the rune index at the end of the cluster that contains the
// rune at clusterStartRuneIdx in s. The target may be at a cluster boundary
// (normal case after clampCursorPos) or inside a multi-rune cluster (insertChar
// after inserting a combining mark).
// Unlike the O(N) clusterRuneStarts-based approach, this walks clusters
// incrementally and stops as soon as it passes the target, making it O(pos).
func clusterEnd(s string, clusterStartRuneIdx int) int {
	runeAt := 0
	for len(s) > 0 {
		_, _, size := nextCluster(s)
		if size == 0 {
			break
		}
		clusterRunes := utf8.RuneCountInString(s[:size])
		if runeAt+clusterRunes > clusterStartRuneIdx {
			// This cluster contains or starts at the target. Return its end.
			return runeAt + clusterRunes
		}
		runeAt += clusterRunes
		s = s[size:]
	}
	return runeAt
}

// snapRuneToClusterStart snaps a rune index down to the nearest cluster start at
// or before it (clamped to [0, total]). Walks clusters incrementally and stops
// as soon as it passes the target, making it O(pos) instead of O(N).
func snapRuneToClusterStart(s string, runeIdx int) int {
	if len(s) == 0 || runeIdx <= 0 {
		return 0
	}
	runeAt := 0
	lastStart := 0
	for len(s) > 0 {
		_, _, size := nextCluster(s)
		if size == 0 {
			break
		}
		clusterRunes := utf8.RuneCountInString(s[:size])
		if runeAt+clusterRunes > runeIdx {
			// runeIdx is inside this cluster: snap to its start.
			return lastStart
		}
		runeAt += clusterRunes
		lastStart = runeAt
		s = s[size:]
	}
	return runeAt
}

// 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.
// Walks clusters incrementally and stops at the target, making it O(pos).
func runeIndexToDisplayCol(s string, runeIdx int) int {
	col := 0
	runeAt := 0
	for len(s) > 0 {
		_, w, size := nextCluster(s)
		if size == 0 {
			break
		}
		clusterRunes := utf8.RuneCountInString(s[:size])
		if runeAt+clusterRunes > runeIdx {
			// runeIdx is at or inside this cluster. Whether it is exactly at
			// the cluster start (single-rune cluster) or somewhere in the
			// interior (multi-rune cluster), col is already the cluster's start
			// column — snap to it.
			break
		}
		col += w
		runeAt += clusterRunes
		s = s[size:]
	}
	return col
}

// runeIndexToClusterIndex returns the grapheme-cluster index for a rune index in
// s: the number of whole clusters that start strictly before runeIdx. A runeIdx
// inside a multi-rune cluster reports that cluster (the marks join the base), so
// the result counts clusters wholly preceding the position. Walks incrementally
// and stops at the target, making it O(runeIdx).
func runeIndexToClusterIndex(s string, runeIdx int) int {
	if runeIdx <= 0 {
		return 0
	}
	clusters := 0
	runeAt := 0
	for len(s) > 0 {
		_, _, size := nextCluster(s)
		if size == 0 {
			break
		}
		clusterRunes := utf8.RuneCountInString(s[:size])
		if runeAt+clusterRunes > runeIdx {
			// runeIdx is at or inside this cluster: it does not advance past it.
			break
		}
		clusters++
		runeAt += clusterRunes
		s = s[size:]
	}
	return clusters
}

// clusterIndexToRuneIndex returns the rune index at the start of the cluster at
// the given cluster index. A cluster index past the end clamps to the total rune
// count. Walks incrementally and stops at the target, making it O(clusterIdx).
func clusterIndexToRuneIndex(s string, clusterIdx int) int {
	runeAt := 0
	clusters := 0
	for len(s) > 0 {
		if clusters == clusterIdx {
			return runeAt
		}
		_, _, size := nextCluster(s)
		if size == 0 {
			break
		}
		runeAt += utf8.RuneCountInString(s[:size])
		clusters++
		s = s[size:]
	}
	return runeAt
}