tinybox

TUI Library written in Go

Files | Log | Commits | Refs | README


7f12499

Author: SM

Date: 2025-09-02

Subject: commit

Diff

commit 7f12499918a44a7ec901da7397bd8b9db285323c
Author: SM <seb.michalk@gmail.com>
Date:   Tue Sep 2 12:02:03 2025 +0200

    commit

diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f6ec4d7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,13 @@
+Copyright (c) Sebastian M. 2025 
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..5837226
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,10 @@
+example: example.go tinybox/tb.go
+	go build -o example example.go
+
+clean:
+	rm -f example
+
+install: example
+	cp example /usr/local/bin/
+
+.PHONY: clean install
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c31e2ce
--- /dev/null
+++ b/README.md
@@ -0,0 +1,40 @@
+# tinybox - Minimalist TUI Library
+
+I needed a simple way to build terminal interfaces in Go. Everything out there was either massive (tcell), abandoned (termbox), or tried to force some architecture on me (bubbletea with its Elm thing). I just wanted to draw stuff on the screen and read keyboard input without pulling in half the internet.
+
+So I wrote tinybox. It's one Go file, about 1000 lines. You can read the whole thing in an afternoon. Copy it into your project and modify it however you want. No dependencies, no build systems, no package managers.
+
+## What It Does
+
+Tinybox gives you raw terminal access. You get a grid of cells, you put characters in them, you call Present() to update the screen. That's the core of it. 
+
+It handles the annoying parts like entering raw mode, parsing escape sequences, tracking what changed so you're not redrawing everything constantly. Mouse events work. Colors work. You can catch Ctrl-Z properly. The stuff you'd expect.
+
+The API is deliberately small. Init() to start, Close() to cleanup, SetCell() to draw, PollEvent() to read input. Maybe 30 functions total. If you need something that's not there, the code is right there - add it yourself.
+
+## How It Works
+
+The terminal is just a 2D grid of cells. Each cell has a character, foreground color, background color, and some attributes like bold or underline. Box maintains two buffers - what you're drawing to, and what's currently on screen. When you call Present(), it figures out what changed and sends only those updates to the terminal.
+
+Input comes through as events - keyboard, mouse, resize. The event loop is yours to write. Tinybox just gives you the events, you decide what to do with them. No callbacks, no handlers, no framework nonsense.
+
+Here's the simplest possible program:
+
+```go
+tui.Init()
+defer tui.Close()
+tui.PrintAt(0, 0, "some string")
+tui.Present()
+tui.PollEvent()  // wait for key
+```
+
+## Example
+
+Please refer to example.go, it contains a minimal program to fetch some Systemdata and displays said data with tinybox.
+
+```
+make
+```
+```
+./example
+```
\ No newline at end of file
diff --git a/example.go b/example.go
new file mode 100644
index 0000000..10a7b0a
--- /dev/null
+++ b/example.go
@@ -0,0 +1,349 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"log"
+	"os"
+	"os/user"
+	"runtime"
+	"strings"
+	"syscall"
+	tb "tb-example/tinybox"
+	"time"
+)
+
+type SystemInfo struct {
+	Hostname    string
+	Username    string
+	OS          string
+	Arch        string
+	CPUs        int
+	Uptime      string
+	LoadAvg     string
+	MemoryTotal uint64
+	MemoryFree  uint64
+	DiskUsage   string
+	CurrentTime string
+}
+
+func getSystemInfo() *SystemInfo {
+	info := &SystemInfo{}
+
+	if hostname, err := os.Hostname(); err == nil {
+		info.Hostname = hostname
+	}
+
+	if currentUser, err := user.Current(); err == nil {
+		info.Username = currentUser.Username
+	}
+
+	info.OS = runtime.GOOS
+	info.Arch = runtime.GOARCH
+	info.CPUs = runtime.NumCPU()
+
+	info.CurrentTime = time.Now().Format("2006-01-02 15:04:05")
+
+	if data, err := os.ReadFile("/proc/uptime"); err == nil {
+		parts := strings.Fields(string(data))
+		if len(parts) > 0 {
+			info.Uptime = parts[0] + " seconds"
+		}
+	}
+
+	if data, err := os.ReadFile("/proc/loadavg"); err == nil {
+		parts := strings.Fields(string(data))
+		if len(parts) >= 3 {
+			info.LoadAvg = fmt.Sprintf("%s %s %s", parts[0], parts[1], parts[2])
+		}
+	}
+
+	if file, err := os.Open("/proc/meminfo"); err == nil {
+		defer file.Close()
+		scanner := bufio.NewScanner(file)
+		for scanner.Scan() {
+			line := scanner.Text()
+			if strings.HasPrefix(line, "MemTotal:") {
+				fmt.Sscanf(line, "MemTotal: %d kB", &info.MemoryTotal)
+				info.MemoryTotal *= 1024
+			} else if strings.HasPrefix(line, "MemAvailable:") {
+				fmt.Sscanf(line, "MemAvailable: %d kB", &info.MemoryFree)
+				info.MemoryFree *= 1024
+			}
+		}
+	}
+
+	var stat syscall.Statfs_t
+	if err := syscall.Statfs("/", &stat); err == nil {
+		total := stat.Blocks * uint64(stat.Bsize)
+		free := stat.Bavail * uint64(stat.Bsize)
+		used := total - free
+		usedPercent := float64(used) / float64(total) * 100
+		info.DiskUsage = fmt.Sprintf("%.1f%% used (%s / %s)",
+			usedPercent,
+			formatBytes(used),
+			formatBytes(total))
+	}
+
+	return info
+}
+
+func formatBytes(bytes uint64) string {
+	const unit = 1024
+	if bytes < unit {
+		return fmt.Sprintf("%d B", bytes)
+	}
+	div, exp := int64(unit), 0
+	for n := bytes / unit; n >= unit; n /= unit {
+		div *= unit
+		exp++
+	}
+	return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
+}
+
+func drawHeader(width int) {
+	tb.SetColor(11, 4)
+	tb.Fill(0, 0, width, 3, ' ')
+
+	tb.SetAttr(true, false, false, false)
+	tb.SetColor(15, 4)
+	tb.DrawTextCenter(1, "Demo using tinybox", 15, 4)
+	tb.ResetAttr()
+}
+
+func drawSystemInfo(info *SystemInfo, startY int) int {
+	y := startY
+
+	tb.Box(1, y, 50, 12)
+
+	tb.SetAttr(true, false, true, false)
+	tb.SetColor(14, 0)
+	tb.PrintAt(3, y+1, "SYSTEM INFORMATION")
+	tb.ResetAttr()
+	y += 3
+
+	fields := []struct{ label, value string }{
+		{"Hostname:", info.Hostname},
+		{"User:", info.Username},
+		{"OS/Arch:", fmt.Sprintf("%s/%s", info.OS, info.Arch)},
+		{"CPUs:", fmt.Sprintf("%d", info.CPUs)},
+		{"Uptime:", info.Uptime},
+		{"Load Avg:", info.LoadAvg},
+		{"Time:", info.CurrentTime},
+	}
+
+	for _, field := range fields {
+		if field.value != "" {
+			tb.SetColor(10, 0)
+			tb.PrintAt(3, y, field.label)
+			tb.SetColor(15, 0)
+			tb.PrintAt(15, y, field.value)
+			y++
+		}
+	}
+
+	return y + 2
+}
+
+func drawMemoryUsage(info *SystemInfo, x, y int) {
+	if info.MemoryTotal == 0 {
+		return
+	}
+
+	tb.Box(x, y, 35, 6)
+
+	tb.SetAttr(true, false, false, false)
+	tb.SetColor(13, 0)
+	tb.PrintAt(x+2, y+1, "MEMORY USAGE")
+	tb.ResetAttr()
+
+	memUsed := info.MemoryTotal - info.MemoryFree
+	memPercent := float64(memUsed) / float64(info.MemoryTotal) * 100
+
+	tb.SetColor(15, 0)
+	tb.PrintAt(x+2, y+3, fmt.Sprintf("Used: %s (%.1f%%)", formatBytes(memUsed), memPercent))
+	tb.PrintAt(x+2, y+4, fmt.Sprintf("Total: %s", formatBytes(info.MemoryTotal)))
+
+	barWidth := 25
+	usedWidth := int(float64(barWidth) * memPercent / 100.0)
+
+	tb.SetColor(2, 0)
+	for i := 0; i < usedWidth; i++ {
+		tb.PrintAt(x+2+i, y+5, "█")
+	}
+	tb.SetColor(8, 0)
+	for i := usedWidth; i < barWidth; i++ {
+		tb.PrintAt(x+2+i, y+5, "░")
+	}
+}
+
+func drawDiskUsage(info *SystemInfo, x, y int) {
+	if info.DiskUsage == "" {
+		return
+	}
+
+	tb.Box(x, y, 35, 4)
+
+	tb.SetAttr(true, false, false, false)
+	tb.SetColor(12, 0)
+	tb.PrintAt(x+2, y+1, "DISK USAGE (/)")
+	tb.ResetAttr()
+
+	tb.SetColor(15, 0)
+	tb.PrintAt(x+2, y+2, info.DiskUsage)
+}
+
+func drawControls(y, width int) int {
+	tb.HLine(0, y, width, '─')
+	y++
+
+	tb.SetColor(8, 0)
+	controls := []string{
+		"R - Refresh", "M - Mouse", "S - Suspend", "B - Bell", "Q - Quit",
+	}
+
+	x := 2
+	for i, ctrl := range controls {
+		if i > 0 {
+			tb.PrintAt(x, y, " | ")
+			x += 3
+		}
+		tb.PrintAt(x, y, ctrl)
+		x += len(ctrl)
+	}
+
+	return y + 2
+}
+
+func drawStatusLine(message string, width, height int) {
+	tb.SetColor(0, 7)
+	tb.Fill(0, height-1, width, 1, ' ')
+	tb.PrintAt(1, height-1, fmt.Sprintf(" Status: %s", message))
+}
+
+func main() {
+	if err := tb.Init(); err != nil {
+		log.Fatal(err)
+	}
+	defer tb.Close()
+
+	info := getSystemInfo()
+	mouseEnabled := false
+	status := "Ready - Press keys to interact"
+
+	for {
+		tb.Clear()
+		width, height := tb.Size()
+
+		drawHeader(width)
+
+		y := drawSystemInfo(info, 4)
+
+		drawMemoryUsage(info, 55, 4)
+		drawDiskUsage(info, 55, 11)
+
+		tb.SaveBuffer()
+
+		tb.SetColor(6, 0)
+		tb.PrintAt(3, y, "Buffer saved - demonstrating save/restore")
+		tb.Present()
+		time.Sleep(500 * time.Millisecond)
+
+		tb.RestoreBuffer()
+
+		controlY := drawControls(height-4, width)
+
+		tb.SetColor(11, 0)
+		mouseStatus := "OFF"
+		if mouseEnabled {
+			mouseStatus = "ON"
+		}
+		tb.PrintAt(2, controlY, fmt.Sprintf("Mouse: %s | Terminal: %dx%d | Raw Mode: %t",
+			mouseStatus, width, height, tb.IsRawMode()))
+
+		drawStatusLine(status, width, height)
+
+		tb.Present()
+
+		event, err := tb.PollEventTimeout(time.Second)
+		if err != nil && err.Error() == "timeout" {
+			info.CurrentTime = time.Now().Format("2006-01-02 15:04:05")
+			status = "Clock updated"
+			continue
+		} else if err != nil {
+			status = "Error: " + err.Error()
+			continue
+		}
+
+		switch event.Type {
+		case tb.EventKey:
+			switch event.Ch {
+			case 'q', 'Q':
+				return
+			case 'r', 'R':
+				info = getSystemInfo()
+				status = "System information refreshed"
+			case 'm', 'M':
+				if mouseEnabled {
+					tb.DisableMouseFunc()
+					mouseEnabled = false
+					status = "Mouse disabled"
+				} else {
+					tb.EnableMouseFunc()
+					mouseEnabled = true
+					status = "Mouse enabled - try clicking!"
+				}
+			case 's', 'S':
+				status = "Suspending... (Ctrl+Z will work too)"
+				tb.Present()
+				time.Sleep(500 * time.Millisecond)
+				tb.Suspend()
+				status = "Resumed from suspension"
+			case 'b', 'B':
+				tb.Bell()
+				status = "Bell rung"
+			default:
+				if event.Ch != 0 {
+					status = fmt.Sprintf("Key pressed: '%c' (code: %d)", event.Ch, event.Ch)
+				}
+			}
+
+			switch event.Key {
+			case tb.KeyCtrlC:
+				return
+			case tb.KeyArrowUp:
+				tb.Scroll(-1)
+				status = "Scrolled up"
+			case tb.KeyArrowDown:
+				tb.Scroll(1)
+				status = "Scrolled down"
+			case tb.KeyArrowLeft, tb.KeyArrowRight:
+				status = fmt.Sprintf("Arrow key: %v", event.Key)
+			case tb.KeyEnter:
+				x, y := tb.GetCursorPos()
+				status = fmt.Sprintf("Cursor position: %d,%d", x, y)
+			}
+
+		case tb.EventMouse:
+			buttonName := map[tb.MouseButton]string{
+				tb.MouseLeft:      "LEFT",
+				tb.MouseMiddle:    "MIDDLE",
+				tb.MouseRight:     "RIGHT",
+				tb.MouseWheelUp:   "WHEEL_UP",
+				tb.MouseWheelDown: "WHEEL_DOWN",
+			}[event.Button]
+
+			if buttonName == "" {
+				buttonName = "UNKNOWN"
+			}
+
+			status = fmt.Sprintf("Mouse %s at (%d,%d)", buttonName, event.X, event.Y)
+
+		case tb.EventResize:
+			status = fmt.Sprintf("Terminal resized to %dx%d", width, height)
+
+		case tb.EventPaste:
+			status = "Paste event detected"
+		}
+	}
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..dc4ab72
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module tb-example
+
+go 1.24.4
diff --git a/tinybox/tb.go b/tinybox/tb.go
new file mode 100644
index 0000000..3cb4f0a
--- /dev/null
+++ b/tinybox/tb.go
@@ -0,0 +1,1108 @@
+/* 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 - minimal tui library */
+
+package tb
+
+import (
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+	"unsafe"
+)
+
+const (
+	TCGETS     = 0x5401
+	TCSETS     = 0x5402
+	TIOCGWINSZ = 0x5413
+	TCSANOW    = 0
+	ICANON     = 0x00000002
+	ECHO       = 0x00000008
+	ISIG       = 0x00000001
+	ICRNL      = 0x00000100
+	INPCK      = 0x00000010
+	ISTRIP     = 0x00000020
+	IXON       = 0x00000400
+	OPOST      = 0x00000001
+	CS8        = 0x00000030
+	VMIN       = 6
+	VTIME      = 5
+
+	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"
+	SetFgColorRGB = ESC + "[38;2;%d;%d;%dm"
+	SetBgColorRGB = ESC + "[48;2;%d;%d;%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
+)
+
+type termios struct {
+	Iflag  uint32
+	Oflag  uint32
+	Cflag  uint32
+	Lflag  uint32
+	Line   uint8
+	Cc     [32]uint8
+	Ispeed uint32
+	Ospeed uint32
+}
+
+type Cell struct {
+	Ch     rune
+	Fg     int
+	Bg     int
+	FgRGB  [3]uint8
+	BgRGB  [3]uint8
+	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
+}
+
+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
+	queueSize     int
+	currentFg     int
+	currentBg     int
+	currentFgRGB  [3]uint8
+	currentBgRGB  [3]uint8
+	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
+	_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), TCGETS, uintptr(unsafe.Pointer(&t)))
+	if errno != 0 {
+		return nil, errno
+	}
+	return &t, nil
+}
+
+func setTermios(fd int, t *termios) error {
+	_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), TCSETS, uintptr(unsafe.Pointer(t)))
+	if errno != 0 {
+		return errno
+	}
+	return nil
+}
+
+func enableRawMode() error {
+	orig, err := getTermios(syscall.Stdin)
+	if err != nil {
+		return err
+	}
+	term.origTermios = *orig
+
+	raw := *orig
+	raw.Lflag &= ^uint32(ECHO | ICANON | ISIG)
+	raw.Iflag &= ^uint32(ICRNL | INPCK | ISTRIP | IXON)
+	raw.Oflag &= ^uint32(OPOST)
+	raw.Cflag |= CS8
+	raw.Cc[VMIN] = 1
+	raw.Cc[VTIME] = 0
+
+	return setTermios(syscall.Stdin, &raw)
+}
+
+func disableRawMode() error {
+	return setTermios(syscall.Stdin, &term.origTermios)
+}
+
+type winsize struct {
+	Row    uint16
+	Col    uint16
+	Xpixel uint16
+	Ypixel uint16
+}
+
+func getTerminalSize() (int, int, error) {
+	var ws winsize
+	_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(syscall.Stdout), TIOCGWINSZ, uintptr(unsafe.Pointer(&ws)))
+	if errno != 0 {
+		return 0, 0, errno
+	}
+	return int(ws.Col), int(ws.Row), nil
+}
+
+func handleSigwinch() {
+	for range term.sigwinchCh {
+		width, height, err := getTerminalSize()
+		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 := getTerminalSize()
+	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() {
+	var output []byte
+	lastY, lastX := -1, -1
+
+	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 = append(output, []byte(fmt.Sprintf(MoveCursor, y+1, x+1))...)
+			}
+
+			if curr.Bold != back.Bold {
+				if curr.Bold {
+					output = append(output, []byte(SetBold)...)
+				} else {
+					output = append(output, []byte(UnsetBold)...)
+				}
+			}
+			if curr.Italic != back.Italic {
+				if curr.Italic {
+					output = append(output, []byte(SetItalic)...)
+				} else {
+					output = append(output, []byte(UnsetItalic)...)
+				}
+			}
+			if curr.Under != back.Under {
+				if curr.Under {
+					output = append(output, []byte(SetUnderline)...)
+				} else {
+					output = append(output, []byte(UnsetUnderline)...)
+				}
+			}
+			if curr.Rev != back.Rev {
+				if curr.Rev {
+					output = append(output, []byte(SetReverse)...)
+				} else {
+					output = append(output, []byte(UnsetReverse)...)
+				}
+			}
+
+			if curr.Fg != back.Fg {
+				output = append(output, []byte(fmt.Sprintf(SetFgColor, curr.Fg))...)
+			}
+			if curr.Bg != back.Bg {
+				output = append(output, []byte(fmt.Sprintf(SetBgColor, curr.Bg))...)
+			}
+
+			output = append(output, []byte(string(curr.Ch))...)
+
+			if curr.Bg != 0 {
+				output = append(output, []byte(fmt.Sprintf(SetBgColor, 0))...)
+			}
+
+			if curr.Bold || curr.Italic || curr.Under || curr.Rev {
+				if curr.Bold {
+					output = append(output, []byte(UnsetBold)...)
+				}
+				if curr.Italic {
+					output = append(output, []byte(UnsetItalic)...)
+				}
+				if curr.Under {
+					output = append(output, []byte(UnsetUnderline)...)
+				}
+				if curr.Rev {
+					output = append(output, []byte(UnsetReverse)...)
+				}
+			}
+
+			*back = *curr
+			curr.Dirty = false
+			lastY, lastX = y, x+1
+		}
+	}
+
+	output = append(output, []byte(ResetColor)...)
+
+	if term.cursorVisible && (term.cursorX >= 0 && term.cursorY >= 0) {
+		output = append(output, []byte(fmt.Sprintf(MoveCursor, term.cursorY+1, term.cursorX+1))...)
+	}
+
+	if len(output) > 0 {
+		syscall.Write(syscall.Stdout, output)
+	}
+}
+
+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
+	}
+
+	buf := make([]byte, 16)
+	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{}
+	fdSet.Bits[fd/64] |= 1 << (uint(fd) % 64)
+
+	tv := syscall.Timeval{
+		Sec:  int64(timeout / time.Second),
+		Usec: int64((timeout % time.Second) / time.Microsecond),
+	}
+
+	n, err := syscall.Select(fd+1, fdSet, nil, nil, &tv)
+	if err != nil {
+		return Event{}, err
+	}
+	if n == 0 {
+		return Event{}, fmt.Errorf("timeout")
+	}
+
+	return PollEvent()
+}
+
+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) >= 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}, 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 SetColorRGB(fg, bg [3]uint8) {
+	term.currentFgRGB = fg
+	term.currentBgRGB = 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) {
+	for x := 0; x < term.width; x++ {
+		SetCell(x, y, ' ', 7, 0)
+	}
+}
+
+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)
+
+	buf := make([]byte, 32)
+	fd := int(syscall.Stdin)
+
+	fdSet := &syscall.FdSet{}
+	fdSet.Bits[fd/64] |= 1 << (uint(fd) % 64)
+	tv := syscall.Timeval{Sec: 1, Usec: 0} // 1 second timeout
+
+	n, err := syscall.Select(fd+1, fdSet, nil, nil, &tv)
+	if err != nil || 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() {
+	term.cursorVisible = false
+	writeString(HideCursor)
+}
+
+func ShowCursorFunc() {
+	term.cursorVisible = true
+	writeString(ShowCursor)
+}
+
+func SetCursorStyle(style int) {
+	term.cursorStyle = style
+	writeString(fmt.Sprintf(ESC+"[%d q", style))
+}
+
+func EnableMouseFunc() {
+	if !term.mouseEnabled {
+		writeString(EnableMouseMode)
+		term.mouseEnabled = true
+	}
+}
+
+func DisableMouseFunc() {
+	if term.mouseEnabled {
+		writeString(DisableMouseMode)
+		term.mouseEnabled = false
+	}
+}
+
+func SetInputMode(escDelay int) {
+	term.escDelay = escDelay
+}
+
+func FlushInput() {
+	flags, _, err := syscall.Syscall(syscall.SYS_FCNTL, uintptr(syscall.Stdin), syscall.F_GETFL, 0)
+	if err != 0 {
+		return
+	}
+
+	syscall.Syscall(syscall.SYS_FCNTL, uintptr(syscall.Stdin), syscall.F_SETFL, flags|syscall.O_NONBLOCK)
+
+	buf := make([]byte, 1024)
+	for {
+		_, err := syscall.Read(syscall.Stdin, buf)
+		if err != nil {
+			break
+		}
+	}
+
+	syscall.Syscall(syscall.SYS_FCNTL, uintptr(syscall.Stdin), syscall.F_SETFL, flags)
+}
+
+func SaveBuffer() {
+	if term.savedBuffer == nil || len(term.savedBuffer) != term.height {
+		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}
+			}
+		}
+	}
+}