Fix styled switch-back repaint
This commit is contained in:
@@ -215,7 +215,9 @@ type uiState struct {
|
||||
// renderer confines focused-child live output to the main viewport.
|
||||
// A fresh renderer is allocated per focused child so partial-escape
|
||||
// state cannot bleed between panes.
|
||||
renderer *viewportRenderer
|
||||
renderer *viewportRenderer
|
||||
repaintNextPTY string
|
||||
repaintNextPTYBudget int
|
||||
|
||||
// attention is the latest request_human_attention surfaced via MCP;
|
||||
// rendered in the status line until cleared.
|
||||
@@ -350,15 +352,33 @@ func (st *uiState) OnChildExited(c *Child) {
|
||||
// disabled only around the replay so long styled runs cannot wrap into
|
||||
// the right rail.
|
||||
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
||||
layout := st.layoutSnapshot()
|
||||
st.mu.Lock()
|
||||
focus := st.focusedID
|
||||
palOpen := st.palette != nil
|
||||
renderer := st.renderer
|
||||
forceRepaint := focus == childID && st.repaintNextPTY == childID && st.repaintNextPTYBudget > 0
|
||||
if forceRepaint {
|
||||
renderer = newViewportRenderer(layout)
|
||||
st.renderer = renderer
|
||||
st.repaintNextPTYBudget--
|
||||
if st.repaintNextPTYBudget == 0 {
|
||||
st.repaintNextPTY = ""
|
||||
}
|
||||
}
|
||||
st.mu.Unlock()
|
||||
if palOpen || focus != childID || renderer == nil {
|
||||
return
|
||||
}
|
||||
out := renderer.Render(chunk)
|
||||
var out []byte
|
||||
if forceRepaint {
|
||||
out = st.renderFocusedSnapshot(childID, renderer, layout)
|
||||
if len(out) == 0 {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
out = renderer.Render(chunk)
|
||||
}
|
||||
st.outMu.Lock()
|
||||
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
|
||||
_, _ = os.Stdout.Write(out)
|
||||
@@ -798,11 +818,11 @@ func (st *uiState) flashTransient(msg string) {
|
||||
// Callers must NOT hold st.mu — repaintFocused takes it
|
||||
// briefly itself.
|
||||
//
|
||||
// We feed the emulator's VT serialization through the viewport
|
||||
// renderer so SGR styling, alt-screen state, and the cursor position
|
||||
// survive a focus switch. The plain-text path (renderScreenSnapshot)
|
||||
// is kept as a fallback for environments where SerializeVT is
|
||||
// unavailable (e.g. the nocgo stub).
|
||||
// We replay the emulator's padded grid snapshot rather than its VT
|
||||
// serialization. SerializeVT can preserve style, but for diff-based TUIs
|
||||
// we've seen it replay stale prompt layout that no longer matches the
|
||||
// emulator grid; the padded snapshot is the source of truth for visible
|
||||
// cells.
|
||||
func (st *uiState) repaintFocused() {
|
||||
st.mu.Lock()
|
||||
id := st.focusedID
|
||||
@@ -824,16 +844,28 @@ func (st *uiState) repaintFocused() {
|
||||
defer c.NudgeRedraw(cols, rows)
|
||||
}
|
||||
|
||||
out := st.renderFocusedSnapshot(id, renderer, layout)
|
||||
if len(out) == 0 {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
if st.focusedID == id {
|
||||
st.repaintNextPTY = id
|
||||
st.repaintNextPTYBudget = 8
|
||||
}
|
||||
st.mu.Unlock()
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
_, _ = os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func (st *uiState) renderFocusedSnapshot(id string, renderer *viewportRenderer, layout terminalLayout) []byte {
|
||||
text, cursor, err := st.sess.SnapshotChild(id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if renderer != nil {
|
||||
if serialized, err := st.sess.SerializeChild(id); err == nil && len(serialized) > 0 {
|
||||
// Reset host terminal state before replaying so leftover
|
||||
// modes from the previously-focused child (DECSTBM,
|
||||
// DECOM, SGR) don't distort the snapshot. The DECSTBM is
|
||||
// pinned to the viewport region in host coordinates; the
|
||||
// cursor parks at the viewport's top-left. The replayed
|
||||
// SerializeVT may re-set these modes if the child
|
||||
// configured them, which is fine — we're just guaranteeing
|
||||
// a known starting baseline.
|
||||
if styled, err := st.sess.StyledSnapshotChild(id); err == nil && len(styled) > 0 {
|
||||
mainBottom := int(layout.statusRow) - statusRows
|
||||
prelude := fmt.Sprintf(
|
||||
"\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25h\x1b[%d;%dH",
|
||||
@@ -841,40 +873,19 @@ func (st *uiState) repaintFocused() {
|
||||
int(layout.mainTop), int(layout.mainLeft),
|
||||
)
|
||||
out := []byte(prelude)
|
||||
out = append(out, renderer.Render(serialized)...)
|
||||
// Ghostty's VT serialization emits the cursor CUP, then
|
||||
// DECSTBM (which moves the cursor to region home as a
|
||||
// documented side effect), then tab-stop setup using CHA
|
||||
// (\x1b[NG) — which leaves the renderer's internal vr.col
|
||||
// tracking pointing at the last tab-stop column, not
|
||||
// where the cursor actually ended up. Re-emit the saved
|
||||
// cursor as a child-space CUP through the renderer so
|
||||
// (a) the host cursor lands at the right place and (b)
|
||||
// the renderer's internal row/col tracking is brought
|
||||
// back in sync with the host. Without this, subsequent
|
||||
// relative moves (CSI C/D) and erase-line widths (CSI K
|
||||
// uses vr.col) operate from a stale column and the input
|
||||
// box gets drawn at the wrong width / row.
|
||||
if _, cursor, err := st.sess.SnapshotChild(id); err == nil {
|
||||
cup := fmt.Sprintf("\x1b[%d;%dH",
|
||||
int(cursor.Row)+1, int(cursor.Col)+1)
|
||||
out = append(out, renderer.Render([]byte(cup))...)
|
||||
}
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
_, _ = os.Stdout.Write(out)
|
||||
return
|
||||
out = append(out, renderer.ClearViewport()...)
|
||||
out = append(out, renderer.Render(styled)...)
|
||||
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
|
||||
out = append(out, renderer.Render([]byte(cup))...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
text, cursor, err := st.sess.SnapshotChild(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out := renderScreenSnapshot(text, cursor, layout)
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
_, _ = os.Stdout.Write(out)
|
||||
if renderer != nil {
|
||||
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
|
||||
out = append(out, renderer.Render([]byte(cup))...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (st *uiState) requestExit() {
|
||||
|
||||
@@ -379,9 +379,10 @@ func (c *Child) signal(sig syscall.Signal) error {
|
||||
// ratatui/ink TUIs re-render coherently against the snapshot we just
|
||||
// replayed. We toggle the PTY size by one row so the kernel reliably
|
||||
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't
|
||||
// change). The emulator is left alone — it already matches our intended
|
||||
// size and the brief mismatch only affects what the child writes during
|
||||
// the second redraw.
|
||||
// change), then send SIGWINCH explicitly for TUIs that miss or coalesce
|
||||
// the size-toggled signal. The emulator is left alone — it already
|
||||
// matches our intended size and the brief mismatch only affects what the
|
||||
// child writes during the second redraw.
|
||||
func (c *Child) NudgeRedraw(cols, rows uint16) {
|
||||
pty := c.PTY()
|
||||
if pty == nil || rows < 2 {
|
||||
@@ -389,6 +390,7 @@ func (c *Child) NudgeRedraw(cols, rows uint16) {
|
||||
}
|
||||
_ = pty.Resize(cols, rows-1)
|
||||
_ = pty.Resize(cols, rows)
|
||||
_ = c.signal(syscall.SIGWINCH)
|
||||
}
|
||||
|
||||
func (c *Child) markExited(err error) {
|
||||
|
||||
@@ -428,6 +428,18 @@ func (s *Session) SerializeChild(id string) ([]byte, error) {
|
||||
return em.SerializeVT()
|
||||
}
|
||||
|
||||
func (s *Session) StyledSnapshotChild(id string) ([]byte, error) {
|
||||
c := s.FindChild(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no such child %q", id)
|
||||
}
|
||||
em := c.Emulator()
|
||||
if em == nil {
|
||||
return nil, fmt.Errorf("child %q has no emulator", id)
|
||||
}
|
||||
return em.StyledScreenVT()
|
||||
}
|
||||
|
||||
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||
c := s.FindChild(id)
|
||||
if c == nil {
|
||||
|
||||
@@ -61,6 +61,12 @@ func (vr *viewportRenderer) Render(in []byte) []byte {
|
||||
return []byte(vr.pending.String())
|
||||
}
|
||||
|
||||
func (vr *viewportRenderer) ClearViewport() []byte {
|
||||
vr.mu.Lock()
|
||||
defer vr.mu.Unlock()
|
||||
return []byte(vr.clearViewport())
|
||||
}
|
||||
|
||||
func (vr *viewportRenderer) feed(b byte) {
|
||||
switch vr.state {
|
||||
case vpNormal:
|
||||
@@ -191,7 +197,7 @@ 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())))
|
||||
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()
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
|
||||
if strings.Contains(got, "\x1b[2J") {
|
||||
t.Fatalf("host clear-screen leaked through: %q", got)
|
||||
}
|
||||
if strings.Count(got, " ") != 3 {
|
||||
if strings.Count(got, "\x1b[20X") != 3 {
|
||||
t.Fatalf("clear rows: got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") {
|
||||
|
||||
Reference in New Issue
Block a user