gitstack

grindlemire/go-tui code browser

2.9 KB Go 145 lines 2026-06-23 · 2a4aa99 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
package tui

import "unicode/utf8"

type styledTokenKind int

const (
	tokenRune    styledTokenKind = iota // printable rune (runeWidth set)
	tokenNewline                        // '\n'
	tokenANSI                           // complete ANSI escape sequence
)

// styledByteScanner iterates over styled byte data, yielding tokens that are
// either printable runes (with display width), newlines, or ANSI escape sequences.
// Control characters (except \n, \t) are silently dropped. Tabs become spaces.
type styledByteScanner struct {
	data []byte
	pos  int

	// Current token (valid after next() returns true).
	kind      styledTokenKind
	start     int  // start offset in data
	end       int  // end offset in data (exclusive)
	runeWidth int  // display width for tokenRune
	runeVal   rune // base rune only; use bytes() for the full cluster glyph
}

func (s *styledByteScanner) reset(data []byte) {
	s.data = data
	s.pos = 0
}

func (s *styledByteScanner) bytes() []byte {
	if s.kind == tokenRune && s.runeVal == ' ' && s.start < len(s.data) && s.data[s.start] == '\t' {
		// Tab was replaced with space — return space byte.
		return []byte{' '}
	}
	return s.data[s.start:s.end]
}

func (s *styledByteScanner) next() bool {
	for s.pos < len(s.data) {
		b := s.data[s.pos]

		// ANSI escape sequence.
		if b == 0x1b {
			return s.scanANSI()
		}

		// Newline.
		if b == '\n' {
			s.kind = tokenNewline
			s.start = s.pos
			s.pos++
			s.end = s.pos
			return true
		}

		// Tab → space.
		if b == '\t' {
			s.kind = tokenRune
			s.start = s.pos
			s.pos++
			s.end = s.pos
			s.runeVal = ' '
			s.runeWidth = 1
			return true
		}

		// Decode UTF-8 rune, then consume the full grapheme cluster.
		r, size := utf8.DecodeRune(s.data[s.pos:])
		if r == utf8.RuneError && size == 1 {
			s.pos++
			continue
		}

		// Drop C0 control chars and DEL.
		if r < 0x20 || r == 0x7f {
			s.pos += size
			continue
		}

		// Consume the whole grapheme cluster from the current position.
		cw, csize, _ := nextClusterBytes(s.data[s.pos:])
		if csize == 0 {
			// Fallback: try the single rune.
			cw = RuneWidth(r)
			csize = size
		}

		s.kind = tokenRune
		s.start = s.pos
		s.pos += csize
		s.end = s.pos
		s.runeVal = r
		s.runeWidth = cw
		return true
	}
	return false
}

func (s *styledByteScanner) scanANSI() bool {
	start := s.pos
	s.pos++ // skip ESC

	if s.pos >= len(s.data) {
		// Lone ESC at end — skip it.
		return s.next()
	}

	switch s.data[s.pos] {
	case '[':
		// CSI: ESC [ ... final-byte (0x40-0x7e)
		s.pos++
		for s.pos < len(s.data) {
			c := s.data[s.pos]
			s.pos++
			if c >= 0x40 && c <= 0x7e {
				break
			}
		}
	case ']':
		// OSC: ESC ] ... BEL or ST
		s.pos++
		for s.pos < len(s.data) {
			c := s.data[s.pos]
			s.pos++
			if c == 0x07 {
				break
			}
			if c == 0x1b && s.pos < len(s.data) && s.data[s.pos] == '\\' {
				s.pos++
				break
			}
		}
	default:
		// Generic 2-byte escape.
		s.pos++
	}

	s.kind = tokenANSI
	s.start = start
	s.end = s.pos
	return true
}