checkpointing, heavy dev

This commit is contained in:
2025-07-24 14:32:50 +02:00
parent a3bc63d2d9
commit c2040a5c08
89 changed files with 741883 additions and 477 deletions

184
internal/fbdraw/EXAMPLE.md Normal file
View File

@@ -0,0 +1,184 @@
# fbdraw Carousel Example
This example demonstrates how to use the fbdraw carousel API to create a rotating display with multiple screens, each updating at its own frame rate.
```go
package main
import (
"fmt"
"math"
"math/rand"
"time"
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
"git.eeqj.de/sneak/hdmistat/internal/font"
)
// SystemStatusGenerator generates frames showing system status
type SystemStatusGenerator struct {
frameCount int
}
func (g *SystemStatusGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
g.frameCount++
w := fbdraw.NewGridWriter(grid)
// Clear and draw header
w.Clear()
w.SetColor(fbdraw.Cyan).SetWeight(font.WeightBold)
w.MoveAbs(0, 0).WriteLine("=== SYSTEM STATUS ===")
// Animate with frame count
w.SetColor(fbdraw.White).SetWeight(font.WeightRegular)
w.MoveAbs(0, 2).WriteLine("Frame: %d", g.frameCount)
w.MoveAbs(0, 3).WriteLine("Time: %s", time.Now().Format("15:04:05.000"))
// Animated CPU meter
cpuUsage := 50 + 30*math.Sin(float64(g.frameCount)*0.1)
w.MoveAbs(0, 5).Write("CPU: [")
w.DrawMeter(cpuUsage, 20)
w.Write("] %.1f%%", cpuUsage)
return nil
}
func (g *SystemStatusGenerator) FramesPerSecond() float64 {
return 15.0 // 15 FPS
}
// NetworkMonitorGenerator shows network activity
type NetworkMonitorGenerator struct {
packets []float64
}
func (g *NetworkMonitorGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
w := fbdraw.NewGridWriter(grid)
// Update data
if len(g.packets) > 50 {
g.packets = g.packets[1:]
}
g.packets = append(g.packets, rand.Float64()*100)
// Draw
w.Clear()
w.SetColor(fbdraw.Green).SetWeight(font.WeightBold)
w.MoveAbs(0, 0).WriteLine("=== NETWORK MONITOR ===")
// Draw graph
w.SetColor(fbdraw.White).SetWeight(font.WeightRegular)
for i, val := range g.packets {
height := int(val / 10) // Scale to 0-10
for y := 10; y > 10-height; y-- {
w.MoveAbs(i+5, y).Write("█")
}
}
w.MoveAbs(0, 12).WriteLine("Packets/sec: %.0f", g.packets[len(g.packets)-1])
return nil
}
func (g *NetworkMonitorGenerator) FramesPerSecond() float64 {
return 10.0 // 10 FPS
}
// ProcessListGenerator shows top processes
type ProcessListGenerator struct {
updateCount int
}
func (g *ProcessListGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
g.updateCount++
w := fbdraw.NewGridWriter(grid)
w.Clear()
w.SetColor(fbdraw.Yellow).SetWeight(font.WeightBold)
w.MoveAbs(0, 0).WriteLine("=== TOP PROCESSES ===")
// Table header
w.MoveAbs(0, 2).SetColor(fbdraw.White).SetWeight(font.WeightBold)
w.WriteLine("PID CPU% PROCESS")
w.WriteLine("----- ----- ----------------")
// Fake process data
w.SetWeight(font.WeightRegular)
processes := []struct {
pid int
cpu float64
name string
}{
{1234, 42.1 + float64(g.updateCount%10), "firefox"},
{5678, 18.7, "vscode"},
{9012, 8.3, "dockerd"},
}
for i, p := range processes {
if p.cpu > 30 {
w.SetColor(fbdraw.Red)
} else if p.cpu > 15 {
w.SetColor(fbdraw.Yellow)
} else {
w.SetColor(fbdraw.White)
}
w.MoveAbs(0, 4+i)
w.WriteLine("%-5d %5.1f %s", p.pid, p.cpu, p.name)
}
w.MoveAbs(0, 10).SetColor(fbdraw.Gray60)
w.WriteLine("Update #%d", g.updateCount)
return nil
}
func (g *ProcessListGenerator) FramesPerSecond() float64 {
return 1.0 // 1 FPS - processes don't change that fast
}
func main() {
// Initialize display (auto-detect framebuffer)
display, err := fbdraw.NewFBDisplayAuto()
if err != nil {
// Fall back to terminal display
display = fbdraw.NewTerminalDisplay(80, 25)
}
defer display.Close()
// Create carousel with 10 second rotation
carousel := fbdraw.NewCarousel(display, 10*time.Second)
// Add screens with their generators
carousel.AddScreen("System Status", &SystemStatusGenerator{})
carousel.AddScreen("Network Monitor", &NetworkMonitorGenerator{})
carousel.AddScreen("Process List", &ProcessListGenerator{})
// Start the carousel (blocks until interrupted)
if err := carousel.Run(); err != nil {
fmt.Printf("Carousel error: %v\n", err)
}
}
```
## Key Features Demonstrated
1. **Multiple Display Types**: The example tries to auto-detect a framebuffer, falling back to terminal display if not available.
2. **Different Frame Rates**: Each screen updates at its own rate:
- System Status: 15 FPS (smooth animations)
- Network Monitor: 10 FPS (moderate updates)
- Process List: 1 FPS (slow changing data)
3. **GridWriter API**: Shows various drawing operations:
- `MoveAbs()` for absolute positioning
- `Move()` for relative movement
- `DrawMeter()` for progress bars with automatic coloring
- `SetColor()`, `SetWeight()` for styling
4. **Carousel Management**: The carousel automatically:
- Rotates screens every 10 seconds
- Manages frame timing for each screen
- Only renders the active screen
5. **Animation**: The system status screen demonstrates smooth animation using frame counting and sine waves.

