761 lines
18 KiB
Go
761 lines
18 KiB
Go
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
|
||
}
|
||
}
|