1package client
2
3import (
4 "bytes"
5 "slices"
6 "strconv"
7 "strings"
8 "time"
9 "unicode/utf8"
10
11 vt "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/protocol"
14)
15
16// Predictive local echo, after mosh's PredictionEngine
17// (src/frontend/terminaloverlay.cc).
18//
19// On a slow link the client guesses the echo of printable keys and backspace
20// on the cursor row and draws it before the daemon confirms it. The guess is
21// checked against a local mirror of the daemon's composed output once the
22// daemon acknowledges the input in Output.Echo. Anything the engine cannot
23// model (control bytes, escape sequences, wide runes, the last column) makes
24// later guesses tentative: they stay hidden until a correct guess of the same
25// epoch confirms it. A wrong tentative guess kills its epoch; a wrong
26// confirmed guess resets the engine.
27//
28// The attached loop is the only caller, so the engine holds no lock.
29
30// Adaptive display thresholds from mosh. They apply to the send interval,
31// half the smoothed round trip clamped to [20ms, 250ms].
32const (
33 echoSRTTTriggerLow = 20 * time.Millisecond
34 echoSRTTTriggerHigh = 30 * time.Millisecond
35 echoFlagTriggerLow = 50 * time.Millisecond
36 echoFlagTriggerHigh = 80 * time.Millisecond
37
38 echoSendIntervalMin = 20 * time.Millisecond
39 echoSendIntervalMax = 250 * time.Millisecond
40
41 // echoGlitchThreshold shows predictions when one is pending this long.
42 echoGlitchThreshold = 250 * time.Millisecond
43 // echoGlitchFlagThreshold also underlines them.
44 echoGlitchFlagThreshold = 5000 * time.Millisecond
45 // echoGlitchRepairCount quick confirmations, each at least
46 // echoGlitchRepairMinInterval apart, cure a glitch.
47 echoGlitchRepairCount = 10
48 echoGlitchRepairMinInterval = 150 * time.Millisecond
49
50 // echoServerDelay is the daemon's echo acknowledgement delay
51 // (usecase/daemon echoAckDelay). It is removed from each round-trip
52 // sample so the thresholds compare network time, as mosh's SRTT does.
53 echoServerDelay = 50 * time.Millisecond
54
55 // maxEchoSent bounds the send-time history used for round-trip samples.
56 maxEchoSent = 256
57 // maxEchoOriginals bounds the overwritten contents kept per cell.
58 maxEchoOriginals = 8
59)
60
61type echoValidity uint8
62
63const (
64 echoInactive echoValidity = iota
65 echoPending
66 echoCorrect
67 echoCorrectNoCredit
68 echoIncorrect
69)
70
71// echoCell is one predicted cell of the prediction row.
72type echoCell struct {
73 active bool
74 unknown bool
75 expires uint64
76 epoch uint64
77 predictedAt time.Time
78 replacement vt.Cell
79 originals []rune
80}
81
82func (c *echoCell) tentative(confirmed uint64) bool { return c.epoch > confirmed }
83
84func (c *echoCell) reset() { *c = echoCell{originals: c.originals[:0]} }
85
86// resetWithOriginal keeps a known replacement as overwritten content, so a
87// later guess that merely restores it earns no credit.
88func (c *echoCell) resetWithOriginal() {
89 if !c.active || c.unknown {
90 c.reset()
91 return
92 }
93 originals := append(c.originals, c.replacement.Rune)
94 if len(originals) > maxEchoOriginals {
95 originals = originals[len(originals)-maxEchoOriginals:]
96 }
97 *c = echoCell{originals: originals}
98}
99
100func (c *echoCell) arm(seq, epoch uint64, now time.Time) {
101 c.active = true
102 c.epoch = epoch
103 c.expires = seq
104 c.predictedAt = now
105}
106
107// echoCursor is the predicted cursor position.
108type echoCursor struct {
109 active bool
110 row int
111 col int
112 expires uint64
113 epoch uint64
114}
115
116type echoSent struct {
117 seq uint64
118 at time.Time
119}
120
121// echoPredictor is the prediction engine of one attachment run.
122type echoPredictor struct {
123 mode domain.EchoPredictMode
124 screen *vt.Screen
125
126 sent []echoSent
127 echo uint64
128 srtt time.Duration
129 srttKnown bool
130 srttOn bool
131 flagging bool
132 glitch int
133 lastQuick time.Time
134 predEpoch uint64
135 confEpoch uint64
136 holdUntil uint64
137 row int
138 cells []echoCell
139 cursor echoCursor
140 drawnRow int
141 drawn []int
142 cursorMove bool
143 apc apcFilter
144}
145
146func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor {
147 return &echoPredictor{mode: mode, predEpoch: 1, screen: vt.NewScreen(1, 1)}
148}
149
150// applyOutput feeds one applied Output to the mirror, samples the round trip
151// when Echo advances, and validates pending guesses.
152func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) {
153 // Side effects (New == 0) may carry a placeholder size; only state
154 // frames describe the grid.
155 if output.New != 0 && output.Size.Valid() && (output.Size.Cols != p.screen.Columns() || output.Size.Rows != p.screen.Rows()) {
156 p.screen.Resize(output.Size.Cols, output.Size.Rows)
157 // The resized frame repaints the whole terminal.
158 p.forget()
159 }
160 if output.Full {
161 // A full frame repainted every drawn guess.
162 p.drawn = p.drawn[:0]
163 p.cursorMove = false
164 }
165 if data := p.apc.strip(output.Data); len(data) > 0 {
166 p.screen.Write(data)
167 }
168 if output.Echo > p.echo {
169 p.sample(output.Echo, now)
170 p.echo = output.Echo
171 }
172 p.cull(now)
173}
174
175// sample updates the smoothed round trip from the send time of seq.
176func (p *echoPredictor) sample(seq uint64, now time.Time) {
177 keep := p.sent[:0]
178 for _, sent := range p.sent {
179 if sent.seq == seq {
180 rtt := max(now.Sub(sent.at)-echoServerDelay, 0)
181 if p.srttKnown {
182 p.srtt = (7*p.srtt + rtt) / 8
183 } else {
184 p.srtt, p.srttKnown = rtt, true
185 }
186 }
187 if sent.seq > seq {
188 keep = append(keep, sent)
189 }
190 }
191 p.sent = keep
192}
193
194func (p *echoPredictor) sendInterval() time.Duration {
195 interval := (p.srtt + 1) / 2
196 return min(max(interval, echoSendIntervalMin), echoSendIntervalMax)
197}
198
199// enabled reports whether the mirror describes a screen the engine can
200// predict on: a visible cursor on a usable grid. The mirror holds the
201// daemon's composed frame, which never enters the alternate screen, so
202// full-screen pane programs rely on epoch validation, as in mosh.
203func (p *echoPredictor) enabled() bool {
204 return p.mode != domain.EchoPredictNever && p.screen.CursorVisible() &&
205 p.screen.Columns() > 1 && p.screen.Rows() > 0
206}
207
208func (p *echoPredictor) active() bool {
209 if p.cursor.active {
210 return true
211 }
212 for i := range p.cells {
213 if p.cells[i].active {
214 return true
215 }
216 }
217 return false
218}
219
220func (p *echoPredictor) displaying() bool {
221 switch p.mode {
222 case domain.EchoPredictNever:
223 return false
224 case domain.EchoPredictAlways:
225 return true
226 }
227 return p.srttOn || p.glitch > 0
228}
229
230func (p *echoPredictor) becomeTentative() { p.predEpoch++ }
231
232// reset drops every guess. Drawn cells stay recorded so render repaints them.
233func (p *echoPredictor) reset() {
234 for i := range p.cells {
235 p.cells[i].reset()
236 }
237 p.cursor = echoCursor{}
238 p.becomeTentative()
239}
240
241// forget drops every guess and the drawn record, for when something else
242// repaints or owns the terminal (a full frame, an overlay).
243func (p *echoPredictor) forget() {
244 p.reset()
245 p.drawn = p.drawn[:0]
246 p.cursorMove = false
247}
248
249func (p *echoPredictor) killEpoch(epoch uint64) {
250 for i := range p.cells {
251 if p.cells[i].active && p.cells[i].epoch >= epoch {
252 p.cells[i].reset()
253 }
254 }
255 if p.cursor.active && p.cursor.epoch >= epoch {
256 p.cursor = echoCursor{}
257 }
258 p.becomeTentative()
259}
260
261// cull validates guesses against the mirror and updates the display
262// triggers, as mosh's PredictionEngine::cull.
263func (p *echoPredictor) cull(now time.Time) {
264 if p.mode == domain.EchoPredictNever {
265 return
266 }
267 if !p.enabled() {
268 p.reset()
269 return
270 }
271 if p.srttKnown {
272 interval := p.sendInterval()
273 if interval > echoSRTTTriggerHigh {
274 p.srttOn = true
275 } else if p.srttOn && interval <= echoSRTTTriggerLow && !p.active() {
276 p.srttOn = false
277 }
278 if interval > echoFlagTriggerHigh {
279 p.flagging = true
280 } else if interval <= echoFlagTriggerLow {
281 p.flagging = false
282 }
283 }
284 if p.glitch > echoGlitchRepairCount {
285 p.flagging = true
286 }
287 if p.row >= p.screen.Rows() || len(p.cells) != p.screen.Columns() {
288 p.cells = nil
289 }
290 for col := range p.cells {
291 cell := &p.cells[col]
292 switch p.cellValidity(col) {
293 case echoIncorrect:
294 if cell.tentative(p.confEpoch) {
295 p.killEpoch(cell.epoch)
296 continue
297 }
298 p.reset()
299 return
300 case echoCorrect:
301 p.confEpoch = max(p.confEpoch, cell.epoch)
302 if now.Sub(cell.predictedAt) < echoGlitchThreshold && p.glitch > 0 && now.Sub(p.lastQuick) >= echoGlitchRepairMinInterval {
303 p.glitch--
304 p.lastQuick = now
305 }
306 // Later guesses take the confirmed style.
307 style := p.screen.Cell(col, p.row).Style
308 for k := col; k < len(p.cells); k++ {
309 p.cells[k].replacement.Style = style
310 }
311 cell.reset()
312 case echoCorrectNoCredit:
313 cell.reset()
314 case echoPending:
315 pending := now.Sub(cell.predictedAt)
316 if pending >= echoGlitchFlagThreshold {
317 p.glitch = 2 * echoGlitchRepairCount
318 } else if pending >= echoGlitchThreshold && p.glitch < echoGlitchRepairCount {
319 p.glitch = echoGlitchRepairCount
320 }
321 }
322 }
323 if p.cursor.active && p.echo >= p.cursor.expires {
324 if p.screen.CursorRow() != p.cursor.row || p.screen.CursorCol() != p.cursor.col {
325 p.reset()
326 return
327 }
328 p.cursor = echoCursor{}
329 }
330}
331
332func (p *echoPredictor) cellValidity(col int) echoValidity {
333 cell := &p.cells[col]
334 if !cell.active {
335 return echoInactive
336 }
337 if p.echo < cell.expires {
338 return echoPending
339 }
340 if cell.unknown || isBlankRune(cell.replacement.Rune) {
341 return echoCorrectNoCredit
342 }
343 current := p.screen.Cell(col, p.row)
344 if current.Rune != cell.replacement.Rune {
345 return echoIncorrect
346 }
347 for _, original := range cell.originals {
348 if original == cell.replacement.Rune {
349 return echoCorrectNoCredit
350 }
351 }
352 return echoCorrect
353}
354
355func isBlankRune(r rune) bool { return r == 0 || r == ' ' }
356
357// isBorderRune reports a box-drawing rune: a pane border or bar the shell
358// line never shifts across.
359func isBorderRune(r rune) bool { return r >= 0x2500 && r <= 0x257f }
360
361// isPlainCell reports a cell the engine can move and repaint exactly: one
362// column, no grapheme or hyperlink payload.
363func isPlainCell(cell vt.Cell) bool {
364 return !cell.Continuation && cell.Payload.Empty() && (cell.Rune == 0 || vt.RuneWidth(cell.Rune) == 1)
365}
366
367// unknownKey gives up on the cursor row until the daemon answers this input.
368func (p *echoPredictor) unknownKey(seq uint64) {
369 p.becomeTentative()
370 p.cursor = echoCursor{}
371 p.holdUntil = seq
372}
373
374// input records the Input numbered seq and predicts its bytes.
375func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) {
376 if seq == 0 || p.mode == domain.EchoPredictNever {
377 return
378 }
379 if len(p.sent) == maxEchoSent {
380 p.sent = append(p.sent[:0], p.sent[1:]...)
381 }
382 p.sent = append(p.sent, echoSent{seq: seq, at: now})
383 p.cull(now)
384 // A daemon that never acknowledges echo (Echo stays 0) could never
385 // confirm or refute a guess, so nothing is predicted before the first
386 // acknowledgement.
387 if !p.enabled() || !p.srttKnown {
388 return
389 }
390 // A bracketed paste arrives as one Input: it is not typing.
391 if bytes.Contains(data, []byte("\x1b[200~")) {
392 p.becomeTentative()
393 return
394 }
395 for len(data) > 0 {
396 if data[0] == 0x1b {
397 data = data[escapeLength(data):]
398 p.becomeTentative()
399 continue
400 }
401 r, size := utf8.DecodeRune(data)
402 data = data[size:]
403 switch {
404 case r == utf8.RuneError && size == 1:
405 p.becomeTentative()
406 case r == 0x7f:
407 p.backspace(seq, now)
408 case r == '\r':
409 // The next row's state is only known once the daemon answers.
410 p.unknownKey(seq)
411 case vt.RuneWidth(r) != 1:
412 p.becomeTentative()
413 default:
414 p.print(r, seq, now)
415 }
416 }
417}
418
419// escapeLength is the length of the escape sequence at the start of data, or
420// all of data when it is incomplete.
421func escapeLength(data []byte) int {
422 if len(data) < 2 {
423 return len(data)
424 }
425 switch data[1] {
426 case '[':
427 for i := 2; i < len(data); i++ {
428 if data[i] >= 0x40 && data[i] <= 0x7e {
429 return i + 1
430 }
431 }
432 return len(data)
433 case 'O':
434 return min(3, len(data))
435 case ']', 'P', '_', '^', 'X':
436 // OSC, DCS, APC, PM, SOS: up to BEL or ST.
437 for i := 2; i < len(data); i++ {
438 if data[i] == 0x07 {
439 return i + 1
440 }
441 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
442 return i + 2
443 }
444 }
445 return len(data)
446 }
447 return 2
448}
449
450// initCursor starts the cursor prediction from the mirror, and the cell row
451// with it. It reports false while the cursor row is not known yet.
452func (p *echoPredictor) initCursor() bool {
453 if p.echo < p.holdUntil {
454 return false
455 }
456 if !p.cursor.active {
457 row, col := p.screen.CursorRow(), p.screen.CursorCol()
458 if row < 0 || row >= p.screen.Rows() || col < 0 || col >= p.screen.Columns() {
459 return false
460 }
461 p.cursor = echoCursor{active: true, row: row, col: col, epoch: p.predEpoch}
462 }
463 if p.cells == nil || p.row != p.cursor.row || len(p.cells) != p.screen.Columns() {
464 p.row = p.cursor.row
465 p.cells = make([]echoCell, p.screen.Columns())
466 }
467 return true
468}
469
470// effective is the cell the user will see at col: a known guess, else the
471// mirror.
472func (p *echoPredictor) effective(col int) (vt.Cell, bool) {
473 cell := &p.cells[col]
474 if cell.active {
475 if cell.unknown {
476 return vt.Cell{}, false
477 }
478 return cell.replacement, true
479 }
480 return p.screen.Cell(col, p.row), true
481}
482
483func (p *echoPredictor) moveCursor(col int, seq uint64) {
484 p.cursor.col = col
485 p.cursor.expires = seq
486 p.cursor.epoch = p.predEpoch
487}
488
489// print predicts one width-1 rune in insert mode. The shift stops at the
490// first blank, so a split layout's borders to the right never move.
491func (p *echoPredictor) print(r rune, seq uint64, now time.Time) {
492 if !p.initCursor() {
493 p.becomeTentative()
494 return
495 }
496 width := len(p.cells)
497 col := p.cursor.col
498 if col+1 >= width {
499 // mosh: the last column is tricky (wrap markers, shells differ).
500 p.becomeTentative()
501 }
502 end := col
503 for end < width-1 {
504 cell, known := p.effective(end)
505 if known && (!isPlainCell(cell) || isBorderRune(cell.Rune)) {
506 // Wide or payload text cannot be shifted exactly, and a border
507 // would move with the line.
508 p.unknownKey(seq)
509 return
510 }
511 if known && isBlankRune(cell.Rune) {
512 break
513 }
514 end++
515 }
516 for i := end; i > col; i-- {
517 cell := &p.cells[i]
518 prev, known := p.effective(i - 1)
519 original := p.screen.Cell(i, p.row).Rune
520 cell.resetWithOriginal()
521 cell.arm(seq, p.predEpoch, now)
522 cell.originals = append(cell.originals, original)
523 if i == width-1 || !known {
524 cell.unknown = true
525 } else {
526 cell.replacement = prev
527 }
528 }
529 style := vt.DefaultStyle()
530 if col > 0 {
531 if left, known := p.effective(col - 1); known {
532 style = left.Style
533 }
534 }
535 cell := &p.cells[col]
536 original := p.screen.Cell(col, p.row).Rune
537 cell.resetWithOriginal()
538 cell.arm(seq, p.predEpoch, now)
539 cell.replacement = vt.Cell{Rune: r, Style: style}
540 cell.originals = append(cell.originals, original)
541 if col < width-1 {
542 p.moveCursor(col+1, seq)
543 } else {
544 p.becomeTentative()
545 p.moveCursor(col, seq)
546 }
547}
548
549// backspace predicts DEL: the cursor moves left and the text after it moves
550// left up to the first blank.
551func (p *echoPredictor) backspace(seq uint64, now time.Time) {
552 if !p.initCursor() {
553 p.becomeTentative()
554 return
555 }
556 if p.cursor.col == 0 {
557 return
558 }
559 width := len(p.cells)
560 col := p.cursor.col - 1
561 if left, known := p.effective(col); known && isBorderRune(left.Rune) {
562 // The input starts after a border (a modal box or pane divider):
563 // backspace cannot erase it.
564 p.becomeTentative()
565 return
566 }
567 for i := col; i < width; i++ {
568 cell, known := p.effective(i)
569 if known && !isPlainCell(cell) {
570 p.unknownKey(seq)
571 return
572 }
573 if known && (isBlankRune(cell.Rune) || isBorderRune(cell.Rune)) {
574 break
575 }
576 }
577 p.moveCursor(col, seq)
578 for i := col; i < width; i++ {
579 cell := &p.cells[i]
580 original := p.screen.Cell(i, p.row).Rune
581 var next vt.Cell
582 known := false
583 if i+2 < width {
584 next, known = p.effective(i + 1)
585 }
586 blank := known && isBlankRune(next.Rune)
587 if known && isBorderRune(next.Rune) {
588 // The text ends at a border: what fills this cell is unknown.
589 known, blank = false, true
590 }
591 cell.resetWithOriginal()
592 cell.arm(seq, p.predEpoch, now)
593 cell.originals = append(cell.originals, original)
594 if !known {
595 cell.unknown = true
596 } else {
597 cell.replacement = next
598 }
599 if blank {
600 break
601 }
602 }
603}
604
605// needsTick reports whether a pending guess still waits on a timing trigger
606// (mosh's timing_tests_necessary), so the caller re-culls without output.
607func (p *echoPredictor) needsTick() bool {
608 return p.mode == domain.EchoPredictAdaptive && p.active() && !(p.glitch > 0 && p.flagging)
609}
610
611// render returns the terminal bytes that bring the screen from the last
612// render to the current guesses: drawn cells that no longer show a guess are
613// repainted from the mirror, shown guesses are drawn again (a daemon frame
614// may have overwritten them), and the cursor goes to the predicted position
615// or back to the real one.
616func (p *echoPredictor) render() []byte {
617 var out bytes.Buffer
618 show := p.displaying() && p.enabled()
619 var want []int
620 if show && p.cells != nil && p.row < p.screen.Rows() {
621 for col := range p.cells {
622 cell := &p.cells[col]
623 if !cell.active || cell.unknown || cell.tentative(p.confEpoch) {
624 continue
625 }
626 if p.screen.Cell(col, p.row).Equal(cell.replacement) && !p.flagging {
627 continue
628 }
629 want = append(want, col)
630 }
631 }
632 for _, col := range p.drawn {
633 if p.drawnRow == p.row && slices.Contains(want, col) {
634 continue
635 }
636 if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() {
637 p.restoreCell(&out, p.drawnRow, col)
638 }
639 }
640 for _, col := range want {
641 cell := p.cells[col].replacement
642 flag := p.flagging && !(isBlankRune(cell.Rune) && isBlankRune(p.screen.Cell(col, p.row).Rune))
643 writeEchoCell(&out, p.row, col, cell, flag)
644 }
645 p.drawn = append(p.drawn[:0], want...)
646 p.drawnRow = p.row
647
648 row, col := p.screen.CursorRow(), p.screen.CursorCol()
649 moved := false
650 if show && p.cursor.active && p.cursor.epoch <= p.confEpoch {
651 moved = p.cursor.row != row || p.cursor.col != col
652 row, col = p.cursor.row, p.cursor.col
653 }
654 if out.Len() > 0 || moved || p.cursorMove {
655 out.WriteString("\x1b[0m")
656 writeEchoCUP(&out, row, col)
657 }
658 p.cursorMove = moved
659 return out.Bytes()
660}
661
662func writeEchoCUP(out *bytes.Buffer, row, col int) {
663 out.WriteString("\x1b[")
664 out.WriteString(strconv.Itoa(row + 1))
665 out.WriteByte(';')
666 out.WriteString(strconv.Itoa(col + 1))
667 out.WriteByte('H')
668}
669
670// restoreCell repaints the mirror's cell at (row, col). The right half of a
671// wide glyph is repainted from its left cell, which covers both columns.
672func (p *echoPredictor) restoreCell(out *bytes.Buffer, row, col int) {
673 cell := p.screen.Cell(col, row)
674 if cell.Continuation && col > 0 {
675 col--
676 cell = p.screen.Cell(col, row)
677 }
678 writeEchoCell(out, row, col, cell, false)
679}
680
681// writeEchoCell draws one cell. Mirror styles come from daemon output already
682// projected to this terminal's color profile, so they are written as is.
683func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) {
684 writeEchoCUP(out, row, col)
685 out.WriteString(echoSGR(cell.Style, underline))
686 switch {
687 case cell.Continuation || cell.Rune == 0:
688 out.WriteByte(' ')
689 case cell.Payload.Grapheme() != "":
690 out.WriteString(cell.Payload.Grapheme())
691 default:
692 out.WriteRune(cell.Rune)
693 }
694}
695
696func echoSGR(style vt.Style, underline bool) string {
697 var b strings.Builder
698 b.WriteString("\x1b[0")
699 if style.Bold {
700 b.WriteString(";1")
701 }
702 if style.Attrs&vt.AttrDim != 0 {
703 b.WriteString(";2")
704 }
705 if style.Italic {
706 b.WriteString(";3")
707 }
708 switch {
709 case underline:
710 b.WriteString(";4")
711 case style.Attrs&vt.AttrUnderline != 0:
712 switch style.UnderlineStyle {
713 case vt.UnderlineDouble:
714 b.WriteString(";21")
715 case vt.UnderlineCurly:
716 b.WriteString(";4:3")
717 case vt.UnderlineDotted:
718 b.WriteString(";4:4")
719 case vt.UnderlineDashed:
720 b.WriteString(";4:5")
721 default:
722 b.WriteString(";4")
723 }
724 }
725 if style.Attrs&vt.AttrBlink != 0 {
726 b.WriteString(";5")
727 }
728 if style.Inverse {
729 b.WriteString(";7")
730 }
731 if style.Attrs&vt.AttrStrikethrough != 0 {
732 b.WriteString(";9")
733 }
734 writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90)
735 writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100)
736 switch {
737 case style.HasUnderlineColorRGB:
738 b.WriteString(";58;2;" + strconv.Itoa(int(style.UnderlineColorRGB.R)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.G)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.B)))
739 case style.HasUnderlineColor:
740 b.WriteString(";58;5;" + strconv.Itoa(style.UnderlineColor))
741 }
742 b.WriteByte('m')
743 return b.String()
744}
745
746func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended, normal, bright int) {
747 switch {
748 case hasRGB:
749 b.WriteString(";" + strconv.Itoa(extended) + ";2;" + strconv.Itoa(int(rgb.R)) + ";" + strconv.Itoa(int(rgb.G)) + ";" + strconv.Itoa(int(rgb.B)))
750 case index < 0:
751 case index < 8:
752 b.WriteString(";" + strconv.Itoa(normal+index))
753 case index < 16:
754 b.WriteString(";" + strconv.Itoa(bright+index-8))
755 case index < 256:
756 b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index))
757 }
758}
759
760// apcFilter drops APC strings (ESC _ ... ST), which carry kitty graphics, from
761// the mirror's input: the engine needs text only, and decoding images would
762// cost the client CPU and memory. Its state spans Output boundaries.
763type apcFilter struct {
764 state uint8
765}
766
767const (
768 apcText uint8 = iota
769 apcEscape
770 apcBody
771 apcBodyEscape
772)
773
774func (f *apcFilter) strip(data []byte) []byte {
775 if f.state == apcText && bytes.IndexByte(data, 0x1b) < 0 {
776 return data
777 }
778 out := make([]byte, 0, len(data))
779 for _, b := range data {
780 switch f.state {
781 case apcText:
782 if b == 0x1b {
783 f.state = apcEscape
784 continue
785 }
786 out = append(out, b)
787 case apcEscape:
788 if b == '_' {
789 f.state = apcBody
790 continue
791 }
792 out = append(out, 0x1b, b)
793 f.state = apcText
794 if b == 0x1b {
795 out = out[:len(out)-1]
796 f.state = apcEscape
797 }
798 case apcBody:
799 if b == 0x1b {
800 f.state = apcBodyEscape
801 }
802 case apcBodyEscape:
803 switch b {
804 case '\\':
805 f.state = apcText
806 case 0x1b:
807 default:
808 f.state = apcBody
809 }
810 }
811 }
812 return out
813}