335
internal/fbdraw/README.md Normal file
View File

@@ -0,0 +1,335 @@
# fbdraw - Simple Framebuffer Drawing for Go
A high-level Go package for creating text-based displays on Linux framebuffers. Designed for system monitors, status displays, and embedded systems.
## API Design
### Basic Example
```go
package main
import (
"github.com/example/fbdraw"
)
func main() {
// Auto-detect and initialize
fb := fbdraw.Init()
defer fb.Close()
// Main render loop
fb.Loop(func(d *fbdraw.Draw) {
// Clear screen
d.Clear()
// Draw header - state is maintained between calls
d.Font(fbdraw.PlexSans).Size(36).Bold()
d.Color(fbdraw.White)
d.TextCenter(d.Width/2, 50, "System Monitor")
// Switch to normal text
d.Font(fbdraw.PlexMono).Size(14).Plain()
// Create a text grid for the main content area
grid := d.Grid(20, 100, 80, 30) // x, y, cols, rows
// Write to the grid using row,col coordinates
grid.Color(fbdraw.Green)
grid.Write(0, 0, "Hostname:")
grid.Write(0, 15, getHostname())
grid.Write(2, 0, "Uptime:")
grid.Write(2, 15, getUptime())
grid.Write(4, 0, "Load Average:")
if load := getLoad(); load > 2.0 {
grid.Color(fbdraw.Red)
}
grid.Write(4, 15, "%.2f %.2f %.2f", getLoad())
// CPU section with meter characters
grid.Color(fbdraw.Blue).Bold()
grid.Write(8, 0, "CPU Usage:")
grid.Plain()
cpus := getCPUPercents()
for i, pct := range cpus {
grid.Write(10+i, 0, "CPU%d [%s] %5.1f%%", i,
fbdraw.Meter(pct, 20), pct)
}
// Memory section
grid.Color(fbdraw.Yellow).Bold()
grid.Write(20, 0, "Memory:")
grid.Plain().Color(fbdraw.White)
mem := getMemoryInfo()
grid.Write(22, 0, "Used: %s / %s",
fbdraw.Bytes(mem.Used), fbdraw.Bytes(mem.Total))
grid.Write(23, 0, "Free: %s", fbdraw.Bytes(mem.Free))
grid.Write(24, 0, "Cache: %s", fbdraw.Bytes(mem.Cache))
// Present the frame
d.Present()
})
}
```
### Dashboard Example with Multiple Grids
```go
func renderDashboard(d *fbdraw.Draw) {
d.Clear(fbdraw.Gray10) // Dark background
// Header
d.Font(fbdraw.PlexSans).Size(48).Bold().Color(fbdraw.Cyan)
d.TextCenter(d.Width/2, 60, "SERVER STATUS")
// Reset to default for grids
d.Font(fbdraw.PlexMono).Size(16).Plain()
// Left panel - System Info
leftGrid := d.Grid(20, 120, 40, 25)
leftGrid.Background(fbdraw.Gray20)
leftGrid.Border(fbdraw.Gray40)
leftGrid.Color(fbdraw.Green).Bold()
leftGrid.WriteCenter(0, "SYSTEM")
leftGrid.Plain().Color(fbdraw.White)
info := getSystemInfo()
leftGrid.Write(2, 1, "OS: %s", info.OS)
leftGrid.Write(3, 1, "Kernel: %s", info.Kernel)
leftGrid.Write(4, 1, "Arch: %s", info.Arch)
leftGrid.Write(6, 1, "Hostname: %s", info.Hostname)
leftGrid.Write(7, 1, "Uptime: %s", info.Uptime)
// Right panel - Performance
rightGrid := d.Grid(d.Width/2+20, 120, 40, 25)
rightGrid.Background(fbdraw.Gray20)
rightGrid.Border(fbdraw.Gray40)
rightGrid.Color(fbdraw.Orange).Bold()
rightGrid.WriteCenter(0, "PERFORMANCE")
rightGrid.Plain()
// CPU bars
rightGrid.Color(fbdraw.Blue)
cpus := getCPUCores()
for i, cpu := range cpus {
rightGrid.Write(2+i, 1, "CPU%d", i)
rightGrid.Bar(2+i, 6, 30, cpu.Percent, fbdraw.Heat(cpu.Percent))
}
// Memory meter
rightGrid.Color(fbdraw.Purple)
mem := getMemory()
rightGrid.Write(12, 1, "Memory")
rightGrid.Bar(12, 8, 28, mem.Percent, fbdraw.Green)
rightGrid.Write(13, 8, "%s / %s",
fbdraw.Bytes(mem.Used), fbdraw.Bytes(mem.Total))
// Bottom status bar
statusGrid := d.Grid(0, d.Height-40, d.Width/12, 1)
statusGrid.Background(fbdraw.Black)
statusGrid.Color(fbdraw.Gray60).Size(12)
statusGrid.Write(0, 1, "Updated: %s | Load: %.2f | Temp: %d°C | Net: %s",
time.Now().Format("15:04:05"),
getLoad1Min(),
getCPUTemp(),
getNetRate())
}
```
### Process Table Example
```go
func renderProcessTable(d *fbdraw.Draw) {
d.Clear()
// Header
d.Font(fbdraw.PlexSans).Size(24).Bold()
d.Text(20, 30, "Top Processes by CPU")
// Create table grid
table := d.Grid(20, 70, 100, 40)
table.Font(fbdraw.PlexMono).Size(14)
// Table headers
table.Background(fbdraw.Gray30).Color(fbdraw.White).Bold()
table.Write(0, 0, "PID")
table.Write(0, 8, "USER")
table.Write(0, 20, "PROCESS")
table.Write(0, 60, "CPU%")
table.Write(0, 70, "MEM%")
table.Write(0, 80, "TIME")
// Reset style for data
table.Background(fbdraw.Black).Plain()
// Process rows
processes := getTopProcesses(38) // 38 rows of data
for i, p := range processes {
row := i + 2 // Skip header row and blank row
// Alternate row backgrounds
if i%2 == 0 {
table.RowBackground(row, fbdraw.Gray10)
}
// Highlight high CPU usage
if p.CPU > 50.0 {
table.RowColor(row, fbdraw.Red)
} else if p.CPU > 20.0 {
table.RowColor(row, fbdraw.Yellow)
} else {
table.RowColor(row, fbdraw.Gray80)
}
table.Write(row, 0, "%5d", p.PID)
table.Write(row, 8, "%-8s", p.User)
table.Write(row, 20, "%-38s", truncate(p.Name, 38))
table.Write(row, 60, "%5.1f", p.CPU)
table.Write(row, 70, "%5.1f", p.Memory)
table.Write(row, 80, "%8s", p.Time)
}
}
```
## Key Features
### Bundled Fonts
```go
const (
PlexMono = iota // IBM Plex Mono - great for tables/code
PlexSans // IBM Plex Sans - clean headers
Terminus // Terminus - sharp bitmap font
)
```
### Drawing State
The Draw object maintains state between calls:
- Current font, size, bold/italic
- Foreground and background colors
- Transformations
### Text Grids
- Define rectangular regions with rows/columns
- Automatic text positioning
- Built-in backgrounds, borders, styling
- Row/column operations
### Utilities
```go
// Format bytes nicely (1.2GB, 456MB, etc)
fbdraw.Bytes(1234567890) // "1.2GB"
// Create text-based meter/progress bars
fbdraw.Meter(75.5, 20) // "███████████████ "
// Color based on value
fbdraw.Heat(temp) // Returns color from blue->green->yellow->red
// Truncate with ellipsis
truncate("very long string", 10) // "very lo..."
```
### Colors
```go
// Basic colors
fbdraw.Black, White, Red, Green, Blue, Yellow, Cyan, Purple, Orange
// Grays
fbdraw.Gray10, Gray20, Gray30, Gray40, Gray50, Gray60, Gray70, Gray80, Gray90
// Custom
fbdraw.RGB(128, 200, 255)
fbdraw.HSL(180, 0.5, 0.5)
```
## Carousel API
The carousel system provides automatic screen rotation with independent frame rates for each screen.
### Core Interfaces
```go
// FrameGenerator generates frames for a screen
type FrameGenerator interface {
// GenerateFrame is called to render a new frame
GenerateFrame(grid *Grid) error
// FramesPerSecond returns the desired frame rate
FramesPerSecond() float64
}
// Display represents the output device (framebuffer, terminal, etc)
type Display interface {
// Write renders a grid to the display
Write(grid *Grid) error
// Size returns the display dimensions in characters
Size() (width, height int)
// Close cleans up resources
Close() error
}
```
### Carousel Usage
```go
// Create display
display, err := fbdraw.NewDisplay("") // auto-detect
// Create carousel with rotation interval
carousel := fbdraw.NewCarousel(display, 10*time.Second)
// Add screens
carousel.AddScreen("System Status", &SystemStatusGenerator{})
carousel.AddScreen("Network", &NetworkMonitorGenerator{})
carousel.AddScreen("Processes", &ProcessListGenerator{})
// Run carousel (blocks)
carousel.Run()
```
### Screen Implementation
```go
type MyScreenGenerator struct {
// Internal state
}
func (g *MyScreenGenerator) GenerateFrame(grid *Grid) error {
w := NewGridWriter(grid)
w.Clear()
// Draw your content
w.MoveTo(0, 0).Write("Hello World")
return nil
}
func (g *MyScreenGenerator) FramesPerSecond() float64 {
return 10.0 // 10 FPS
}
```
### Features
- **Independent frame rates**: Each screen can update at its own rate
- **Automatic rotation**: Screens rotate on a timer
- **Clean separation**: Generators just draw, carousel handles timing
- **Resource efficient**: Only the active screen generates frames
- **Graceful shutdown**: Handles signals properly
## Design Philosophy
- **Immediate mode**: Draw calls happen immediately in your render function
- **Stateful**: Common properties persist until changed
- **Grid-based**: Most text UIs are grids - embrace it
- **Batteries included**: Common fonts, colors, and utilities built-in
- **Zero allocation**: Reuse buffers where possible for smooth updates
- **Simple interfaces**: Easy to implement custom screens

