tinybox

Owner: IIIlllIIIllI URL: git@github.com:nyangkosense/tinybox.git

pkg/tb.go

/* MIT License

Copyright (c) 2025 Sebastian <sebastian.michalk@pm.me>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */

/* tinybox */

package tb

import (
	"fmt"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"
	"unicode/utf8"
	"unsafe"
)

const (
	ESC = "\033"
	BEL = "\x07"

	ClearScreen     = ESC + "[2J"
	ClearToEOL      = ESC + "[K"
	MoveCursor      = ESC + "[%d;%dH"
	SaveCursor      = ESC + "[s"
	RestoreCursor   = ESC + "[u"
	HideCursor      = ESC + "[?25l"
	ShowCursor      = ESC + "[?25h"
	AlternateScreen = ESC + "[?1049h"
	NormalScreen    = ESC + "[?1049l"
	QueryCursorPos  = ESC + "[6n"

	EnableMouseMode     = ESC + "[?1000h" + ESC + "[?1002h" + ESC + "[?1015h" + ESC + "[?1006h"
	DisableMouseMode    = ESC + "[?1000l" + ESC + "[?1002l" + ESC + "[?1015l" + ESC + "[?1006l"
	EnableBracketPaste  = ESC + "[?2004h"
	DisableBracketPaste = ESC + "[?2004l"

	ResetColor = ESC + "[0m"
	SetFgColor = ESC + "[38;5;%dm"
	SetBgColor = ESC + "[48;5;%dm"

	SetBold        = ESC + "[1m"
	SetItalic      = ESC + "[3m"
	SetUnderline   = ESC + "[4m"
	SetReverse     = ESC + "[7m"
	UnsetBold      = ESC + "[22m"
	UnsetItalic    = ESC + "[23m"
	UnsetUnderline = ESC + "[24m"
	UnsetReverse   = ESC + "[27m"

	BoxTopLeft     = '┌'
	BoxTopRight    = '┐'
	BoxBottomLeft  = '└'
	BoxBottomRight = '┘'
	BoxHorizontal  = '─'
	BoxVertical    = '│'

	CursorBlock     = 1
	CursorLine      = 3
	CursorUnderline = 5
)

var (
	seqSetBold        = []byte(SetBold)
	seqUnsetBold      = []byte(UnsetBold)
	seqSetItalic      = []byte(SetItalic)
	seqUnsetItalic    = []byte(UnsetItalic)
	seqSetUnderline   = []byte(SetUnderline)
	seqUnsetUnderline = []byte(UnsetUnderline)
	seqSetReverse     = []byte(SetReverse)
	seqUnsetReverse   = []byte(UnsetReverse)
	resetColorSeq     = []byte(ResetColor)
)

type termios = syscall.Termios

type winsize struct {
	Row, Col, Xpixel, Ypixel uint16
}

type Cell struct {
	Ch     rune
	Fg     int
	Bg     int
	Bold   bool
	Italic bool
	Under  bool
	Rev    bool
	Dirty  bool
}

type Buffer struct {
	Width  int
	Height int
	Cells  [][]Cell
}

type Event struct {
	Type   EventType
	Key    Key
	Ch     rune
	X      int
	Y      int
	Button MouseButton
	Mod    KeyMod
	Press  bool
}

type EventType int

const (
	EventKey EventType = iota
	EventMouse
	EventResize
	EventPaste
)

type Key int

const (
	KeyCtrlC Key = iota + 1
	KeyCtrlD
	KeyEscape
	KeyEnter
	KeyTab
	KeyBackspace
	KeyArrowUp
	KeyArrowDown
	KeyArrowLeft
	KeyArrowRight
	KeyCtrlA
	KeyCtrlE
	KeyCtrlK
	KeyCtrlU
	KeyCtrlW
	KeyF1
	KeyF2
	KeyF3
	KeyF4
	KeyF5
	KeyF6
	KeyF7
	KeyF8
	KeyF9
	KeyF10
	KeyF11
	KeyF12
	KeyHome
	KeyEnd
	KeyPageUp
	KeyPageDown
	KeyDelete
)

type KeyMod int

