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			SchemeKnown: retained.SchemeKnown,
145			Light:       retained.Light,
146		},
147	}
148	c.active = true
149	actions = append(actions, paletteGenerationAction{kind: paletteActionPublishCleared, id: id, theme: cleared})
150	if replacement {
151		c.current.phase = generationDraining
152		actions = append(actions,
153			paletteGenerationAction{kind: paletteActionWriteDrain, id: id, bytes: paletteBoundaryQuery},
154			paletteGenerationAction{kind: paletteActionArmDrainDeadline, id: id, deadline: paletteGenerationDeadline},
155		)
156		return actions
157	}
158	return append(actions,
159		paletteGenerationAction{kind: paletteActionWriteBatch, id: id, bytes: paletteColorBatch},
160		paletteGenerationAction{kind: paletteActionArmCompletionDeadline, id: id, deadline: paletteGenerationDeadline},
161	)
162}
163
164func (c *paletteGenerationCoordinator) cancelCurrent() []paletteGenerationAction {
165	if !c.active {
166		return nil
167	}
168	id := c.current.id
169	c.active = false
170	c.current.phase = generationFinalized
171	return []paletteGenerationAction{
172		{kind: paletteActionCancelDrainDeadline, id: id},
173		{kind: paletteActionCancelCompletionDeadline, id: id},
174	}
175}
176
177func (c *paletteGenerationCoordinator) handle(event paletteGenerationEvent) []paletteGenerationAction {
178	if !c.active || event.id != c.current.id {
179		return nil
180	}
181	switch event.kind {
182	case paletteEventMarker:
183		return c.onMarker()
184	case paletteEventDrainDeadline:
185		if c.current.phase != generationDraining {
186			return nil
187		}
188		c.current.phase = generationCollecting
189		// A late drain response may still arrive after the batch marker. Exclude
190		// OSC reports until its boundary is consumed: otherwise stale drain-era
191		// reports could become part of this generation's definitive snapshot.
192		c.current.expectedMarkers = 2
193		c.current.awaitingLateDrainBoundary = true
194		return []paletteGenerationAction{
195			{kind: paletteActionWriteBatch, id: event.id, bytes: paletteColorBatch},
196			{kind: paletteActionArmCompletionDeadline, id: event.id, deadline: paletteGenerationDeadline},
197		}
198	case paletteEventCompletionDeadline:
199		if c.current.phase != generationCollecting {
200			return nil
201		}
202		return c.finalize()
203	case paletteEventForeground:
204		if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
205			c.current.theme.HasForeground = true
206			c.current.theme.Foreground = event.rgb
207		}
208	case paletteEventBackground:
209		if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary {
210			c.current.theme.HasBackground = true
211			c.current.theme.Background = event.rgb
212		}
213	case paletteEventPalette:
214		if c.current.phase == generationCollecting && !c.current.awaitingLateDrainBoundary && event.slot < 16 {
215			c.current.theme.PaletteKnown |= uint16(1) << event.slot
216			c.current.theme.Palette[event.slot] = event.rgb
217		}
218	}
219	return nil
220}
221
222func (c *paletteGenerationCoordinator) onMarker() []paletteGenerationAction {
223	switch c.current.phase {
224	case generationDraining:
225		c.current.phase = generationCollecting
226		c.current.expectedMarkers = 1
227		return []paletteGenerationAction{
228			{kind: paletteActionCancelDrainDeadline, id: c.current.id},
229			{kind: paletteActionWriteBatch, id: c.current.id, bytes: paletteColorBatch},
230			{kind: paletteActionArmCompletionDeadline, id: c.current.id, deadline: paletteGenerationDeadline},
231		}
232	case generationCollecting:
233		if c.current.expectedMarkers == 0 {
234			c.current.expectedMarkers = 1
235		}
236		if c.current.awaitingLateDrainBoundary {
237			c.current.awaitingLateDrainBoundary = false
238		}
239		c.current.expectedMarkers--
240		if c.current.expectedMarkers == 0 {
241			return c.finalize()
242		}
243	}
244	return nil
245}
246
247func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
248	if !c.active || c.current.phase != generationCollecting {
249		return nil
250	}
251	c.current.phase = generationFinalized
252	c.active = false
253	c.finalized = c.current.theme
254	return []paletteGenerationAction{
255		{kind: paletteActionCancelDrainDeadline, id: c.current.id},
256		{kind: paletteActionCancelCompletionDeadline, id: c.current.id},
257		{kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized},
258	}
259}
260
261// paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains
262// possible prefixes across reads, forwarding all ordinary input exactly once.
263type paletteMarkerScanner struct{ pending []byte }
264
265var paletteMarkerResponses = [][]byte{
266	[]byte("\x1b[?2031;0$y"),
267	[]byte("\x1b[?2031;1$y"),
268	[]byte("\x1b[?2031;2$y"),
269	[]byte("\x1b[?2031;3$y"),
270	[]byte("\x1b[?2031;4$y"),
271}
272
273func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) {
274	for i, b := range data {
275		s.pending = append(s.pending, b)
276		matched, possible := false, false
277		for _, response := range paletteMarkerResponses {
278			if len(s.pending) > len(response) || string(s.pending) != string(response[:len(s.pending)]) {
279				continue
280			}
281			if len(s.pending) == len(response) {
282				matched = true
283			} else {
284				possible = true
285			}
286		}
287		if matched {
288			s.pending = nil
289			onMarker()
290			continue
291		}
292		if possible {
293			continue
294		}
295		if s.pending[0] != '\x1b' {
296			onBytes(s.pending[:1])
297			s.pending = s.pending[1:]
298			continue
299		}
300		// This is not a marker. Forward the entire remaining read as one
301		// contiguous input run so CSI mouse reports and escape sequences keep
302		// their framing.
303		s.pending = append(s.pending, data[i+1:]...)
304		onBytes(s.pending)
305		s.pending = nil
306		return
307	}
308}
309func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
310	if len(s.pending) == 0 {
311		return
312	}
313	onBytes(s.pending)
314	s.pending = nil
315}
316
317func (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