162
internal/fbdraw/carousel.go Normal file
View File

@@ -0,0 +1,162 @@
package fbdraw
import (
"context"
"fmt"
"sync"
"time"
)
// Screen represents a single screen in the carousel
type Screen struct {
Name string
Generator FrameGenerator
ticker *time.Ticker
stop chan struct{}
}
// Carousel manages rotating between multiple screens
type Carousel struct {
display FramebufferDisplay
screens []*Screen
currentIndex int
rotationInterval time.Duration
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewCarousel creates a new carousel
func NewCarousel(display FramebufferDisplay, rotationInterval time.Duration) *Carousel {
ctx, cancel := context.WithCancel(context.Background())
return &Carousel{
display: display,
screens: make([]*Screen, 0),
currentIndex: 0,
rotationInterval: rotationInterval,
ctx: ctx,
cancel: cancel,
}
}
// AddScreen adds a new screen to the carousel
func (c *Carousel) AddScreen(name string, generator FrameGenerator) {
c.mu.Lock()
defer c.mu.Unlock()
screen := &Screen{
Name: name,
Generator: generator,
stop: make(chan struct{}),
}
c.screens = append(c.screens, screen)
}
// Run starts the carousel
func (c *Carousel) Run() error {
if len(c.screens) == 0 {
return fmt.Errorf("no screens added to carousel")
}
// Start rotation timer
rotationTicker := time.NewTicker(c.rotationInterval)
defer rotationTicker.Stop()
// Start with first screen
if err := c.activateScreen(0); err != nil {
return err
}
// Main loop
for {
select {
case <-c.ctx.Done():
return c.ctx.Err()
case <-rotationTicker.C:
// Move to next screen
c.mu.Lock()
nextIndex := (c.currentIndex + 1) % len(c.screens)
c.mu.Unlock()
if err := c.activateScreen(nextIndex); err != nil {
return err
}
}
}
}
// Stop stops the carousel
func (c *Carousel) Stop() {
c.cancel()
c.wg.Wait()
}
// activateScreen switches to the specified screen
func (c *Carousel) activateScreen(index int) error {
c.mu.Lock()
defer c.mu.Unlock()
// Stop current screen if any
if c.currentIndex >= 0 && c.currentIndex < len(c.screens) {
close(c.screens[c.currentIndex].stop)
if c.screens[c.currentIndex].ticker != nil {
c.screens[c.currentIndex].ticker.Stop()
}
}
// Wait for current screen to stop
c.wg.Wait()
// Start new screen
c.currentIndex = index
screen := c.screens[index]
// Calculate frame interval
fps := screen.Generator.FramesPerSecond()
if fps <= 0 {
fps = 1 // Default to 1 FPS if invalid
}
frameInterval := time.Duration(float64(time.Second) / fps)
// Create new stop channel and ticker
screen.stop = make(chan struct{})
screen.ticker = time.NewTicker(frameInterval)
// Start frame generation goroutine
c.wg.Add(1)
go c.runScreen(screen)
return nil
}
// runScreen runs a single screen's frame generation loop
func (c *Carousel) runScreen(screen *Screen) {
defer c.wg.Done()
// Get display size
width, height := c.display.Size()
grid := NewCharGrid(width, height)
// Generate first frame immediately
if err := screen.Generator.GenerateFrame(grid); err == nil {
_ = c.display.Write(grid)
}
// Frame generation loop
for {
select {
case <-screen.stop:
return
case <-screen.ticker.C:
if err := screen.Generator.GenerateFrame(grid); err == nil {
_ = c.display.Write(grid)
}
}
}
}

309
internal/fbdraw/display.go Normal file
View File

@@ -0,0 +1,309 @@
package fbdraw
import (
"fmt"
"log"
"os"
"syscall"
"unsafe"
)
// FBDisplay renders to a Linux framebuffer device
type FBDisplay struct {
device string
file *os.File
info fbVarScreeninfo
fixInfo fbFixScreeninfo
data []byte
charWidth int
charHeight int
}
// fbFixScreeninfo from linux/fb.h
type fbFixScreeninfo struct {
ID [16]byte
SMEMStart uint64
SMEMLen uint32
Type uint32
TypeAux uint32
Visual uint32
XPanStep uint16
YPanStep uint16
YWrapStep uint16
_ uint16
LineLength uint32
MMIOStart uint64
MMIOLen uint32
Accel uint32
Capabilities uint16
Reserved [2]uint16
}
// fbVarScreeninfo from linux/fb.h
type fbVarScreeninfo struct {
XRes uint32
YRes uint32
XResVirtual uint32
YResVirtual uint32
XOffset uint32
YOffset uint32
BitsPerPixel uint32
Grayscale uint32
Red fbBitfield
Green fbBitfield
Blue fbBitfield
Transp fbBitfield
NonStd uint32
Activate uint32
Height uint32
Width uint32
AccelFlags uint32
PixClock uint32
LeftMargin uint32
RightMargin uint32
UpperMargin uint32
LowerMargin uint32
HSyncLen uint32
VSyncLen uint32
Sync uint32
VMode uint32
Rotate uint32
Colorspace uint32
Reserved [4]uint32
}
type fbBitfield struct {
Offset uint32
Length uint32
Right uint32
}
const (
fbiogetVscreeninfo = 0x4600
fbiogetFscreeninfo = 0x4602
)
// NewFBDisplay creates a display for a specific framebuffer device
func NewFBDisplay(device string) (*FBDisplay, error) {
file, err := os.OpenFile(device, os.O_RDWR, 0) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("opening framebuffer %s: %w", device, err)
}
display := &FBDisplay{
device: device,
file: file,
}
// Get variable screen info
if err := display.getScreenInfo(); err != nil {
_ = file.Close()
return nil, err
}
// Get fixed screen info
if err := display.getFixedInfo(); err != nil {
_ = file.Close()
return nil, err
}
// Memory map the framebuffer
size := int(display.fixInfo.SMEMLen)
display.data, err = syscall.Mmap(
int(file.Fd()),
0,
size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_SHARED,
)
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("mmap framebuffer: %w", err)
}
// Calculate character dimensions (rough approximation)
display.charWidth = 8
display.charHeight = 16
return display, nil
}
// NewFBDisplayAuto auto-detects and opens the framebuffer
func NewFBDisplayAuto() (*FBDisplay, error) {
// Try common framebuffer devices
devices := []string{"/dev/fb0", "/dev/fb1", "/dev/graphics/fb0"}
for _, device := range devices {
if _, err := os.Stat(device); err == nil {
display, err := NewFBDisplay(device)
if err == nil {
return display, nil
}
}
}
return nil, fmt.Errorf("no framebuffer device found")
}
func (d *FBDisplay) getScreenInfo() error {
_, _, errno := syscall.Syscall(
syscall.SYS_IOCTL,
d.file.Fd(),
fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&d.info)), //nolint:gosec
)
if errno != 0 {
return fmt.Errorf("ioctl FBIOGET_VSCREENINFO: %w", errno)
}
return nil
}
func (d *FBDisplay) getFixedInfo() error {
_, _, errno := syscall.Syscall(
syscall.SYS_IOCTL,
d.file.Fd(),
fbiogetFscreeninfo,
uintptr(unsafe.Pointer(&d.fixInfo)), //nolint:gosec
)
if errno != 0 {
return fmt.Errorf("ioctl FBIOGET_FSCREENINFO: %w", errno)
}
return nil
}
// Write renders a grid to the framebuffer
func (d *FBDisplay) Write(grid *CharGrid) error {
// Render grid to image
img, err := grid.Render()
if err != nil {
return fmt.Errorf("rendering grid: %w", err)
}
// Clear framebuffer
for i := range d.data {
d.data[i] = 0
}
// Copy image to framebuffer
bounds := img.Bounds()
bytesPerPixel := int(d.info.BitsPerPixel / 8)
lineLength := int(d.fixInfo.LineLength)
for y := bounds.Min.Y; y < bounds.Max.Y && y < int(d.info.YRes); y++ {
for x := bounds.Min.X; x < bounds.Max.X && x < int(d.info.XRes); x++ {
r, g, b, _ := img.At(x, y).RGBA()
offset := y*lineLength + x*bytesPerPixel
if offset+bytesPerPixel <= len(d.data) {
// Assuming 32-bit BGRA format (most common)
if bytesPerPixel == 4 {
d.data[offset+0] = byte(b >> 8)
d.data[offset+1] = byte(g >> 8)
d.data[offset+2] = byte(r >> 8)
d.data[offset+3] = 0xFF
}
}
}
}
return nil
}
// Size returns the display size in characters
func (d *FBDisplay) Size() (width, height int) {
width = int(d.info.XRes) / d.charWidth
height = int(d.info.YRes) / d.charHeight
return
}
// Close closes the framebuffer
func (d *FBDisplay) Close() error {
if d.data != nil {
if err := syscall.Munmap(d.data); err != nil {
log.Printf("munmap error: %v", err)
}
d.data = nil
}
if d.file != nil {
if err := d.file.Close(); err != nil {
return err
}
d.file = nil
}
return nil
}
// TerminalDisplay renders to the terminal using ANSI escape codes
type TerminalDisplay struct {
width int
height int
}
// NewTerminalDisplay creates a terminal display
func NewTerminalDisplay(width, height int) *TerminalDisplay {
return &TerminalDisplay{
width: width,
height: height,
}
}
// Write renders a grid to the terminal
func (d *TerminalDisplay) Write(grid *CharGrid) error {
// Clear screen
fmt.Print("\033[2J\033[H")
// Print ANSI representation
fmt.Print(grid.ToANSI())
return nil
}
// Size returns the terminal size in characters
func (d *TerminalDisplay) Size() (width, height int) {
return d.width, d.height
}
// Close is a no-op for terminal display
func (d *TerminalDisplay) Close() error {
// Clear screen one last time
fmt.Print("\033[2J\033[H")
return nil
}
// LogDisplay renders to a logger for debugging
type LogDisplay struct {
width int
height int
logger *log.Logger
}
// NewLogDisplay creates a log display
func NewLogDisplay(width, height int, logger *log.Logger) *LogDisplay {
if logger == nil {
logger = log.New(os.Stderr, "[fbdraw] ", log.LstdFlags)
}
return &LogDisplay{
width: width,
height: height,
logger: logger,
}
}
// Write logs the grid as text
func (d *LogDisplay) Write(grid *CharGrid) error {
d.logger.Printf("=== Frame ===\n%s\n", grid.ToText())
return nil
}
// Size returns the display size
func (d *LogDisplay) Size() (width, height int) {
return d.width, d.height
}
// Close is a no-op for log display
func (d *LogDisplay) Close() error {
return nil
}

