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