339 lines
6.8 KiB
Go
339 lines
6.8 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.
|
|
// cell — satu sel layar; bg menandai sel ditulis saat SGR latar aktif
|
|
// (dipakai tes untuk memverifikasi baris ter-highlight).
|
|
type cell struct {
|
|
r rune
|
|
bg bool
|
|
}
|
|
|
|
type screen struct {
|
|
rows, cols int
|
|
grid [][]cell
|
|
curR, curC int
|
|
savR, savC int
|
|
bg bool // status SGR latar saat ini
|
|
|
|
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([][]cell, rows)
|
|
for i := range g {
|
|
g[i] = make([]cell, cols)
|
|
for j := range g[i] {
|
|
g[i][j] = cell{r: ' '}
|
|
}
|
|
}
|
|
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] = cell{r: ' '}
|
|
}
|
|
for r := s.curR + 1; r < s.rows; r++ {
|
|
for c := range s.grid[r] {
|
|
s.grid[r][c] = cell{r: ' '}
|
|
}
|
|
}
|
|
case 1: // dari awal layar sampai kursor
|
|
for r := 0; r < s.curR; r++ {
|
|
for c := range s.grid[r] {
|
|
s.grid[r][c] = cell{r: ' '}
|
|
}
|
|
}
|
|
for c := 0; c <= s.curC && c < s.cols; c++ {
|
|
s.grid[s.curR][c] = cell{r: ' '}
|
|
}
|
|
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] = cell{r: ' '}
|
|
}
|
|
case 'h':
|
|
if priv == '?' && at(nums, 0, 0) == 1049 { // masuk alt-screen
|
|
s.clearAll()
|
|
}
|
|
case 'm':
|
|
// Lacak SGR latar: 48 (set bg) · 0/49 (reset). Dipakai sel & Highlighted().
|
|
if len(nums) == 0 {
|
|
s.bg = false
|
|
return
|
|
}
|
|
for _, n := range nums {
|
|
switch n {
|
|
case 0, 49:
|
|
s.bg = false
|
|
case 48:
|
|
s.bg = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *screen) put(r rune) {
|
|
s.clampCursor()
|
|
s.grid[s.curR][s.curC] = cell{r: r, bg: s.bg}
|
|
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] = cell{r: ' '}
|
|
}
|
|
case 1: // dari awal ke kursor
|
|
if c <= s.curC {
|
|
s.grid[s.curR][c] = cell{r: ' '}
|
|
}
|
|
case 2: // seluruh baris
|
|
s.grid[s.curR][c] = cell{r: ' '}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *screen) clearAll() {
|
|
for i := range s.grid {
|
|
for j := range s.grid[i] {
|
|
s.grid[i][j] = cell{r: ' '}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(rowString(row), " ")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// Highlighted — true bila ada baris yang memuat substr dan posisi awal
|
|
// substr itu berlatar (SGR bg aktif saat sel ditulis).
|
|
func (s *screen) Highlighted(substr string) bool {
|
|
if substr == "" {
|
|
return false
|
|
}
|
|
for _, row := range s.grid {
|
|
text := rowString(row)
|
|
i := strings.Index(text, substr)
|
|
if i < 0 {
|
|
continue
|
|
}
|
|
pos := len([]rune(text[:i]))
|
|
if pos < len(row) && row[pos].bg {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func rowString(row []cell) string {
|
|
var b strings.Builder
|
|
for _, c := range row {
|
|
b.WriteRune(c.r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
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
|
|
}
|