gitstack

grindlemire/go-tui code browser

8.6 KB Go 229 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
package tui

import (
	"strings"
	"unicode"
	"unicode/utf8"
)

// Cell represents a single character cell in the terminal buffer.
// Wide characters (CJK, emoji) and grapheme clusters (flags, ZWJ families,
// skin-tone emoji, accented letters) occupy multiple cells; the first cell
// holds the cluster's glyph, subsequent cells are marked as continuations.
//
// A cell stores its glyph as a base Rune plus an optional Combining string
// holding the remaining runes of a multi-rune cluster. The vast majority of
// cells (all ASCII, all CJK, single-code-point emoji) are a single rune stored
// inline with no allocation. Only genuine multi-rune clusters (flags, ZWJ
// families, decomposed accents, skin-tone, keycaps) populate Combining, which
// newClusterCell clones so the buffer never pins a large source string alive.
type Cell struct {
	Rune      rune   // Base (first) rune of the cluster; 0 for continuation/blank cells
	Combining string // Remaining runes of the cluster ("" for single-rune cells)
	Style     Style  // Visual styling
	Width     uint8  // Display width (1 or 2; 0 for continuation)
	Link      string // Optional OSC 8 hyperlink target ("" = none)
}

// NewCell creates a new Cell with automatic width detection.
func NewCell(r rune, style Style) Cell {
	return Cell{
		Rune:  r,
		Style: style,
		Width: uint8(RuneWidth(r)),
	}
}

// NewCellWithWidth creates a new Cell from a single rune with an explicit width.
// Use this for continuation cells (width 0) or when width is already known.
// A continuation cell (width 0) or a zero rune leaves Rune 0, so the cell reads
// as empty.
func NewCellWithWidth(r rune, style Style, width uint8) Cell {
	if width == 0 || r == 0 {
		return Cell{
			Style: style,
			Width: width,
		}
	}
	return Cell{
		Rune:  r,
		Style: style,
		Width: width,
	}
}

// newClusterCell creates a Cell holding a multi-rune (or single-rune) grapheme
// cluster. The cluster's base rune is stored inline; only the remaining runes of
// a true multi-rune cluster are cloned so the cell owns an independent backing
// array without pinning a larger source string. Pass width 0 for continuation
// cells (text is ignored). RuneWidth never returns 0 for printable characters,
// so width==0 only occurs for explicit continuation-cell writes.
func newClusterCell(text string, width uint8, style Style, link string) Cell {
	if width == 0 || text == "" {
		return Cell{Width: width, Style: style, Link: link} // Rune 0, Combining ""
	}
	r, sz := utf8.DecodeRuneInString(text)
	var comb string
	if sz < len(text) {
		// Only true multi-rune clusters reach here; clone so the cell does not
		// pin the source string alive.
		comb = strings.Clone(text[sz:])
	}
	return Cell{
		Rune:      r,
		Combining: comb,
		Style:     style,
		Width:     width,
		Link:      link,
	}
}

// IsContinuation returns true if this cell is a continuation of a wide character.
// Continuation cells have Width == 0 and are placed after the primary cell.
func (c Cell) IsContinuation() bool {
	return c.Width == 0
}

// Equal returns true if both cells are identical.
func (c Cell) Equal(other Cell) bool {
	return c.Rune == other.Rune && c.Combining == other.Combining && c.Style.Equal(other.Style) && c.Width == other.Width && c.Link == other.Link
}

// IsEmpty returns true if this cell represents an empty/blank cell.
// A cell is empty if it holds no glyph (or a single space) with default styling.
func (c Cell) IsEmpty() bool {
	// No glyph (zero/continuation cell) is considered empty
	if c.Rune == 0 {
		return true
	}
	// Space with default style is considered empty
	if c.Rune == ' ' && c.Combining == "" {
		return c.Style.Equal(NewStyle())
	}
	return false
}

// RuneWidth returns the display width of a single rune in terminal cells.
// Returns 1 for most characters, 2 for wide characters (CJK/fullwidth, emoji).
//
// RuneWidth is low-level and per-rune: it is wrong for multi-rune grapheme
// clusters (a combining mark, ZWJ joiner, or regional-indicator half counts as
// 1 here). For string and cursor-column math use StringWidth, which measures
// whole clusters.
//
// Note: this cell model reserves Width==0 for continuation cells only.
// Runes that are logically zero-width (combining marks, variation selectors,
// format controls) are explicitly recognized but still treated as width 1.
func RuneWidth(r rune) int {
	// Keep invalid/control runes narrow so they don't disrupt layout.
	if r < 0 || r > unicode.MaxRune {
		return 1
	}

	// C0 and C1 controls.
	if r < 0x20 || (r >= 0x7F && r < 0xA0) {
		return 1
	}

	// Combining marks and format code points are logically zero-width, but this
	// buffer model uses Width==0 only for continuation cells.
	if isZeroWidthRune(r) {
		return 1
	}

	if inRuneRanges(r, eastAsianWideRanges) || inRuneRanges(r, emojiWideRanges) {
		return 2
	}

	return 1
}

type runeRange struct {
	min rune
	max rune
}

