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 }