v0.3.0 99dc266

palette_generation.go RawHistoryFolder

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