Polish chrome and rework tab-switch repaint
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.
This commit is contained in:
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/preset"
|
||||
"github.com/hjbdev/patterm/internal/preset"
|
||||
)
|
||||
|
||||
// paletteAction is what the palette returns when the user picks an item.
|
||||
@@ -141,6 +141,40 @@ const (
|
||||
kittyKeyDown = 57353
|
||||
)
|
||||
|
||||
// peekArrowEvent classifies the CSI sequence at chunk[i:] as Up ('U'),
|
||||
// Down ('D'), or none (0) and returns the byte length of that sequence.
|
||||
// Used by the palette input loop to suppress duplicate adjacent
|
||||
// arrow events some terminals emit for a single physical keypress
|
||||
// (either two legacy `CSI B` in a row, or a legacy + kitty pair).
|
||||
func peekArrowEvent(chunk []byte, i int) (nav byte, advance int) {
|
||||
if i >= len(chunk) || chunk[i] != 0x1b {
|
||||
return 0, 0
|
||||
}
|
||||
n := csiLen(chunk, i)
|
||||
if n == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
final := chunk[i+n-1]
|
||||
switch final {
|
||||
case 'A':
|
||||
return 'U', n
|
||||
case 'B':
|
||||
return 'D', n
|
||||
case 'u':
|
||||
k, ok := decodeCSIu(string(chunk[i+2 : i+n-1]))
|
||||
if !ok || k.event != 1 {
|
||||
return 0, n
|
||||
}
|
||||
switch k.key {
|
||||
case kittyKeyUp:
|
||||
return 'U', n
|
||||
case kittyKeyDown:
|
||||
return 'D', n
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// handleInput consumes one keystroke from chunk[i:] and updates palette
|
||||
// state. advance is how many bytes the keystroke occupies (1 for legacy
|
||||
// keys, longer for CSI sequences). Returning done=true tells the caller
|
||||
@@ -266,42 +300,63 @@ func (p *paletteState) cursorDown() {
|
||||
}
|
||||
}
|
||||
|
||||
// render draws the palette onto out. Geometry: title bar + filter line +
|
||||
// items + footer, centred. The caller is responsible for the screen
|
||||
// clear before the first render.
|
||||
// render draws the palette onto out. Layout is a rounded box with a
|
||||
// title bar, query line, divider, item list, divider, and footer.
|
||||
// The caller is responsible for the screen clear before the first
|
||||
// render.
|
||||
func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||||
if cols < 20 {
|
||||
cols = 20
|
||||
if cols < 32 {
|
||||
cols = 32
|
||||
}
|
||||
if rows < 6 {
|
||||
rows = 6
|
||||
if rows < 10 {
|
||||
rows = 10
|
||||
}
|
||||
width := cols - 4
|
||||
if width > 80 {
|
||||
width = 80
|
||||
width := cols - 8
|
||||
if width > 72 {
|
||||
width = 72
|
||||
}
|
||||
if width < 40 {
|
||||
width = cols - 2
|
||||
}
|
||||
if width < 32 {
|
||||
width = 32
|
||||
}
|
||||
leftPad := (cols - width) / 2
|
||||
if leftPad < 1 {
|
||||
leftPad = 1
|
||||
}
|
||||
row := 2
|
||||
content := width - 4 // visible cells between the " " padding on each side
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
|
||||
|
||||
row := 2
|
||||
titleText := "patterm"
|
||||
keyHint := "Ctrl-K"
|
||||
// ╭─ patterm ─...─ Ctrl-K ─╮ uses: 3 + len(title) + 1 + dashes + 1 + len(hint) + 3
|
||||
dashes := width - 3 - len(titleText) - 1 - 1 - len(keyHint) - 3
|
||||
if dashes < 2 {
|
||||
dashes = 2
|
||||
}
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString("\x1b[1;7m")
|
||||
b.WriteString(padRight(" patterm — Ctrl-K", width))
|
||||
b.WriteString("\x1b[0m")
|
||||
b.WriteString(styleBorder + "╭─ " + styleActive + titleText + styleReset + styleBorder + " " +
|
||||
strings.Repeat("─", dashes) + " " + styleHint + keyHint + styleReset + styleBorder + " ─╮" + styleReset)
|
||||
row++
|
||||
|
||||
queryStr := string(p.query)
|
||||
queryRow := row
|
||||
qLen := utf8.RuneCountInString(queryStr)
|
||||
qPad := content - 2 - qLen
|
||||
if qPad < 0 {
|
||||
qPad = 0
|
||||
}
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "❯" + styleReset + " " + queryStr +
|
||||
strings.Repeat(" ", qPad) + " " + styleBorder + "│" + styleReset)
|
||||
row++
|
||||
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString("\x1b[7m")
|
||||
b.WriteString(padRight(" › "+string(p.query)+"_", width))
|
||||
b.WriteString("\x1b[0m")
|
||||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||||
row++
|
||||
|
||||
maxItems := rows - 6
|
||||
@@ -319,43 +374,116 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||||
if end > len(p.items) {
|
||||
end = len(p.items)
|
||||
}
|
||||
for i := start; i < end; i++ {
|
||||
it := p.items[i]
|
||||
|
||||
for i := 0; i < maxItems; i++ {
|
||||
moveTo(&b, row, leftPad)
|
||||
if i == p.cursor {
|
||||
b.WriteString("\x1b[7m")
|
||||
} else {
|
||||
b.WriteString("\x1b[0m")
|
||||
if start+i >= end {
|
||||
if len(p.items) == 0 && i == 0 {
|
||||
msg := styleDim + "no matches" + styleReset
|
||||
pad := content - 2 - 10
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
b.WriteString(styleBorder + "│" + styleReset + " " + msg +
|
||||
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
|
||||
} else {
|
||||
b.WriteString(styleBorder + "│" + styleReset + strings.Repeat(" ", width-2) +
|
||||
styleBorder + "│" + styleReset)
|
||||
}
|
||||
row++
|
||||
continue
|
||||
}
|
||||
line := " " + it.label
|
||||
if it.hint != "" {
|
||||
line += " \x1b[2m— " + it.hint + "\x1b[0m"
|
||||
if i == p.cursor {
|
||||
line += "\x1b[7m"
|
||||
|
||||
it := p.items[start+i]
|
||||
isSel := (start + i) == p.cursor
|
||||
avail := content - 2 // 2 cells reserved for the selection indicator
|
||||
|
||||
label := it.label
|
||||
hint := it.hint
|
||||
labelLen := utf8.RuneCountInString(label)
|
||||
hintLen := utf8.RuneCountInString(hint)
|
||||
|
||||
if labelLen > avail {
|
||||
label = clipRunes(label, avail-1) + "…"
|
||||
labelLen = utf8.RuneCountInString(label)
|
||||
hint = ""
|
||||
hintLen = 0
|
||||
} else if hintLen > 0 {
|
||||
gap := avail - labelLen - hintLen
|
||||
if gap < 3 {
|
||||
budget := avail - labelLen - 3
|
||||
if budget > 1 {
|
||||
hint = clipRunes(hint, budget-1) + "…"
|
||||
hintLen = utf8.RuneCountInString(hint)
|
||||
} else {
|
||||
hint = ""
|
||||
hintLen = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString(padRight(line, width+countAnsi(line)))
|
||||
b.WriteString("\x1b[0m")
|
||||
row++
|
||||
}
|
||||
if len(p.items) == 0 {
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString("\x1b[2m no matches\x1b[0m")
|
||||
gap := avail - labelLen - hintLen
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
|
||||
var indicator, labelStr, hintStr string
|
||||
if isSel {
|
||||
indicator = styleAccent + "▎" + styleReset + " "
|
||||
labelStr = styleBold + label + styleReset
|
||||
} else {
|
||||
indicator = " "
|
||||
labelStr = label
|
||||
}
|
||||
if hint != "" {
|
||||
hintStr = styleHint + hint + styleReset
|
||||
}
|
||||
|
||||
b.WriteString(styleBorder + "│" + styleReset + " " + indicator + labelStr +
|
||||
strings.Repeat(" ", gap) + hintStr + " " + styleBorder + "│" + styleReset)
|
||||
row++
|
||||
}
|
||||
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString("\x1b[2m")
|
||||
b.WriteString(padRight(" Enter to run · Esc to close · ↑↓ to navigate", width))
|
||||
b.WriteString("\x1b[0m")
|
||||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||||
row++
|
||||
|
||||
moveTo(&b, 3, leftPad+4+utf8.RuneCountInString(string(p.query)))
|
||||
footer := "↵ run · esc close · ↑↓ navigate"
|
||||
fLen := utf8.RuneCountInString(footer)
|
||||
fPad := content - fLen
|
||||
if fPad < 0 {
|
||||
fPad = 0
|
||||
}
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + footer + styleReset +
|
||||
strings.Repeat(" ", fPad) + " " + styleBorder + "│" + styleReset)
|
||||
row++
|
||||
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
|
||||
|
||||
// Park the real terminal cursor at the end of the query so it
|
||||
// blinks naturally in place of the old underscore stub.
|
||||
moveTo(&b, queryRow, leftPad+4+qLen)
|
||||
b.WriteString("\x1b[?25h")
|
||||
|
||||
_, _ = out.Write([]byte(b.String()))
|
||||
_ = out.Flush()
|
||||
}
|
||||
|
||||
func clipRunes(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == n {
|
||||
return s[:i]
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type writeFlusher interface {
|
||||
Write(p []byte) (int, error)
|
||||
Flush() error
|
||||
|
||||
Reference in New Issue
Block a user