76
internal/fbdraw/doc.go Normal file
View File

@@ -0,0 +1,76 @@
/*
Package fbdraw provides a simple, immediate-mode API for rendering
monospaced text to Linux framebuffers.
# Basic Usage
The simplest way to create a display:
display, err := fbdraw.Init()
if err != nil {
log.Fatal(err)
}
defer display.Close()
display.Loop(func(d *fbdraw.Draw) {
d.Clear()
d.Font(fbdraw.PlexMono).Size(24).Bold()
d.Color(fbdraw.White)
d.TextCenter(d.Width/2, 50, "Hello, Framebuffer!")
d.Present()
})
# Working with Grids
For structured layouts, use the grid system:
grid := d.Grid(10, 10, 80, 25) // x, y, columns, rows
grid.Border(fbdraw.White)
grid.Color(fbdraw.Green).Bold()
grid.WriteCenter(0, "System Status")
grid.Plain().Color(fbdraw.White)
grid.Write(2, 0, "CPU:")
grid.Bar(2, 10, 30, cpuPercent, fbdraw.Heat(cpuPercent))
# Drawing State
The Draw object maintains state between calls:
d.Font(fbdraw.PlexMono).Size(14).Bold()
d.Color(fbdraw.Green)
d.Text(10, 10, "This is green bold text")
d.Text(10, 30, "This is still green bold text")
d.Plain().Color(fbdraw.White)
d.Text(10, 50, "This is white plain text")
# Performance
The package is designed for status displays that update at most a few times
per second. It prioritizes ease of use over performance. Each Present() call
updates the entire framebuffer.
# Fonts
Three monospace fonts are bundled:
- PlexMono: IBM Plex Mono, excellent readability
- Terminus: Classic bitmap terminal font
- SourceCodePro: Adobe's coding font
# Colors
Common colors are provided as package variables (Black, White, Red, etc).
Grays are available from Gray10 (darkest) to Gray90 (lightest).
# Error Handling
Most operations fail silently to keep the API simple. Use explicit error
checks where needed:
if err := d.Present(); err != nil {
// Handle framebuffer write error
}
*/
package fbdraw