const (
	ModShift KeyMod = 1 << iota
	ModAlt
	ModCtrl
)

type MouseButton int

const (
	MouseLeft MouseButton = iota
	MouseMiddle
	MouseRight
	MouseWheelUp
	MouseWheelDown
)

type Terminal struct {
	origTermios   termios
	buffer        Buffer
	backBuffer    Buffer
	savedBuffer   [][]Cell
	width         int
	height        int
	initialized   bool
	isRaw         bool
	mouseEnabled  bool
	pasteEnabled  bool
	eventQueue    []Event
	currentFg     int
	currentBg     int
	currentBold   bool
	currentItalic bool
	currentUnder  bool
	currentRev    bool
	cursorX       int
	cursorY       int
	cursorVisible bool
	cursorStyle   int
	escDelay      int
	sigwinchCh    chan os.Signal
	sigcontCh     chan os.Signal
}

var term Terminal

func getTermios(fd int) (*termios, error) {
	var t termios
	_, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), TCGETS, uintptr(unsafe.Pointer(&t)))
	if e != 0 {
		return nil, e
	}
	return &t, nil
}

func setTermios(fd int, t *termios) error {
	_, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), TCSETS, uintptr(unsafe.Pointer(t)))
	if e != 0 {
		return e
	}
	return nil
}

func enableRawMode() error {
	orig, err := getTermios(int(syscall.Stdin))
	if err != nil {
		return err
	}
	term.origTermios = *orig
	raw := *orig
	raw.Lflag &= ^uint32(ECHO | ICANON | ISIG | IEXTEN)
	raw.Iflag &= ^uint32(BRKINT | ICRNL | INPCK | ISTRIP | IXON)
	raw.Oflag &= ^uint32(OPOST)
	raw.Cflag |= CS8
	raw.Cc[VMIN] = 1
	raw.Cc[VTIME] = 0
	return setTermios(int(syscall.Stdin), &raw)
}

func disableRawMode() error {
	return setTermios(int(syscall.Stdin), &term.origTermios)
}

func queryTermSize() (int, int, error) {
	writeString("\033[999;999H\033[6n")

	var buf [32]byte
	fd := int(syscall.Stdin)

	fdSet := &syscall.FdSet{}
	setFd(fdSet, fd)
	tv := syscall.Timeval{Sec: 1, Usec: 0}

	n, err := selectRead(fd, fdSet, &tv)
	if err != nil {
		return 80, 24, err
	}
	if n <= 0 {
		return 80, 24, fmt.Errorf("timeout")
	}

	n, err = syscall.Read(syscall.Stdin, buf[:])
	if err != nil || n < 6 {
		return 80, 24, fmt.Errorf("failed to read terminal response")
	}

	response := string(buf[:n])
	if len(response) >= 6 && response[0] == '\x1b' && response[1] == '[' {
		var row, col int
		if _, err := fmt.Sscanf(response[2:], "%d;%dR", &row, &col); err == nil {
			return col, row, nil
		}
	}
	return 80, 24, nil
}

func getTermSize() (int, int, error) {
	cols, _ := strconv.Atoi(os.Getenv("COLUMNS"))
	lines, _ := strconv.Atoi(os.Getenv("LINES"))
	if cols > 0 && lines > 0 {
		return cols, lines, nil
	}
	var ws winsize
	_, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(syscall.Stdout), TIOCGWINSZ, uintptr(unsafe.Pointer(&ws)))
	if e == 0 {
		return int(ws.Col), int(ws.Row), nil
	}
	return queryTermSize()
}

func handleSigwinch() {
	for range term.sigwinchCh {
		width, height, err := getTermSize()
		if err == nil && (width != term.width || height != term.height) {
			term.width = width
			term.height = height
			term.buffer = initBuffer(width, height)
			term.backBuffer = initBuffer(width, height)

			if len(term.eventQueue) < cap(term.eventQueue) {
				term.eventQueue = append(term.eventQueue, Event{Type: EventResize})
			}
		}
	}
}

func handleSigcont() {
	for range term.sigcontCh {
		Resume()
	}
}

func writeString(s string) {
	syscall.Write(syscall.Stdout, []byte(s))
}

