palette_generation.go RawHistoryFolder

1package client
2
3import (
4	"sync"
5	"time"
6
7	renderer "github.com/bnema/vev-vt"
8	"github.com/bnema/vev/internal/protocol"
9)
10
11// Palette queries always have a DECRQM boundary. The first marker drains
12// replies from a replaced generation; the second completes the color batch.
13const paletteBoundaryQuery = "\x1b[?2031$p"
14
15const paletteColorBatch = "\x1b]10;?\x07\x1b]11;?\x07" +
16	"\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" +
17	paletteBoundaryQuery
18
19// paletteGenerationDeadline is a local terminal capability fallback, not a
20// user-facing timeout. Timer ownership belongs to the attach loop.
21const paletteGenerationDeadline = 200 * time.Millisecond
22
23// paletteMarkerAmbiguityDeadline bounds how long an Escape byte may remain
24// withheld while it could still become a DECRQM completion response. Match
25// the existing local input-prefix bound so a keypress remains prompt.
26const paletteMarkerAmbiguityDeadline = pasteFlushDelay
27
28type paletteGenerationID uint64
29
30type paletteGenerationPhase uint8
31
32const (
33	generationIdle paletteGenerationPhase = iota
34	generationDraining
35	generationCollecting
36	generationFinalized
37)
38
39type paletteGeneration struct {
40	id                        paletteGenerationID
41	phase                     paletteGenerationPhase
42	theme                     protocol.Theme
43	expectedMarkers           int
44	awaitingLateDrainBoundary bool
45}
46
47type paletteGenerationEventKind uint8
48
49const (
50	paletteEventMarker paletteGenerationEventKind = iota
51	paletteEventDrainDeadline
52	paletteEventCompletionDeadline
53	paletteEventForeground
54	paletteEventBackground
55	paletteEventPalette
56	paletteEventScheme
57)
58
59type paletteGenerationEvent struct {
60	id    paletteGenerationID
61	kind  paletteGenerationEventKind
62	rgb   renderer.RGB
63	slot  uint8
64	light bool
65}
66
67// terminalThemeState retains the latest terminal-reported colors across
68// attachments so a replacement attachment can restore the daemon theme
69// without relying on another OSC response. The supervisor owns one per run.
70type terminalThemeState struct {
71	mu    sync.Mutex
72	theme protocol.Theme
73}
74
75func (s *terminalThemeState) update(update func(*protocol.Theme)) protocol.Theme {
76	s.mu.Lock()
77	defer s.mu.Unlock()
78	update(&s.theme)
79	return s.theme
80}
81
82// paletteSlot narrows a scanner palette slot to an ANSI slot, rejecting
83// anything outside 0..15 before the int is converted.
84func paletteSlot(slot int) (uint8, bool) {
85	if slot < 0 || slot > 15 {
86		return 0, false
87	}
88	return uint8(slot), true
89}
90
91// paletteGenerationAction is deliberately declarative. The attached loop is
92// the sole terminal writer and protocol sender; timers only wake that loop
93// with a generation-tagged event.
94type paletteGenerationActionKind uint8
95
96const (
97	paletteActionPublishCleared paletteGenerationActionKind = iota
98	paletteActionWriteDrain
99	paletteActionWriteBatch
100	paletteActionArmDrainDeadline
101	paletteActionArmCompletionDeadline
102	paletteActionCancelDrainDeadline
103	paletteActionCancelCompletionDeadline
104	paletteActionPublishFinal
105)
106
107type paletteGenerationAction struct {
108	kind     paletteGenerationActionKind
109	id       paletteGenerationID
110	bytes    string
111	theme    protocol.Theme
112	deadline time.Duration
113}
114
115// paletteGenerationCoordinator has no I/O, clocks, channels, or goroutines.
116// All state is scoped to one monotonically increasing generation.
117type paletteGenerationCoordinator struct {
118	next      paletteGenerationID
119	current   paletteGeneration
120	active    bool
121	finalized protocol.Theme
122}
123
124func newPaletteGenerationCoordinator() *paletteGenerationCoordinator {
125	return &paletteGenerationCoordinator{}
126}
127
128// start clears the last definitive palette before issuing either the initial
129// batch or a replacement drain. The retained foreground/background and
130// capability avoid a full neutral flash, while the accumulator begins with no
131// defaults or palette values so a definitive result never mixes generations.
132func (c *paletteGenerationCoordinator) start(retained protocol.Theme, replacement bool) []paletteGenerationAction {
133	actions := c.cancelCurrent()
134	c.next++
135	id := c.next
136
137	cleared := retained
138	cleared.PaletteKnown = 0
139	cleared.Palette = [16]renderer.RGB{}
140	c.current = paletteGeneration{
141		id:    id,
142		phase: generationCollecting,
143		theme: protocol.Theme{
144			TrueColor:   retained.TrueColor,
145			SchemeKnown: retained.SchemeKnown,
146			Light:       retained.Light,
147		},
148	}
149	c.active = true
150	actions = append(actions, paletteGenerationAction{kind: paletteActionPublishCleared, id: id, theme: cleared})
151	if replacement {
152		c.current.phase = generationDraining
153		actions = append(actions,
154			paletteGenerationAction{kind: paletteActionWriteDrain, id: id, bytes: paletteBoundaryQuery},
155			paletteGenerationAction{kind: paletteActionArmDrainDeadline, id: id, deadline: paletteGenerationDeadline},
156		)
157		return actions
158	}
159	return append(actions,
160		paletteGenerationAction{kind: paletteActionWriteBatch, id: id, bytes: paletteColorBatch},
161		paletteGenerationAction{kind: paletteActionArmCompletionDeadline, id: id, deadline: paletteGenerationDeadline},
162	)
163}
164
165func (c *paletteGenerationCoordinator) cancelCurrent() []paletteGenerationAction {
166	if !c.active {
167		return nil
168	}
169	id := c.current.id
170	c.active = false
171	c.current.phase = generationFinalized
172	return []paletteGenerationAction{
173		{kind: paletteActionCancelDrainDeadline, id: id},
174		{kind: paletteActionCancelCompletionDeadline, id: id},
175	}
176}
177
178func (c *paletteGenerationCoordinator) handle(event paletteGenerationEvent) []paletteGenerationAction {
179	if !c.active || event.id != c.current.id {
180		return nil
181	}
182	switch event.kind {
183	case paletteEventMarker:
184		return c.onMarker()
185	case paletteEventDrainDeadline:
186		if c.current.phase != generationDraining {
187			return nil
188		}
189		c.current.phase = generationCollecting
190		// A late drain response may still arrive after the batch marker. Exclude
191		// OSC reports until its boundary is consumed: otherwise stale drain-era
192		// reports could become part of this generation's definitive snapshot.
193		c.current.expectedMarkers = 2
194		c.current.awaitingLateDrainBoundary = true
195		return []paletteGenerationAction{
196			{kind: paletteActionWriteBatch, id: event.id, bytes: paletteColorBatch},
197			{kind: paletteActionArmCompletionDeadline, id: event.id, deadline: paletteGenerationDeadline},
198		}
199	case paletteEventCompletionDeadline:
200		if c.current.phase != generationCollecting {
201			return nil
202		}
203		return c.finalize()
204	case paletteEventForeground:
205		if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
206			c.current.theme.HasForeground = true
207			c.current.theme.Foreground = event.rgb
208		}
209	case paletteEventBackground:
210		if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
211			c.current.theme.HasBackground = true
212			c.current.theme.Background = event.rgb
213		}
214	case paletteEventPalette:
215		if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary && event.slot < 16 {
216			c.current.theme.PaletteKnown |= uint16(1) << event.slot
217			c.current.theme.Palette[event.slot] = event.rgb
218		}
219	}
220	return nil
221}
222
223func (c *paletteGenerationCoordinator) onMarker() []paletteGenerationAction {
224	switch c.current.phase {
225	case generationDraining:
226		c.current.phase = generationCollecting
227		c.current.expectedMarkers = 1
228		return []paletteGenerationAction{
229			{kind: paletteActionCancelDrainDeadline, id: c.current.id},
230			{kind: paletteActionWriteBatch, id: c.current.id, bytes: paletteColorBatch},
231			{kind: paletteActionArmCompletionDeadline, id: c.current.id, deadline: paletteGenerationDeadline},
232		}
233	case generationCollecting:
234		if c.current.expectedMarkers == 0 {
235			c.current.expectedMarkers = 1
236		}
237		if c.current.awaitingLateDrainBoundary {
238			c.current.awaitingLateDrainBoundary = false
239		}
240		c.current.expectedMarkers--
241		if c.current.expectedMarkers == 0 {
242			return c.finalize()
243		}
244	}
245	return nil
246}
247
248func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
249	if !c.active || c.current.phase != generationCollecting {
250		return nil
251	}
252	c.current.phase = generationFinalized
253	c.active = false
254	c.finalized = c.current.theme
255	return []paletteGenerationAction{
256		{kind: paletteActionCancelDrainDeadline, id: c.current.id},
257		{kind: paletteActionCancelCompletionDeadline, id: c.current.id},
258		{kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized},
259	}
260}
261
262// paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains
263// possible prefixes across reads, forwarding all ordinary input exactly once.
264type paletteMarkerScanner struct{ pending []byte }
265
266var paletteMarkerResponses = [][]byte{
267	[]byte("\x1b[?2031;0$y"),
268	[]byte("\x1b[?2031;1$y"),
269	[]byte("\x1b[?2031;2$y"),
270	[]byte("\x1b[?2031;3$y"),
271	[]byte("\x1b[?2031;4$y"),
272}
273
274func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) {
275	for i, b := range data {
276		s.pending = append(s.pending, b)
277		matched, possible := false, false
278		for _, response := range paletteMarkerResponses {
279			if len(s.pending) > len(response) || string(s.pending) != string(response[:len(s.pending)]) {
280				continue
281			}
282			if len(s.pending) == len(response) {
283				matched = true
284			} else {
285				possible = true
286			}
287		}
288		if matched {
289			s.pending = nil
290			onMarker()
291			continue
292		}
293		if possible {
294			continue
295		}
296		if s.pending[0] != '\x1b' {
297			onBytes(s.pending[:1])
298			s.pending = s.pending[1:]
299			continue
300		}
301		// This is not a marker. Forward the entire remaining read as one
302		// contiguous input run so CSI mouse reports and escape sequences keep
303		// their framing.
304		s.pending = append(s.pending, data[i+1:]...)
305		onBytes(s.pending)
306		s.pending = nil
307		return
308	}
309}
310func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
311	if len(s.pending) == 0 {
312		return
313	}
314	onBytes(s.pending)
315	s.pending = nil
316}
317
318func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }

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 12

Tags 11

↑↓ moveenter openesc close