490
internal/fbdraw/grid.go Normal file
View File

@@ -0,0 +1,490 @@
package fbdraw
import (
"fmt"
"image"
"image/color"
"strings"
"sync"
"git.eeqj.de/sneak/hdmistat/internal/font"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
)
// Common colors
//
//nolint:gochecknoglobals
var (
Black = color.RGBA{0, 0, 0, 255}
White = color.RGBA{255, 255, 255, 255}
Red = color.RGBA{255, 0, 0, 255}
Green = color.RGBA{0, 255, 0, 255}
Blue = color.RGBA{0, 0, 255, 255}
Yellow = color.RGBA{255, 255, 0, 255}
Cyan = color.RGBA{0, 255, 255, 255}
Magenta = color.RGBA{255, 0, 255, 255}
Orange = color.RGBA{255, 165, 0, 255}
Purple = color.RGBA{128, 0, 128, 255}
// Grays
Gray10 = color.RGBA{26, 26, 26, 255}
Gray20 = color.RGBA{51, 51, 51, 255}
Gray30 = color.RGBA{77, 77, 77, 255}
Gray40 = color.RGBA{102, 102, 102, 255}
Gray50 = color.RGBA{128, 128, 128, 255}
Gray60 = color.RGBA{153, 153, 153, 255}
Gray70 = color.RGBA{179, 179, 179, 255}
Gray80 = color.RGBA{204, 204, 204, 255}
Gray90 = color.RGBA{230, 230, 230, 255}
)
// Cell represents a single character cell in the grid
type Cell struct {
Rune rune
Foreground color.Color
Background color.Color
Weight font.FontWeight
Italic bool
}
// CharGrid represents a monospace character grid
type CharGrid struct {
Width int // Width in characters
Height int // Height in characters
Cells [][]Cell // 2D array [y][x]
// Font settings
FontFamily font.FontFamily
FontSize float64 // Points
// Computed values
CharWidth int // Pixel width of a character
CharHeight int // Pixel height of a character
// Rendering cache
fontCache map[fontKey]*truetype.Font
mu sync.RWMutex
}
type fontKey struct {
family font.FontFamily
weight font.FontWeight
italic bool
}
// NewCharGrid creates a new character grid
func NewCharGrid(width, height int) *CharGrid {
// Create 2D array
cells := make([][]Cell, height)
for y := 0; y < height; y++ {
cells[y] = make([]Cell, width)
// Initialize with spaces and default colors
for x := 0; x < width; x++ {
cells[y][x] = Cell{
Rune: ' ',
Foreground: White,
Background: Black,
Weight: font.WeightRegular,
Italic: false,
}
}
}
return &CharGrid{
Width: width,
Height: height,
Cells: cells,
FontFamily: font.FamilyIBMPlexMono,
FontSize: 14,
CharWidth: 8, // Will be computed based on font
CharHeight: 16, // Will be computed based on font
fontCache: make(map[fontKey]*truetype.Font),
}
}
// SetCell sets a single cell's content
func (g *CharGrid) SetCell(x, y int, r rune, fg, bg color.Color, weight font.FontWeight, italic bool) {
if x < 0 || x >= g.Width || y < 0 || y >= g.Height {
return
}
g.Cells[y][x] = Cell{
Rune: r,
Foreground: fg,
Background: bg,
Weight: weight,
Italic: italic,
}
}
// WriteString writes a string starting at position (x, y)
func (g *CharGrid) WriteString(x, y int, s string, fg, bg color.Color, weight font.FontWeight, italic bool) {
runes := []rune(s)
for i, r := range runes {
g.SetCell(x+i, y, r, fg, bg, weight, italic)
}
}
// Clear clears the grid with the specified background color
func (g *CharGrid) Clear(bg color.Color) {
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
g.Cells[y][x] = Cell{
Rune: ' ',
Foreground: White,
Background: bg,
Weight: font.WeightRegular,
Italic: false,
}
}
}
}
// getFont retrieves a font from cache or loads it
func (g *CharGrid) getFont(weight font.FontWeight, italic bool) (*truetype.Font, error) {
key := fontKey{
family: g.FontFamily,
weight: weight,
italic: italic,
}
g.mu.RLock()
if f, ok := g.fontCache[key]; ok {
g.mu.RUnlock()
return f, nil
}
g.mu.RUnlock()
// Load font
g.mu.Lock()
defer g.mu.Unlock()
// Double-check after acquiring write lock
if f, ok := g.fontCache[key]; ok {
return f, nil
}
f, err := font.LoadFont(g.FontFamily, weight, italic)
if err != nil {
return nil, err
}
g.fontCache[key] = f
return f, nil
}
// computeCharSize computes the character cell size based on font metrics
func (g *CharGrid) computeCharSize() error {
f, err := g.getFont(font.WeightRegular, false)
if err != nil {
return err
}
// Use freetype to measure a typical character
opts := truetype.Options{
Size: g.FontSize,
DPI: 72,
}
face := truetype.NewFace(f, &opts)
// Measure 'M' for width (typically widest regular character in monospace)
bounds, _, _ := face.GlyphBounds('M')
g.CharWidth = (bounds.Max.X - bounds.Min.X).Round()
// Use font metrics for height
metrics := face.Metrics()
g.CharHeight = (metrics.Ascent + metrics.Descent).Round()
return nil
}
// Render renders the grid to an image
func (g *CharGrid) Render() (*image.RGBA, error) {
// Ensure character dimensions are computed
if err := g.computeCharSize(); err != nil {
return nil, err
}
// Create image
width := g.Width * g.CharWidth
height := g.Height * g.CharHeight
img := image.NewRGBA(image.Rect(0, 0, width, height))
// First pass: draw backgrounds
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
cell := g.Cells[y][x]
// Draw background rectangle
x0 := x * g.CharWidth
y0 := y * g.CharHeight
x1 := x0 + g.CharWidth
y1 := y0 + g.CharHeight
for py := y0; py < y1; py++ {
for px := x0; px < x1; px++ {
img.Set(px, py, cell.Background)
}
}
}
}
// Second pass: draw text
ctx := freetype.NewContext()
ctx.SetDPI(72)
ctx.SetFont(nil) // Will be set per cell
ctx.SetFontSize(g.FontSize)
ctx.SetClip(img.Bounds())
ctx.SetDst(img)
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
cell := g.Cells[y][x]
if cell.Rune == ' ' {
continue // Skip spaces
}
// Get font for this cell
f, err := g.getFont(cell.Weight, cell.Italic)
if err != nil {
continue // Skip cells with font errors
}
ctx.SetFont(f)
ctx.SetSrc(image.NewUniform(cell.Foreground))
// Calculate text position
// X: left edge of cell
// Y: baseline (ascent from top of cell)
opts := truetype.Options{
Size: g.FontSize,
DPI: 72,
}
face := truetype.NewFace(f, &opts)
metrics := face.Metrics()
px := x * g.CharWidth
py := y*g.CharHeight + metrics.Ascent.Round()
pt := freetype.Pt(px, py)
_, _ = ctx.DrawString(string(cell.Rune), pt)
}
}
return img, nil
}
// ToText renders the grid as text for debugging/logging
func (g *CharGrid) ToText() string {
var sb strings.Builder
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
sb.WriteRune(g.Cells[y][x].Rune)
}
if y < g.Height-1 {
sb.WriteRune('\n')
}
}
return sb.String()
}
// ToANSI renders the grid as ANSI escape sequences for terminal display
func (g *CharGrid) ToANSI() string {
var sb strings.Builder
// Track last colors to minimize escape sequences
var lastFg, lastBg color.Color
var lastWeight font.FontWeight
var lastItalic bool
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
cell := g.Cells[y][x]
// Update styles if changed
if cell.Foreground != lastFg || cell.Background != lastBg ||
cell.Weight != lastWeight || cell.Italic != lastItalic {
// Reset
sb.WriteString("\033[0m")
// Weight
if cell.Weight == font.WeightBold ||
cell.Weight == font.WeightExtraBold ||
cell.Weight == font.WeightBlack {
sb.WriteString("\033[1m") // Bold
}
// Italic
if cell.Italic {
sb.WriteString("\033[3m")
}
// Foreground color
if r, g, b, _ := cell.Foreground.RGBA(); r != 0 || g != 0 || b != 0 {
sb.WriteString(fmt.Sprintf("\033[38;2;%d;%d;%dm",
r>>8, g>>8, b>>8))
}
// Background color
if r, g, b, _ := cell.Background.RGBA(); r != 0 || g != 0 || b != 0 {
sb.WriteString(fmt.Sprintf("\033[48;2;%d;%d;%dm",
r>>8, g>>8, b>>8))
}
lastFg = cell.Foreground
lastBg = cell.Background
lastWeight = cell.Weight
lastItalic = cell.Italic
}
sb.WriteRune(cell.Rune)
}
// Reset at end of line and add newline
sb.WriteString("\033[0m\n")
lastFg = nil
lastBg = nil
lastWeight = ""
lastItalic = false
}
// Final reset
sb.WriteString("\033[0m")
return sb.String()
}
// GridWriter provides a convenient API for writing to a grid
type GridWriter struct {
Grid *CharGrid
X, Y int // Current position
Foreground color.Color
Background color.Color
Weight font.FontWeight
Italic bool
}
// NewGridWriter creates a new GridWriter
func NewGridWriter(grid *CharGrid) *GridWriter {
return &GridWriter{
Grid: grid,
X: 0,
Y: 0,
Foreground: White,
Background: Black,
Weight: font.WeightRegular,
Italic: false,
}
}
// MoveAbs moves the cursor to an absolute position
func (w *GridWriter) MoveAbs(x, y int) *GridWriter {
w.X = x
w.Y = y
return w
}
// Move moves the cursor relative to the current position
func (w *GridWriter) Move(dx, dy int) *GridWriter {
w.X += dx
w.Y += dy
return w
}
// SetColor sets the foreground color
func (w *GridWriter) SetColor(c color.Color) *GridWriter {
w.Foreground = c
return w
}
// SetBackground sets the background color
func (w *GridWriter) SetBackground(c color.Color) *GridWriter {
w.Background = c
return w
}
// SetWeight sets the font weight
func (w *GridWriter) SetWeight(weight font.FontWeight) *GridWriter {
w.Weight = weight
return w
}
// SetItalic sets italic style
func (w *GridWriter) SetItalic(italic bool) *GridWriter {
w.Italic = italic
return w
}
// Write writes a string at the current position
func (w *GridWriter) Write(format string, args ...interface{}) *GridWriter {
s := fmt.Sprintf(format, args...)
w.Grid.WriteString(w.X, w.Y, s, w.Foreground, w.Background, w.Weight, w.Italic)
w.X += len([]rune(s))
return w
}
// WriteLine writes a string and moves to the next line
func (w *GridWriter) WriteLine(format string, args ...interface{}) *GridWriter {
w.Write(format, args...)
w.X = 0
w.Y++
return w
}
// NewLine moves to the next line
func (w *GridWriter) NewLine() *GridWriter {
w.X = 0
w.Y++
return w
}
// Clear clears the grid with the current background color
func (w *GridWriter) Clear() *GridWriter {
w.Grid.Clear(w.Background)
w.X = 0
w.Y = 0
return w
}
// DrawMeter draws a progress meter at the current position
func (w *GridWriter) DrawMeter(percent float64, width int) *GridWriter {
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := int(percent / 100.0 * float64(width))
// Save original color
origColor := w.Foreground
// Set color based on percentage
if percent > 80 {
w.SetColor(Red)
} else if percent > 50 {
w.SetColor(Yellow)
} else {
w.SetColor(Green)
}
// Draw the meter
for i := 0; i < width; i++ {
if i < filled {
w.Write("█")
} else {
w.Write("▒")
}
}
// Restore original color
w.SetColor(origColor)
return w
}

