Files
patterm/internal/app/viewport_renderer.go
Harry Bayliss 58dbb56937 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.
2026-05-14 20:01:14 +01:00

396 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"strconv"
"strings"
"sync"
)
// viewportRenderer rewrites child PTY output so it lands inside the
// main viewport instead of controlling patterm's full host terminal.
type viewportRenderer struct {
mu sync.Mutex
shifter *cursorShifter
layout terminalLayout
row int
col int
state viewportState
buf []byte
pending strings.Builder
// scrolled is set when the chunk contained an escape that shifts
// content row-wise within the host's scroll region — RI / IND /
// NEL / SU / SD / IL / DL. DECSTBM constrains rows but not columns,
// so these scrolls drag the right-hand sidebar content with them.
// OnPTYOut consumes the flag and invalidates the sidebar chrome
// cache so the next drawSidebar repaints over the clobber.
scrolled bool
}
type viewportState int
const (
vpNormal viewportState = iota
vpEsc
vpCSI
vpOSC
vpOSCEsc
vpDCS
vpDCSEsc
vpSOSPMAPC
vpSOSPMAPCEsc
)
func newViewportRenderer(l terminalLayout) *viewportRenderer {
return &viewportRenderer{
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows())),
layout: l,
row: 1,
col: 1,
}
}
func (vr *viewportRenderer) SetLayout(l terminalLayout) {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.layout = l
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()))
}
func (vr *viewportRenderer) Render(in []byte) []byte {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.pending.Reset()
for _, b := range in {
vr.feed(b)
}
return []byte(vr.pending.String())
}
func (vr *viewportRenderer) ClearViewport() []byte {
vr.mu.Lock()
defer vr.mu.Unlock()
return []byte(vr.clearViewport())
}
// TookScrollAction reports whether the most recent Render emitted (or
// forwarded) a scroll-triggering escape — RI / IND / NEL / SU / SD /
// IL / DL — since the previous call. The flag is reset on read.
// Callers use it to invalidate sidebar-cache state, because the host's
// scroll region spans the full row width and any scroll there drags
// the sidebar content downward.
func (vr *viewportRenderer) TookScrollAction() bool {
vr.mu.Lock()
defer vr.mu.Unlock()
out := vr.scrolled
vr.scrolled = false
return out
}
func (vr *viewportRenderer) feed(b byte) {
switch vr.state {
case vpNormal:
if b == 0x1b {
vr.state = vpEsc
vr.buf = vr.buf[:0]
vr.buf = append(vr.buf, b)
return
}
vr.pending.WriteByte(b)
vr.advancePrintable(b)
case vpEsc:
vr.buf = append(vr.buf, b)
switch b {
case '[':
vr.state = vpCSI
case ']':
vr.state = vpOSC
case 'P':
vr.state = vpDCS
case 'X', '^', '_':
vr.state = vpSOSPMAPC
case 'M', 'D', 'E':
// RI (ESC M), IND (ESC D), NEL (ESC E). All three can scroll
// the host's scroll region when the cursor is at the top
// (RI) or bottom (IND/NEL) edge. The region spans the full
// row width, so the scroll drags the sidebar columns along
// with the main pane. Forward as-is and flag for sidebar
// cache invalidation. Codex emits 8× RI on startup, which
// is what motivated this branch.
vr.pending.Write(vr.buf)
vr.scrolled = true
vr.state = vpNormal
vr.buf = vr.buf[:0]
default:
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
}
case vpCSI:
vr.buf = append(vr.buf, b)
if isCSIFinal(b) {
vr.emitCSI()
vr.state = vpNormal
vr.buf = vr.buf[:0]
}
case vpOSC:
vr.buf = append(vr.buf, b)
switch b {
case 0x07:
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
case 0x1b:
vr.state = vpOSCEsc
}
case vpOSCEsc:
vr.buf = append(vr.buf, b)
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
case vpDCS:
vr.buf = append(vr.buf, b)
if b == 0x1b {
vr.state = vpDCSEsc
}
case vpDCSEsc:
vr.buf = append(vr.buf, b)
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
case vpSOSPMAPC:
vr.buf = append(vr.buf, b)
if b == 0x1b {
vr.state = vpSOSPMAPCEsc
}
case vpSOSPMAPCEsc:
vr.buf = append(vr.buf, b)
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
}
}
func (vr *viewportRenderer) emitCSI() {
if len(vr.buf) < 3 {
vr.pending.Write(vr.buf)
return
}
final := vr.buf[len(vr.buf)-1]
params := vr.buf[2 : len(vr.buf)-1]
if final == 'h' || final == 'l' {
if isAltScreenMode(params) {
return
}
}
switch final {
case 'J':
n, ok := parseOneParam(params, 0)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
switch n {
case 0:
vr.pending.WriteString(vr.clearViewportFromCursor())
case 1:
vr.pending.WriteString(vr.clearViewportToCursor())
case 2, 3:
vr.pending.WriteString(vr.clearViewport())
default:
vr.pending.Write(vr.shifter.Shift(vr.buf))
}
case 'K':
n, ok := parseOneParam(params, 0)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
vr.pending.WriteString(vr.clearLine(n))
case 'S', 'T', 'L', 'M':
// SU (S) / SD (T) / IL (L) / DL (M) all shift content within
// the host's scroll region row-wise across every column. The
// sidebar lives at the right of the host, inside the scroll
// region's row range, so any of these drag its cells along
// with the main pane. Forward verbatim and flag the chunk so
// the sidebar is repainted afterwards.
vr.pending.Write(vr.shifter.Shift(vr.buf))
vr.scrolled = true
default:
vr.pending.Write(vr.shifter.Shift(vr.buf))
}
vr.trackCSI(final, params)
}
func isAltScreenMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
switch p {
case "47", "1047", "1049":
return true
}
}
return false
}
func (vr *viewportRenderer) clearViewport() string {
var b strings.Builder
b.WriteString("\x1b7")
for r := uint16(0); r < vr.layout.childRows(); r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", int(vr.layout.mainTop+r), int(vr.layout.mainLeft), int(vr.layout.childCols()))
}
b.WriteString("\x1b8")
return b.String()
}
// clearViewportFromCursor implements `CSI 0 J` clamped to the viewport.
// Without clamping, the child's "clear to end of screen" would reach the
// rightmost columns and erase the sidebar.
func (vr *viewportRenderer) clearViewportFromCursor() string {
row, col := vr.row, vr.col
cols := int(vr.layout.childCols())
rows := int(vr.layout.childRows())
if row < 1 {
row = 1
}
if col < 1 {
col = 1
}
var b strings.Builder
b.WriteString("\x1b7")
if remaining := cols - col + 1; remaining > 0 {
fmt.Fprintf(&b, "\x1b[%dX", remaining)
}
for r := row + 1; r <= rows; r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX",
int(vr.layout.mainTop)+r-1, int(vr.layout.mainLeft), cols)
}
b.WriteString("\x1b8")
return b.String()
}
// clearViewportToCursor implements `CSI 1 J` clamped to the viewport.
func (vr *viewportRenderer) clearViewportToCursor() string {
row, col := vr.row, vr.col
cols := int(vr.layout.childCols())
if row < 1 {
row = 1
}
if col < 1 {
col = 1
}
var b strings.Builder
b.WriteString("\x1b7")
for r := 1; r < row; r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX",
int(vr.layout.mainTop)+r-1, int(vr.layout.mainLeft), cols)
}
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX",
int(vr.layout.mainTop)+row-1, int(vr.layout.mainLeft), col)
b.WriteString("\x1b8")
return b.String()
}
func (vr *viewportRenderer) clearLine(n int) string {
right := int(vr.layout.childCols())
if vr.col < 1 {
vr.col = 1
}
if vr.col > right {
vr.col = right
}
switch n {
case 0:
return "\x1b[" + strconv.Itoa(right-vr.col+1) + "X"
case 1:
return "\x1b7\r\x1b[" + strconv.Itoa(vr.col) + "X\x1b8"
case 2:
return "\x1b7\r\x1b[" + strconv.Itoa(right) + "X\x1b8"
default:
return "\x1b[" + strconv.Itoa(right-vr.col+1) + "X"
}
}
func (vr *viewportRenderer) advancePrintable(b byte) {
switch b {
case '\r':
vr.col = 1
case '\n':
vr.row++
case '\b':
if vr.col > 1 {
vr.col--
}
case '\t':
vr.col += 8 - ((vr.col - 1) % 8)
default:
if b >= 0x20 && b != 0x7f {
vr.col++
}
}
vr.clampCursor()
}
func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
switch final {
case 'H', 'f':
r, c, ok := parseTwoParams(params)
if ok {
vr.row, vr.col = r, c
}
case 'G', '`':
c, ok := parseOneParam(params, 1)
if ok {
vr.col = c
}
case 'd':
r, ok := parseOneParam(params, 1)
if ok {
vr.row = r
}
case 'A':
n, ok := parseOneParam(params, 1)
if ok {
vr.row -= n
}
case 'B', 'e':
n, ok := parseOneParam(params, 1)
if ok {
vr.row += n
}
case 'C', 'a':
n, ok := parseOneParam(params, 1)
if ok {
vr.col += n
}
case 'D':
n, ok := parseOneParam(params, 1)
if ok {
vr.col -= n
}
}
vr.clampCursor()
}
func (vr *viewportRenderer) clampCursor() {
if vr.row < 1 {
vr.row = 1
}
if vr.col < 1 {
vr.col = 1
}
if max := int(vr.layout.childRows()); vr.row > max {
vr.row = max
}
if max := int(vr.layout.childCols()); vr.col > max {
vr.col = max
}
}