// East Asian wide/fullwidth code point ranges.
var eastAsianWideRanges = []runeRange{
	{min: 0x1100, max: 0x115F},   // Hangul Jamo init. consonants
	{min: 0x2329, max: 0x232A},   // Angle brackets
	{min: 0x2E80, max: 0x303E},   // CJK radicals + punctuation (excluding U+303F)
	{min: 0x3040, max: 0xA4CF},   // Hiragana/Katakana/Bopomofo/CJK/Yi
	{min: 0xAC00, max: 0xD7A3},   // Hangul syllables
	{min: 0xF900, max: 0xFAFF},   // CJK compatibility ideographs
	{min: 0xFE10, max: 0xFE19},   // Vertical forms
	{min: 0xFE30, max: 0xFE6F},   // CJK compatibility forms + small forms
	{min: 0xFF00, max: 0xFF60},   // Fullwidth forms
	{min: 0xFFE0, max: 0xFFE6},   // Fullwidth symbol variants
	{min: 0x1B000, max: 0x1B12F}, // Kana supplement + Kana ext. A
	{min: 0x1B130, max: 0x1B167}, // Kana extended B
	{min: 0x20000, max: 0x2FFFD}, // CJK extensions
	{min: 0x30000, max: 0x3FFFD}, // CJK extensions
}

// Emoji ranges that terminals commonly render as 2-cell glyphs.
var emojiWideRanges = []runeRange{
	// Emoji_Presentation=Yes BMP emoji — these render at width 2 without VS16.
	{min: 0x231A, max: 0x231B}, // Watch, hourglass
	{min: 0x23E9, max: 0x23EC}, // Fast-forward, rewind, etc.
	{min: 0x23F0, max: 0x23F0}, // Alarm clock
	{min: 0x23F3, max: 0x23F3}, // Hourglass not done
	{min: 0x25FD, max: 0x25FE}, // Medium-small squares
	{min: 0x2614, max: 0x2615}, // Umbrella, hot beverage
	{min: 0x2648, max: 0x2653}, // Zodiac
	{min: 0x267F, max: 0x267F}, // Wheelchair
	{min: 0x2693, max: 0x2693}, // Anchor
	{min: 0x26A1, max: 0x26A1}, // High voltage
	{min: 0x26AA, max: 0x26AB}, // Circles
	{min: 0x26BD, max: 0x26BE}, // Soccer, baseball
	{min: 0x26C4, max: 0x26C5}, // Snowman, sun behind cloud
	{min: 0x26CE, max: 0x26CE}, // Ophiuchus
	{min: 0x26D4, max: 0x26D4}, // No entry
	{min: 0x26EA, max: 0x26EA}, // Church
	{min: 0x26F2, max: 0x26F3}, // Fountain, flag in hole
	{min: 0x26F5, max: 0x26F5}, // Sailboat
	{min: 0x26FA, max: 0x26FA}, // Tent
	{min: 0x26FD, max: 0x26FD}, // Fuel pump
	{min: 0x270A, max: 0x270B}, // Raised fist, raised hand
	{min: 0x2705, max: 0x2705}, // Check mark button
	{min: 0x2728, max: 0x2728}, // Sparkles
	{min: 0x274C, max: 0x274C}, // Cross mark
	{min: 0x274E, max: 0x274E}, // Cross mark in box
	{min: 0x2753, max: 0x2755}, // Question, exclamation marks
	{min: 0x2757, max: 0x2757}, // Exclamation mark
	{min: 0x2795, max: 0x2797}, // Plus, minus, divide
	{min: 0x27B0, max: 0x27B0}, // Curly loop
	{min: 0x27BF, max: 0x27BF}, // Double curly loop
	{min: 0x2B1B, max: 0x2B1C}, // Black/white large squares
	{min: 0x2B50, max: 0x2B50}, // Star
	{min: 0x2B55, max: 0x2B55}, // Hollow red circle
	// SMP emoji (U+1Fxxx) — all are Emoji_Presentation=Yes (emoji-default).
	{min: 0x1F004, max: 0x1F004}, // Mahjong tile red dragon
	{min: 0x1F0CF, max: 0x1F0CF}, // Playing card black joker
	{min: 0x1F18E, max: 0x1F18E}, // Negative squared AB
	{min: 0x1F191, max: 0x1F19A}, // Squared symbols
	{min: 0x1F1E6, max: 0x1F1FF}, // Regional indicator symbols (flags)
	{min: 0x1F201, max: 0x1F202}, // Squared Katakana words
	{min: 0x1F21A, max: 0x1F21A}, // Squared CJK ideograph
	{min: 0x1F22F, max: 0x1F22F}, // Squared CJK ideograph
	{min: 0x1F232, max: 0x1F23A}, // Squared CJK ideographs
	{min: 0x1F250, max: 0x1F251}, // Circled ideographs
	{min: 0x1F300, max: 0x1F64F}, // Pictographs + emoticons
	{min: 0x1F680, max: 0x1F6FF}, // Transport/map symbols
	{min: 0x1F7E0, max: 0x1F7EB}, // Large colored circles/squares
	{min: 0x1F900, max: 0x1F9FF}, // Supplemental symbols/pictographs
	{min: 0x1FA70, max: 0x1FAFF}, // Symbols/pictographs ext. A
}

func isZeroWidthRune(r rune) bool {
	return unicode.In(r, unicode.Mn, unicode.Me, unicode.Cf, unicode.Variation_Selector, unicode.Join_Control)
}

func inRuneRanges(r rune, ranges []runeRange) bool {
	for _, rr := range ranges {
		if r >= rr.min && r <= rr.max {
			return true
		}
	}
	return false
}