Files
tia-love/e2e/screen_test.go
T

289 lines
5.7 KiB
Go

package e2e
import (
"strconv"
"strings"
"unicode/utf8"
)
// screen — emulator terminal mini yang cukup untuk output Bubble Tea v2.
//
// Parsing escape: ESC → CSI (`ESC [ params final`) / OSC (`ESC ] … BEL|ESC\`)
// / two-char escape (ESC 7, ESC 8, ESC D, …). CSI yang diterapkan ke grid:
// CUP (H/f), relative moves (A-D), G/d, erase line (K), erase display (J),
// erase chars (X), save/restore cursor (ESC 7/8). SGR (m) & mode diam-diam
// diabaikan.
type screen struct {
rows, cols int
grid [][]rune
curR, curC int
savR, savC int
st int // 0 normal · 1 esc · 2 esc-intern · 3 csi · 4 osc · 5 osc-esc
seq []byte
pend []byte // byte UTF-8 yang belum lengkap
}
const (
stNormal = iota
stEsc
stEscInterm
stCSI
stOSC
stOSCEsc
)
func newScreen(rows, cols int) *screen {
g := make([][]rune, rows)
for i := range g {
g[i] = make([]rune, cols)
for j := range g[i] {
g[i][j] = ' '
}
}
return &screen{rows: rows, cols: cols, grid: g}
}
func (s *screen) write(b []byte) {
for _, c := range b {
switch s.st {
case stNormal:
switch {
case c == 0x1b:
s.st = stEsc
case c == '\r':
s.curC = 0
case c == '\n':
s.curR++
s.curC = 0
s.clampCursor()
case c == '\b':
if s.curC > 0 {
s.curC--
}
case c < 0x20:
// kontrol lain diabaikan
case c < 0x80:
s.put(rune(c))
default:
s.pend = append(s.pend, c)
r, size := utf8.DecodeRune(s.pend)
if r == utf8.RuneError && size <= 1 && len(s.pend) < utf8.UTFMax {
continue // tunggu byte UTF-8 berikutnya
}
s.put(r)
s.pend = s.pend[size:]
}
case stEsc:
switch {
case c == '[':
s.st, s.seq = stCSI, s.seq[:0]
case c == ']':
s.st, s.seq = stOSC, s.seq[:0]
case c >= 0x20 && c <= 0x2f:
s.st, s.seq = stEscInterm, []byte{c}
default:
s.applySimple(rune(c))
s.st = stNormal
}
case stEscInterm:
if c >= 0x30 { // final byte (mis. ESC ( B)
s.applySimple(rune(c))
s.st = stNormal
} else {
s.seq = append(s.seq, c)
}
case stCSI:
if c >= 0x40 && c <= 0x7e { // final byte
s.applyCSI(rune(c), string(s.seq))
s.st = stNormal
} else {
s.seq = append(s.seq, c)
}
case stOSC:
if c == 0x07 {
s.st = stNormal
} else if c == 0x1b {
s.st = stOSCEsc
}
case stOSCEsc:
if c == '\\' {
s.st = stNormal
} else {
s.st = stOSC
}
}
}
}
func (s *screen) applySimple(c rune) {
switch c {
case '7': // save cursor
s.savR, s.savC = s.curR, s.curC
case '8': // restore cursor
s.curR, s.curC = s.savR, s.savC
case 'D': // index (turun 1 baris)
s.curR++
s.clampCursor()
case 'M': // reverse index (naik 1 baris)
s.curR--
s.clampCursor()
case 'E': // baris berikutnya, kolom 0
s.curR++
s.curC = 0
s.clampCursor()
case 'c': // reset — layar bersih
s.clearAll()
}
}
func (s *screen) applyCSI(final rune, params string) {
priv := byte(0)
if len(params) > 0 && (params[0] == '?' || params[0] == '>' || params[0] == '<' || params[0] == '=') {
priv = params[0]
params = params[1:]
}
if i := strings.IndexByte(params, '$'); i >= 0 { // "$p" dsb — abaikan
params = params[:i]
}
nums := parseNums(params)
switch final {
case 'H', 'f':
s.curR = at(nums, 0, 1) - 1
s.curC = at(nums, 1, 1) - 1
s.clampCursor()
case 'A':
s.curR -= at(nums, 0, 1)
s.clampCursor()
case 'B':
s.curR += at(nums, 0, 1)
s.clampCursor()
case 'C':
s.curC += at(nums, 0, 1)
s.clampCursor()
case 'D':
s.curC -= at(nums, 0, 1)
s.clampCursor()
case 'G':
s.curC = at(nums, 0, 1) - 1
s.clampCursor()
case 'd':
s.curR = at(nums, 0, 1) - 1
s.clampCursor()
case 'J':
switch at(nums, 0, 0) {
case 0: // dari kursor sampai akhir layar
for c := s.curC; c < s.cols; c++ {
s.grid[s.curR][c] = ' '
}
for r := s.curR + 1; r < s.rows; r++ {
for c := range s.grid[r] {
s.grid[r][c] = ' '
}
}
case 1: // dari awal layar sampai kursor
for r := 0; r < s.curR; r++ {
for c := range s.grid[r] {
s.grid[r][c] = ' '
}
}
for c := 0; c <= s.curC && c < s.cols; c++ {
s.grid[s.curR][c] = ' '
}
default: // 2 (layar penuh) & 3 (scrollback) → bersihkan
s.clearAll()
}
case 'K':
s.eraseLine(at(nums, 0, 0))
case 'X': // erase n karakter (kursor tidak pindah)
n := at(nums, 0, 1)
for i := 0; i < n && s.curC+i < s.cols; i++ {
s.grid[s.curR][s.curC+i] = ' '
}
case 'h':
if priv == '?' && at(nums, 0, 0) == 1049 { // masuk alt-screen
s.clearAll()
}
}
}
func (s *screen) put(r rune) {
s.clampCursor()
s.grid[s.curR][s.curC] = r
s.curC++
}
func (s *screen) clampCursor() {
if s.curR < 0 {
s.curR = 0
} else if s.curR >= s.rows {
s.curR = s.rows - 1
}
if s.curC < 0 {
s.curC = 0
} else if s.curC >= s.cols {
s.curC = s.cols - 1
}
}
func (s *screen) eraseLine(mode int) {
for c := range s.grid[s.curR] {
switch mode {
case 0: // dari kursor ke akhir
if c >= s.curC {
s.grid[s.curR][c] = ' '
}
case 1: // dari awal ke kursor
if c <= s.curC {
s.grid[s.curR][c] = ' '
}
case 2: // seluruh baris
s.grid[s.curR][c] = ' '
}
}
}
func (s *screen) clearAll() {
for i := range s.grid {
for j := range s.grid[i] {
s.grid[i][j] = ' '
}
}
}
// String — snapshot layar (spasi kanan dipangkas per baris).
func (s *screen) String() string {
lines := make([]string, s.rows)
for i, row := range s.grid {
lines[i] = strings.TrimRight(string(row), " ")
}
return strings.Join(lines, "\n")
}
func parseNums(s string) []int {
if s == "" {
return nil
}
parts := strings.Split(s, ";")
nums := make([]int, 0, len(parts))
for _, p := range parts {
n, _ := strconv.Atoi(p)
nums = append(nums, n)
}
return nums
}
func at(nums []int, i, def int) int {
if i < len(nums) && nums[i] > 0 {
return nums[i]
}
return def
}