fix(client): keep predicted echo exact on wide text, borders and resizes
- Refuse to shift wide, payload or border cells and repaint wide glyphs whole. - Emit underline styles and colors when restoring cells. - Strip APC graphics from the mirror and skip OSC/DCS input. - Erase drawn guesses on a same-size resize. - Predict nothing before the first echo acknowledgement. - Default echo.predict to never outside the user's config and apply it on every terminal attach path. - Test the attachment glue with a generated prediction foreground mock.
- commit
- a5a6cfeadb0c83ac3f7de6725868d4fa5b24e0b3
- author
- brice · · 2026-10-07 13:38 UTC
- parent
- a686aba
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email - refs
- feat/predictive-echo
10 files · +494 -23
M .mockery.yml +7 -0
| @@ -47,6 +47,13 @@ packages: | |||
| 47 | 47 | dir: internal/usecase/daemon | |
| 48 | 48 | filename: action_runner_mock_test.go | |
| 49 | 49 | pkgname: daemon | |
| 50 | + | github.com/bnema/vev/internal/usecase/client: | |
| 51 | + | interfaces: | |
| 52 | + | attachmentPredictionForeground: | |
| 53 | + | config: | |
| 54 | + | dir: internal/usecase/client | |
| 55 | + | filename: prediction_foreground_mock_test.go | |
| 56 | + | pkgname: client | |
| 50 | 57 | github.com/bnema/vev/internal/usecase/command: | |
| 51 | 58 | interfaces: | |
| 52 | 59 | Context: | |
| @@ -47,6 +47,13 @@ packages: | |||||
| 47 | dir: internal/usecase/daemon | 47 | dir: internal/usecase/daemon | ||
| 48 | filename: action_runner_mock_test.go | 48 | filename: action_runner_mock_test.go | ||
| 49 | pkgname: daemon | 49 | pkgname: daemon | ||
| 50 | + | github.com/bnema/vev/internal/usecase/client: | |||
| 51 | + | interfaces: | |||
| 52 | + | attachmentPredictionForeground: | |||
| 53 | + | config: | |||
| 54 | + | dir: internal/usecase/client | |||
| 55 | + | filename: prediction_foreground_mock_test.go | |||
| 56 | + | pkgname: client | |||
| 50 | github.com/bnema/vev/internal/usecase/command: | 57 | github.com/bnema/vev/internal/usecase/command: | ||
| 51 | interfaces: | 58 | interfaces: | ||
| 52 | Context: | 59 | Context: | ||
M internal/adapters/config/config_test.go +3 -0
| @@ -355,6 +355,9 @@ func TestParse(t *testing.T) { | |||
| 355 | 355 | if !tt.want.Keyboard.KittyProtocol { | |
| 356 | 356 | tt.want.Keyboard = domain.Defaults().Keyboard | |
| 357 | 357 | } | |
| 358 | + | if tt.want.Echo.Predict == domain.EchoPredictNever { | |
| 359 | + | tt.want.Echo = domain.Defaults().Echo | |
| 360 | + | } | |
| 358 | 361 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 359 | 362 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | |
| 360 | 363 | } | |
| @@ -355,6 +355,9 @@ func TestParse(t *testing.T) { | |||||
| 355 | if !tt.want.Keyboard.KittyProtocol { | 355 | if !tt.want.Keyboard.KittyProtocol { | ||
| 356 | tt.want.Keyboard = domain.Defaults().Keyboard | 356 | tt.want.Keyboard = domain.Defaults().Keyboard | ||
| 357 | } | 357 | } | ||
| 358 | + | if tt.want.Echo.Predict == domain.EchoPredictNever { | |||
| 359 | + | tt.want.Echo = domain.Defaults().Echo | |||
| 360 | + | } | |||
| 358 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | 361 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | ||
| 359 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | 362 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | ||
| 360 | } | 363 | } | ||
M internal/app/broker_client.go +5 -2
| @@ -685,9 +685,12 @@ func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConf | |||
| 685 | 685 | } | |
| 686 | 686 | ||
| 687 | 687 | // configuredTerminalAttachmentEnvironment loads the user's config and returns | |
| 688 | - | // userTerminalAttachmentEnvironment for it. | |
| 688 | + | // userTerminalAttachmentEnvironment for it, with the user's echo setting. | |
| 689 | 689 | func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | |
| 690 | - | return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal) | |
| 690 | + | cfg := loadConfigOrDefaults(log, platform.ConfigPath()) | |
| 691 | + | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | |
| 692 | + | env.EchoPredict = cfg.Echo.Predict | |
| 693 | + | return env | |
| 691 | 694 | } | |
| 692 | 695 | ||
| 693 | 696 | // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment | |
| @@ -685,9 +685,12 @@ func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConf | |||||
| 685 | } | 685 | } | ||
| 686 | 686 | ||||
| 687 | // configuredTerminalAttachmentEnvironment loads the user's config and returns | 687 | // configuredTerminalAttachmentEnvironment loads the user's config and returns | ||
| 688 | - | // userTerminalAttachmentEnvironment for it. | 688 | + | // userTerminalAttachmentEnvironment for it, with the user's echo setting. |
| 689 | func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | 689 | func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment { | ||
| 690 | - | return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal) | 690 | + | cfg := loadConfigOrDefaults(log, platform.ConfigPath()) |
| 691 | + | env := userTerminalAttachmentEnvironment(log, cfg.Terminal) | |||
| 692 | + | env.EchoPredict = cfg.Echo.Predict | |||
| 693 | + | return env | |||
| 691 | } | 694 | } | ||
| 692 | 695 | ||||
| 693 | // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment | 696 | // outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment | ||
M internal/domain/config.go +8 -3
| @@ -106,12 +106,14 @@ type KeyboardConfig struct { | |||
| 106 | 106 | type EchoPredictMode uint8 | |
| 107 | 107 | ||
| 108 | 108 | const ( | |
| 109 | + | // EchoPredictNever disables prediction. It is the zero value, so a | |
| 110 | + | // client composed without the user's config (virtual terminals, UI | |
| 111 | + | // drivers) never predicts. | |
| 112 | + | EchoPredictNever EchoPredictMode = iota | |
| 109 | 113 | // EchoPredictAdaptive shows predictions only on a slow or glitchy link. | |
| 110 | - | EchoPredictAdaptive EchoPredictMode = iota | |
| 114 | + | EchoPredictAdaptive | |
| 111 | 115 | // EchoPredictAlways shows predictions whatever the link latency. | |
| 112 | 116 | EchoPredictAlways | |
| 113 | - | // EchoPredictNever disables prediction. | |
| 114 | - | EchoPredictNever | |
| 115 | 117 | ) | |
| 116 | 118 | ||
| 117 | 119 | // EchoConfig contains client-side predictive echo settings. | |
| @@ -218,6 +220,9 @@ func Defaults() Config { | |||
| 218 | 220 | Keyboard: KeyboardConfig{ | |
| 219 | 221 | KittyProtocol: true, | |
| 220 | 222 | }, | |
| 223 | + | Echo: EchoConfig{ | |
| 224 | + | Predict: EchoPredictAdaptive, | |
| 225 | + | }, | |
| 221 | 226 | Scrollback: DefaultScrollbackConfig(), | |
| 222 | 227 | } | |
| 223 | 228 | } | |
| @@ -106,12 +106,14 @@ type KeyboardConfig struct { | |||||
| 106 | type EchoPredictMode uint8 | 106 | type EchoPredictMode uint8 | ||
| 107 | 107 | ||||
| 108 | const ( | 108 | const ( | ||
| 109 | + | // EchoPredictNever disables prediction. It is the zero value, so a | |||
| 110 | + | // client composed without the user's config (virtual terminals, UI | |||
| 111 | + | // drivers) never predicts. | |||
| 112 | + | EchoPredictNever EchoPredictMode = iota | |||
| 109 | // EchoPredictAdaptive shows predictions only on a slow or glitchy link. | 113 | // EchoPredictAdaptive shows predictions only on a slow or glitchy link. | ||
| 110 | - | EchoPredictAdaptive EchoPredictMode = iota | 114 | + | EchoPredictAdaptive |
| 111 | // EchoPredictAlways shows predictions whatever the link latency. | 115 | // EchoPredictAlways shows predictions whatever the link latency. | ||
| 112 | EchoPredictAlways | 116 | EchoPredictAlways | ||
| 113 | - | // EchoPredictNever disables prediction. | |||
| 114 | - | EchoPredictNever | |||
| 115 | ) | 117 | ) | ||
| 116 | 118 | ||||
| 117 | // EchoConfig contains client-side predictive echo settings. | 119 | // EchoConfig contains client-side predictive echo settings. | ||
| @@ -218,6 +220,9 @@ func Defaults() Config { | |||||
| 218 | Keyboard: KeyboardConfig{ | 220 | Keyboard: KeyboardConfig{ | ||
| 219 | KittyProtocol: true, | 221 | KittyProtocol: true, | ||
| 220 | }, | 222 | }, | ||
| 223 | + | Echo: EchoConfig{ | |||
| 224 | + | Predict: EchoPredictAdaptive, | |||
| 225 | + | }, | |||
| 221 | Scrollback: DefaultScrollbackConfig(), | 226 | Scrollback: DefaultScrollbackConfig(), | ||
| 222 | } | 227 | } | ||
| 223 | } | 228 | } | ||
M internal/usecase/client/attachment_echo.go +12 -4
| @@ -62,12 +62,20 @@ func (e *attachmentEcho) output(output protocol.Output) error { | |||
| 62 | 62 | return e.draw() | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | - | // resized drops every guess: the next frame repaints the new geometry. | |
| 66 | - | func (e *attachmentEcho) resized() { | |
| 65 | + | // resized handles a terminal resize. A new size makes the daemon repaint | |
| 66 | + | // everything, so the guesses are simply forgotten. The same size brings no | |
| 67 | + | // repaint, so drawn guesses are erased from the mirror instead. | |
| 68 | + | func (e *attachmentEcho) resized(size domain.Size) error { | |
| 67 | 69 | if e == nil { | |
| 68 | - | return | |
| 70 | + | return nil | |
| 71 | + | } | |
| 72 | + | screen := e.predictor.screen | |
| 73 | + | if size.Cols != screen.Columns() || size.Rows != screen.Rows() { | |
| 74 | + | e.predictor.forget() | |
| 75 | + | return nil | |
| 69 | 76 | } | |
| 70 | - | e.predictor.forget() | |
| 77 | + | e.predictor.reset() | |
| 78 | + | return e.draw() | |
| 71 | 79 | } | |
| 72 | 80 | ||
| 73 | 81 | // tickC fires while a pending guess waits on a timing trigger. | |
| @@ -62,12 +62,20 @@ func (e *attachmentEcho) output(output protocol.Output) error { | |||||
| 62 | return e.draw() | 62 | return e.draw() | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | - | // resized drops every guess: the next frame repaints the new geometry. | 65 | + | // resized handles a terminal resize. A new size makes the daemon repaint |
| 66 | - | func (e *attachmentEcho) resized() { | 66 | + | // everything, so the guesses are simply forgotten. The same size brings no |
| 67 | + | // repaint, so drawn guesses are erased from the mirror instead. | |||
| 68 | + | func (e *attachmentEcho) resized(size domain.Size) error { | |||
| 67 | if e == nil { | 69 | if e == nil { | ||
| 68 | - | return | 70 | + | return nil |
| 71 | + | } | |||
| 72 | + | screen := e.predictor.screen | |||
| 73 | + | if size.Cols != screen.Columns() || size.Rows != screen.Rows() { | |||
| 74 | + | e.predictor.forget() | |||
| 75 | + | return nil | |||
| 69 | } | 76 | } | ||
| 70 | - | e.predictor.forget() | 77 | + | e.predictor.reset() |
| 78 | + | return e.draw() | |||
| 71 | } | 79 | } | ||
| 72 | 80 | ||||
| 73 | // tickC fires while a pending guess waits on a timing trigger. | 81 | // tickC fires while a pending guess waits on a timing trigger. | ||
A internal/usecase/client/attachment_echo_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func newTestAttachmentEcho(t *testing.T, mode domain.EchoPredictMode) (*attachmentEcho, *mockattachmentPredictionForeground, *portsmocks.MockClock) { | |
| 16 | + | t.Helper() | |
| 17 | + | out := newMockattachmentPredictionForeground(t) | |
| 18 | + | clock := portsmocks.NewMockClock(t) | |
| 19 | + | clock.EXPECT().Now().Return(echoTestStart).Maybe() | |
| 20 | + | echo := &attachmentEcho{predictor: newEchoPredictor(mode), out: out, clock: clock} | |
| 21 | + | echo.seed(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}) | |
| 22 | + | // One acknowledged input gives the engine its first round trip. | |
| 23 | + | echo.predictor.input(1, nil, echoTestStart) | |
| 24 | + | echo.predictor.applyOutput(protocol.Output{Epoch: 1, Echo: 1}, echoTestStart.Add(echoServerDelay)) | |
| 25 | + | echo.predictor.confEpoch = echo.predictor.predEpoch | |
| 26 | + | return echo, out, clock | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestNewAttachmentEchoSkipsNonPredictingAttachments(t *testing.T) { | |
| 30 | + | t.Parallel() | |
| 31 | + | fg := newMockattachmentPredictionForeground(t) | |
| 32 | + | tests := []struct { | |
| 33 | + | name string | |
| 34 | + | local bool | |
| 35 | + | mode domain.EchoPredictMode | |
| 36 | + | fg AttachmentForeground | |
| 37 | + | }{ | |
| 38 | + | {name: "local attachment", local: true, mode: domain.EchoPredictAdaptive}, | |
| 39 | + | {name: "prediction off", mode: domain.EchoPredictNever}, | |
| 40 | + | {name: "foreground without prediction seam", mode: domain.EchoPredictAdaptive}, | |
| 41 | + | } | |
| 42 | + | for _, tt := range tests { | |
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |
| 44 | + | t.Parallel() | |
| 45 | + | w := &sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: tt.mode}} | |
| 46 | + | w.cfg.Request.Local = tt.local | |
| 47 | + | require.Nil(t, newAttachmentEcho(w, tt.fg)) | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | require.NotNil(t, newAttachmentEcho(&sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: domain.EchoPredictAdaptive, Clock: systemClock{}}}, struct { | |
| 51 | + | AttachmentForeground | |
| 52 | + | attachmentPredictionForeground | |
| 53 | + | }{attachmentPredictionForeground: fg}), "a remote attachment with the seam predicts") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestAttachmentEchoDrawsGuessAndArmsTick(t *testing.T) { | |
| 57 | + | t.Parallel() | |
| 58 | + | echo, out, clock := newTestAttachmentEcho(t, domain.EchoPredictAdaptive) | |
| 59 | + | // A slow link: guesses show, and the tick waits on the glitch trigger. | |
| 60 | + | echo.predictor.srtt = 100 * time.Millisecond | |
| 61 | + | tick := portsmocks.NewMockTimer(t) | |
| 62 | + | clock.EXPECT().NewTimer(echoTickInterval).Return(tick).Once() | |
| 63 | + | var drawn []byte | |
| 64 | + | out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) { | |
| 65 | + | drawn = append([]byte(nil), data...) | |
| 66 | + | return true, nil | |
| 67 | + | }).Once() | |
| 68 | + | ||
| 69 | + | require.NoError(t, echo.input(2, []byte("b"))) | |
| 70 | + | require.Equal(t, "\x1b[1;3H\x1b[0mb\x1b[0m\x1b[1;4H", string(drawn)) | |
| 71 | + | ||
| 72 | + | fired := make(chan time.Time) | |
| 73 | + | tick.EXPECT().C().Return(fired).Once() | |
| 74 | + | require.Equal(t, (<-chan time.Time)(fired), echo.tickC()) | |
| 75 | + | tick.EXPECT().Stop().Return(true).Once() | |
| 76 | + | echo.close() | |
| 77 | + | require.Nil(t, echo.tickC()) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestAttachmentEchoForgetsGuessesWhileOverlayOwnsTerminal(t *testing.T) { | |
| 81 | + | t.Parallel() | |
| 82 | + | echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways) | |
| 83 | + | out.EXPECT().writePrediction(mock.Anything).Return(false, nil).Once() | |
| 84 | + | ||
| 85 | + | require.NoError(t, echo.input(2, []byte("b"))) | |
| 86 | + | require.False(t, echo.predictor.active(), "guesses survived an overlay") | |
| 87 | + | require.Empty(t, echo.predictor.drawn, "an overlay-covered guess must not be repainted later") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestAttachmentEchoSameSizeResizeRepaintsDrawnGuesses(t *testing.T) { | |
| 91 | + | t.Parallel() | |
| 92 | + | echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways) | |
| 93 | + | out.EXPECT().writePrediction(mock.Anything).Return(true, nil).Once() | |
| 94 | + | require.NoError(t, echo.input(2, []byte("b"))) | |
| 95 | + | ||
| 96 | + | var repaint []byte | |
| 97 | + | out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) { | |
| 98 | + | repaint = append([]byte(nil), data...) | |
| 99 | + | return true, nil | |
| 100 | + | }).Once() | |
| 101 | + | require.NoError(t, echo.resized(domain.Size{Cols: 20, Rows: 3})) | |
| 102 | + | require.Equal(t, "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H", string(repaint)) | |
| 103 | + | ||
| 104 | + | // A new size brings a full repaint from the daemon: nothing is written. | |
| 105 | + | require.NoError(t, echo.resized(domain.Size{Cols: 30, Rows: 3})) | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/mock" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 12 | + | "github.com/bnema/vev/internal/protocol" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func newTestAttachmentEcho(t *testing.T, mode domain.EchoPredictMode) (*attachmentEcho, *mockattachmentPredictionForeground, *portsmocks.MockClock) { | |||
| 16 | + | t.Helper() | |||
| 17 | + | out := newMockattachmentPredictionForeground(t) | |||
| 18 | + | clock := portsmocks.NewMockClock(t) | |||
| 19 | + | clock.EXPECT().Now().Return(echoTestStart).Maybe() | |||
| 20 | + | echo := &attachmentEcho{predictor: newEchoPredictor(mode), out: out, clock: clock} | |||
| 21 | + | echo.seed(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}) | |||
| 22 | + | // One acknowledged input gives the engine its first round trip. | |||
| 23 | + | echo.predictor.input(1, nil, echoTestStart) | |||
| 24 | + | echo.predictor.applyOutput(protocol.Output{Epoch: 1, Echo: 1}, echoTestStart.Add(echoServerDelay)) | |||
| 25 | + | echo.predictor.confEpoch = echo.predictor.predEpoch | |||
| 26 | + | return echo, out, clock | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestNewAttachmentEchoSkipsNonPredictingAttachments(t *testing.T) { | |||
| 30 | + | t.Parallel() | |||
| 31 | + | fg := newMockattachmentPredictionForeground(t) | |||
| 32 | + | tests := []struct { | |||
| 33 | + | name string | |||
| 34 | + | local bool | |||
| 35 | + | mode domain.EchoPredictMode | |||
| 36 | + | fg AttachmentForeground | |||
| 37 | + | }{ | |||
| 38 | + | {name: "local attachment", local: true, mode: domain.EchoPredictAdaptive}, | |||
| 39 | + | {name: "prediction off", mode: domain.EchoPredictNever}, | |||
| 40 | + | {name: "foreground without prediction seam", mode: domain.EchoPredictAdaptive}, | |||
| 41 | + | } | |||
| 42 | + | for _, tt := range tests { | |||
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 44 | + | t.Parallel() | |||
| 45 | + | w := &sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: tt.mode}} | |||
| 46 | + | w.cfg.Request.Local = tt.local | |||
| 47 | + | require.Nil(t, newAttachmentEcho(w, tt.fg)) | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | require.NotNil(t, newAttachmentEcho(&sessionAttachmentWorker{cfg: sessionAttachmentConfig{EchoPredict: domain.EchoPredictAdaptive, Clock: systemClock{}}}, struct { | |||
| 51 | + | AttachmentForeground | |||
| 52 | + | attachmentPredictionForeground | |||
| 53 | + | }{attachmentPredictionForeground: fg}), "a remote attachment with the seam predicts") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestAttachmentEchoDrawsGuessAndArmsTick(t *testing.T) { | |||
| 57 | + | t.Parallel() | |||
| 58 | + | echo, out, clock := newTestAttachmentEcho(t, domain.EchoPredictAdaptive) | |||
| 59 | + | // A slow link: guesses show, and the tick waits on the glitch trigger. | |||
| 60 | + | echo.predictor.srtt = 100 * time.Millisecond | |||
| 61 | + | tick := portsmocks.NewMockTimer(t) | |||
| 62 | + | clock.EXPECT().NewTimer(echoTickInterval).Return(tick).Once() | |||
| 63 | + | var drawn []byte | |||
| 64 | + | out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) { | |||
| 65 | + | drawn = append([]byte(nil), data...) | |||
| 66 | + | return true, nil | |||
| 67 | + | }).Once() | |||
| 68 | + | ||||
| 69 | + | require.NoError(t, echo.input(2, []byte("b"))) | |||
| 70 | + | require.Equal(t, "\x1b[1;3H\x1b[0mb\x1b[0m\x1b[1;4H", string(drawn)) | |||
| 71 | + | ||||
| 72 | + | fired := make(chan time.Time) | |||
| 73 | + | tick.EXPECT().C().Return(fired).Once() | |||
| 74 | + | require.Equal(t, (<-chan time.Time)(fired), echo.tickC()) | |||
| 75 | + | tick.EXPECT().Stop().Return(true).Once() | |||
| 76 | + | echo.close() | |||
| 77 | + | require.Nil(t, echo.tickC()) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestAttachmentEchoForgetsGuessesWhileOverlayOwnsTerminal(t *testing.T) { | |||
| 81 | + | t.Parallel() | |||
| 82 | + | echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways) | |||
| 83 | + | out.EXPECT().writePrediction(mock.Anything).Return(false, nil).Once() | |||
| 84 | + | ||||
| 85 | + | require.NoError(t, echo.input(2, []byte("b"))) | |||
| 86 | + | require.False(t, echo.predictor.active(), "guesses survived an overlay") | |||
| 87 | + | require.Empty(t, echo.predictor.drawn, "an overlay-covered guess must not be repainted later") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestAttachmentEchoSameSizeResizeRepaintsDrawnGuesses(t *testing.T) { | |||
| 91 | + | t.Parallel() | |||
| 92 | + | echo, out, _ := newTestAttachmentEcho(t, domain.EchoPredictAlways) | |||
| 93 | + | out.EXPECT().writePrediction(mock.Anything).Return(true, nil).Once() | |||
| 94 | + | require.NoError(t, echo.input(2, []byte("b"))) | |||
| 95 | + | ||||
| 96 | + | var repaint []byte | |||
| 97 | + | out.EXPECT().writePrediction(mock.Anything).RunAndReturn(func(data []byte) (bool, error) { | |||
| 98 | + | repaint = append([]byte(nil), data...) | |||
| 99 | + | return true, nil | |||
| 100 | + | }).Once() | |||
| 101 | + | require.NoError(t, echo.resized(domain.Size{Cols: 20, Rows: 3})) | |||
| 102 | + | require.Equal(t, "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H", string(repaint)) | |||
| 103 | + | ||||
| 104 | + | // A new size brings a full repaint from the daemon: nothing is written. | |||
| 105 | + | require.NoError(t, echo.resized(domain.Size{Cols: 30, Rows: 3})) | |||
| 106 | + | } | |||
M internal/usecase/client/attachment_session.go +3 -1
| @@ -639,7 +639,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 639 | 639 | event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | |
| 640 | 640 | } | |
| 641 | 641 | if resize, ok := event.message.(protocol.Resize); ok { | |
| 642 | - | echo.resized() | |
| 642 | + | if err := echo.resized(resize.Size); err != nil { | |
| 643 | + | return w.settle(ctx, fg, stream, token, err) | |
| 644 | + | } | |
| 643 | 645 | if err := picker.resize(ctx, state, resize.Size); err != nil { | |
| 644 | 646 | return w.settle(ctx, fg, stream, token, err) | |
| 645 | 647 | } | |
| @@ -639,7 +639,9 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 639 | event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | 639 | event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | ||
| 640 | } | 640 | } | ||
| 641 | if resize, ok := event.message.(protocol.Resize); ok { | 641 | if resize, ok := event.message.(protocol.Resize); ok { | ||
| 642 | - | echo.resized() | 642 | + | if err := echo.resized(resize.Size); err != nil { |
| 643 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 644 | + | } | |||
| 643 | if err := picker.resize(ctx, state, resize.Size); err != nil { | 645 | if err := picker.resize(ctx, state, resize.Size); err != nil { | ||
| 644 | return w.settle(ctx, fg, stream, token, err) | 646 | return w.settle(ctx, fg, stream, token, err) | ||
| 645 | } | 647 | } | ||
A internal/usecase/client/prediction_foreground_mock_test.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package client | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | mock "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // newMockattachmentPredictionForeground creates a new instance of mockattachmentPredictionForeground. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12 | + | // The first argument is typically a *testing.T value. | |
| 13 | + | func newMockattachmentPredictionForeground(t interface { | |
| 14 | + | mock.TestingT | |
| 15 | + | Cleanup(func()) | |
| 16 | + | }) *mockattachmentPredictionForeground { | |
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 18 | + | helper.Helper() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | mock := &mockattachmentPredictionForeground{} | |
| 22 | + | mock.Mock.Test(t) | |
| 23 | + | ||
| 24 | + | t.Cleanup(func() { | |
| 25 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 26 | + | helper.Helper() | |
| 27 | + | } | |
| 28 | + | mock.AssertExpectations(t) | |
| 29 | + | }) | |
| 30 | + | ||
| 31 | + | return mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // mockattachmentPredictionForeground is an autogenerated mock type for the attachmentPredictionForeground type | |
| 35 | + | type mockattachmentPredictionForeground struct { | |
| 36 | + | mock.Mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | type mockattachmentPredictionForeground_Expecter struct { | |
| 40 | + | mock *mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (_m *mockattachmentPredictionForeground) EXPECT() *mockattachmentPredictionForeground_Expecter { | |
| 44 | + | return &mockattachmentPredictionForeground_Expecter{mock: &_m.Mock} | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // writePrediction provides a mock function for the type mockattachmentPredictionForeground | |
| 48 | + | func (_mock *mockattachmentPredictionForeground) writePrediction(data []byte) (bool, error) { | |
| 49 | + | ret := _mock.Called(data) | |
| 50 | + | ||
| 51 | + | if len(ret) == 0 { | |
| 52 | + | panic("no return value specified for writePrediction") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | var r0 bool | |
| 56 | + | var r1 error | |
| 57 | + | if returnFunc, ok := ret.Get(0).(func([]byte) (bool, error)); ok { | |
| 58 | + | return returnFunc(data) | |
| 59 | + | } | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func([]byte) bool); ok { | |
| 61 | + | r0 = returnFunc(data) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Get(0).(bool) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { | |
| 66 | + | r1 = returnFunc(data) | |
| 67 | + | } else { | |
| 68 | + | r1 = ret.Error(1) | |
| 69 | + | } | |
| 70 | + | return r0, r1 | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // mockattachmentPredictionForeground_writePrediction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'writePrediction' | |
| 74 | + | type mockattachmentPredictionForeground_writePrediction_Call struct { | |
| 75 | + | *mock.Call | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // writePrediction is a helper method to define mock.On call | |
| 79 | + | // - data []byte | |
| 80 | + | func (_e *mockattachmentPredictionForeground_Expecter) writePrediction(data any) *mockattachmentPredictionForeground_writePrediction_Call { | |
| 81 | + | return &mockattachmentPredictionForeground_writePrediction_Call{Call: _e.mock.On("writePrediction", data)} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) Run(run func(data []byte)) *mockattachmentPredictionForeground_writePrediction_Call { | |
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 86 | + | var arg0 []byte | |
| 87 | + | if args[0] != nil { | |
| 88 | + | arg0 = args[0].([]byte) | |
| 89 | + | } | |
| 90 | + | run( | |
| 91 | + | arg0, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) Return(b bool, err error) *mockattachmentPredictionForeground_writePrediction_Call { | |
| 98 | + | _c.Call.Return(b, err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) RunAndReturn(run func(data []byte) (bool, error)) *mockattachmentPredictionForeground_writePrediction_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package client | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // newMockattachmentPredictionForeground creates a new instance of mockattachmentPredictionForeground. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12 | + | // The first argument is typically a *testing.T value. | |||
| 13 | + | func newMockattachmentPredictionForeground(t interface { | |||
| 14 | + | mock.TestingT | |||
| 15 | + | Cleanup(func()) | |||
| 16 | + | }) *mockattachmentPredictionForeground { | |||
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 18 | + | helper.Helper() | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | mock := &mockattachmentPredictionForeground{} | |||
| 22 | + | mock.Mock.Test(t) | |||
| 23 | + | ||||
| 24 | + | t.Cleanup(func() { | |||
| 25 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 26 | + | helper.Helper() | |||
| 27 | + | } | |||
| 28 | + | mock.AssertExpectations(t) | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | return mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // mockattachmentPredictionForeground is an autogenerated mock type for the attachmentPredictionForeground type | |||
| 35 | + | type mockattachmentPredictionForeground struct { | |||
| 36 | + | mock.Mock | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type mockattachmentPredictionForeground_Expecter struct { | |||
| 40 | + | mock *mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (_m *mockattachmentPredictionForeground) EXPECT() *mockattachmentPredictionForeground_Expecter { | |||
| 44 | + | return &mockattachmentPredictionForeground_Expecter{mock: &_m.Mock} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // writePrediction provides a mock function for the type mockattachmentPredictionForeground | |||
| 48 | + | func (_mock *mockattachmentPredictionForeground) writePrediction(data []byte) (bool, error) { | |||
| 49 | + | ret := _mock.Called(data) | |||
| 50 | + | ||||
| 51 | + | if len(ret) == 0 { | |||
| 52 | + | panic("no return value specified for writePrediction") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | var r0 bool | |||
| 56 | + | var r1 error | |||
| 57 | + | if returnFunc, ok := ret.Get(0).(func([]byte) (bool, error)); ok { | |||
| 58 | + | return returnFunc(data) | |||
| 59 | + | } | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func([]byte) bool); ok { | |||
| 61 | + | r0 = returnFunc(data) | |||
| 62 | + | } else { | |||
| 63 | + | r0 = ret.Get(0).(bool) | |||
| 64 | + | } | |||
| 65 | + | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { | |||
| 66 | + | r1 = returnFunc(data) | |||
| 67 | + | } else { | |||
| 68 | + | r1 = ret.Error(1) | |||
| 69 | + | } | |||
| 70 | + | return r0, r1 | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // mockattachmentPredictionForeground_writePrediction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'writePrediction' | |||
| 74 | + | type mockattachmentPredictionForeground_writePrediction_Call struct { | |||
| 75 | + | *mock.Call | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // writePrediction is a helper method to define mock.On call | |||
| 79 | + | // - data []byte | |||
| 80 | + | func (_e *mockattachmentPredictionForeground_Expecter) writePrediction(data any) *mockattachmentPredictionForeground_writePrediction_Call { | |||
| 81 | + | return &mockattachmentPredictionForeground_writePrediction_Call{Call: _e.mock.On("writePrediction", data)} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) Run(run func(data []byte)) *mockattachmentPredictionForeground_writePrediction_Call { | |||
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 86 | + | var arg0 []byte | |||
| 87 | + | if args[0] != nil { | |||
| 88 | + | arg0 = args[0].([]byte) | |||
| 89 | + | } | |||
| 90 | + | run( | |||
| 91 | + | arg0, | |||
| 92 | + | ) | |||
| 93 | + | }) | |||
| 94 | + | return _c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) Return(b bool, err error) *mockattachmentPredictionForeground_writePrediction_Call { | |||
| 98 | + | _c.Call.Return(b, err) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *mockattachmentPredictionForeground_writePrediction_Call) RunAndReturn(run func(data []byte) (bool, error)) *mockattachmentPredictionForeground_writePrediction_Call { | |||
| 103 | + | _c.Call.Return(run) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
M internal/usecase/client/predictive_echo.go +152 -13
| @@ -140,6 +140,7 @@ type echoPredictor struct { | |||
| 140 | 140 | drawnRow int | |
| 141 | 141 | drawn []int | |
| 142 | 142 | cursorMove bool | |
| 143 | + | apc apcFilter | |
| 143 | 144 | } | |
| 144 | 145 | ||
| 145 | 146 | func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor { | |
| @@ -161,7 +162,9 @@ func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) { | |||
| 161 | 162 | p.drawn = p.drawn[:0] | |
| 162 | 163 | p.cursorMove = false | |
| 163 | 164 | } | |
| 164 | - | p.screen.Write(output.Data) | |
| 165 | + | if data := p.apc.strip(output.Data); len(data) > 0 { | |
| 166 | + | p.screen.Write(data) | |
| 167 | + | } | |
| 165 | 168 | if output.Echo > p.echo { | |
| 166 | 169 | p.sample(output.Echo, now) | |
| 167 | 170 | p.echo = output.Echo | |
| @@ -351,6 +354,23 @@ func (p *echoPredictor) cellValidity(col int) echoValidity { | |||
| 351 | 354 | ||
| 352 | 355 | func isBlankRune(r rune) bool { return r == 0 || r == ' ' } | |
| 353 | 356 | ||
| 357 | + | // isBorderRune reports a box-drawing rune: a pane border or bar the shell | |
| 358 | + | // line never shifts across. | |
| 359 | + | func 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. | |
| 363 | + | func 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. | |
| 368 | + | func (p *echoPredictor) unknownKey(seq uint64) { | |
| 369 | + | p.becomeTentative() | |
| 370 | + | p.cursor = echoCursor{} | |
| 371 | + | p.holdUntil = seq | |
| 372 | + | } | |
| 373 | + | ||
| 354 | 374 | // input records the Input numbered seq and predicts its bytes. | |
| 355 | 375 | func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | |
| 356 | 376 | if seq == 0 || p.mode == domain.EchoPredictNever { | |
| @@ -361,7 +381,10 @@ func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | |||
| 361 | 381 | } | |
| 362 | 382 | p.sent = append(p.sent, echoSent{seq: seq, at: now}) | |
| 363 | 383 | p.cull(now) | |
| 364 | - | if !p.enabled() { | |
| 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 { | |
| 365 | 388 | return | |
| 366 | 389 | } | |
| 367 | 390 | // A bracketed paste arrives as one Input: it is not typing. | |
| @@ -384,9 +407,7 @@ func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | |||
| 384 | 407 | p.backspace(seq, now) | |
| 385 | 408 | case r == '\r': | |
| 386 | 409 | // The next row's state is only known once the daemon answers. | |
| 387 | - | p.becomeTentative() | |
| 388 | - | p.cursor = echoCursor{} | |
| 389 | - | p.holdUntil = seq | |
| 410 | + | p.unknownKey(seq) | |
| 390 | 411 | case vt.RuneWidth(r) != 1: | |
| 391 | 412 | p.becomeTentative() | |
| 392 | 413 | default: | |
| @@ -411,6 +432,17 @@ func escapeLength(data []byte) int { | |||
| 411 | 432 | return len(data) | |
| 412 | 433 | case 'O': | |
| 413 | 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) | |
| 414 | 446 | } | |
| 415 | 447 | return 2 | |
| 416 | 448 | } | |
| @@ -470,6 +502,12 @@ func (p *echoPredictor) print(r rune, seq uint64, now time.Time) { | |||
| 470 | 502 | end := col | |
| 471 | 503 | for end < width-1 { | |
| 472 | 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 | + | } | |
| 473 | 511 | if known && isBlankRune(cell.Rune) { | |
| 474 | 512 | break | |
| 475 | 513 | } | |
| @@ -520,6 +558,16 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) { | |||
| 520 | 558 | } | |
| 521 | 559 | width := len(p.cells) | |
| 522 | 560 | col := p.cursor.col - 1 | |
| 561 | + | for i := col; i < width; i++ { | |
| 562 | + | cell, known := p.effective(i) | |
| 563 | + | if known && !isPlainCell(cell) { | |
| 564 | + | p.unknownKey(seq) | |
| 565 | + | return | |
| 566 | + | } | |
| 567 | + | if known && (isBlankRune(cell.Rune) || isBorderRune(cell.Rune)) { | |
| 568 | + | break | |
| 569 | + | } | |
| 570 | + | } | |
| 523 | 571 | p.moveCursor(col, seq) | |
| 524 | 572 | for i := col; i < width; i++ { | |
| 525 | 573 | cell := &p.cells[i] | |
| @@ -530,6 +578,10 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) { | |||
| 530 | 578 | next, known = p.effective(i + 1) | |
| 531 | 579 | } | |
| 532 | 580 | blank := known && isBlankRune(next.Rune) | |
| 581 | + | if known && isBorderRune(next.Rune) { | |
| 582 | + | // The text ends at a border: what fills this cell is unknown. | |
| 583 | + | known, blank = false, true | |
| 584 | + | } | |
| 533 | 585 | cell.resetWithOriginal() | |
| 534 | 586 | cell.arm(seq, p.predEpoch, now) | |
| 535 | 587 | cell.originals = append(cell.originals, original) | |
| @@ -576,7 +628,7 @@ func (p *echoPredictor) render() []byte { | |||
| 576 | 628 | continue | |
| 577 | 629 | } | |
| 578 | 630 | if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() { | |
| 579 | - | writeEchoCell(&out, p.drawnRow, col, p.screen.Cell(col, p.drawnRow), false) | |
| 631 | + | p.restoreCell(&out, p.drawnRow, col) | |
| 580 | 632 | } | |
| 581 | 633 | } | |
| 582 | 634 | for _, col := range want { | |
| @@ -609,18 +661,30 @@ func writeEchoCUP(out *bytes.Buffer, row, col int) { | |||
| 609 | 661 | out.WriteByte('H') | |
| 610 | 662 | } | |
| 611 | 663 | ||
| 664 | + | // restoreCell repaints the mirror's cell at (row, col). The right half of a | |
| 665 | + | // wide glyph is repainted from its left cell, which covers both columns. | |
| 666 | + | func (p *echoPredictor) restoreCell(out *bytes.Buffer, row, col int) { | |
| 667 | + | cell := p.screen.Cell(col, row) | |
| 668 | + | if cell.Continuation && col > 0 { | |
| 669 | + | col-- | |
| 670 | + | cell = p.screen.Cell(col, row) | |
| 671 | + | } | |
| 672 | + | writeEchoCell(out, row, col, cell, false) | |
| 673 | + | } | |
| 674 | + | ||
| 612 | 675 | // writeEchoCell draws one cell. Mirror styles come from daemon output already | |
| 613 | 676 | // projected to this terminal's color profile, so they are written as is. | |
| 614 | 677 | func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) { | |
| 615 | 678 | writeEchoCUP(out, row, col) | |
| 616 | 679 | out.WriteString(echoSGR(cell.Style, underline)) | |
| 617 | - | r := cell.Rune | |
| 618 | - | if cell.Continuation || r == 0 || vt.RuneWidth(r) != 1 { | |
| 619 | - | // A wide rune's halves cannot be drawn alone; a blank is the safe | |
| 620 | - | // stand-in until the daemon's next frame. | |
| 621 | - | r = ' ' | |
| 680 | + | switch { | |
| 681 | + | case cell.Continuation || cell.Rune == 0: | |
| 682 | + | out.WriteByte(' ') | |
| 683 | + | case cell.Payload.Grapheme() != "": | |
| 684 | + | out.WriteString(cell.Payload.Grapheme()) | |
| 685 | + | default: | |
| 686 | + | out.WriteRune(cell.Rune) | |
| 622 | 687 | } | |
| 623 | - | out.WriteRune(r) | |
| 624 | 688 | } | |
| 625 | 689 | ||
| 626 | 690 | func echoSGR(style vt.Style, underline bool) string { | |
| @@ -635,8 +699,22 @@ func echoSGR(style vt.Style, underline bool) string { | |||
| 635 | 699 | if style.Italic { | |
| 636 | 700 | b.WriteString(";3") | |
| 637 | 701 | } | |
| 638 | - | if underline || style.Attrs&vt.AttrUnderline != 0 { | |
| 702 | + | switch { | |
| 703 | + | case underline: | |
| 639 | 704 | b.WriteString(";4") | |
| 705 | + | case style.Attrs&vt.AttrUnderline != 0: | |
| 706 | + | switch style.UnderlineStyle { | |
| 707 | + | case vt.UnderlineDouble: | |
| 708 | + | b.WriteString(";21") | |
| 709 | + | case vt.UnderlineCurly: | |
| 710 | + | b.WriteString(";4:3") | |
| 711 | + | case vt.UnderlineDotted: | |
| 712 | + | b.WriteString(";4:4") | |
| 713 | + | case vt.UnderlineDashed: | |
| 714 | + | b.WriteString(";4:5") | |
| 715 | + | default: | |
| 716 | + | b.WriteString(";4") | |
| 717 | + | } | |
| 640 | 718 | } | |
| 641 | 719 | if style.Attrs&vt.AttrBlink != 0 { | |
| 642 | 720 | b.WriteString(";5") | |
| @@ -649,6 +727,12 @@ func echoSGR(style vt.Style, underline bool) string { | |||
| 649 | 727 | } | |
| 650 | 728 | writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90) | |
| 651 | 729 | writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100) | |
| 730 | + | switch { | |
| 731 | + | case style.HasUnderlineColorRGB: | |
| 732 | + | b.WriteString(";58;2;" + strconv.Itoa(int(style.UnderlineColorRGB.R)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.G)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.B))) | |
| 733 | + | case style.HasUnderlineColor: | |
| 734 | + | b.WriteString(";58;5;" + strconv.Itoa(style.UnderlineColor)) | |
| 735 | + | } | |
| 652 | 736 | b.WriteByte('m') | |
| 653 | 737 | return b.String() | |
| 654 | 738 | } | |
| @@ -666,3 +750,58 @@ func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended | |||
| 666 | 750 | b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index)) | |
| 667 | 751 | } | |
| 668 | 752 | } | |
| 753 | + | ||
| 754 | + | // apcFilter drops APC strings (ESC _ ... ST), which carry kitty graphics, from | |
| 755 | + | // the mirror's input: the engine needs text only, and decoding images would | |
| 756 | + | // cost the client CPU and memory. Its state spans Output boundaries. | |
| 757 | + | type apcFilter struct { | |
| 758 | + | state uint8 | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | const ( | |
| 762 | + | apcText uint8 = iota | |
| 763 | + | apcEscape | |
| 764 | + | apcBody | |
| 765 | + | apcBodyEscape | |
| 766 | + | ) | |
| 767 | + | ||
| 768 | + | func (f *apcFilter) strip(data []byte) []byte { | |
| 769 | + | if f.state == apcText && bytes.IndexByte(data, 0x1b) < 0 { | |
| 770 | + | return data | |
| 771 | + | } | |
| 772 | + | out := make([]byte, 0, len(data)) | |
| 773 | + | for _, b := range data { | |
| 774 | + | switch f.state { | |
| 775 | + | case apcText: | |
| 776 | + | if b == 0x1b { | |
| 777 | + | f.state = apcEscape | |
| 778 | + | continue | |
| 779 | + | } | |
| 780 | + | out = append(out, b) | |
| 781 | + | case apcEscape: | |
| 782 | + | if b == '_' { | |
| 783 | + | f.state = apcBody | |
| 784 | + | continue | |
| 785 | + | } | |
| 786 | + | out = append(out, 0x1b, b) | |
| 787 | + | f.state = apcText | |
| 788 | + | if b == 0x1b { | |
| 789 | + | out = out[:len(out)-1] | |
| 790 | + | f.state = apcEscape | |
| 791 | + | } | |
| 792 | + | case apcBody: | |
| 793 | + | if b == 0x1b { | |
| 794 | + | f.state = apcBodyEscape | |
| 795 | + | } | |
| 796 | + | case apcBodyEscape: | |
| 797 | + | switch b { | |
| 798 | + | case '\\': | |
| 799 | + | f.state = apcText | |
| 800 | + | case 0x1b: | |
| 801 | + | default: | |
| 802 | + | f.state = apcBody | |
| 803 | + | } | |
| 804 | + | } | |
| 805 | + | } | |
| 806 | + | return out | |
| 807 | + | } | |
| @@ -140,6 +140,7 @@ type echoPredictor struct { | |||||
| 140 | drawnRow int | 140 | drawnRow int | ||
| 141 | drawn []int | 141 | drawn []int | ||
| 142 | cursorMove bool | 142 | cursorMove bool | ||
| 143 | + | apc apcFilter | |||
| 143 | } | 144 | } | ||
| 144 | 145 | ||||
| 145 | func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor { | 146 | func newEchoPredictor(mode domain.EchoPredictMode) *echoPredictor { | ||
| @@ -161,7 +162,9 @@ func (p *echoPredictor) applyOutput(output protocol.Output, now time.Time) { | |||||
| 161 | p.drawn = p.drawn[:0] | 162 | p.drawn = p.drawn[:0] | ||
| 162 | p.cursorMove = false | 163 | p.cursorMove = false | ||
| 163 | } | 164 | } | ||
| 164 | - | p.screen.Write(output.Data) | 165 | + | if data := p.apc.strip(output.Data); len(data) > 0 { |
| 166 | + | p.screen.Write(data) | |||
| 167 | + | } | |||
| 165 | if output.Echo > p.echo { | 168 | if output.Echo > p.echo { | ||
| 166 | p.sample(output.Echo, now) | 169 | p.sample(output.Echo, now) | ||
| 167 | p.echo = output.Echo | 170 | p.echo = output.Echo | ||
| @@ -351,6 +354,23 @@ func (p *echoPredictor) cellValidity(col int) echoValidity { | |||||
| 351 | 354 | ||||
| 352 | func isBlankRune(r rune) bool { return r == 0 || r == ' ' } | 355 | func isBlankRune(r rune) bool { return r == 0 || r == ' ' } | ||
| 353 | 356 | ||||
| 357 | + | // isBorderRune reports a box-drawing rune: a pane border or bar the shell | |||
| 358 | + | // line never shifts across. | |||
| 359 | + | func 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. | |||
| 363 | + | func 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. | |||
| 368 | + | func (p *echoPredictor) unknownKey(seq uint64) { | |||
| 369 | + | p.becomeTentative() | |||
| 370 | + | p.cursor = echoCursor{} | |||
| 371 | + | p.holdUntil = seq | |||
| 372 | + | } | |||
| 373 | + | ||||
| 354 | // input records the Input numbered seq and predicts its bytes. | 374 | // input records the Input numbered seq and predicts its bytes. | ||
| 355 | func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | 375 | func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | ||
| 356 | if seq == 0 || p.mode == domain.EchoPredictNever { | 376 | if seq == 0 || p.mode == domain.EchoPredictNever { | ||
| @@ -361,7 +381,10 @@ func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | |||||
| 361 | } | 381 | } | ||
| 362 | p.sent = append(p.sent, echoSent{seq: seq, at: now}) | 382 | p.sent = append(p.sent, echoSent{seq: seq, at: now}) | ||
| 363 | p.cull(now) | 383 | p.cull(now) | ||
| 364 | - | if !p.enabled() { | 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 { | |||
| 365 | return | 388 | return | ||
| 366 | } | 389 | } | ||
| 367 | // A bracketed paste arrives as one Input: it is not typing. | 390 | // A bracketed paste arrives as one Input: it is not typing. | ||
| @@ -384,9 +407,7 @@ func (p *echoPredictor) input(seq uint64, data []byte, now time.Time) { | |||||
| 384 | p.backspace(seq, now) | 407 | p.backspace(seq, now) | ||
| 385 | case r == '\r': | 408 | case r == '\r': | ||
| 386 | // The next row's state is only known once the daemon answers. | 409 | // The next row's state is only known once the daemon answers. | ||
| 387 | - | p.becomeTentative() | 410 | + | p.unknownKey(seq) |
| 388 | - | p.cursor = echoCursor{} | |||
| 389 | - | p.holdUntil = seq | |||
| 390 | case vt.RuneWidth(r) != 1: | 411 | case vt.RuneWidth(r) != 1: | ||
| 391 | p.becomeTentative() | 412 | p.becomeTentative() | ||
| 392 | default: | 413 | default: | ||
| @@ -411,6 +432,17 @@ func escapeLength(data []byte) int { | |||||
| 411 | return len(data) | 432 | return len(data) | ||
| 412 | case 'O': | 433 | case 'O': | ||
| 413 | return min(3, len(data)) | 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) | |||
| 414 | } | 446 | } | ||
| 415 | return 2 | 447 | return 2 | ||
| 416 | } | 448 | } | ||
| @@ -470,6 +502,12 @@ func (p *echoPredictor) print(r rune, seq uint64, now time.Time) { | |||||
| 470 | end := col | 502 | end := col | ||
| 471 | for end < width-1 { | 503 | for end < width-1 { | ||
| 472 | cell, known := p.effective(end) | 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 | + | } | |||
| 473 | if known && isBlankRune(cell.Rune) { | 511 | if known && isBlankRune(cell.Rune) { | ||
| 474 | break | 512 | break | ||
| 475 | } | 513 | } | ||
| @@ -520,6 +558,16 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) { | |||||
| 520 | } | 558 | } | ||
| 521 | width := len(p.cells) | 559 | width := len(p.cells) | ||
| 522 | col := p.cursor.col - 1 | 560 | col := p.cursor.col - 1 | ||
| 561 | + | for i := col; i < width; i++ { | |||
| 562 | + | cell, known := p.effective(i) | |||
| 563 | + | if known && !isPlainCell(cell) { | |||
| 564 | + | p.unknownKey(seq) | |||
| 565 | + | return | |||
| 566 | + | } | |||
| 567 | + | if known && (isBlankRune(cell.Rune) || isBorderRune(cell.Rune)) { | |||
| 568 | + | break | |||
| 569 | + | } | |||
| 570 | + | } | |||
| 523 | p.moveCursor(col, seq) | 571 | p.moveCursor(col, seq) | ||
| 524 | for i := col; i < width; i++ { | 572 | for i := col; i < width; i++ { | ||
| 525 | cell := &p.cells[i] | 573 | cell := &p.cells[i] | ||
| @@ -530,6 +578,10 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) { | |||||
| 530 | next, known = p.effective(i + 1) | 578 | next, known = p.effective(i + 1) | ||
| 531 | } | 579 | } | ||
| 532 | blank := known && isBlankRune(next.Rune) | 580 | blank := known && isBlankRune(next.Rune) | ||
| 581 | + | if known && isBorderRune(next.Rune) { | |||
| 582 | + | // The text ends at a border: what fills this cell is unknown. | |||
| 583 | + | known, blank = false, true | |||
| 584 | + | } | |||
| 533 | cell.resetWithOriginal() | 585 | cell.resetWithOriginal() | ||
| 534 | cell.arm(seq, p.predEpoch, now) | 586 | cell.arm(seq, p.predEpoch, now) | ||
| 535 | cell.originals = append(cell.originals, original) | 587 | cell.originals = append(cell.originals, original) | ||
| @@ -576,7 +628,7 @@ func (p *echoPredictor) render() []byte { | |||||
| 576 | continue | 628 | continue | ||
| 577 | } | 629 | } | ||
| 578 | if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() { | 630 | if p.drawnRow < p.screen.Rows() && col < p.screen.Columns() { | ||
| 579 | - | writeEchoCell(&out, p.drawnRow, col, p.screen.Cell(col, p.drawnRow), false) | 631 | + | p.restoreCell(&out, p.drawnRow, col) |
| 580 | } | 632 | } | ||
| 581 | } | 633 | } | ||
| 582 | for _, col := range want { | 634 | for _, col := range want { | ||
| @@ -609,18 +661,30 @@ func writeEchoCUP(out *bytes.Buffer, row, col int) { | |||||
| 609 | out.WriteByte('H') | 661 | out.WriteByte('H') | ||
| 610 | } | 662 | } | ||
| 611 | 663 | ||||
| 664 | + | // restoreCell repaints the mirror's cell at (row, col). The right half of a | |||
| 665 | + | // wide glyph is repainted from its left cell, which covers both columns. | |||
| 666 | + | func (p *echoPredictor) restoreCell(out *bytes.Buffer, row, col int) { | |||
| 667 | + | cell := p.screen.Cell(col, row) | |||
| 668 | + | if cell.Continuation && col > 0 { | |||
| 669 | + | col-- | |||
| 670 | + | cell = p.screen.Cell(col, row) | |||
| 671 | + | } | |||
| 672 | + | writeEchoCell(out, row, col, cell, false) | |||
| 673 | + | } | |||
| 674 | + | ||||
| 612 | // writeEchoCell draws one cell. Mirror styles come from daemon output already | 675 | // writeEchoCell draws one cell. Mirror styles come from daemon output already | ||
| 613 | // projected to this terminal's color profile, so they are written as is. | 676 | // projected to this terminal's color profile, so they are written as is. | ||
| 614 | func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) { | 677 | func writeEchoCell(out *bytes.Buffer, row, col int, cell vt.Cell, underline bool) { | ||
| 615 | writeEchoCUP(out, row, col) | 678 | writeEchoCUP(out, row, col) | ||
| 616 | out.WriteString(echoSGR(cell.Style, underline)) | 679 | out.WriteString(echoSGR(cell.Style, underline)) | ||
| 617 | - | r := cell.Rune | 680 | + | switch { |
| 618 | - | if cell.Continuation || r == 0 || vt.RuneWidth(r) != 1 { | 681 | + | case cell.Continuation || cell.Rune == 0: |
| 619 | - | // A wide rune's halves cannot be drawn alone; a blank is the safe | 682 | + | out.WriteByte(' ') |
| 620 | - | // stand-in until the daemon's next frame. | 683 | + | case cell.Payload.Grapheme() != "": |
| 621 | - | r = ' ' | 684 | + | out.WriteString(cell.Payload.Grapheme()) |
| 685 | + | default: | |||
| 686 | + | out.WriteRune(cell.Rune) | |||
| 622 | } | 687 | } | ||
| 623 | - | out.WriteRune(r) | |||
| 624 | } | 688 | } | ||
| 625 | 689 | ||||
| 626 | func echoSGR(style vt.Style, underline bool) string { | 690 | func echoSGR(style vt.Style, underline bool) string { | ||
| @@ -635,8 +699,22 @@ func echoSGR(style vt.Style, underline bool) string { | |||||
| 635 | if style.Italic { | 699 | if style.Italic { | ||
| 636 | b.WriteString(";3") | 700 | b.WriteString(";3") | ||
| 637 | } | 701 | } | ||
| 638 | - | if underline || style.Attrs&vt.AttrUnderline != 0 { | 702 | + | switch { |
| 703 | + | case underline: | |||
| 639 | b.WriteString(";4") | 704 | b.WriteString(";4") | ||
| 705 | + | case style.Attrs&vt.AttrUnderline != 0: | |||
| 706 | + | switch style.UnderlineStyle { | |||
| 707 | + | case vt.UnderlineDouble: | |||
| 708 | + | b.WriteString(";21") | |||
| 709 | + | case vt.UnderlineCurly: | |||
| 710 | + | b.WriteString(";4:3") | |||
| 711 | + | case vt.UnderlineDotted: | |||
| 712 | + | b.WriteString(";4:4") | |||
| 713 | + | case vt.UnderlineDashed: | |||
| 714 | + | b.WriteString(";4:5") | |||
| 715 | + | default: | |||
| 716 | + | b.WriteString(";4") | |||
| 717 | + | } | |||
| 640 | } | 718 | } | ||
| 641 | if style.Attrs&vt.AttrBlink != 0 { | 719 | if style.Attrs&vt.AttrBlink != 0 { | ||
| 642 | b.WriteString(";5") | 720 | b.WriteString(";5") | ||
| @@ -649,6 +727,12 @@ func echoSGR(style vt.Style, underline bool) string { | |||||
| 649 | } | 727 | } | ||
| 650 | writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90) | 728 | writeEchoColor(&b, style.HasForegroundRGB, style.ForegroundRGB, style.Foreground, 38, 30, 90) | ||
| 651 | writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100) | 729 | writeEchoColor(&b, style.HasBackgroundRGB, style.BackgroundRGB, style.Background, 48, 40, 100) | ||
| 730 | + | switch { | |||
| 731 | + | case style.HasUnderlineColorRGB: | |||
| 732 | + | b.WriteString(";58;2;" + strconv.Itoa(int(style.UnderlineColorRGB.R)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.G)) + ";" + strconv.Itoa(int(style.UnderlineColorRGB.B))) | |||
| 733 | + | case style.HasUnderlineColor: | |||
| 734 | + | b.WriteString(";58;5;" + strconv.Itoa(style.UnderlineColor)) | |||
| 735 | + | } | |||
| 652 | b.WriteByte('m') | 736 | b.WriteByte('m') | ||
| 653 | return b.String() | 737 | return b.String() | ||
| 654 | } | 738 | } | ||
| @@ -666,3 +750,58 @@ func writeEchoColor(b *strings.Builder, hasRGB bool, rgb vt.RGB, index, extended | |||||
| 666 | b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index)) | 750 | b.WriteString(";" + strconv.Itoa(extended) + ";5;" + strconv.Itoa(index)) | ||
| 667 | } | 751 | } | ||
| 668 | } | 752 | } | ||
| 753 | + | ||||
| 754 | + | // apcFilter drops APC strings (ESC _ ... ST), which carry kitty graphics, from | |||
| 755 | + | // the mirror's input: the engine needs text only, and decoding images would | |||
| 756 | + | // cost the client CPU and memory. Its state spans Output boundaries. | |||
| 757 | + | type apcFilter struct { | |||
| 758 | + | state uint8 | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | const ( | |||
| 762 | + | apcText uint8 = iota | |||
| 763 | + | apcEscape | |||
| 764 | + | apcBody | |||
| 765 | + | apcBodyEscape | |||
| 766 | + | ) | |||
| 767 | + | ||||
| 768 | + | func (f *apcFilter) strip(data []byte) []byte { | |||
| 769 | + | if f.state == apcText && bytes.IndexByte(data, 0x1b) < 0 { | |||
| 770 | + | return data | |||
| 771 | + | } | |||
| 772 | + | out := make([]byte, 0, len(data)) | |||
| 773 | + | for _, b := range data { | |||
| 774 | + | switch f.state { | |||
| 775 | + | case apcText: | |||
| 776 | + | if b == 0x1b { | |||
| 777 | + | f.state = apcEscape | |||
| 778 | + | continue | |||
| 779 | + | } | |||
| 780 | + | out = append(out, b) | |||
| 781 | + | case apcEscape: | |||
| 782 | + | if b == '_' { | |||
| 783 | + | f.state = apcBody | |||
| 784 | + | continue | |||
| 785 | + | } | |||
| 786 | + | out = append(out, 0x1b, b) | |||
| 787 | + | f.state = apcText | |||
| 788 | + | if b == 0x1b { | |||
| 789 | + | out = out[:len(out)-1] | |||
| 790 | + | f.state = apcEscape | |||
| 791 | + | } | |||
| 792 | + | case apcBody: | |||
| 793 | + | if b == 0x1b { | |||
| 794 | + | f.state = apcBodyEscape | |||
| 795 | + | } | |||
| 796 | + | case apcBodyEscape: | |||
| 797 | + | switch b { | |||
| 798 | + | case '\\': | |||
| 799 | + | f.state = apcText | |||
| 800 | + | case 0x1b: | |||
| 801 | + | default: | |||
| 802 | + | f.state = apcBody | |||
| 803 | + | } | |||
| 804 | + | } | |||
| 805 | + | } | |||
| 806 | + | return out | |||
| 807 | + | } | |||
M internal/usecase/client/predictive_echo_test.go +93 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| @@ -149,6 +150,7 @@ func TestEchoPredictorBackspace(t *testing.T) { | |||
| 149 | 150 | func TestEchoPredictorInsertShiftsText(t *testing.T) { | |
| 150 | 151 | t.Parallel() | |
| 151 | 152 | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 153 | + | h.warm(10 * time.Millisecond) | |
| 152 | 154 | // "$ ac" with the cursor on c. | |
| 153 | 155 | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now) | |
| 154 | 156 | h.p.confEpoch = h.p.predEpoch | |
| @@ -318,3 +320,94 @@ func TestEchoSGR(t *testing.T) { | |||
| 318 | 320 | t.Fatalf("echoSGR rgb = %q, want %q", got, want) | |
| 319 | 321 | } | |
| 320 | 322 | } | |
| 323 | + | ||
| 324 | + | func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) { | |
| 325 | + | t.Parallel() | |
| 326 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 327 | + | h.p.confEpoch = h.p.predEpoch | |
| 328 | + | if got := h.typed("b"); len(got) != 0 || h.p.active() { | |
| 329 | + | t.Fatalf("predicted before any echo acknowledgement: %q", got) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func TestEchoPredictorRefusesUnshiftableText(t *testing.T) { | |
| 334 | + | t.Parallel() | |
| 335 | + | tests := []struct { | |
| 336 | + | name string | |
| 337 | + | frame string | |
| 338 | + | }{ | |
| 339 | + | {name: "wide rune after the cursor", frame: "a界\x1b[1;3H"}, | |
| 340 | + | {name: "pane border after the cursor", frame: "ab│\x1b[1;3H"}, | |
| 341 | + | } | |
| 342 | + | for _, tt := range tests { | |
| 343 | + | t.Run(tt.name, func(t *testing.T) { | |
| 344 | + | t.Parallel() | |
| 345 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 346 | + | h.warm(10 * time.Millisecond) | |
| 347 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now) | |
| 348 | + | h.p.confEpoch = h.p.predEpoch | |
| 349 | + | if got := h.typed("x"); len(got) != 0 || h.p.active() { | |
| 350 | + | t.Fatalf("shifted unshiftable text: %q", got) | |
| 351 | + | } | |
| 352 | + | }) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestEchoPredictorRestoresWideCellWhole(t *testing.T) { | |
| 357 | + | t.Parallel() | |
| 358 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 359 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[1;1H\x1b[4:3m界\x1b[0m")}, h.now) | |
| 360 | + | var out bytes.Buffer | |
| 361 | + | h.p.restoreCell(&out, 0, 1) | |
| 362 | + | if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want { | |
| 363 | + | t.Fatalf("restoreCell = %q, want %q", got, want) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) { | |
| 368 | + | t.Parallel() | |
| 369 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 370 | + | h.warm(10 * time.Millisecond) | |
| 371 | + | h.confirm() | |
| 372 | + | if got := h.typed("b"); len(got) == 0 { | |
| 373 | + | t.Fatal("guess not drawn") | |
| 374 | + | } | |
| 375 | + | h.p.reset() | |
| 376 | + | if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want { | |
| 377 | + | t.Fatalf("render after reset = %q, want %q", got, want) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | |
| 382 | + | t.Parallel() | |
| 383 | + | var f apcFilter | |
| 384 | + | chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"} | |
| 385 | + | var got []byte | |
| 386 | + | for _, chunk := range chunks { | |
| 387 | + | got = append(got, f.strip([]byte(chunk))...) | |
| 388 | + | } | |
| 389 | + | got = append(got, f.strip([]byte("[0m"))...) | |
| 390 | + | if want := "ab\x1b[1mc\x1b[0m"; string(got) != want { | |
| 391 | + | t.Fatalf("strip = %q, want %q", got, want) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestEscapeLength(t *testing.T) { | |
| 396 | + | t.Parallel() | |
| 397 | + | tests := []struct { | |
| 398 | + | in string | |
| 399 | + | want int | |
| 400 | + | }{ | |
| 401 | + | {in: "\x1b[A", want: 3}, | |
| 402 | + | {in: "\x1bOA", want: 3}, | |
| 403 | + | {in: "\x1b]11;?\x07x", want: 7}, | |
| 404 | + | {in: "\x1b]11;?\x1b\\x", want: 8}, | |
| 405 | + | {in: "\x1b]11;", want: 5}, | |
| 406 | + | {in: "\x1bx", want: 2}, | |
| 407 | + | } | |
| 408 | + | for _, tt := range tests { | |
| 409 | + | if got := escapeLength([]byte(tt.in)); got != tt.want { | |
| 410 | + | t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want) | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package client | 1 | package client | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| @@ -149,6 +150,7 @@ func TestEchoPredictorBackspace(t *testing.T) { | |||||
| 149 | func TestEchoPredictorInsertShiftsText(t *testing.T) { | 150 | func TestEchoPredictorInsertShiftsText(t *testing.T) { | ||
| 150 | t.Parallel() | 151 | t.Parallel() | ||
| 151 | h := newEchoHarness(t, domain.EchoPredictAlways) | 152 | h := newEchoHarness(t, domain.EchoPredictAlways) | ||
| 153 | + | h.warm(10 * time.Millisecond) | |||
| 152 | // "$ ac" with the cursor on c. | 154 | // "$ ac" with the cursor on c. | ||
| 153 | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now) | 155 | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now) | ||
| 154 | h.p.confEpoch = h.p.predEpoch | 156 | h.p.confEpoch = h.p.predEpoch | ||
| @@ -318,3 +320,94 @@ func TestEchoSGR(t *testing.T) { | |||||
| 318 | t.Fatalf("echoSGR rgb = %q, want %q", got, want) | 320 | t.Fatalf("echoSGR rgb = %q, want %q", got, want) | ||
| 319 | } | 321 | } | ||
| 320 | } | 322 | } | ||
| 323 | + | ||||
| 324 | + | func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) { | |||
| 325 | + | t.Parallel() | |||
| 326 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 327 | + | h.p.confEpoch = h.p.predEpoch | |||
| 328 | + | if got := h.typed("b"); len(got) != 0 || h.p.active() { | |||
| 329 | + | t.Fatalf("predicted before any echo acknowledgement: %q", got) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func TestEchoPredictorRefusesUnshiftableText(t *testing.T) { | |||
| 334 | + | t.Parallel() | |||
| 335 | + | tests := []struct { | |||
| 336 | + | name string | |||
| 337 | + | frame string | |||
| 338 | + | }{ | |||
| 339 | + | {name: "wide rune after the cursor", frame: "a界\x1b[1;3H"}, | |||
| 340 | + | {name: "pane border after the cursor", frame: "ab│\x1b[1;3H"}, | |||
| 341 | + | } | |||
| 342 | + | for _, tt := range tests { | |||
| 343 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 344 | + | t.Parallel() | |||
| 345 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 346 | + | h.warm(10 * time.Millisecond) | |||
| 347 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now) | |||
| 348 | + | h.p.confEpoch = h.p.predEpoch | |||
| 349 | + | if got := h.typed("x"); len(got) != 0 || h.p.active() { | |||
| 350 | + | t.Fatalf("shifted unshiftable text: %q", got) | |||
| 351 | + | } | |||
| 352 | + | }) | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestEchoPredictorRestoresWideCellWhole(t *testing.T) { | |||
| 357 | + | t.Parallel() | |||
| 358 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 359 | + | h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[1;1H\x1b[4:3m界\x1b[0m")}, h.now) | |||
| 360 | + | var out bytes.Buffer | |||
| 361 | + | h.p.restoreCell(&out, 0, 1) | |||
| 362 | + | if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want { | |||
| 363 | + | t.Fatalf("restoreCell = %q, want %q", got, want) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) { | |||
| 368 | + | t.Parallel() | |||
| 369 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 370 | + | h.warm(10 * time.Millisecond) | |||
| 371 | + | h.confirm() | |||
| 372 | + | if got := h.typed("b"); len(got) == 0 { | |||
| 373 | + | t.Fatal("guess not drawn") | |||
| 374 | + | } | |||
| 375 | + | h.p.reset() | |||
| 376 | + | if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want { | |||
| 377 | + | t.Fatalf("render after reset = %q, want %q", got, want) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | |||
| 382 | + | t.Parallel() | |||
| 383 | + | var f apcFilter | |||
| 384 | + | chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"} | |||
| 385 | + | var got []byte | |||
| 386 | + | for _, chunk := range chunks { | |||
| 387 | + | got = append(got, f.strip([]byte(chunk))...) | |||
| 388 | + | } | |||
| 389 | + | got = append(got, f.strip([]byte("[0m"))...) | |||
| 390 | + | if want := "ab\x1b[1mc\x1b[0m"; string(got) != want { | |||
| 391 | + | t.Fatalf("strip = %q, want %q", got, want) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestEscapeLength(t *testing.T) { | |||
| 396 | + | t.Parallel() | |||
| 397 | + | tests := []struct { | |||
| 398 | + | in string | |||
| 399 | + | want int | |||
| 400 | + | }{ | |||
| 401 | + | {in: "\x1b[A", want: 3}, | |||
| 402 | + | {in: "\x1bOA", want: 3}, | |||
| 403 | + | {in: "\x1b]11;?\x07x", want: 7}, | |||
| 404 | + | {in: "\x1b]11;?\x1b\\x", want: 8}, | |||
| 405 | + | {in: "\x1b]11;", want: 5}, | |||
| 406 | + | {in: "\x1bx", want: 2}, | |||
| 407 | + | } | |||
| 408 | + | for _, tt := range tests { | |||
| 409 | + | if got := escapeLength([]byte(tt.in)); got != tt.want { | |||
| 410 | + | t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want) | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | } | |||