checkpointing, heavy dev
This commit is contained in:
184
internal/fbdraw/EXAMPLE.md
Normal file
184
internal/fbdraw/EXAMPLE.md
Normal 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
335
internal/fbdraw/README.md
Normal 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
162
internal/fbdraw/carousel.go
Normal 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
309
internal/fbdraw/display.go
Normal 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
76
internal/fbdraw/doc.go
Normal 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
490
internal/fbdraw/grid.go
Normal 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
|
||||
}
|
||||
160
internal/fbdraw/grid_test.go
Normal file
160
internal/fbdraw/grid_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
22
internal/fbdraw/interfaces.go
Normal file
22
internal/fbdraw/interfaces.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user