func initBuffer(width, height int) Buffer {
	cells := make([][]Cell, height)
	for i := range cells {
		cells[i] = make([]Cell, width)
		for j := range cells[i] {
			cells[i][j] = Cell{Ch: ' ', Fg: 7, Bg: 0, Dirty: true}
		}
	}
	return Buffer{Width: width, Height: height, Cells: cells}
}

func Init() error {
	if term.initialized {
		return fmt.Errorf("terminal already initialized")
	}

	width, height, err := getTermSize()
	if err != nil {
		return err
	}

	err = enableRawMode()
	if err != nil {
		return err
	}

	term.width = width
	term.height = height
	term.buffer = initBuffer(width, height)
	term.backBuffer = initBuffer(width, height)
	term.eventQueue = make([]Event, 0, 256)
	term.initialized = true
	term.isRaw = true
	term.currentFg = 7
	term.currentBg = 0
	term.cursorVisible = true
	term.cursorStyle = CursorBlock
	term.escDelay = 25

	term.sigwinchCh = make(chan os.Signal, 1)
	term.sigcontCh = make(chan os.Signal, 1)
	signal.Notify(term.sigwinchCh, syscall.SIGWINCH)
	signal.Notify(term.sigcontCh, syscall.SIGCONT)
	go handleSigwinch()
	go handleSigcont()

	writeString(AlternateScreen)
	writeString(HideCursor)
	writeString(ClearScreen)

	return nil
}

func Close() error {
	if !term.initialized {
		return nil
	}

	if term.mouseEnabled {
		writeString(DisableMouseMode)
	}
	if term.pasteEnabled {
		writeString(DisableBracketPaste)
	}

	signal.Stop(term.sigwinchCh)
	signal.Stop(term.sigcontCh)
	close(term.sigwinchCh)
	close(term.sigcontCh)

	writeString(ShowCursor)
	writeString(NormalScreen)
	writeString(ResetColor)

	err := disableRawMode()
	term.initialized = false
	term.isRaw = false
	return err
}

func Clear() {
	term.currentFg = 7
	term.currentBg = 0
	term.currentBold = false
	term.currentItalic = false
	term.currentUnder = false
	term.currentRev = false

	for y := 0; y < term.height; y++ {
		for x := 0; x < term.width; x++ {
			term.buffer.Cells[y][x] = Cell{Ch: ' ', Fg: 7, Bg: 0, Dirty: true}
			term.backBuffer.Cells[y][x] = Cell{Ch: 'X', Fg: 0, Bg: 0, Dirty: false}
		}
	}
}

func SetCell(x, y int, ch rune, fg, bg int) {
	if x < 0 || x >= term.width || y < 0 || y >= term.height {
		return
	}
	cell := &term.buffer.Cells[y][x]
	if cell.Ch != ch || cell.Fg != fg || cell.Bg != bg ||
		cell.Bold != term.currentBold || cell.Italic != term.currentItalic ||
		cell.Under != term.currentUnder || cell.Rev != term.currentRev {
		cell.Ch = ch
		cell.Fg = fg
		cell.Bg = bg
		cell.Bold = term.currentBold
		cell.Italic = term.currentItalic
		cell.Under = term.currentUnder
		cell.Rev = term.currentRev
		cell.Dirty = true
	}
}

