Fix sidebar repaint and command restart navigation

This commit is contained in:
2026-05-14 22:41:24 +01:00
parent 83eb4f6b2d
commit 2f969fa215
9 changed files with 247 additions and 29 deletions

View File

@@ -10,11 +10,13 @@ import (
// 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
mu sync.Mutex
shifter *cursorShifter
layout terminalLayout
row int
col int
scrollTop int
scrollBottom int
state viewportState
buf []byte
@@ -22,8 +24,9 @@ type viewportRenderer struct {
// 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.
// NEL / SU / SD / IL / DL, or LF / VT / FF at the bottom margin.
// 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
@@ -50,12 +53,14 @@ const (
)
func newViewportRenderer(l terminalLayout) *viewportRenderer {
return &viewportRenderer{
vr := &viewportRenderer{
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())),
layout: l,
row: 1,
col: 1,
}
vr.resetScrollRegion()
return vr
}
func (vr *viewportRenderer) SetLayout(l terminalLayout) {
@@ -63,6 +68,7 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
defer vr.mu.Unlock()
vr.layout = l
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols()))
vr.resetScrollRegion()
}
func (vr *viewportRenderer) Render(in []byte) []byte {
@@ -82,11 +88,10 @@ func (vr *viewportRenderer) ClearViewport() []byte {
}
// 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.
// forwarded) a scroll action since the previous call. 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 vertically.
func (vr *viewportRenderer) TookScrollAction() bool {
vr.mu.Lock()
defer vr.mu.Unlock()
@@ -326,6 +331,22 @@ func (vr *viewportRenderer) clearLine(n int) string {
}
}
func (vr *viewportRenderer) resetScrollRegion() {
vr.scrollTop = 1
vr.scrollBottom = int(vr.layout.childRows())
if vr.scrollBottom < 1 {
vr.scrollBottom = 1
}
}
func (vr *viewportRenderer) lineFeed() {
if vr.row >= vr.scrollTop && vr.row == vr.scrollBottom {
vr.scrolled = true
return
}
vr.row++
}
// feedPrintable handles one non-ESC byte in the vpNormal state. It both
// advances vr's cursor model and decides whether the byte should be
// forwarded to the host. Bytes that would land past the viewport's
@@ -342,8 +363,8 @@ func (vr *viewportRenderer) feedPrintable(b byte) {
switch b {
case '\r':
vr.col = 1
case '\n':
vr.row++
case '\n', '\v', '\f':
vr.lineFeed()
case '\b':
if vr.col > 1 {
vr.col--
@@ -437,10 +458,38 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
if ok {
vr.col -= n
}
case 'r':
vr.trackScrollRegion(params)
}
vr.clampCursor()
}
func (vr *viewportRenderer) trackScrollRegion(params []byte) {
if len(params) == 0 {
vr.resetScrollRegion()
return
}
top, bottom, ok := parseTwoParams(params)
if !ok {
return
}
maxRows := int(vr.layout.childRows())
if maxRows < 1 {
maxRows = 1
}
if top < 1 {
top = 1
}
if bottom < 1 || bottom > maxRows {
bottom = maxRows
}
if top >= bottom {
return
}
vr.scrollTop = top
vr.scrollBottom = bottom
}
func (vr *viewportRenderer) clampCursor() {
if vr.row < 1 {
vr.row = 1