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.
|
// renderer confines focused-child live output to the main viewport.
|
||||||
// A fresh renderer is allocated per focused child so partial-escape
|
// A fresh renderer is allocated per focused child so partial-escape
|
||||||
// state cannot bleed between panes.
|
// state cannot bleed between panes.
|
||||||
renderer *viewportRenderer
|
renderer *viewportRenderer
|
||||||
|
repaintNextPTY string
|
||||||
|
repaintNextPTYBudget int
|
||||||
|
|
||||||
// attention is the latest request_human_attention surfaced via MCP;
|
// attention is the latest request_human_attention surfaced via MCP;
|
||||||
// rendered in the status line until cleared.
|
// 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
|
// disabled only around the replay so long styled runs cannot wrap into
|
||||||
// the right rail.
|
// the right rail.
|
||||||
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
||||||
|
layout := st.layoutSnapshot()
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
focus := st.focusedID
|
focus := st.focusedID
|
||||||
palOpen := st.palette != nil
|
palOpen := st.palette != nil
|
||||||
renderer := st.renderer
|
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()
|
st.mu.Unlock()
|
||||||
if palOpen || focus != childID || renderer == nil {
|
if palOpen || focus != childID || renderer == nil {
|
||||||
return
|
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()
|
st.outMu.Lock()
|
||||||
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
|
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
|
||||||
_, _ = os.Stdout.Write(out)
|
_, _ = os.Stdout.Write(out)
|
||||||
@@ -798,11 +818,11 @@ func (st *uiState) flashTransient(msg string) {
|
|||||||
// Callers must NOT hold st.mu — repaintFocused takes it
|
// Callers must NOT hold st.mu — repaintFocused takes it
|
||||||
// briefly itself.
|
// briefly itself.
|
||||||
//
|
//
|
||||||
// We feed the emulator's VT serialization through the viewport
|
// We replay the emulator's padded grid snapshot rather than its VT
|
||||||
// renderer so SGR styling, alt-screen state, and the cursor position
|
// serialization. SerializeVT can preserve style, but for diff-based TUIs
|
||||||
// survive a focus switch. The plain-text path (renderScreenSnapshot)
|
// we've seen it replay stale prompt layout that no longer matches the
|
||||||
// is kept as a fallback for environments where SerializeVT is
|
// emulator grid; the padded snapshot is the source of truth for visible
|
||||||
// unavailable (e.g. the nocgo stub).
|
// cells.
|
||||||
func (st *uiState) repaintFocused() {
|
func (st *uiState) repaintFocused() {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
id := st.focusedID
|
id := st.focusedID
|
||||||
@@ -824,16 +844,28 @@ func (st *uiState) repaintFocused() {
|
|||||||
defer c.NudgeRedraw(cols, rows)
|
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 renderer != nil {
|
||||||
if serialized, err := st.sess.SerializeChild(id); err == nil && len(serialized) > 0 {
|
if styled, err := st.sess.StyledSnapshotChild(id); err == nil && len(styled) > 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.
|
|
||||||
mainBottom := int(layout.statusRow) - statusRows
|
mainBottom := int(layout.statusRow) - statusRows
|
||||||
prelude := fmt.Sprintf(
|
prelude := fmt.Sprintf(
|
||||||
"\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25h\x1b[%d;%dH",
|
"\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),
|
int(layout.mainTop), int(layout.mainLeft),
|
||||||
)
|
)
|
||||||
out := []byte(prelude)
|
out := []byte(prelude)
|
||||||
out = append(out, renderer.Render(serialized)...)
|
out = append(out, renderer.ClearViewport()...)
|
||||||
// Ghostty's VT serialization emits the cursor CUP, then
|
out = append(out, renderer.Render(styled)...)
|
||||||
// DECSTBM (which moves the cursor to region home as a
|
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
|
||||||
// documented side effect), then tab-stop setup using CHA
|
out = append(out, renderer.Render([]byte(cup))...)
|
||||||
// (\x1b[NG) — which leaves the renderer's internal vr.col
|
return out
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
text, cursor, err := st.sess.SnapshotChild(id)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := renderScreenSnapshot(text, cursor, layout)
|
out := renderScreenSnapshot(text, cursor, layout)
|
||||||
st.outMu.Lock()
|
if renderer != nil {
|
||||||
defer st.outMu.Unlock()
|
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
|
||||||
_, _ = os.Stdout.Write(out)
|
out = append(out, renderer.Render([]byte(cup))...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *uiState) requestExit() {
|
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
|
// ratatui/ink TUIs re-render coherently against the snapshot we just
|
||||||
// replayed. We toggle the PTY size by one row so the kernel reliably
|
// replayed. We toggle the PTY size by one row so the kernel reliably
|
||||||
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't
|
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't
|
||||||
// change). The emulator is left alone — it already matches our intended
|
// change), then send SIGWINCH explicitly for TUIs that miss or coalesce
|
||||||
// size and the brief mismatch only affects what the child writes during
|
// the size-toggled signal. The emulator is left alone — it already
|
||||||
// the second redraw.
|
// 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) {
|
func (c *Child) NudgeRedraw(cols, rows uint16) {
|
||||||
pty := c.PTY()
|
pty := c.PTY()
|
||||||
if pty == nil || rows < 2 {
|
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-1)
|
||||||
_ = pty.Resize(cols, rows)
|
_ = pty.Resize(cols, rows)
|
||||||
|
_ = c.signal(syscall.SIGWINCH)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Child) markExited(err error) {
|
func (c *Child) markExited(err error) {
|
||||||
|
|||||||
@@ -428,6 +428,18 @@ func (s *Session) SerializeChild(id string) ([]byte, error) {
|
|||||||
return em.SerializeVT()
|
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) {
|
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||||
c := s.FindChild(id)
|
c := s.FindChild(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ func (vr *viewportRenderer) Render(in []byte) []byte {
|
|||||||
return []byte(vr.pending.String())
|
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) {
|
func (vr *viewportRenderer) feed(b byte) {
|
||||||
switch vr.state {
|
switch vr.state {
|
||||||
case vpNormal:
|
case vpNormal:
|
||||||
@@ -191,7 +197,7 @@ func (vr *viewportRenderer) clearViewport() string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("\x1b7")
|
b.WriteString("\x1b7")
|
||||||
for r := uint16(0); r < vr.layout.childRows(); r++ {
|
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")
|
b.WriteString("\x1b8")
|
||||||
return b.String()
|
return b.String()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
|
|||||||
if strings.Contains(got, "\x1b[2J") {
|
if strings.Contains(got, "\x1b[2J") {
|
||||||
t.Fatalf("host clear-screen leaked through: %q", got)
|
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)
|
t.Fatalf("clear rows: got %q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") {
|
if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package harness
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +75,43 @@ func runStep(s *Session, step Step, results map[string]json.RawMessage) error {
|
|||||||
return fmt.Errorf("screen does not contain %q:\n%s", step.Contains, screen)
|
return fmt.Errorf("screen does not contain %q:\n%s", step.Contains, screen)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
case "assert_not_contains":
|
||||||
|
screen, err := s.Screen()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Contains(screen, step.Contains) {
|
||||||
|
return fmt.Errorf("screen contains %q:\n%s", step.Contains, screen)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "mark_raw":
|
||||||
|
if step.SaveAs == "" {
|
||||||
|
return fmt.Errorf("mark_raw requires save_as")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(s.RawOffset())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
results[step.SaveAs] = raw
|
||||||
|
return nil
|
||||||
|
case "assert_raw_since_regex":
|
||||||
|
raw, ok := results[step.From]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no saved result %q", step.From)
|
||||||
|
}
|
||||||
|
var offset int
|
||||||
|
if err := json.Unmarshal(raw, &offset); err != nil {
|
||||||
|
return fmt.Errorf("saved result %q is not a raw offset: %w", step.From, err)
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(step.Regex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b := s.RawSince(offset)
|
||||||
|
if !re.Match(b) {
|
||||||
|
return fmt.Errorf("raw output since %q does not match %q:\n%s", step.From, step.Regex, string(b))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
case "assert_regex":
|
case "assert_regex":
|
||||||
return s.WaitForRegex(step.Regex, timeoutMS(step.TimeoutMS))
|
return s.WaitForRegex(step.Regex, timeoutMS(step.TimeoutMS))
|
||||||
case "wait_text":
|
case "wait_text":
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "switch_replay_clears_viewport",
|
||||||
|
"cols": 80,
|
||||||
|
"rows": 24,
|
||||||
|
"scripts": [
|
||||||
|
{
|
||||||
|
"name": "blanktop",
|
||||||
|
"body": "#!/bin/sh\nprintf '\\033[2;1HFIRST-ROW-TWO\\n'\nsleep 5\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": { "kind": "command", "argv": ["blanktop"], "name": "first" },
|
||||||
|
"save_as": "first"
|
||||||
|
},
|
||||||
|
{ "type": "wait_text", "contains": "FIRST-ROW-TWO", "timeout_ms": 5000 },
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": { "kind": "command", "argv": ["sh", "-lc", "echo SECOND READY; sleep 5"], "name": "second" },
|
||||||
|
"save_as": "second"
|
||||||
|
},
|
||||||
|
{ "type": "wait_text", "contains": "SECOND READY", "timeout_ms": 5000 },
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "select_process",
|
||||||
|
"params": { "process_id": "{{first.process_id}}" }
|
||||||
|
},
|
||||||
|
{ "type": "wait_stable", "timeout_ms": 5000 },
|
||||||
|
{ "type": "assert_contains", "contains": "FIRST-ROW-TWO" },
|
||||||
|
{ "type": "assert_not_contains", "contains": "SECOND READY" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "switch_replay_preserves_color",
|
||||||
|
"cols": 80,
|
||||||
|
"rows": 24,
|
||||||
|
"scripts": [
|
||||||
|
{
|
||||||
|
"name": "color-frame",
|
||||||
|
"body": "#!/bin/sh\nprintf '\\033[31mREDMARK\\033[0m\\n'\nsleep 5\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": { "kind": "command", "argv": ["color-frame"], "name": "color" },
|
||||||
|
"save_as": "color"
|
||||||
|
},
|
||||||
|
{ "type": "wait_text", "contains": "REDMARK", "timeout_ms": 5000 },
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "spawn_process",
|
||||||
|
"params": { "kind": "command", "argv": ["sh", "-lc", "echo SECOND READY; sleep 5"], "name": "second" },
|
||||||
|
"save_as": "second"
|
||||||
|
},
|
||||||
|
{ "type": "wait_text", "contains": "SECOND READY", "timeout_ms": 5000 },
|
||||||
|
{ "type": "mark_raw", "save_as": "before_switch_back" },
|
||||||
|
{
|
||||||
|
"type": "mcp_call",
|
||||||
|
"method": "select_process",
|
||||||
|
"params": { "process_id": "{{color.process_id}}" }
|
||||||
|
},
|
||||||
|
{ "type": "wait_stable", "timeout_ms": 5000 },
|
||||||
|
{ "type": "assert_contains", "contains": "REDMARK" },
|
||||||
|
{ "type": "assert_raw_since_regex", "from": "before_switch_back", "regex": "\u001b\\[[0-9;]*38;2;[^m]*mREDMARK" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -262,3 +262,23 @@ func (s *Session) rawBytes() []byte {
|
|||||||
copy(out, s.bytes)
|
copy(out, s.bytes)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Session) RawOffset() int {
|
||||||
|
s.bytesMu.Lock()
|
||||||
|
defer s.bytesMu.Unlock()
|
||||||
|
return len(s.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) RawSince(offset int) []byte {
|
||||||
|
s.bytesMu.Lock()
|
||||||
|
defer s.bytesMu.Unlock()
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
if offset > len(s.bytes) {
|
||||||
|
offset = len(s.bytes)
|
||||||
|
}
|
||||||
|
out := make([]byte, len(s.bytes)-offset)
|
||||||
|
copy(out, s.bytes[offset:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ type Emulator interface {
|
|||||||
// frame" for newly-attached clients.
|
// frame" for newly-attached clients.
|
||||||
SerializeVT() ([]byte, error)
|
SerializeVT() ([]byte, error)
|
||||||
|
|
||||||
|
// StyledScreenVT returns the active screen's visible cell grid as VT
|
||||||
|
// bytes with SGR styling and child-space cursor movement, but without
|
||||||
|
// terminal modes, scroll regions, tabstops, or formatter cursor side
|
||||||
|
// effects.
|
||||||
|
StyledScreenVT() ([]byte, error)
|
||||||
|
|
||||||
// Cursor returns cursor position and visibility on the active screen.
|
// Cursor returns cursor position and visibility on the active screen.
|
||||||
Cursor() (CursorState, error)
|
Cursor() (CursorState, error)
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/cgo"
|
"runtime/cgo"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -309,6 +310,199 @@ func (e *GhosttyEmulator) SerializeVT() ([]byte, error) {
|
|||||||
return C.GoBytes(unsafe.Pointer(buf), C.int(n)), nil
|
return C.GoBytes(unsafe.Pointer(buf), C.int(n)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type styledCellSGR struct {
|
||||||
|
fgSet, bgSet bool
|
||||||
|
fgR, fgG, fgB uint8
|
||||||
|
bgR, bgG, bgB uint8
|
||||||
|
|
||||||
|
bold, italic, faint, blink, inverse, invisible, strikethrough, overline bool
|
||||||
|
underline int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s styledCellSGR) equal(o styledCellSGR) bool {
|
||||||
|
return s == o
|
||||||
|
}
|
||||||
|
|
||||||
|
func sgrSeq(s styledCellSGR) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\x1b[0")
|
||||||
|
if s.bold {
|
||||||
|
b.WriteString(";1")
|
||||||
|
}
|
||||||
|
if s.faint {
|
||||||
|
b.WriteString(";2")
|
||||||
|
}
|
||||||
|
if s.italic {
|
||||||
|
b.WriteString(";3")
|
||||||
|
}
|
||||||
|
if s.underline != 0 {
|
||||||
|
b.WriteString(";4")
|
||||||
|
}
|
||||||
|
if s.blink {
|
||||||
|
b.WriteString(";5")
|
||||||
|
}
|
||||||
|
if s.inverse {
|
||||||
|
b.WriteString(";7")
|
||||||
|
}
|
||||||
|
if s.invisible {
|
||||||
|
b.WriteString(";8")
|
||||||
|
}
|
||||||
|
if s.strikethrough {
|
||||||
|
b.WriteString(";9")
|
||||||
|
}
|
||||||
|
if s.overline {
|
||||||
|
b.WriteString(";53")
|
||||||
|
}
|
||||||
|
if s.fgSet {
|
||||||
|
fmt.Fprintf(&b, ";38;2;%d;%d;%d", s.fgR, s.fgG, s.fgB)
|
||||||
|
}
|
||||||
|
if s.bgSet {
|
||||||
|
fmt.Fprintf(&b, ";48;2;%d;%d;%d", s.bgR, s.bgG, s.bgB)
|
||||||
|
}
|
||||||
|
b.WriteByte('m')
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GhosttyEmulator) StyledScreenVT() ([]byte, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if e.closed {
|
||||||
|
return nil, errors.New("vt: emulator closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var state C.GhosttyRenderState
|
||||||
|
if rc := C.ghostty_render_state_new(nil, &state); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: render_state_new failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
defer C.ghostty_render_state_free(state)
|
||||||
|
if rc := C.ghostty_render_state_update(state, e.term); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: render_state_update failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows C.uint16_t
|
||||||
|
if rc := C.ghostty_render_state_get(state, C.GHOSTTY_RENDER_STATE_DATA_ROWS, unsafe.Pointer(&rows)); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: render_state rows failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
var iter C.GhosttyRenderStateRowIterator
|
||||||
|
if rc := C.ghostty_render_state_row_iterator_new(nil, &iter); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: row_iterator_new failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
defer C.ghostty_render_state_row_iterator_free(iter)
|
||||||
|
if rc := C.ghostty_render_state_get(state, C.GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, unsafe.Pointer(&iter)); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: render_state row iterator failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cells C.GhosttyRenderStateRowCells
|
||||||
|
if rc := C.ghostty_render_state_row_cells_new(nil, &cells); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: row_cells_new failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
defer C.ghostty_render_state_row_cells_free(cells)
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
for row := 0; row < int(rows) && C.ghostty_render_state_row_iterator_next(iter); row++ {
|
||||||
|
if rc := C.ghostty_render_state_row_get(iter, C.GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, unsafe.Pointer(&cells)); rc != C.GHOSTTY_SUCCESS {
|
||||||
|
return nil, fmt.Errorf("vt: render_state row cells failed: %s", ghosttyResultStr(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
rowCells := make([]struct {
|
||||||
|
text string
|
||||||
|
sgr styledCellSGR
|
||||||
|
draw bool
|
||||||
|
}, 0, int(e.cols))
|
||||||
|
lastDraw := -1
|
||||||
|
for col := 0; col < int(e.cols) && C.ghostty_render_state_row_cells_next(cells); col++ {
|
||||||
|
var cell C.GhosttyCell
|
||||||
|
_ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, unsafe.Pointer(&cell))
|
||||||
|
var wide C.GhosttyCellWide
|
||||||
|
_ = C.ghostty_cell_get(cell, C.GHOSTTY_CELL_DATA_WIDE, unsafe.Pointer(&wide))
|
||||||
|
if wide == C.GHOSTTY_CELL_WIDE_SPACER_TAIL || wide == C.GHOSTTY_CELL_WIDE_SPACER_HEAD {
|
||||||
|
rowCells = append(rowCells, struct {
|
||||||
|
text string
|
||||||
|
sgr styledCellSGR
|
||||||
|
draw bool
|
||||||
|
}{})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var style C.GhosttyStyle
|
||||||
|
style.size = C.size_t(unsafe.Sizeof(style))
|
||||||
|
_ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, unsafe.Pointer(&style))
|
||||||
|
|
||||||
|
sgr := styledCellSGR{
|
||||||
|
bold: bool(style.bold),
|
||||||
|
italic: bool(style.italic),
|
||||||
|
faint: bool(style.faint),
|
||||||
|
blink: bool(style.blink),
|
||||||
|
inverse: bool(style.inverse),
|
||||||
|
invisible: bool(style.invisible),
|
||||||
|
strikethrough: bool(style.strikethrough),
|
||||||
|
overline: bool(style.overline),
|
||||||
|
underline: int(style.underline),
|
||||||
|
}
|
||||||
|
var fg C.GhosttyColorRgb
|
||||||
|
if rc := C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR, unsafe.Pointer(&fg)); rc == C.GHOSTTY_SUCCESS {
|
||||||
|
sgr.fgSet, sgr.fgR, sgr.fgG, sgr.fgB = true, uint8(fg.r), uint8(fg.g), uint8(fg.b)
|
||||||
|
}
|
||||||
|
var bg C.GhosttyColorRgb
|
||||||
|
if rc := C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR, unsafe.Pointer(&bg)); rc == C.GHOSTTY_SUCCESS {
|
||||||
|
sgr.bgSet, sgr.bgR, sgr.bgG, sgr.bgB = true, uint8(bg.r), uint8(bg.g), uint8(bg.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
var graphemeLen C.uint32_t
|
||||||
|
_ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, unsafe.Pointer(&graphemeLen))
|
||||||
|
text := ""
|
||||||
|
if graphemeLen > 0 {
|
||||||
|
buf := make([]C.uint32_t, int(graphemeLen))
|
||||||
|
_ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, unsafe.Pointer(&buf[0]))
|
||||||
|
rs := make([]rune, len(buf))
|
||||||
|
for i, r := range buf {
|
||||||
|
rs[i] = rune(r)
|
||||||
|
}
|
||||||
|
text = string(rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
draw := text != "" || sgr.bgSet
|
||||||
|
if draw {
|
||||||
|
lastDraw = col
|
||||||
|
if text == "" {
|
||||||
|
text = " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowCells = append(rowCells, struct {
|
||||||
|
text string
|
||||||
|
sgr styledCellSGR
|
||||||
|
draw bool
|
||||||
|
}{text: text, sgr: sgr, draw: draw})
|
||||||
|
}
|
||||||
|
if lastDraw < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&out, "\x1b[%d;1H", row+1)
|
||||||
|
cur := styledCellSGR{}
|
||||||
|
out.WriteString("\x1b[0m")
|
||||||
|
for col := 0; col <= lastDraw && col < len(rowCells); col++ {
|
||||||
|
cell := rowCells[col]
|
||||||
|
if !cell.draw {
|
||||||
|
if !cur.equal(styledCellSGR{}) {
|
||||||
|
cur = styledCellSGR{}
|
||||||
|
out.WriteString("\x1b[0m")
|
||||||
|
}
|
||||||
|
out.WriteByte(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !cell.sgr.equal(cur) {
|
||||||
|
cur = cell.sgr
|
||||||
|
out.WriteString(sgrSeq(cur))
|
||||||
|
}
|
||||||
|
out.WriteString(cell.text)
|
||||||
|
}
|
||||||
|
out.WriteString("\x1b[0m")
|
||||||
|
}
|
||||||
|
return []byte(out.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *GhosttyEmulator) Cursor() (CursorState, error) {
|
func (e *GhosttyEmulator) Cursor() (CursorState, error) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
|
|||||||
return nil, errors.New("vt: built with -tags nocgo; libghostty-vt is unavailable")
|
return nil, errors.New("vt: built with -tags nocgo; libghostty-vt is unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
|
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
|
||||||
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
|
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
|
||||||
func (e *GhosttyEmulator) Size() (uint16, uint16) { return 0, 0 }
|
func (e *GhosttyEmulator) Size() (uint16, uint16) { return 0, 0 }
|
||||||
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
|
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
|
||||||
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
|
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
|
||||||
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
|
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
|
||||||
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
|
func (e *GhosttyEmulator) StyledScreenVT() ([]byte, error) { return nil, errStub }
|
||||||
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
|
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
|
||||||
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
|
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
|
||||||
func (e *GhosttyEmulator) Close() error { return nil }
|
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
|
||||||
|
func (e *GhosttyEmulator) Close() error { return nil }
|
||||||
|
|
||||||
var errStub = errors.New("vt: built with -tags nocgo")
|
var errStub = errors.New("vt: built with -tags nocgo")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user