Module renamed github.com/harrybrwn/patterm → github.com/hjbdev/patterm across imports. Chrome: - Palette redrawn with rounded box-drawing borders, accent left-bar for the selected item, dim hints, and a separator-aware footer. - Tab bar grew from 1 row to 3: labels with breathing room, a dim argv subtitle truncated to each tab's width, and an accent thick underline for the focused tab with a faint divider extending across the rest of the host width. Layout, viewport-renderer, and screen- renderer tests updated for the new mainTop. - Sidebar reuses the same palette: accent section headers, `▎` selection marker, `●`/`○` status glyphs, dim previews. - Shared SGR constants moved into internal/app/style.go. Palette input: - Adjacent duplicate arrow events (legacy `\x1b[B` + kitty `\x1b[57353u` for one keypress, or two of the same form) are now collapsed via peekArrowEvent + chunk-level dedupe in processStdin. - On open, push `\x1b[>0u` onto the host's kitty keyboard stack so palette input is in plain legacy mode regardless of what the child pushed (codex/ratatui pushes its own flags which had been leaking to the host). Popped on close. Tab-switch repaint (repaintFocused): - Use the emulator's SerializeVT bytes (with SGR / cursor / DECSTBM / tabstops) instead of plain text, fed through the per-focused viewport renderer so the shifter translates row positions. - Prelude resets host SGR / DECOM / DECSTBM (pinned to viewport) / cursor visibility before the replay, so leftover modes from the previously-focused child don't distort the new snapshot. - Re-emit the saved cursor as a child-space CUP after the serialized bytes so the host cursor lands at the emulator's actual position (overriding DECSTBM's home side-effect and the tabstop-setup CHA sequences) AND the renderer's vr.row/vr.col get re-synced via trackCSI. - cursorShifter now carries childRows and rewrites empty `\x1b[r` to `\x1b[<mainTop>;<mainBottom>r` (host coords) — the default (1,1) shifted to (4,4) was producing a one-row scrolling region that scroll-exploded the replay. - After the snapshot lands, nudge the focused child with a one-row PTY winsize toggle so the kernel emits SIGWINCH and ratatui-style TUIs throw away their diff state and emit a fresh frame. Codex still renders incorrectly after a focus switch; see TODO.md "Switch-back render divergence" for the deep investigation handoff.
295 lines
5.4 KiB
Go
295 lines
5.4 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
|
|
|
|
state viewportState
|
|
buf []byte
|
|
pending strings.Builder
|
|
}
|
|
|
|
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) 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
|
|
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 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))
|
|
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%s", int(vr.layout.mainTop+r), int(vr.layout.mainLeft), strings.Repeat(" ", int(vr.layout.childCols())))
|
|
}
|
|
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
|
|
}
|
|
}
|