func Present() {
	if term.width == 0 || term.height == 0 {
		return
	}

	output := make([]byte, 0, term.width*term.height)
	lastY, lastX := -1, -1
	activeFg, activeBg := -1, -1
	activeBold, activeItalic, activeUnder, activeRev := false, false, false, false
	var runeBuf [utf8.UTFMax]byte
	dirtyWritten := false

	for y := 0; y < term.height; y++ {
		for x := 0; x < term.width; x++ {
			curr := &term.buffer.Cells[y][x]
			back := &term.backBuffer.Cells[y][x]

			if !curr.Dirty {
				continue
			}

			if curr.Ch == back.Ch && curr.Fg == back.Fg && curr.Bg == back.Bg &&
				curr.Bold == back.Bold && curr.Italic == back.Italic &&
				curr.Under == back.Under && curr.Rev == back.Rev {
				curr.Dirty = false
				continue
			}

			if lastY != y || lastX != x {
				output = appendCursorMove(output, y+1, x+1)
			}

			if curr.Bold != activeBold {
				if curr.Bold {
					output = append(output, seqSetBold...)
				} else {
					output = append(output, seqUnsetBold...)
				}
				activeBold = curr.Bold
			}
			if curr.Italic != activeItalic {
				if curr.Italic {
					output = append(output, seqSetItalic...)
				} else {
					output = append(output, seqUnsetItalic...)
				}
				activeItalic = curr.Italic
			}
			if curr.Under != activeUnder {
				if curr.Under {
					output = append(output, seqSetUnderline...)
				} else {
					output = append(output, seqUnsetUnderline...)
				}
				activeUnder = curr.Under
			}
			if curr.Rev != activeRev {
				if curr.Rev {
					output = append(output, seqSetReverse...)
				} else {
					output = append(output, seqUnsetReverse...)
				}
				activeRev = curr.Rev
			}

			if curr.Fg != activeFg {
				output = appendSet256Color(output, true, curr.Fg)
				activeFg = curr.Fg
			}
			if curr.Bg != activeBg {
				output = appendSet256Color(output, false, curr.Bg)
				activeBg = curr.Bg
			}

			n := utf8.EncodeRune(runeBuf[:], curr.Ch)
			output = append(output, runeBuf[:n]...)

			*back = *curr
			curr.Dirty = false
			dirtyWritten = true
			lastY, lastX = y, x+1
		}
	}

	if dirtyWritten {
		output = append(output, resetColorSeq...)
		activeFg, activeBg = 7, 0
		activeBold, activeItalic, activeUnder, activeRev = false, false, false, false
	}

	if term.cursorVisible && (term.cursorX >= 0 && term.cursorY >= 0) {
		output = appendCursorMove(output, term.cursorY+1, term.cursorX+1)
	}

	if len(output) > 0 {
		syscall.Write(syscall.Stdout, output)
	}
}

func appendCursorMove(out []byte, row, col int) []byte {
	if row < 1 {
		row = 1
	}
	if col < 1 {
		col = 1
	}
	out = append(out, '', '[')
	out = appendInt(out, row)
	out = append(out, ';')
	out = appendInt(out, col)
	return append(out, 'H')
}

func appendSet256Color(out []byte, fg bool, value int) []byte {
	if value < 0 {
		value = 0
	} else if value > 255 {
		value = 255
	}
	out = append(out, '', '[')
	if fg {
		out = append(out, '3', '8')
	} else {
		out = append(out, '4', '8')
	}
	out = append(out, ';', '5', ';')
	out = appendInt(out, value)
	return append(out, 'm')
}

func appendInt(out []byte, value int) []byte {
	return strconv.AppendInt(out, int64(value), 10)
}

func DrawTextLeft(y int, text string, fg, bg int) {
	for i, ch := range text {
		if i < term.width {
			SetCell(i, y, ch, fg, bg)
		}
	}
}

func DrawTextCenter(y int, text string, fg, bg int) {
	startX := (term.width - len(text)) / 2
	if startX < 0 {
		startX = 0
	}
	for i, ch := range text {
		x := startX + i
		if x < term.width {
			SetCell(x, y, ch, fg, bg)
		}
	}
}

func DrawTextRight(y int, text string, fg, bg int) {
	startX := term.width - len(text)
	if startX < 0 {
		startX = 0
	}
	for i, ch := range text {
		x := startX + i
		if x < term.width && x >= 0 {
			SetCell(x, y, ch, fg, bg)
		}
	}
}

func ClearLine(y int) {
	for x := 0; x < term.width; x++ {
		SetCell(x, y, ' ', 7, 0)
	}
}

func GetTerminalSize() (width, height int) {
	return term.width, term.height
}

func PollEvent() (Event, error) {
	if len(term.eventQueue) > 0 {
		evt := term.eventQueue[0]
		term.eventQueue = term.eventQueue[1:]
		return evt, nil
	}

	var buf [16]byte
	n, err := syscall.Read(syscall.Stdin, buf[:])
	if err != nil {
		return Event{}, err
	}
	if n == 0 {
		return Event{}, fmt.Errorf("no input")
	}

	return parseInput(buf[:n])
}

