Repaint sidebar after child scrolls the host scroll region
Codex (Ratatui) emits an 8x RI burst on startup right after setting DECSTBM. RI at the top of the scroll region scrolls the region down, and DECSTBM only constrains rows -- so the scroll spans every column and drags the right-rail session-tree entries down with the main pane. The chrome cache then hid the clobber because the computed sidebar frame was unchanged. The viewport renderer now flags any chunk containing RI / IND / NEL / SU / SD / IL / DL and OnPTYOut drops the sidebar cache when the flag is set, so the next drawSidebar repaints over the drift. Adds unit tests for the new flag and a harness regression scenario (sidebar_survives_ri_scroll) that fails without the fix.
This commit is contained in:
@@ -102,3 +102,55 @@ func TestViewportRendererTracksPrintableCursor(t *testing.T) {
|
||||
t.Fatalf("clear-line after five chars should erase 15 cells: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererFlagsRIAsScrolling(t *testing.T) {
|
||||
// Reproduces the sidebar-gap bug: codex emits `\x1b[1;1H` followed
|
||||
// by 8× `\x1bM` (RI) on startup. RI at the top of the host scroll
|
||||
// region scrolls the region down — across all columns — pushing
|
||||
// sidebar content out of place. The renderer must flag the chunk
|
||||
// so the sidebar cache gets invalidated and repainted afterwards.
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
if vr.TookScrollAction() {
|
||||
t.Fatalf("scroll flag set before any input")
|
||||
}
|
||||
_ = vr.Render([]byte("\x1b[1;1H"))
|
||||
if vr.TookScrollAction() {
|
||||
t.Fatalf("plain CUP should not flag scroll")
|
||||
}
|
||||
_ = vr.Render([]byte("\x1bM"))
|
||||
if !vr.TookScrollAction() {
|
||||
t.Fatalf("RI (ESC M) should flag scroll")
|
||||
}
|
||||
if vr.TookScrollAction() {
|
||||
t.Fatalf("flag should reset after read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererFlagsScrollVerbs(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"IND": []byte("\x1bD"),
|
||||
"NEL": []byte("\x1bE"),
|
||||
"SU": []byte("\x1b[3S"),
|
||||
"SD": []byte("\x1b[2T"),
|
||||
}
|
||||
for name, in := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
_ = vr.Render(in)
|
||||
if !vr.TookScrollAction() {
|
||||
t.Fatalf("%s should flag scroll", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererForwardsRIVerbatim(t *testing.T) {
|
||||
// We rely on the host terminal performing the scroll inside the
|
||||
// DECSTBM region; the renderer must not eat or transform RI. If a
|
||||
// future change ever rewrites RI, this test catches the regression.
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
got := string(vr.Render([]byte("\x1bM")))
|
||||
if got != "\x1bM" {
|
||||
t.Fatalf("RI should pass through unchanged: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user