View File

@@ -0,0 +1,160 @@
package fbdraw
import (
"fmt"
"image/color"
"testing"
"git.eeqj.de/sneak/hdmistat/internal/font"
)
func ExampleGrid() {
// Create a 80x25 character grid (standard terminal size)
grid := NewGrid(80, 25)
// Create a writer for convenience
w := NewGridWriter(grid)
// Clear with dark background
w.SetBackground(Gray10).Clear()
// Draw header
w.MoveTo(0, 0).
SetBackground(Blue).
SetColor(White).
SetWeight(font.WeightBold).
WriteLine(" System Monitor v1.0 ")
// Reset to normal style
w.SetBackground(Black).SetWeight(font.WeightRegular)
// System info section
w.MoveTo(2, 2).
SetColor(Green).SetWeight(font.WeightBold).
Write("SYSTEM INFO").
SetWeight(font.WeightRegular).SetColor(White)
w.MoveTo(2, 4).Write("Hostname: ").SetColor(Cyan).Write("server01.example.com")
w.MoveTo(2, 5).SetColor(White).Write("Uptime: ").SetColor(Yellow).Write("14 days, 3:42:15")
w.MoveTo(2, 6).SetColor(White).Write("Load: ").SetColor(Red).Write("2.45 1.82 1.65")
// CPU meters
w.MoveTo(2, 8).SetColor(Blue).SetWeight(font.WeightBold).Write("CPU USAGE")
w.SetWeight(font.WeightRegular)
cpuValues := []float64{45.2, 78.9, 23.4, 91.5}
for i, cpu := range cpuValues {
w.MoveTo(2, 10+i)
w.SetColor(White).Write("CPU%d [", i)
// Draw meter
meterWidth := 20
filled := int(cpu / 100.0 * float64(meterWidth))
// Choose color based on usage
if cpu > 80 {
w.SetColor(Red)
} else if cpu > 50 {
w.SetColor(Yellow)
} else {
w.SetColor(Green)
}
for j := 0; j < meterWidth; j++ {
if j < filled {
w.Write("█")
} else {
w.Write("░")
}
}
w.SetColor(White).Write("] %5.1f%%", cpu)
}
// Memory section
w.MoveTo(45, 8).SetColor(Purple).SetWeight(font.WeightBold).Write("MEMORY")
w.SetWeight(font.WeightRegular).SetColor(White)
w.MoveTo(45, 10).Write("Used: ").SetColor(Green).Write("4.2 GB / 16.0 GB")
w.MoveTo(45, 11).SetColor(White).Write("Free: ").SetColor(Green).Write("11.8 GB")
w.MoveTo(45, 12).SetColor(White).Write("Cache: ").SetColor(Blue).Write("2.1 GB")
// Process table
w.MoveTo(2, 16).SetColor(Orange).SetWeight(font.WeightBold).Write("TOP PROCESSES")
w.SetWeight(font.WeightRegular)
// Table header
w.MoveTo(2, 18).SetBackground(Gray30).SetColor(White).SetWeight(font.WeightBold)
w.Write(" PID USER PROCESS CPU% MEM% ")
w.SetBackground(Black).SetWeight(font.WeightRegular)
// Table rows
processes := []struct {
pid int
user string
name string
cpu float64
mem float64
}{
{1234, "root", "systemd", 0.2, 0.1},
{5678, "user", "firefox", 15.8, 12.4},
{9012, "user", "vscode", 42.1, 8.7},
}
for i, p := range processes {
y := 19 + i
// Alternate row backgrounds
if i%2 == 0 {
w.SetBackground(Gray10)
} else {
w.SetBackground(Black)
}
// Highlight high CPU
if p.cpu > 40 {
w.SetColor(Red)
} else if p.cpu > 20 {
w.SetColor(Yellow)
} else {
w.SetColor(Gray70)
}
w.MoveTo(0, y)
w.Write(" %5d %-8s %-25s %5.1f %5.1f ",
p.pid, p.user, p.name, p.cpu, p.mem)
}
// Output as text
fmt.Println(grid.ToText())
}
func TestGrid(t *testing.T) {
grid := NewGrid(40, 10)
w := NewGridWriter(grid)
// Test basic writing
w.MoveTo(5, 2).Write("Hello, World!")
// Test colors and styles
w.MoveTo(5, 4).
SetColor(Red).SetWeight(font.WeightBold).
Write("Bold Red Text")
// Test unicode
w.MoveTo(5, 6).
SetColor(Green).
Write("Progress: [████████▒▒▒▒▒▒▒▒] 50%")
// Check text output
text := grid.ToText()
if text == "" {
t.Error("Grid should not be empty")
}
// Check specific cell
cell := grid.Cells[2][5] // Row 2, Column 5 should be 'H'
if cell.Rune != 'H' {
t.Errorf("Expected 'H' at (5,2), got '%c'", cell.Rune)
}
}

View File

@@ -0,0 +1,22 @@
package fbdraw
// FrameGenerator generates frames for a screen
type FrameGenerator interface {
// GenerateFrame is called to render a new frame
GenerateFrame(grid *CharGrid) error
// FramesPerSecond returns the desired frame rate
FramesPerSecond() float64
}
// FramebufferDisplay interface represents the output device
type FramebufferDisplay interface {
// Write renders a grid to the display
Write(grid *CharGrid) error
// Size returns the display dimensions in characters
Size() (width, height int)
// Close cleans up resources
Close() error
}