func PollEventTimeout(timeout time.Duration) (Event, error) {
	if len(term.eventQueue) > 0 {
		evt := term.eventQueue[0]
		term.eventQueue = term.eventQueue[1:]
		return evt, nil
	}

	fd := int(syscall.Stdin)
	fdSet := &syscall.FdSet{}
	setFd(fdSet, fd)

	tv := syscall.Timeval{
		Sec:  int64(timeout / time.Second),
		Usec: int64((timeout % time.Second) / time.Microsecond),
	}

	n, err := selectRead(fd, fdSet, &tv)
	if err != nil {
		return Event{}, err
	}
	if n <= 0 {
		return Event{}, fmt.Errorf("timeout")
	}

	return PollEvent()
}

func parseSGRMouse(buf []byte) (Event, error) {
	// SGR format: \033[<button;x;y[Mm]
	if len(buf) < 9 || buf[0] != 27 || buf[1] != '[' || buf[2] != '<' {
		return Event{}, fmt.Errorf("not SGR mouse format")
	}

	i := 3
	button, next, ok := parseDecimal(buf, i)
	if !ok {
		return Event{}, fmt.Errorf("invalid SGR mouse button")
	}
	i = next
	if i >= len(buf) || buf[i] != ';' {
		return Event{}, fmt.Errorf("invalid SGR mouse separator")
	}
	i++

	x, next, ok := parseDecimal(buf, i)
	if !ok {
		return Event{}, fmt.Errorf("invalid SGR mouse x")
	}
	i = next
	if i >= len(buf) || buf[i] != ';' {
		return Event{}, fmt.Errorf("invalid SGR mouse separator")
	}
	i++

	y, next, ok := parseDecimal(buf, i)
	if !ok {
		return Event{}, fmt.Errorf("invalid SGR mouse y")
	}
	i = next
	if i >= len(buf) {
		return Event{}, fmt.Errorf("no SGR terminator found")
	}

	press := false
	switch buf[i] {
	case 'M':
		press = true
	case 'm':
		press = false
	default:
		return Event{}, fmt.Errorf("invalid SGR mouse terminator")
	}

	var mouseButton MouseButton
	switch button & 3 {
	case 0:
		mouseButton = MouseLeft
	case 1:
		mouseButton = MouseMiddle
	case 2:
		mouseButton = MouseRight
	}

	if button >= 64 {
		if button&1 != 0 {
			mouseButton = MouseWheelDown
		} else {
			mouseButton = MouseWheelUp
		}
	}

	return Event{Type: EventMouse, Button: mouseButton, X: x - 1, Y: y - 1, Press: press}, nil
}

func parseDecimal(buf []byte, idx int) (value, next int, ok bool) {
	if idx >= len(buf) {
		return 0, idx, false
	}
	start := idx
	val := 0
	for idx < len(buf) {
		b := buf[idx]
		if b < '0' || b > '9' {
			break
		}
		val = val*10 + int(b-'0')
		idx++
	}
	if idx == start {
		return 0, idx, false
	}
	return val, idx, true
}

