v0.48.1 deaab59

appearance_protocol.go RawHistoryFolder

1package tui
2
3import (
4	"bytes"
5	"strconv"
6	"strings"
7	"sync"
8	"time"
9)
10
11// Appearance query and DEC mode 2031 control sequences.
12const (
13	SeqQueryColorScheme = "\x1b[?996n"
14	SeqQueryMode2031    = "\x1b[?2031$p"
15	SeqEnableMode2031   = "\x1b[?2031h"
16	SeqDisableMode2031  = "\x1b[?2031l"
17)
18
19// AppearanceQuery returns the complete profile query used at startup and on a
20// replacement generation. The ANSI palette is requested in one OSC 4 batch.
21func AppearanceQuery() string {
22	var b strings.Builder
23	b.WriteString(SeqQueryColorScheme)
24	b.WriteString("\x1b]10;?\a\x1b]11;?\a\x1b]4;")
25	for slot := 0; slot < 16; slot++ {
26		if slot > 0 {
27			b.WriteByte(';')
28		}
29		b.WriteString(strconv.Itoa(slot))
30		b.WriteString(";?")
31	}
32	b.WriteByte('\a')
33	return b.String()
34}
35
36// SchemeEvent is a reported terminal color-scheme preference. Solicited is
37// true only for the reply to a query initiated by zut.
38type SchemeEvent struct {
39	Light     bool
40	Solicited bool
41}
42
43// ColorEvent is one default foreground/background or ANSI-palette reply.
44type ColorEvent struct {
45	Kind  int // 10 foreground, 11 background, 4 palette
46	Slot  int
47	Color TerminalColor
48}
49
50// ModeReportEvent is a DECRQM response. Status follows the terminal protocol:
51// 1/3 set, 2 reset, 0/4 unsupported or permanently reset.
52type ModeReportEvent struct {
53	Mode   int
54	Status int
55}
56
57// InputEvent is emitted by the appearance-aware input path. Exactly one field
58// is set. Key bytes that do not form an accepted protocol reply remain keys.
59type InputEvent struct {
60	Key    *Key
61	Scheme *SchemeEvent
62	Color  *ColorEvent
63	Mode   *ModeReportEvent
64}
65
66// AppearanceParser controls which terminal replies may be consumed. Keeping
67// the gate here prevents OSC-looking editor input from disappearing unless zut
68// actively issued a matching query, while 997 notifications are accepted only
69// after notification support is known.
70type AppearanceParser struct {
71	mu            sync.RWMutex
72	pendingColors bool
73	pendingScheme bool
74	notifications bool
75}
76
77func (p *AppearanceParser) SetPendingColors(pending bool) {
78	p.mu.Lock()
79	p.pendingColors = pending
80	p.mu.Unlock()
81}
82
83func (p *AppearanceParser) SetNotifications(enabled bool) {
84	p.mu.Lock()
85	p.notifications = enabled
86	p.mu.Unlock()
87}
88
89// SetPendingScheme permits one solicited 997 response and marks it so the
90// event loop does not mistake it for a new terminal notification.
91func (p *AppearanceParser) SetPendingScheme(pending bool) {
92	p.mu.Lock()
93	p.pendingScheme = pending
94	p.mu.Unlock()
95}
96
97func (p *AppearanceParser) acceptsColors() bool {
98	p.mu.RLock()
99	defer p.mu.RUnlock()
100	return p.pendingColors
101}
102
103func (p *AppearanceParser) acceptsNotifications() bool {
104	p.mu.RLock()
105	defer p.mu.RUnlock()
106	return p.notifications
107}
108
109func (p *AppearanceParser) consumeScheme() (solicited bool, accepted bool) {
110	p.mu.Lock()
111	defer p.mu.Unlock()
112	if p.pendingScheme {
113		p.pendingScheme = false
114		return true, true
115	}
116	return false, p.notifications
117}
118
119// ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.
120// Combined OSC 4 responses produce one ColorEvent for every valid pair.
121func ParseAppearanceOSC(raw []byte) []ColorEvent {
122	s := strings.TrimSuffix(string(raw), "\a")
123	s = strings.TrimSuffix(s, "\x1b\\")
124	if !strings.HasPrefix(s, "\x1b]") {
125		return nil
126	}
127	body := strings.TrimPrefix(s, "\x1b]")
128	parts := strings.Split(body, ";")
129	if len(parts) < 2 {
130		return nil
131	}
132	kind, err := strconv.Atoi(parts[0])
133	if err != nil {
134		return nil
135	}
136	switch kind {
137	case 10, 11:
138		color, ok := parseTerminalRGB(strings.Join(parts[1:], ";"))
139		if !ok {
140			return nil
141		}
142		return []ColorEvent{{Kind: kind, Slot: -1, Color: color}}
143	case 4:
144		var events []ColorEvent
145		for idx := 1; idx+1 < len(parts); idx += 2 {
146			slot, err := strconv.Atoi(parts[idx])
147			if err != nil || slot < 0 || slot >= 16 {
148				continue
149			}
150			color, ok := parseTerminalRGB(parts[idx+1])
151			if ok {
152				events = append(events, ColorEvent{Kind: 4, Slot: slot, Color: color})
153			}
154		}
155		return events
156	default:
157		return nil
158	}
159}
160
161// ParseAppearanceCSI parses the two runtime CSI response families used by
162// terminal-owned themes: current scheme (997) and mode ownership (DECRQM).
163func ParseAppearanceCSI(raw []byte) (scheme *SchemeEvent, mode *ModeReportEvent) {
164	s := string(raw)
165	if !strings.HasPrefix(s, "\x1b[") {
166		return nil, nil
167	}
168	body := strings.TrimPrefix(s, "\x1b[")
169	if strings.HasPrefix(body, "?997;") && strings.HasSuffix(body, "n") {
170		value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?997;"), "n"))
171		if err == nil && (value == 1 || value == 2) {
172			return &SchemeEvent{Light: value == 2}, nil
173		}
174	}
175	if strings.HasPrefix(body, "?2031;") && strings.HasSuffix(body, "$y") {
176		value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?2031;"), "$y"))
177		if err == nil {
178			return nil, &ModeReportEvent{Mode: 2031, Status: value}
179		}
180	}
181	return nil, nil
182}
183
184// AppearanceSource filters accepted terminal appearance replies before they
185// reach Reader. It deliberately leaves bracketed-paste payload opaque.
186type AppearanceSource struct {
187	read   func() (byte, error)
188	peek   func(time.Duration) (byte, bool, error)
189	parser *AppearanceParser
190	emit   func(InputEvent)
191
192	pending   []byte
193	inPaste   bool
194	pasteTail []byte
195}
196
197func NewAppearanceSource(read func() (byte, error), peek func(time.Duration) (byte, bool, error), parser *AppearanceParser, emit func(InputEvent)) *AppearanceSource {
198	return &AppearanceSource{read: read, peek: peek, parser: parser, emit: emit}
199}
200
201func (s *AppearanceSource) ReadByte() (byte, error) {
202	if len(s.pending) > 0 {
203		b := s.pending[0]
204		s.pending = s.pending[1:]
205		return b, nil
206	}
207	for {
208		b, err := s.read()
209		if err != nil || b != '\x1b' || s.inPaste || s.peek == nil {
210			s.trackPaste(b)
211			return b, err
212		}
213		lookahead := time.Millisecond
214		if s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
215			lookahead = 50 * time.Millisecond
216		}
217		next, ok, err := s.peek(lookahead)
218		if err != nil || !ok || (next != ']' && next != '[') {
219			if ok {
220				s.pending = append(s.pending, next)
221			}
222			return b, err
223		}
224		raw := []byte{'\x1b', next}
225		if next == ']' {
226			sequence, err := s.readOSC(raw)
227			if err != nil {
228				return 0, err
229			}
230			if events := ParseAppearanceOSC(sequence); len(events) != 0 && s.parser != nil && s.parser.acceptsColors() {
231				for _, event := range events {
232					e := event
233					s.emit(InputEvent{Color: &e})
234				}
235				continue
236			}
237			s.pending = append(s.pending, sequence[1:]...)
238			return '\x1b', nil
239		}
240		sequence, err := s.readCSI(raw)
241		if err != nil {
242			return 0, err
243		}
244		if bytes.Equal(sequence, []byte("\x1b[200~")) {
245			s.inPaste = true
246		}
247		scheme, mode := ParseAppearanceCSI(sequence)
248		if scheme != nil && s.parser != nil {
249			if solicited, accepted := s.parser.consumeScheme(); accepted {
250				scheme.Solicited = solicited
251				s.emit(InputEvent{Scheme: scheme})
252				continue
253			}
254		}
255		if mode != nil {
256			s.emit(InputEvent{Mode: mode})
257			continue
258		}
259		s.pending = append(s.pending, sequence[1:]...)
260		return '\x1b', nil
261	}
262}
263
264func (s *AppearanceSource) readOSC(raw []byte) ([]byte, error) {
265	const limit = 4096
266	for len(raw) < limit {
267		b, err := s.read()
268		if err != nil {
269			return raw, err
270		}
271		raw = append(raw, b)
272		if b == '\a' || (len(raw) >= 2 && raw[len(raw)-2] == '\x1b' && b == '\\') {
273			return raw, nil
274		}
275	}
276	return raw, nil
277}
278
279func (s *AppearanceSource) readCSI(raw []byte) ([]byte, error) {
280	const limit = 256
281	for len(raw) < limit {
282		b, err := s.read()
283		if err != nil {
284			return raw, err
285		}
286		raw = append(raw, b)
287		if b >= 0x40 && b <= 0x7e {
288			return raw, nil
289		}
290	}
291	return raw, nil
292}
293
294func (s *AppearanceSource) trackPaste(b byte) {
295	if !s.inPaste {
296		return
297	}
298	const end = "\x1b[201~"
299	s.pasteTail = append(s.pasteTail, b)
300	if len(s.pasteTail) > len(end) {
301		s.pasteTail = s.pasteTail[1:]
302	}
303	if string(s.pasteTail) == end {
304		s.inPaste = false
305		s.pasteTail = nil
306	}
307}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close