Files
patterm/internal/app/viewport_renderer.go
2026-05-15 00:28:06 +01:00

761 lines
18 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
scrollTop int
scrollBottom int
originMode bool
lrMarginMode bool
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, 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
// skipUTF8 is set when the current multi-byte UTF-8 character started
// past the viewport's right edge. The starter byte was dropped, so
// the remaining continuation bytes must be dropped too instead of
// leaking into the sidebar columns.
skipUTF8 bool
}
type viewportState int
const (
vpNormal viewportState = iota
vpEsc
vpCSI
vpOSC
vpOSCEsc
vpDCS
vpDCSEsc
vpSOSPMAPC
vpSOSPMAPCEsc
)
func newViewportRenderer(l terminalLayout) *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) {
vr.mu.Lock()
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 {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.pending.Reset()
// Fast path: while we're in vpNormal and have a run of plain ASCII
// printables that fit the remaining column budget, copy en bloc
// instead of round-tripping each byte through the feed state
// machine. UTF-8 leaders and any control byte fall back to the
// per-byte path so the cursor/skipUTF8/clamp logic stays exact.
for i := 0; i < len(in); {
if vr.state == vpNormal {
maxCol := int(vr.layout.childCols())
if maxCol > 0 && vr.col >= 1 && vr.col <= maxCol {
budget := maxCol - vr.col + 1
j := i
for j < len(in) && budget > 0 {
b := in[j]
// Pure ASCII printables only — any control byte
// (0x1b ESC included), UTF-8 leader, or trailer
// kicks back to the state machine.
if b < 0x20 || b == 0x7f || b >= 0x80 {
break
}
j++
budget--
}
if j-i >= 4 {
vr.pending.Write(in[i:j])
vr.col += j - i
vr.skipUTF8 = false
vr.clampCursor()
i = j
continue
}
}
}
vr.feed(in[i])
i++
}
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 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()
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.feedPrintable(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 isOriginMode(params) {
vr.setOriginMode(final == 'h')
vr.emitCursorPosition(vr.row, vr.col)
return
}
if isLeftRightMarginMode(params) {
vr.lrMarginMode = final == 'h'
return
}
if isAltScreenMode(params) {
return
}
if isMouseTrackingMode(params) {
// Patterm owns mouse reporting on the host so wheel events keep
// flowing for scroll-viewport. The child's own emulator still
// observes the mode set/reset (it processes the same bytes we
// hand to ghostty_terminal_vt_write), so we know whether the
// child wants mouse input — we just don't let it disarm our
// host listener.
return
}
}
if final == 's' && vr.lrMarginMode {
return
}
switch final {
case 'H', 'f':
r, c, ok := parseTwoParams(params)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
vr.row = vr.originRow(r)
vr.col = c
vr.emitCursorPosition(vr.row, c)
vr.clampCursor()
case 'd':
r, ok := parseOneParam(params, 1)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
vr.row = vr.originRow(r)
vr.pending.Write(vr.shifter.Shift([]byte(fmt.Sprintf("\x1b[%dd", vr.row))))
vr.clampCursor()
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
case 'r':
vr.pending.Write(vr.shifter.Shift(vr.buf))
if vr.trackScrollRegion(params) {
vr.emitHomeAfterScrollRegion()
}
case 'A', 'B', 'E', 'F':
// Relative cursor moves: CUU (A) / CUD (B) / CNL (E) / CPL (F).
// The cursor shifter only rewrites absolute positioning, so a
// child that asks the cursor to "go up 50" from viewport row 1
// would walk the host cursor into the tab bar (and the next
// printable would write there). Clamp the step using the
// renderer's tracked row so the host cursor stays inside the
// viewport. E / F additionally home the column to 1.
vr.emitRelativeRowMove(final, params)
return
default:
vr.pending.Write(vr.shifter.Shift(vr.buf))
}
if final != 'H' && final != 'f' && final != 'd' && final != 'r' {
vr.trackCSI(final, params)
}
}
// emitRelativeRowMove rewrites CSI A / B / E / F so the resulting host
// cursor stays within rows 1..childRows in viewport coordinates. The
// renderer already tracks vr.row for clear-line bookkeeping; reusing
// that here avoids a second cursor model. n is normalized — a step of
// 0 is treated as 1 to match xterm. After clamping, if the effective
// step is zero we drop the sequence (the cursor is already pinned to
// the boundary). E / F also move the cursor to column 1 even when no
// row step is emitted.
func (vr *viewportRenderer) emitRelativeRowMove(final byte, params []byte) {
n, ok := parseOneParam(params, 1)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
if n <= 0 {
n = 1
}
rows := int(vr.layout.childRows())
if rows < 1 {
rows = 1
}
row := vr.row
if row < 1 {
row = 1
}
if row > rows {
row = rows
}
up := final == 'A' || final == 'F'
var safe int
if up {
safe = row - 1
} else {
safe = rows - row
}
if safe < 0 {
safe = 0
}
if n > safe {
n = safe
}
if n > 0 {
if up {
vr.row -= n
} else {
vr.row += n
}
fmt.Fprintf(&vr.pending, "\x1b[%d%c", n, final)
}
if final == 'E' || final == 'F' {
// CNL / CPL anchor the column at 1 regardless of whether the
// row step was clamped to zero, matching xterm.
vr.col = 1
vr.pending.WriteByte('\r')
}
vr.clampCursor()
}
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 isOriginMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
if p == "6" {
return true
}
}
return false
}
func isLeftRightMarginMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
if p == "69" {
return true
}
}
return false
}
// isMouseTrackingMode reports whether any of the modes in a CSI ? … h/l
// is a mouse-tracking or mouse-encoding DEC private mode. The host runs
// with SGR mouse reporting permanently armed; we drop the child's set/
// reset for these modes from the host stream so wheel events keep
// reaching patterm.
func isMouseTrackingMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
switch p {
case "9", "1000", "1001", "1002", "1003", "1004",
"1005", "1006", "1007", "1015", "1016":
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
}
if col > cols {
col = cols
}
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) resetScrollRegion() {
vr.scrollTop = 1
vr.scrollBottom = int(vr.layout.childRows())
if vr.scrollBottom < 1 {
vr.scrollBottom = 1
}
}
func (vr *viewportRenderer) setOriginMode(on bool) {
vr.originMode = on
if on {
vr.row = vr.scrollTop
} else {
vr.row = 1
}
vr.col = 1
vr.clampCursor()
}
func (vr *viewportRenderer) originRow(row int) int {
if row < 1 {
row = 1
}
if !vr.originMode {
return row
}
row = vr.scrollTop + row - 1
if row < vr.scrollTop {
row = vr.scrollTop
}
if row > vr.scrollBottom {
row = vr.scrollBottom
}
return row
}
func (vr *viewportRenderer) homeAfterScrollRegion() {
if vr.originMode {
vr.row = vr.scrollTop
} else {
vr.row = 1
}
vr.col = 1
vr.clampCursor()
}
func (vr *viewportRenderer) emitHomeAfterScrollRegion() {
vr.homeAfterScrollRegion()
vr.emitCursorPosition(vr.row, vr.col)
}
func (vr *viewportRenderer) emitCursorPosition(row, col int) {
vr.pending.Write(vr.shifter.Shift([]byte(fmt.Sprintf("\x1b[%d;%dH", row, col))))
}
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
// right edge (childCols) are dropped so a child whose internal column
// state drifted past the viewport can't spray into the sidebar columns.
// UTF-8 continuation bytes follow the fate of their starter so a
// multi-byte glyph drops as a unit.
func (vr *viewportRenderer) feedPrintable(b byte) {
// Control codes (CR, LF, BS, TAB, BEL, etc.) move the cursor or
// signal state and must always be forwarded. They never produce
// glyphs, so they can't clobber the sidebar themselves.
if b < 0x20 || b == 0x7f {
vr.pending.WriteByte(b)
switch b {
case '\r':
vr.col = 1
case '\n', '\v', '\f':
vr.lineFeed()
case '\b':
if vr.col > 1 {
vr.col--
}
case '\t':
vr.col += 8 - ((vr.col - 1) % 8)
}
vr.skipUTF8 = false
vr.clampCursor()
return
}
// UTF-8 continuation byte (10xxxxxx) belongs to the current glyph.
if b >= 0x80 && b < 0xC0 {
if vr.skipUTF8 {
return
}
vr.pending.WriteByte(b)
return
}
// Glyph starter (ASCII 0x20..0x7E or UTF-8 leading byte 0xC0+). If
// the cursor sits past the viewport we'd be spraying into the
// sidebar columns — drop the glyph (and the continuation bytes that
// follow, via skipUTF8).
maxCol := int(vr.layout.childCols())
if maxCol > 0 && vr.col > maxCol {
vr.skipUTF8 = b >= 0xC0
return
}
vr.skipUTF8 = false
vr.pending.WriteByte(b)
vr.col++
vr.clampCursor()
}
// advancePrintable is retained for tests that exercise cursor tracking
// directly; the runtime path goes through feedPrintable.
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 && (b < 0x80 || b >= 0xC0) {
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 = vr.originRow(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 = vr.originRow(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
}
case 'r':
if vr.trackScrollRegion(params) {
vr.homeAfterScrollRegion()
}
}
vr.clampCursor()
}
func (vr *viewportRenderer) trackScrollRegion(params []byte) bool {
if len(params) == 0 {
vr.resetScrollRegion()
return true
}
top, bottom, ok := parseTwoParams(params)
if !ok {
return false
}
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 false
}
vr.scrollTop = top
vr.scrollBottom = bottom
return true
}
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
}
// Intentionally do NOT clamp vr.col to childCols here. feedPrintable
// drops glyphs once vr.col exceeds childCols (so a child whose
// internal column state drifted past the viewport can't spray bytes
// into the sidebar). If we clamped col back to childCols on every
// printable, every subsequent byte would look like it was still "at
// the right margin" and would write again. We cap at childCols+1
// instead so clear-line bookkeeping doesn't see arbitrarily large
// numbers.
if max := int(vr.layout.childCols()); vr.col > max+1 {
vr.col = max + 1
}
}