func parseInput(buf []byte) (Event, error) {
	if len(buf) == 0 {
		return Event{}, fmt.Errorf("no input")
	}

	ch := buf[0]

	if ch == 27 { // ESC
		if len(buf) == 1 {
			return Event{Type: EventKey, Key: KeyEscape}, nil
		}
		if len(buf) >= 6 && buf[1] == '[' && buf[2] == '<' {
			if evt, err := parseSGRMouse(buf); err == nil {
				return evt, nil
			}
		}
		if len(buf) >= 3 && buf[1] == '[' {
			switch buf[2] {
			case 'A':
				return Event{Type: EventKey, Key: KeyArrowUp}, nil
			case 'B':
				return Event{Type: EventKey, Key: KeyArrowDown}, nil
			case 'C':
				return Event{Type: EventKey, Key: KeyArrowRight}, nil
			case 'D':
				return Event{Type: EventKey, Key: KeyArrowLeft}, nil
			case 'H':
				return Event{Type: EventKey, Key: KeyHome}, nil
			case 'F':
				return Event{Type: EventKey, Key: KeyEnd}, nil
			case '1':
				if len(buf) >= 4 && buf[3] == '~' {
					return Event{Type: EventKey, Key: KeyHome}, nil
				}
			case '3':
				if len(buf) >= 4 && buf[3] == '~' {
					return Event{Type: EventKey, Key: KeyDelete}, nil
				}
			case '5':
				if len(buf) >= 4 && buf[3] == '~' {
					return Event{Type: EventKey, Key: KeyPageUp}, nil
				}
			case '6':
				if len(buf) >= 4 && buf[3] == '~' {
					return Event{Type: EventKey, Key: KeyPageDown}, nil
				}
			case 'M':
				if len(buf) >= 6 {
					return parseMouseEvent(buf[3:6])
				}
			}
			if len(buf) >= 5 && buf[2] == '1' {
				switch buf[3] {
				case '1', '2', '3', '4', '5':
					if buf[4] == '~' {
						return Event{Type: EventKey, Key: Key(int(KeyF1) + int(buf[3]-'1'))}, nil
					}
				}
			}
		}
		return Event{Type: EventKey, Key: KeyEscape}, nil
	}

	switch ch {
	case 1:
		return Event{Type: EventKey, Key: KeyCtrlA}, nil
	case 3:
		return Event{Type: EventKey, Key: KeyCtrlC}, nil
	case 4:
		return Event{Type: EventKey, Key: KeyCtrlD}, nil
	case 5:
		return Event{Type: EventKey, Key: KeyCtrlE}, nil
	case 9:
		return Event{Type: EventKey, Key: KeyTab}, nil
	case 11:
		return Event{Type: EventKey, Key: KeyCtrlK}, nil
	case 13:
		return Event{Type: EventKey, Key: KeyEnter}, nil
	case 21:
		return Event{Type: EventKey, Key: KeyCtrlU}, nil
	case 23:
		return Event{Type: EventKey, Key: KeyCtrlW}, nil
	case 127:
		return Event{Type: EventKey, Key: KeyBackspace}, nil
	default:
		return Event{Type: EventKey, Ch: rune(ch)}, nil
	}
}

func parseMouseEvent(buf []byte) (Event, error) {
	if len(buf) < 3 {
		return Event{}, fmt.Errorf("incomplete mouse event")
	}

	b := buf[0] - 32
	x := int(buf[1]) - 32
	y := int(buf[2]) - 32

	var button MouseButton
	switch b & 3 {
	case 0:
		button = MouseLeft
	case 1:
		button = MouseMiddle
	case 2:
		button = MouseRight
	}

	if b&64 != 0 {
		if b&1 != 0 {
			button = MouseWheelDown
		} else {
			button = MouseWheelUp
		}
	}

	return Event{Type: EventMouse, Button: button, X: x, Y: y, Press: true}, nil
}

func EnableMouse() {
	if !term.mouseEnabled {
		writeString(EnableMouseMode)
		term.mouseEnabled = true
	}
}

func DisableMouse() {
	if term.mouseEnabled {
		writeString(DisableMouseMode)
		term.mouseEnabled = false
	}
}

func EnableBracketedPaste() {
	if !term.pasteEnabled {
		writeString(EnableBracketPaste)
		term.pasteEnabled = true
	}
}

func DisableBracketedPaste() {
	if term.pasteEnabled {
		writeString(DisableBracketPaste)
		term.pasteEnabled = false
	}
}

func SetColor(fg, bg int) {
	term.currentFg = fg
	term.currentBg = bg
}

func SetAttr(bold, italic, underline, reverse bool) {
	term.currentBold = bold
	term.currentItalic = italic
	term.currentUnder = underline
	term.currentRev = reverse
}

func ResetAttr() {
	term.currentBold = false
	term.currentItalic = false
	term.currentUnder = false
	term.currentRev = false
	term.currentFg = 7
	term.currentBg = 0
}

func Size() (width, height int) {
	return term.width, term.height
}

func Flush() {
	Present()
}

func Fill(x, y, w, h int, ch rune) {
	for dy := 0; dy < h; dy++ {
		for dx := 0; dx < w; dx++ {
			SetCell(x+dx, y+dy, ch, term.currentFg, term.currentBg)
		}
	}
}

