tinybox
Owner: IIIlllIIIllI URL: git@github.com:nyangkosense/tinybox.git
stream escape codes without Sprinf, keeps track of terminal state, and emits runes via utf8.EncodeRune, cutting allocations and redundant attribute resets
Commit 82d818cfd54b8a52ec486a1d66be86f434cea263 by SM <seb.michalk@gmail.com> on 2025-09-20 11:52:31 +0200
diff --git a/README.md b/README.md
index 69f8c08..4bee9b7 100644
--- a/README.md
+++ b/README.md
@@ -12,13 +12,14 @@ So I wrote Tinybox. It's one Go file, about 1000 lines. You can read the whole t
</table>
</div>
-
## What It Does
-Tinybox gives you raw terminal access which means you get a grid of cells, you put characters in them, you call Present() to update the screen. That's basically the core of it.
+Tinybox gives you raw terminal access which means you get a grid of cells, you put characters in them, you call Present() to update the screen. That's basically 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 output path is now tighter: no fmt.Sprintf in the hot loop, we stream bytes directly to the terminal and reuse buffers. Which results in fewer allocations.
+
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 - so you can simply add it yourself.
## How It Works
@@ -39,6 +40,8 @@ tb.PollEvent() // wait for key
Look at example.go if you want to see something more complex. It's a basic system monitor that shows how to handle resize, use colors, and create a simple table layout (screenshot).
The API won't change because there's no version to track. You have the code. If you need it to work differently, change it.
+There’s also a tiny demo app under `demo/` if you want to poke at the API without writing boilerplate. It uses the same primitives (draw cells, poll events, toggle mouse) and nothing more.
+
## Example
```
make
@@ -54,7 +57,7 @@ No Unicode normalization or grapheme clustering or any of that. The terminal han
### Colors
-Colors use the 256-color palette because that's what every modern terminal supports. RGB is there if you want it but honestly, 256 colors is plenty.
+Colors use the 256-color palette because that's what every modern terminal supports.
## What's Included
diff --git a/demo/main.go b/demo/main.go
new file mode 100644
index 0000000..15d08bf
--- /dev/null
+++ b/demo/main.go
@@ -0,0 +1,200 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ tb "tinybox-example/tinybox"
+)
+
+type model struct {
+ tick int
+ message string
+ cursorOn bool
+ mouseOn bool
+ spinnerIx int
+ logs []string
+}
+
+func newModel() *model {
+ m := &model{message: "Press ? for help", cursorOn: true}
+ m.log("demo ready")
+ return m
+}
+
+func (m *model) log(format string, args ...interface{}) {
+ entry := fmt.Sprintf(format, args...)
+ m.logs = append(m.logs, entry)
+ if len(m.logs) > 6 {
+ m.logs = m.logs[len(m.logs)-6:]
+ }
+}
+
+func main() {
+ if err := tb.Init(); err != nil {
+ log.Fatal(err)
+ }
+ defer tb.Close()
+
+ m := newModel()
+ tb.SetCursorStyle(tb.CursorUnderline)
+
+ loop(m)
+}
+
+func loop(m *model) {
+ spinners := []rune{'|', '/', '-', '\\'}
+
+ for {
+ draw(m, spinners[m.spinnerIx%len(spinners)])
+
+ evt, err := tb.PollEventTimeout(200 * time.Millisecond)
+ if err != nil {
+ if err.Error() == "timeout" {
+ m.tick++
+ m.spinnerIx++
+ continue
+ }
+ m.log("input error: %v", err)
+ continue
+ }
+
+ switch evt.Type {
+ case tb.EventKey:
+ if handled := handleKey(m, evt); handled {
+ return
+ }
+ case tb.EventMouse:
+ action := "released"
+ if evt.Press {
+ action = "pressed"
+ }
+ m.log("mouse %s at %d,%d", action, evt.X, evt.Y)
+ case tb.EventResize:
+ w, h := tb.Size()
+ m.log("resize: %dx%d", w, h)
+ case tb.EventPaste:
+ m.log("paste event")
+ }
+
+ m.tick++
+ m.spinnerIx++
+ }
+}
+
+func handleKey(m *model, evt tb.Event) bool {
+ switch evt.Key {
+ case tb.KeyCtrlC:
+ return true
+ case tb.KeyArrowUp:
+ m.message = "Arrow up"
+ case tb.KeyArrowDown:
+ m.message = "Arrow down"
+ case tb.KeyArrowLeft:
+ m.message = "Arrow left"
+ case tb.KeyArrowRight:
+ m.message = "Arrow right"
+ }
+
+ switch evt.Ch {
+ case 'q', 'Q':
+ return true
+ case 'c', 'C':
+ m.cursorOn = !m.cursorOn
+ tb.SetCursorVisible(m.cursorOn)
+ m.log("cursor %v", boolLabel(m.cursorOn))
+ case 'm', 'M':
+ m.mouseOn = !m.mouseOn
+ if m.mouseOn {
+ tb.EnableMouse()
+ } else {
+ tb.DisableMouse()
+ }
+ m.log("mouse tracking %v", boolLabel(m.mouseOn))
+ case '?':
+ m.message = "Keys: Q quit, C cursor, M mouse, P bell, S suspend"
+ case 'p', 'P':
+ tb.Bell()
+ m.log("bell triggered")
+ case 's', 'S':
+ m.message = "Suspending... resume with fg"
+ tb.Present()
+ tb.Suspend()
+ m.message = "Resumed"
+ default:
+ if evt.Ch != 0 {
+ m.log("key '%c'", evt.Ch)
+ }
+ }
+ return false
+}
+
+func draw(m *model, spinner rune) {
+ tb.Clear()
+ w, h := tb.Size()
+
+ drawHeader(w, spinner)
+ drawBody(w, h, m)
+ drawFooter(w, h, m)
+
+ tb.SetCursor(2, 2)
+ if m.cursorOn {
+ tb.SetCursorVisible(true)
+ }
+
+ tb.Present()
+}
+
+func drawHeader(width int, spinner rune) {
+ tb.SetColor(15, 4)
+ tb.Fill(0, 0, width, 1, ' ')
+ tb.DrawTextLeft(0, fmt.Sprintf(" tinybox demo %c", spinner), 15, 4)
+ tb.DrawTextRight(0, time.Now().Format("15:04:05"), 15, 4)
+}
+
+func drawBody(width, height int, m *model) {
+ boxHeight := height - 4
+ if boxHeight < 6 {
+ return
+ }
+
+ tb.Box(1, 1, width-2, boxHeight)
+ tb.SetColor(14, 0)
+ tb.PrintAt(3, 2, "Controls")
+
+ tb.SetColor(15, 0)
+ items := []string{
+ "Q: quit", "C: toggle cursor", "M: toggle mouse tracking",
+ "P: bell", "S: suspend", "Arrows: update status",
+ }
+
+ for i, item := range items {
+ tb.PrintAt(3, 4+i, item)
+ }
+
+ logY := 4 + len(items) + 1
+ tb.SetColor(14, 0)
+ tb.PrintAt(3, logY, "Recent events")
+
+ for i, entry := range m.logs {
+ tb.SetColor(10, 0)
+ tb.PrintAt(5, logY+2+i, entry)
+ }
+}
+
+func drawFooter(width, height int, m *model) {
+ tb.SetColor(0, 7)
+ tb.Fill(0, height-2, width, 2, ' ')
+ tb.PrintAt(1, height-2, fmt.Sprintf(" Status: %s", m.message))
+ tb.PrintAt(1, height-1, fmt.Sprintf(" Cursor: %s Mouse: %s Tick: %d",
+ boolLabel(m.cursorOn), boolLabel(m.mouseOn), m.tick))
+}
+
+func boolLabel(state bool) string {
+ if state {
+ return "ON"
+ }
+ return "OFF"
+}
+
diff --git a/tinybox/tb.go b/tinybox/tb.go
index 0ec37cc..9938611 100644
--- a/tinybox/tb.go
+++ b/tinybox/tb.go
@@ -29,9 +29,9 @@ import (
"os"
"os/signal"
"strconv"
- "strings"
"syscall"
"time"
+ "unicode/utf8"
"unsafe"
)
@@ -71,11 +71,9 @@ const (
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"
+ ResetColor = ESC + "[0m"
+ SetFgColor = ESC + "[38;5;%dm"
+ SetBgColor = ESC + "[48;5;%dm"
SetBold = ESC + "[1m"
SetItalic = ESC + "[3m"
@@ -98,6 +96,18 @@ const (
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 struct {
Iflag uint32
Oflag uint32
@@ -113,8 +123,6 @@ type Cell struct {
Ch rune
Fg int
Bg int
- FgRGB [3]uint8
- BgRGB [3]uint8
Bold bool
Italic bool
Under bool
@@ -215,11 +223,8 @@ type Terminal struct {
mouseEnabled bool
pasteEnabled bool
eventQueue []Event
- queueSize int
currentFg int
currentBg int
- currentFgRGB [3]uint8
- currentBgRGB [3]uint8
currentBold bool
currentItalic bool
currentUnder bool
@@ -432,8 +437,16 @@ func SetCell(x, y int, ch rune, fg, bg int) {
}
func Present() {
- var output []byte
+ 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++ {
@@ -452,76 +465,69 @@ func Present() {
}
if lastY != y || lastX != x {
- output = append(output, []byte(fmt.Sprintf(MoveCursor, y+1, x+1))...)
+ output = appendCursorMove(output, y+1, x+1)
}
- if curr.Bold != back.Bold {
+ if curr.Bold != activeBold {
if curr.Bold {
- output = append(output, []byte(SetBold)...)
+ output = append(output, seqSetBold...)
} else {
- output = append(output, []byte(UnsetBold)...)
+ output = append(output, seqUnsetBold...)
}
+ activeBold = curr.Bold
}
- if curr.Italic != back.Italic {
+ if curr.Italic != activeItalic {
if curr.Italic {
- output = append(output, []byte(SetItalic)...)
+ output = append(output, seqSetItalic...)
} else {
- output = append(output, []byte(UnsetItalic)...)
+ output = append(output, seqUnsetItalic...)
}
+ activeItalic = curr.Italic
}
- if curr.Under != back.Under {
+ if curr.Under != activeUnder {
if curr.Under {
- output = append(output, []byte(SetUnderline)...)
+ output = append(output, seqSetUnderline...)
} else {
- output = append(output, []byte(UnsetUnderline)...)
+ output = append(output, seqUnsetUnderline...)
}
+ activeUnder = curr.Under
}
- if curr.Rev != back.Rev {
+ if curr.Rev != activeRev {
if curr.Rev {
- output = append(output, []byte(SetReverse)...)
+ output = append(output, seqSetReverse...)
} else {
- output = append(output, []byte(UnsetReverse)...)
+ output = append(output, seqUnsetReverse...)
}
+ activeRev = curr.Rev
}
- if curr.Fg != back.Fg {
- output = append(output, []byte(fmt.Sprintf(SetFgColor, curr.Fg))...)
+ if curr.Fg != activeFg {
+ output = appendSet256Color(output, true, curr.Fg)
+ activeFg = curr.Fg
}
- if curr.Bg != back.Bg {
- output = append(output, []byte(fmt.Sprintf(SetBgColor, curr.Bg))...)
+ if curr.Bg != activeBg {
+ output = appendSet256Color(output, false, curr.Bg)
+ activeBg = 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)...)
- }
- }
+ n := utf8.EncodeRune(runeBuf[:], curr.Ch)
+ output = append(output, runeBuf[:n]...)
*back = *curr
curr.Dirty = false
+ dirtyWritten = true
lastY, lastX = y, x+1
}
}
- output = append(output, []byte(ResetColor)...)
+ 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 = append(output, []byte(fmt.Sprintf(MoveCursor, term.cursorY+1, term.cursorX+1))...)
+ output = appendCursorMove(output, term.cursorY+1, term.cursorX+1)
}
if len(output) > 0 {
@@ -529,6 +535,41 @@ func Present() {
}
}
+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 {
@@ -580,8 +621,8 @@ func PollEvent() (Event, error) {
return evt, nil
}
- buf := make([]byte, 16)
- n, err := syscall.Read(syscall.Stdin, buf)
+ var buf [16]byte
+ n, err := syscall.Read(syscall.Stdin, buf[:])
if err != nil {
return Event{}, err
}
@@ -625,43 +666,44 @@ func parseSGRMouse(buf []byte) (Event, error) {
return Event{}, fmt.Errorf("not SGR mouse format")
}
- endIdx := -1
- press := false
- for i := 3; i < len(buf); i++ {
- if buf[i] == 'M' {
- endIdx = i
- press = true
- break
- } else if buf[i] == 'm' {
- endIdx = i
- press = false
- break
- }
+ i := 3
+ button, next, ok := parseDecimal(buf, i)
+ if !ok {
+ return Event{}, fmt.Errorf("invalid SGR mouse button")
}
-
- if endIdx == -1 {
- return Event{}, fmt.Errorf("no SGR terminator found")
+ i = next
+ if i >= len(buf) || buf[i] != ';' {
+ return Event{}, fmt.Errorf("invalid SGR mouse separator")
}
+ i++
- params := string(buf[3:endIdx])
- parts := strings.Split(params, ";")
- if len(parts) != 3 {
- return Event{}, fmt.Errorf("invalid SGR parameter count")
+ x, next, ok := parseDecimal(buf, i)
+ if !ok {
+ return Event{}, fmt.Errorf("invalid SGR mouse x")
}
-
- button, err := strconv.Atoi(parts[0])
- if err != nil {
- return Event{}, fmt.Errorf("invalid button: %v", err)
+ i = next
+ if i >= len(buf) || buf[i] != ';' {
+ return Event{}, fmt.Errorf("invalid SGR mouse separator")
}
+ i++
- x, err := strconv.Atoi(parts[1])
- if err != nil {
- return Event{}, fmt.Errorf("invalid x: %v", err)
+ 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")
}
- y, err := strconv.Atoi(parts[2])
- if err != nil {
- return Event{}, fmt.Errorf("invalid y: %v", err)
+ 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
@@ -685,6 +727,26 @@ func parseSGRMouse(buf []byte) (Event, error) {
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")
@@ -837,11 +899,6 @@ func SetColor(fg, bg int) {
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
@@ -902,9 +959,7 @@ func Box(x, y, w, h int) {
}
func ClearLineToEOL(y int) {
- for x := 0; x < term.width; x++ {
- SetCell(x, y, ' ', 7, 0)
- }
+ ClearLine(y)
}
func ClearRegion(x, y, w, h int) {
@@ -985,7 +1040,7 @@ func GetCursorPos() (x, y int) {
writeString(QueryCursorPos)
- buf := make([]byte, 32)
+ var buf [32]byte
fd := int(syscall.Stdin)
fdSet := &syscall.FdSet{}
@@ -997,7 +1052,7 @@ func GetCursorPos() (x, y int) {
return 0, 0
}
- n, err = syscall.Read(syscall.Stdin, buf)
+ n, err = syscall.Read(syscall.Stdin, buf[:])
if err != nil || n < 6 {
return 0, 0
}
@@ -1054,13 +1109,11 @@ func SetCursor(x, y int) {
}
func HideCursorFunc() {
- term.cursorVisible = false
- writeString(HideCursor)
+ SetCursorVisible(false)
}
func ShowCursorFunc() {
- term.cursorVisible = true
- writeString(ShowCursor)
+ SetCursorVisible(true)
}
func SetCursorStyle(style int) {
@@ -1069,17 +1122,11 @@ func SetCursorStyle(style int) {
}
func EnableMouseFunc() {
- if !term.mouseEnabled {
- writeString(EnableMouseMode)
- term.mouseEnabled = true
- }
+ EnableMouse()
}
func DisableMouseFunc() {
- if term.mouseEnabled {
- writeString(DisableMouseMode)
- term.mouseEnabled = false
- }
+ DisableMouse()
}
func SetInputMode(escDelay int) {
@@ -1094,9 +1141,9 @@ func FlushInput() {
syscall.Syscall(syscall.SYS_FCNTL, uintptr(syscall.Stdin), syscall.F_SETFL, flags|syscall.O_NONBLOCK)
- buf := make([]byte, 1024)
+ var buf [1024]byte
for {
- _, err := syscall.Read(syscall.Stdin, buf)
+ _, err := syscall.Read(syscall.Stdin, buf[:])
if err != nil {
break
}