fix(client): restore the real cursor when only a predicted cursor moved
- commit
- f0493e8b37547ae9cfd26afa7fc76a018e3219ab
- author
- brice · · 2026-10-08 08:52 UTC
- committer
- brice · · 2026-10-08 09:31 UTC
- parent
- fe83619
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +23 -11
M internal/usecase/client/predictive_echo.go +1 -1
| @@ -619,7 +619,7 @@ func (p *echoPredictor) needsTick() bool { | |||
| 619 | 619 | // scrolls would otherwise move a guess where nothing ever repairs it. The | |
| 620 | 620 | // guesses stay pending and render draws them again after the frame. | |
| 621 | 621 | func (p *echoPredictor) undraw() []byte { | |
| 622 | - | if len(p.drawn) == 0 { | |
| 622 | + | if len(p.drawn) == 0 && !p.cursorMove { | |
| 623 | 623 | return nil | |
| 624 | 624 | } | |
| 625 | 625 | var out bytes.Buffer | |
| @@ -619,7 +619,7 @@ func (p *echoPredictor) needsTick() bool { | |||||
| 619 | // scrolls would otherwise move a guess where nothing ever repairs it. The | 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. | 620 | // guesses stay pending and render draws them again after the frame. | ||
| 621 | func (p *echoPredictor) undraw() []byte { | 621 | func (p *echoPredictor) undraw() []byte { | ||
| 622 | - | if len(p.drawn) == 0 { | 622 | + | if len(p.drawn) == 0 && !p.cursorMove { |
| 623 | return nil | 623 | return nil | ||
| 624 | } | 624 | } | ||
| 625 | var out bytes.Buffer | 625 | var out bytes.Buffer | ||
M internal/usecase/client/predictive_echo_test.go +22 -10
| @@ -141,17 +141,29 @@ func TestEchoPredictorEnterKeepsDrawnGuessesUntilAFrame(t *testing.T) { | |||
| 141 | 141 | ||
| 142 | 142 | func TestEchoPredictorUndrawRestoresMirror(t *testing.T) { | |
| 143 | 143 | t.Parallel() | |
| 144 | - | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 145 | - | h.warm(100 * time.Millisecond) | |
| 146 | - | h.confirm() | |
| 147 | - | if got := h.typed("b"); !bytes.Contains(got, []byte("b")) { | |
| 148 | - | t.Fatalf("render = %q, want the guess drawn", got) | |
| 149 | - | } | |
| 150 | - | if got, want := string(h.p.undraw()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want { | |
| 151 | - | t.Fatalf("undraw = %q, want %q", got, want) | |
| 144 | + | tests := []struct { | |
| 145 | + | name, typed, want string | |
| 146 | + | }{ | |
| 147 | + | {name: "drawn guess", typed: "b", want: "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"}, | |
| 148 | + | // A blank guess matches the mirror cell, so only the cursor moved. | |
| 149 | + | {name: "cursor-only guess", typed: " ", want: "\x1b[0m\x1b[1;4H"}, | |
| 152 | 150 | } | |
| 153 | - | if got := h.p.undraw(); len(got) != 0 { | |
| 154 | - | t.Fatalf("second undraw = %q, want nothing", got) | |
| 151 | + | for _, tt := range tests { | |
| 152 | + | t.Run(tt.name, func(t *testing.T) { | |
| 153 | + | t.Parallel() | |
| 154 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 155 | + | h.warm(100 * time.Millisecond) | |
| 156 | + | h.confirm() | |
| 157 | + | if got := h.typed(tt.typed); len(got) == 0 { | |
| 158 | + | t.Fatal("guess rendered nothing") | |
| 159 | + | } | |
| 160 | + | if got := string(h.p.undraw()); got != tt.want { | |
| 161 | + | t.Fatalf("undraw = %q, want %q", got, tt.want) | |
| 162 | + | } | |
| 163 | + | if got := h.p.undraw(); len(got) != 0 { | |
| 164 | + | t.Fatalf("second undraw = %q, want nothing", got) | |
| 165 | + | } | |
| 166 | + | }) | |
| 155 | 167 | } | |
| 156 | 168 | } | |
| 157 | 169 | ||
| @@ -141,17 +141,29 @@ func TestEchoPredictorEnterKeepsDrawnGuessesUntilAFrame(t *testing.T) { | |||||
| 141 | 141 | ||||
| 142 | func TestEchoPredictorUndrawRestoresMirror(t *testing.T) { | 142 | func TestEchoPredictorUndrawRestoresMirror(t *testing.T) { | ||
| 143 | t.Parallel() | 143 | t.Parallel() | ||
| 144 | - | h := newEchoHarness(t, domain.EchoPredictAlways) | 144 | + | tests := []struct { |
| 145 | - | h.warm(100 * time.Millisecond) | 145 | + | name, typed, want string |
| 146 | - | h.confirm() | 146 | + | }{ |
| 147 | - | if got := h.typed("b"); !bytes.Contains(got, []byte("b")) { | 147 | + | {name: "drawn guess", typed: "b", want: "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"}, |
| 148 | - | t.Fatalf("render = %q, want the guess drawn", got) | 148 | + | // A blank guess matches the mirror cell, so only the cursor moved. |
| 149 | - | } | 149 | + | {name: "cursor-only guess", typed: " ", want: "\x1b[0m\x1b[1;4H"}, |
| 150 | - | if got, want := string(h.p.undraw()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want { | |||
| 151 | - | t.Fatalf("undraw = %q, want %q", got, want) | |||
| 152 | } | 150 | } | ||
| 153 | - | if got := h.p.undraw(); len(got) != 0 { | 151 | + | for _, tt := range tests { |
| 154 | - | t.Fatalf("second undraw = %q, want nothing", got) | 152 | + | t.Run(tt.name, func(t *testing.T) { |
| 153 | + | t.Parallel() | |||
| 154 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 155 | + | h.warm(100 * time.Millisecond) | |||
| 156 | + | h.confirm() | |||
| 157 | + | if got := h.typed(tt.typed); len(got) == 0 { | |||
| 158 | + | t.Fatal("guess rendered nothing") | |||
| 159 | + | } | |||
| 160 | + | if got := string(h.p.undraw()); got != tt.want { | |||
| 161 | + | t.Fatalf("undraw = %q, want %q", got, tt.want) | |||
| 162 | + | } | |||
| 163 | + | if got := h.p.undraw(); len(got) != 0 { | |||
| 164 | + | t.Fatalf("second undraw = %q, want nothing", got) | |||
| 165 | + | } | |||
| 166 | + | }) | |||
| 155 | } | 167 | } | ||
| 156 | } | 168 | } | ||
| 157 | 169 | ||||