func PrintAt(x, y int, text string) {
	for i, ch := range text {
		SetCell(x+i, y, ch, term.currentFg, term.currentBg)
	}
}

func Box(x, y, w, h int) {
	if w < 2 || h < 2 {
		return
	}

	SetCell(x, y, BoxTopLeft, term.currentFg, term.currentBg)
	SetCell(x+w-1, y, BoxTopRight, term.currentFg, term.currentBg)
	SetCell(x, y+h-1, BoxBottomLeft, term.currentFg, term.currentBg)
	SetCell(x+w-1, y+h-1, BoxBottomRight, term.currentFg, term.currentBg)

	for i := 1; i < w-1; i++ {
		SetCell(x+i, y, BoxHorizontal, term.currentFg, term.currentBg)
		SetCell(x+i, y+h-1, BoxHorizontal, term.currentFg, term.currentBg)
	}

	for i := 1; i < h-1; i++ {
		SetCell(x, y+i, BoxVertical, term.currentFg, term.currentBg)
		SetCell(x+w-1, y+i, BoxVertical, term.currentFg, term.currentBg)
	}
}

func ClearLineToEOL(y int) {
	ClearLine(y)
}

func ClearRegion(x, y, w, h int) {
	for dy := 0; dy < h; dy++ {
		for dx := 0; dx < w; dx++ {
			SetCell(x+dx, y+dy, ' ', 7, 0)
		}
	}
}

func SaveCursorPos() {
	writeString(SaveCursor)
}

func RestoreCursorPos() {
	writeString(RestoreCursor)
}

func SetCursorVisible(visible bool) {
	if visible != term.cursorVisible {
		term.cursorVisible = visible
		if visible {
			writeString(ShowCursor)
		} else {
			writeString(HideCursor)
		}
	}
}

func IsRawMode() bool {
	return term.isRaw
}

func Bell() {
	writeString(BEL)
}

func Suspend() {
	if !term.initialized {
		return
	}

	disableRawMode()
	term.isRaw = false

	writeString(ClearScreen)
	writeString(ShowCursor)
	writeString(NormalScreen)

	syscall.Kill(syscall.Getpid(), syscall.SIGTSTP)
}

func Resume() {
	if !term.initialized {
		return
	}

	enableRawMode()
	term.isRaw = true

	writeString(AlternateScreen)
	if !term.cursorVisible {
		writeString(HideCursor)
	}
	writeString(ClearScreen)

	for y := 0; y < term.height; y++ {
		for x := 0; x < term.width; x++ {
			term.buffer.Cells[y][x].Dirty = true
		}
	}
}

func GetCursorPos() (x, y int) {
	if !term.initialized {
		return 0, 0
	}

	writeString(QueryCursorPos)

	var buf [32]byte
	fd := int(syscall.Stdin)

	fdSet := &syscall.FdSet{}
	setFd(fdSet, fd)
	tv := syscall.Timeval{Sec: 1, Usec: 0} // 1 second timeout

	n, err := selectRead(fd, fdSet, &tv)
	if err != nil {
		return 0, 0
	}
	if n <= 0 {
		return 0, 0
	}

	n, err = syscall.Read(syscall.Stdin, buf[:])
	if err != nil || n < 6 {
		return 0, 0
	}

	// Parse response: \x1b[row;colR
	response := string(buf[:n])
	if len(response) >= 6 && response[0] == '\x1b' && response[1] == '[' {
		var row, col int
		if _, err := fmt.Sscanf(response[2:], "%d;%dR", &row, &col); err == nil {
			return col - 1, row - 1 // Convert to 0-based
		}
	}

	return 0, 0
}

func HLine(x, y, length int, ch rune) {
	for i := 0; i < length; i++ {
		if x+i < term.width {
			SetCell(x+i, y, ch, term.currentFg, term.currentBg)
		}
	}
}

func VLine(x, y, length int, ch rune) {
	for i := 0; i < length; i++ {
		if y+i < term.height {
			SetCell(x, y+i, ch, term.currentFg, term.currentBg)
		}
	}
}

func DrawBytes(x, y int, data []byte) {
	for i, b := range data {
		if x+i < term.width && x+i >= 0 {
			SetCell(x+i, y, rune(b), term.currentFg, term.currentBg)
		}
	}
}

func ClearRect(x, y, w, h int) {
	for dy := 0; dy < h; dy++ {
		for dx := 0; dx < w; dx++ {
			if x+dx >= 0 && x+dx < term.width && y+dy >= 0 && y+dy < term.height {
				SetCell(x+dx, y+dy, ' ', 7, term.currentBg)
			}
		}
	}
}

func SetCursor(x, y int) {
	term.cursorX = x
	term.cursorY = y
}

func HideCursorFunc() {
	SetCursorVisible(false)
}

func ShowCursorFunc() {
	SetCursorVisible(true)
}

func SetCursorStyle(style int) {
	term.cursorStyle = style
	writeString(fmt.Sprintf(ESC+"[%d q", style))
}

func EnableMouseFunc() {
	EnableMouse()
}

func DisableMouseFunc() {
	DisableMouse()
}

func SetInputMode(escDelay int) {
	term.escDelay = escDelay
}

func FlushInput() {
	fd := int(syscall.Stdin)
	flags, _, _ := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), F_GETFL, 0)
	syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), F_SETFL, flags|O_NONBLOCK)
	var buf [1024]byte
	for {
		_, err := syscall.Read(syscall.Stdin, buf[:])
		if err != nil {
			break
		}
	}
	syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), F_SETFL, flags)
}

func SaveBuffer() {
	needRealloc := term.savedBuffer == nil ||
		len(term.savedBuffer) != term.height ||
		(len(term.savedBuffer) > 0 && len(term.savedBuffer[0]) != term.width)

	if needRealloc {
		term.savedBuffer = make([][]Cell, term.height)
		for i := range term.savedBuffer {
			term.savedBuffer[i] = make([]Cell, term.width)
		}
	}

	for y := 0; y < term.height; y++ {
		for x := 0; x < term.width; x++ {
			if y < len(term.buffer.Cells) && x < len(term.buffer.Cells[y]) {
				term.savedBuffer[y][x] = term.buffer.Cells[y][x]
			}
		}
	}
}

func RestoreBuffer() {
	if term.savedBuffer == nil {
		return
	}

	for y := 0; y < term.height && y < len(term.savedBuffer); y++ {
		for x := 0; x < term.width && x < len(term.savedBuffer[y]); x++ {
			if y < len(term.buffer.Cells) && x < len(term.buffer.Cells[y]) {
				term.buffer.Cells[y][x] = term.savedBuffer[y][x]
				term.buffer.Cells[y][x].Dirty = true
			}
		}
	}
}

func GetCell(x, y int) (ch rune, fg, bg int) {
	if x < 0 || x >= term.width || y < 0 || y >= term.height {
		return ' ', 7, 0
	}

	cell := term.buffer.Cells[y][x]
	return cell.Ch, cell.Fg, cell.Bg
}

func Scroll(lines int) {
	if lines == 0 {
		return
	}

	if lines > 0 {
		for y := term.height - 1; y >= lines; y-- {
			for x := 0; x < term.width; x++ {
				term.buffer.Cells[y][x] = term.buffer.Cells[y-lines][x]
				term.buffer.Cells[y][x].Dirty = true
			}
		}

		for y := 0; y < lines && y < term.height; y++ {
			for x := 0; x < term.width; x++ {
				term.buffer.Cells[y][x] = Cell{Ch: ' ', Fg: 7, Bg: 0, Dirty: true}
			}
		}
	} else {
		lines = -lines
		for y := 0; y < term.height-lines; y++ {
			for x := 0; x < term.width; x++ {
				term.buffer.Cells[y][x] = term.buffer.Cells[y+lines][x]
				term.buffer.Cells[y][x].Dirty = true
			}
		}

		for y := term.height - lines; y < term.height; y++ {
			for x := 0; x < term.width; x++ {
				term.buffer.Cells[y][x] = Cell{Ch: ' ', Fg: 7, Bg: 0, Dirty: true}
			}
		}
	}
}