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

View File

@@ -1,3 +1,4 @@
// Package app contains the main application logic for hdmistat
package app
import (
@@ -19,7 +20,7 @@ type App struct {
collector statcollector.Collector
renderer *renderer.Renderer
screens []renderer.Screen
logger *slog.Logger
log *slog.Logger
ctx context.Context
cancel context.CancelFunc
@@ -30,8 +31,8 @@ type App struct {
updateInterval time.Duration
}
// AppOptions contains all dependencies for the App
type AppOptions struct {
// Options contains all dependencies for the App
type Options struct {
fx.In
Lifecycle fx.Lifecycle
@@ -44,12 +45,12 @@ type AppOptions struct {
}
// NewApp creates a new application instance
func NewApp(opts AppOptions) *App {
func NewApp(opts Options) *App {
app := &App{
display: opts.Display,
collector: opts.Collector,
renderer: opts.Renderer,
logger: opts.Logger,
log: opts.Logger,
currentScreen: 0,
rotationInterval: opts.Config.GetRotationDuration(),
updateInterval: opts.Config.GetUpdateDuration(),
@@ -57,18 +58,21 @@ func NewApp(opts AppOptions) *App {
// Initialize screens
app.screens = []renderer.Screen{
renderer.NewOverviewScreen(),
renderer.NewStatusScreen(), // New status screen
renderer.NewOverviewScreen(), // Old overview screen
renderer.NewProcessScreenCPU(),
renderer.NewProcessScreenMemory(),
}
// Use the injected context, not the lifecycle context
app.ctx, app.cancel = context.WithCancel(opts.Context)
opts.Lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
app.ctx, app.cancel = context.WithCancel(ctx)
OnStart: func(_ context.Context) error {
app.Start()
return nil
},
OnStop: func(ctx context.Context) error {
OnStop: func(_ context.Context) error {
return app.Stop()
},
})
@@ -78,7 +82,7 @@ func NewApp(opts AppOptions) *App {
// Start begins the application main loop
func (a *App) Start() {
a.logger.Info("starting hdmistat app",
a.log.Info("starting hdmistat app",
"screens", len(a.screens),
"rotation_interval", a.rotationInterval,
"update_interval", a.updateInterval)
@@ -94,19 +98,19 @@ func (a *App) Start() {
// Stop stops the application
func (a *App) Stop() error {
a.logger.Info("stopping hdmistat app")
a.log.Info("stopping hdmistat app")
a.cancel()
a.wg.Wait()
// Clear display
if err := a.display.Clear(); err != nil {
a.logger.Error("clearing display", "error", err)
a.log.Error("clearing display", "error", err)
}
// Close display
if err := a.display.Close(); err != nil {
a.logger.Error("closing display", "error", err)
a.log.Error("closing display", "error", err)
}
return nil
@@ -115,13 +119,28 @@ func (a *App) Stop() error {
// updateLoop continuously updates the current screen
func (a *App) updateLoop() {
defer a.wg.Done()
defer func() {
a.log.Info("updateLoop exiting")
if r := recover(); r != nil {
a.log.Error("updateLoop panic", "error", r)
}
}()
ticker := time.NewTicker(a.updateInterval)
defer ticker.Stop()
a.log.Debug("updateLoop started")
// DISABLED FOR DEBUGGING - Only render once on screen switch
// ticker := time.NewTicker(a.updateInterval)
// defer ticker.Stop()
// Initial render
a.renderCurrentScreen()
// Just wait for context cancellation
a.log.Debug("updateLoop waiting for context cancellation")
<-a.ctx.Done()
a.log.Debug("updateLoop context cancelled")
/* COMMENTED OUT FOR DEBUGGING
for {
select {
case <-a.ctx.Done():
@@ -130,11 +149,20 @@ func (a *App) updateLoop() {
a.renderCurrentScreen()
}
}
*/
}
// rotationLoop rotates through screens
func (a *App) rotationLoop() {
defer a.wg.Done()
defer func() {
a.log.Info("rotationLoop exiting")
if r := recover(); r != nil {
a.log.Error("rotationLoop panic", "error", r)
}
}()
a.log.Debug("rotationLoop started", "interval", a.rotationInterval)
ticker := time.NewTicker(a.rotationInterval)
defer ticker.Stop()
@@ -142,8 +170,10 @@ func (a *App) rotationLoop() {
for {
select {
case <-a.ctx.Done():
a.log.Debug("rotationLoop context cancelled")
return
case <-ticker.C:
a.log.Debug("rotationLoop ticker fired")
a.nextScreen()
}
}
@@ -155,20 +185,23 @@ func (a *App) renderCurrentScreen() {
return
}
// Get current screen
screen := a.screens[a.currentScreen]
a.log.Debug("rendering screen",
"index", a.currentScreen,
"name", screen.Name())
// Collect system info
info, err := a.collector.Collect()
if err != nil {
a.logger.Error("collecting system info", "error", err)
a.log.Error("collecting system info", "error", err)
return
}
// Get current screen
screen := a.screens[a.currentScreen]
// Render screen
img, err := a.renderer.RenderScreen(screen, info)
if err != nil {
a.logger.Error("rendering screen",
a.log.Error("rendering screen",
"screen", screen.Name(),
"error", err)
return
@@ -176,7 +209,7 @@ func (a *App) renderCurrentScreen() {
// Display image
if err := a.display.Show(img); err != nil {
a.logger.Error("displaying image", "error", err)
a.log.Error("displaying image", "error", err)
}
}
@@ -187,7 +220,10 @@ func (a *App) nextScreen() {
}
a.currentScreen = (a.currentScreen + 1) % len(a.screens)
a.logger.Info("switching screen",
a.log.Info("switching screen",
"index", a.currentScreen,
"name", a.screens[a.currentScreen].Name())
// Render the new screen immediately
a.renderCurrentScreen()
}

View File

@@ -1,13 +1,20 @@
// Package config provides configuration management for hdmistat
package config
import (
"context"
"log/slog"
"os"
"time"
"git.eeqj.de/sneak/smartconfig"
)
const (
defaultWidth = 1920
defaultHeight = 1080
)
// Config holds the application configuration
type Config struct {
FramebufferDevice string
@@ -24,7 +31,7 @@ type Config struct {
}
// Load loads configuration from all available sources
func Load(ctx context.Context) (*Config, error) {
func Load(_ context.Context) (*Config, error) {
// Start with defaults
cfg := &Config{
FramebufferDevice: "/dev/fb0",
@@ -32,8 +39,8 @@ func Load(ctx context.Context) (*Config, error) {
UpdateInterval: "1s",
Screens: []string{"overview", "top_cpu", "top_memory"},
LogLevel: "info",
Width: 1920,
Height: 1080,
Width: defaultWidth,
Height: defaultHeight,
}
// Try to load from the default location for hdmistat
@@ -77,6 +84,11 @@ func Load(ctx context.Context) (*Config, error) {
}
}
// Override with environment variables if set
if envLogLevel := os.Getenv("HDMISTAT_LOG_LEVEL"); envLogLevel != "" {
cfg.LogLevel = envLogLevel
}
// Parse durations
cfg.rotationDuration, err = time.ParseDuration(cfg.RotationInterval)
if err != nil {

View File

@@ -1,3 +1,4 @@
// Package display provides framebuffer display functionality
package display
import (
@@ -18,10 +19,12 @@ type Display interface {
// FramebufferDisplay implements Display for Linux framebuffer
type FramebufferDisplay struct {
file *os.File
info *fbVarScreenInfo
memory []byte
device string
logger *slog.Logger
// Cached screen info
width uint32
height uint32
bpp uint32
}
type fbVarScreenInfo struct {
@@ -48,29 +51,29 @@ type fbBitfield struct {
const (
fbiogetVscreeninfo = 0x4600
bitsPerByte = 8
bpp32 = 32
bpp24 = 24
colorShift = 8
)
// NewFramebufferDisplay creates a new framebuffer display
func NewFramebufferDisplay(device string, logger *slog.Logger) (*FramebufferDisplay, error) {
file, err := os.OpenFile(device, os.O_RDWR, 0)
// Open framebuffer device temporarily to get screen info
file, err := os.OpenFile(device, os.O_RDWR, 0) // #nosec G304 - device path is controlled
if err != nil {
return nil, fmt.Errorf("opening framebuffer: %w", err)
}
defer func() { _ = file.Close() }()
var info fbVarScreenInfo
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), fbiogetVscreeninfo, uintptr(unsafe.Pointer(&info)))
// #nosec G103 - required for framebuffer ioctl
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&info)))
if errno != 0 {
_ = file.Close()
return nil, fmt.Errorf("getting screen info: %v", errno)
}
size := int(info.XRes * info.YRes * info.BitsPerPixel / 8)
memory, 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("mapping framebuffer: %w", err)
}
logger.Info("framebuffer initialized",
"device", device,
"width", info.XRes,
@@ -78,45 +81,87 @@ func NewFramebufferDisplay(device string, logger *slog.Logger) (*FramebufferDisp
"bpp", info.BitsPerPixel)
return &FramebufferDisplay{
file: file,
info: &info,
memory: memory,
device: device,
logger: logger,
width: info.XRes,
height: info.YRes,
bpp: info.BitsPerPixel,
}, nil
}
// Show displays an image on the framebuffer
func (d *FramebufferDisplay) Show(img *image.RGBA) error {
if d == nil || d.device == "" {
return fmt.Errorf("invalid display")
}
// Open framebuffer device
file, err := os.OpenFile(d.device, os.O_RDWR, 0) // #nosec G304 - device path is controlled
if err != nil {
return fmt.Errorf("opening framebuffer: %w", err)
}
defer func() { _ = file.Close() }()
// Get screen information (in case it changed)
var info fbVarScreenInfo
// #nosec G103 - required for framebuffer ioctl
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&info)))
if errno != 0 {
return fmt.Errorf("getting screen info: %v", errno)
}
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width > int(d.info.XRes) {
width = int(d.info.XRes)
if width > int(info.XRes) {
width = int(info.XRes)
}
if height > int(d.info.YRes) {
height = int(d.info.YRes)
if height > int(info.YRes) {
height = int(info.YRes)
}
// Create buffer for one line at a time
lineSize := int(info.XRes * info.BitsPerPixel / bitsPerByte)
line := make([]byte, lineSize)
// Write image data line by line
for y := 0; y < height; y++ {
// Clear line buffer
for i := range line {
line[i] = 0
}
// Fill line buffer with pixel data
for x := 0; x < width; x++ {
r, g, b, a := img.At(x, y).RGBA()
r, g, b = r>>8, g>>8, b>>8
r, g, b = r>>colorShift, g>>colorShift, b>>colorShift
offset := (y*int(d.info.XRes) + x) * int(d.info.BitsPerPixel/8)
if offset+3 < len(d.memory) {
if d.info.BitsPerPixel == 32 {
d.memory[offset] = byte(b)
d.memory[offset+1] = byte(g)
d.memory[offset+2] = byte(r)
d.memory[offset+3] = byte(a >> 8)
} else if d.info.BitsPerPixel == 24 {
d.memory[offset] = byte(b)
d.memory[offset+1] = byte(g)
d.memory[offset+2] = byte(r)
offset := x * int(info.BitsPerPixel/bitsPerByte)
if offset+3 < len(line) {
switch info.BitsPerPixel {
case bpp32:
line[offset] = byte(b)
line[offset+1] = byte(g)
line[offset+2] = byte(r)
line[offset+3] = byte(a >> colorShift)
case bpp24:
line[offset] = byte(b)
line[offset+1] = byte(g)
line[offset+2] = byte(r)
}
}
}
// Seek to correct position and write line
seekPos := int64(y) * int64(lineSize)
if _, err := file.Seek(seekPos, 0); err != nil {
return fmt.Errorf("seeking in framebuffer: %w", err)
}
if _, err := file.Write(line); err != nil {
return fmt.Errorf("writing to framebuffer: %w", err)
}
}
return nil
@@ -124,16 +169,56 @@ func (d *FramebufferDisplay) Show(img *image.RGBA) error {
// Clear clears the framebuffer
func (d *FramebufferDisplay) Clear() error {
for i := range d.memory {
d.memory[i] = 0
if d == nil || d.device == "" {
return fmt.Errorf("invalid display")
}
// Open framebuffer device
file, err := os.OpenFile(d.device, os.O_RDWR, 0) // #nosec G304 - device path is controlled
if err != nil {
return fmt.Errorf("opening framebuffer: %w", err)
}
defer func() { _ = file.Close() }()
// Get screen information
var info fbVarScreenInfo
// #nosec G103 - required for framebuffer ioctl
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&info)))
if errno != 0 {
return fmt.Errorf("getting screen info: %v", errno)
}
// Create empty buffer
lineSize := int(info.XRes * info.BitsPerPixel / bitsPerByte)
emptyLine := make([]byte, lineSize)
// Write empty lines
for y := 0; y < int(info.YRes); y++ {
seekPos := int64(y) * int64(lineSize)
if _, err := file.Seek(seekPos, 0); err != nil {
return fmt.Errorf("seeking in framebuffer: %w", err)
}
if _, err := file.Write(emptyLine); err != nil {
return fmt.Errorf("writing to framebuffer: %w", err)
}
}
return nil
}
// Close closes the framebuffer
func (d *FramebufferDisplay) Close() error {
if err := syscall.Munmap(d.memory); err != nil {
d.logger.Error("unmapping framebuffer", "error", err)
}
return d.file.Close()
// Nothing to close since we open/close on each operation
return nil
}
// GetWidth returns the framebuffer width
func (d *FramebufferDisplay) GetWidth() uint32 {
return d.width
}
// GetHeight returns the framebuffer height
func (d *FramebufferDisplay) GetHeight() uint32 {
return d.height
}

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
}

View File

@@ -1,3 +1,4 @@
// Package font provides embedded font resources for hdmistat
package font
import (
@@ -7,38 +8,260 @@ import (
"github.com/golang/freetype/truetype"
)
// IBM Plex Mono fonts
//
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Thin.ttf
var ibmPlexMonoThin []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-ThinItalic.ttf
var ibmPlexMonoThinItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-ExtraLight.ttf
var ibmPlexMonoExtraLight []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-ExtraLightItalic.ttf
var ibmPlexMonoExtraLightItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf
var ibmPlexMonoLight []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf
var ibmPlexMonoLightItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf
var ibmPlexMonoRegular []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf
var ibmPlexMonoItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf
var ibmPlexMonoMedium []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf
var ibmPlexMonoMediumItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf
var ibmPlexMonoSemiBold []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf
var ibmPlexMonoSemiBoldItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Bold.ttf
var ibmPlexMonoBold []byte
// LoadIBMPlexMono loads the embedded IBM Plex Mono font (Light weight)
func LoadIBMPlexMono() (*truetype.Font, error) {
font, err := truetype.Parse(ibmPlexMonoLight)
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-BoldItalic.ttf
var ibmPlexMonoBoldItalic []byte
// Source Code Pro fonts
//
//go:embed fonts/Source_Code_Pro/SourceCodePro-ExtraLight.ttf
var sourceCodeProExtraLight []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-ExtraLightItalic.ttf
var sourceCodeProExtraLightItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Light.ttf
var sourceCodeProLight []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-LightItalic.ttf
var sourceCodeProLightItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Regular.ttf
var sourceCodeProRegular []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Italic.ttf
var sourceCodeProItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Medium.ttf
var sourceCodeProMedium []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-MediumItalic.ttf
var sourceCodeProMediumItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-SemiBold.ttf
var sourceCodeProSemiBold []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-SemiBoldItalic.ttf
var sourceCodeProSemiBoldItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Bold.ttf
var sourceCodeProBold []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-BoldItalic.ttf
var sourceCodeProBoldItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-ExtraBold.ttf
var sourceCodeProExtraBold []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-ExtraBoldItalic.ttf
var sourceCodeProExtraBoldItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Black.ttf
var sourceCodeProBlack []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-BlackItalic.ttf
var sourceCodeProBlackItalic []byte
// FontWeight represents font weight
type FontWeight string
// Font weight constants
const (
// WeightThin represents thin font weight
WeightThin FontWeight = "thin"
WeightExtraLight FontWeight = "extralight"
WeightLight FontWeight = "light"
WeightRegular FontWeight = "regular"
WeightMedium FontWeight = "medium"
WeightSemiBold FontWeight = "semibold"
WeightBold FontWeight = "bold"
WeightExtraBold FontWeight = "extrabold"
WeightBlack FontWeight = "black"
)
// FontFamily represents a font family
type FontFamily string
// Font family constants
const (
// FamilyIBMPlexMono represents IBM Plex Mono font family
FamilyIBMPlexMono FontFamily = "ibmplexmono"
FamilySourceCodePro FontFamily = "sourcecodepro"
)
// LoadFont loads a font with the specified family, weight, and italic style
func LoadFont(family FontFamily, weight FontWeight, italic bool) (*truetype.Font, error) {
var fontData []byte
switch family {
case FamilyIBMPlexMono:
switch weight {
case WeightThin:
if italic {
fontData = ibmPlexMonoThinItalic
} else {
fontData = ibmPlexMonoThin
}
case WeightExtraLight:
if italic {
fontData = ibmPlexMonoExtraLightItalic
} else {
fontData = ibmPlexMonoExtraLight
}
case WeightLight:
if italic {
fontData = ibmPlexMonoLightItalic
} else {
fontData = ibmPlexMonoLight
}
case WeightRegular:
if italic {
fontData = ibmPlexMonoItalic
} else {
fontData = ibmPlexMonoRegular
}
case WeightMedium:
if italic {
fontData = ibmPlexMonoMediumItalic
} else {
fontData = ibmPlexMonoMedium
}
case WeightSemiBold:
if italic {
fontData = ibmPlexMonoSemiBoldItalic
} else {
fontData = ibmPlexMonoSemiBold
}
case WeightBold:
if italic {
fontData = ibmPlexMonoBoldItalic
} else {
fontData = ibmPlexMonoBold
}
default:
return nil, fmt.Errorf("unsupported weight %s for IBM Plex Mono", weight)
}
case FamilySourceCodePro:
switch weight {
case WeightExtraLight:
if italic {
fontData = sourceCodeProExtraLightItalic
} else {
fontData = sourceCodeProExtraLight
}
case WeightLight:
if italic {
fontData = sourceCodeProLightItalic
} else {
fontData = sourceCodeProLight
}
case WeightRegular:
if italic {
fontData = sourceCodeProItalic
} else {
fontData = sourceCodeProRegular
}
case WeightMedium:
if italic {
fontData = sourceCodeProMediumItalic
} else {
fontData = sourceCodeProMedium
}
case WeightSemiBold:
if italic {
fontData = sourceCodeProSemiBoldItalic
} else {
fontData = sourceCodeProSemiBold
}
case WeightBold:
if italic {
fontData = sourceCodeProBoldItalic
} else {
fontData = sourceCodeProBold
}
case WeightExtraBold:
if italic {
fontData = sourceCodeProExtraBoldItalic
} else {
fontData = sourceCodeProExtraBold
}
case WeightBlack:
if italic {
fontData = sourceCodeProBlackItalic
} else {
fontData = sourceCodeProBlack
}
default:
return nil, fmt.Errorf("unsupported weight %s for Source Code Pro", weight)
}
default:
return nil, fmt.Errorf("unsupported font family: %s", family)
}
if len(fontData) == 0 {
return nil, fmt.Errorf("font data not found for %s %s italic=%v", family, weight, italic)
}
font, err := truetype.Parse(fontData)
if err != nil {
return nil, fmt.Errorf("parsing font: %w", err)
}
return font, nil
}
// LoadIBMPlexMonoRegular loads the regular weight font
func LoadIBMPlexMonoRegular() (*truetype.Font, error) {
font, err := truetype.Parse(ibmPlexMonoRegular)
if err != nil {
return nil, fmt.Errorf("parsing regular font: %w", err)
}
return font, nil
// LoadIBMPlexMono loads the default IBM Plex Mono font (ExtraLight)
func LoadIBMPlexMono() (*truetype.Font, error) {
return LoadFont(FamilyIBMPlexMono, WeightExtraLight, false)
}
// LoadIBMPlexMonoBold loads the bold weight font
func LoadIBMPlexMonoBold() (*truetype.Font, error) {
font, err := truetype.Parse(ibmPlexMonoBold)
if err != nil {
return nil, fmt.Errorf("parsing bold font: %w", err)
}
return font, nil
// LoadIBMPlexMonoRegular loads IBM Plex Mono Regular font
func LoadIBMPlexMonoRegular() (*truetype.Font, error) {
return LoadFont(FamilyIBMPlexMono, WeightRegular, false)
}
// LoadIBMPlexMonoBold loads IBM Plex Mono Bold font
func LoadIBMPlexMonoBold() (*truetype.Font, error) {
return LoadFont(FamilyIBMPlexMono, WeightBold, false)
}

View File

@@ -0,0 +1,93 @@
Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,79 @@
Source Code Pro Variable Font
=============================
This download contains Source Code Pro as both variable fonts and static fonts.
Source Code Pro is a variable font with this axis:
wght
This means all the styles are contained in these files:
Source_Code_Pro/SourceCodePro-VariableFont_wght.ttf
Source_Code_Pro/SourceCodePro-Italic-VariableFont_wght.ttf
If your app fully supports variable fonts, you can now pick intermediate styles
that arent available as static fonts. Not all apps support variable fonts, and
in those cases you can use the static font files for Source Code Pro:
Source_Code_Pro/static/SourceCodePro-ExtraLight.ttf
Source_Code_Pro/static/SourceCodePro-Light.ttf
Source_Code_Pro/static/SourceCodePro-Regular.ttf
Source_Code_Pro/static/SourceCodePro-Medium.ttf
Source_Code_Pro/static/SourceCodePro-SemiBold.ttf
Source_Code_Pro/static/SourceCodePro-Bold.ttf
Source_Code_Pro/static/SourceCodePro-ExtraBold.ttf
Source_Code_Pro/static/SourceCodePro-Black.ttf
Source_Code_Pro/static/SourceCodePro-ExtraLightItalic.ttf
Source_Code_Pro/static/SourceCodePro-LightItalic.ttf
Source_Code_Pro/static/SourceCodePro-Italic.ttf
Source_Code_Pro/static/SourceCodePro-MediumItalic.ttf
Source_Code_Pro/static/SourceCodePro-SemiBoldItalic.ttf
Source_Code_Pro/static/SourceCodePro-BoldItalic.ttf
Source_Code_Pro/static/SourceCodePro-ExtraBoldItalic.ttf
Source_Code_Pro/static/SourceCodePro-BlackItalic.ttf
Get started
-----------
1. Install the font files you want to use
2. Use your app's font picker to view the font family and all the
available styles
Learn more about variable fonts
-------------------------------
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
https://variablefonts.typenetwork.com
https://medium.com/variable-fonts
In desktop apps
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
Online
https://developers.google.com/fonts/docs/getting_started
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
Installing fonts
MacOS: https://support.apple.com/en-us/HT201749
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
Android Apps
https://developers.google.com/fonts/docs/android
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
License
-------
Please read the full license text (OFL.txt) to understand the permissions,
restrictions and requirements for usage, redistribution, and modification.
You can use them in your products & projects print or digital,
commercial or otherwise.
This isn't legal advice, please consider consulting a lawyer and see the full
license for all details.

View File

@@ -0,0 +1,94 @@
Copyright (C) 2020 Dimitar Toshkov Zhekov,
with Reserved Font Name "Terminus Font".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,280 @@
# Framebuffer Display API
A high-level Go package for easily creating text-based status displays on Linux framebuffers. Perfect for system monitors, embedded displays, IoT dashboards, and more.
## API Design Concepts
Below are four different API design approaches for creating framebuffer displays. Each example shows how you might implement a system status display.
### Concept 1: Builder Pattern
```go
package main
import (
"time"
fb "github.com/example/framebufferdisplay"
)
func main() {
// Create and configure display with fluent interface
display := fb.New().
AutoDetect(). // Find first available framebuffer
WithFont("IBM Plex Mono", 14). // Default font
WithUpdateInterval(time.Second). // Auto-refresh rate
Build()
defer display.Close()
// Define the layout
display.Layout(func(canvas *fb.Canvas) {
// Header section
canvas.Section("header", fb.TopCenter).
Font("IBM Plex Mono", 24).
Color(fb.White).
Text("System Status")
// System info section
canvas.Section("info", fb.TopLeft).
Margin(20).
Rows(
fb.Row().Label("Hostname:").Value(getHostname()),
fb.Row().Label("Uptime:").Value(getUptime()),
fb.Row().Label("Load:").Value(getLoad()).Color(fb.Red),
)
// CPU meters
canvas.Section("cpu", fb.CenterLeft).
Title("CPU Usage").
Meters(getCPUMeters()...)
// Memory bar
canvas.Section("memory", fb.BottomLeft).
Title("Memory").
ProgressBar(getMemoryPercent(), fb.Green)
})
// Start the display loop
display.Run()
}
```
### Concept 2: Declarative/React-like
```go
package main
import (
fb "github.com/example/framebufferdisplay"
)
type SystemStatus struct {
fb.Component
hostname string
uptime time.Duration
}
func (s *SystemStatus) Render() fb.Element {
return fb.Screen(
fb.Header(
fb.Text("System Status").
Font("IBM Plex Mono", 48).
Color(fb.RGB(100, 200, 255)),
),
fb.Grid(fb.GridOptions{Columns: 2, Gap: 20},
// Left column
fb.Column(
fb.Card(
fb.Title("System Info"),
fb.List(
fb.ListItem("Hostname", s.hostname),
fb.ListItem("Uptime", formatDuration(s.uptime)),
fb.ListItem("OS", getOS()),
),
),
fb.Card(
fb.Title("Network"),
fb.List(getNetworkInfo()...),
),
),
// Right column
fb.Column(
fb.Card(
fb.Title("CPU Usage"),
fb.BarChart(getCPUData(), fb.ChartOptions{
Height: 200,
Color: fb.Gradient(fb.Green, fb.Red),
}),
),
fb.Card(
fb.Title("Memory"),
fb.CircularProgress(getMemoryPercent(), fb.Blue),
fb.Text(getMemoryDetails()).Size(12),
),
),
),
)
}
func main() {
fb.Run(&SystemStatus{})
}
```
### Concept 3: Immediate Mode
```go
package main
import (
fb "github.com/example/framebufferdisplay"
)
func main() {
// Auto-detect and initialize
ctx := fb.Init()
defer ctx.Close()
// Main render loop
ctx.Loop(func(d *fb.Draw) {
// Clear with background
d.Clear(fb.Black)
// Draw header
d.SetFont("IBM Plex Mono Bold", 36)
d.SetColor(fb.White)
d.TextCenter(d.Width/2, 50, "System Monitor")
// System info box
d.SetFont("IBM Plex Mono", 14)
d.Box(20, 100, 400, 200, fb.Gray)
d.SetColor(fb.Green)
d.Text(30, 120, "Hostname: %s", getHostname())
d.Text(30, 140, "Uptime: %s", getUptime())
d.Text(30, 160, "Load: %.2f %.2f %.2f", getLoad())
// CPU visualization
cpus := getCPUPercents()
for i, cpu := range cpus {
y := 320 + i*30
d.Text(30, y, "CPU%d", i)
d.ProgressBar(80, y-10, 300, 20, cpu, fb.Heat(cpu))
}
// Memory meter
mem := getMemoryPercent()
d.SetFont("IBM Plex Mono", 18)
d.Text(30, 500, "Memory: %.1f%%", mem)
d.Gauge(30, 520, 350, 40, mem, fb.Blue)
// Update display
d.Present()
})
}
```
### Concept 4: Template/Widget-based
```go
package main
import (
fb "github.com/example/framebufferdisplay"
)
func main() {
// Create display with auto-detection
display := fb.NewDisplay()
// Create a dashboard with predefined widgets
dashboard := fb.Dashboard{
Title: "System Status",
Theme: fb.Themes.Dark,
Layout: fb.GridLayout(3, 3), // 3x3 grid
Widgets: []fb.Widget{
// Row 1
fb.BigNumber{
GridPos: fb.Pos(0, 0),
Label: "CPU Temp",
Value: getCPUTemp,
Unit: "°C",
Color: fb.TempColor, // Auto-colors based on value
},
fb.LineGraph{
GridPos: fb.Pos(1, 0).Span(2, 1), // Spans 2 columns
Title: "CPU History",
Duration: 5 * time.Minute,
Source: streamCPUData,
},
// Row 2
fb.InfoTable{
GridPos: fb.Pos(0, 1),
Rows: []fb.TableRow{
{"Host", getHostname},
{"Kernel", getKernel},
{"Uptime", getUptime},
},
},
fb.MultiMeter{
GridPos: fb.Pos(1, 1),
Title: "CPU Cores",
Meters: getCPUCoreMeters,
Compact: true,
},
fb.PieChart{
GridPos: fb.Pos(2, 1),
Title: "Disk Usage",
Data: getDiskUsage,
},
// Row 3
fb.MemoryWidget{
GridPos: fb.Pos(0, 2).Span(2, 1),
ShowDetails: true,
},
fb.NetworkTraffic{
GridPos: fb.Pos(2, 2),
Interface: "eth0",
},
},
// Optional: Add alerts
Alerts: []fb.Alert{
fb.Alert{
Condition: func() bool { return getCPUTemp() > 80 },
Message: "High CPU Temperature!",
Color: fb.Red,
},
},
}
// Run the dashboard
display.RunDashboard(dashboard)
}
```
## Key Features Across All Concepts
- **Auto-detection**: Automatically finds and configures the first available framebuffer
- **Resolution independence**: Layouts adapt to the detected resolution
- **Font management**: Easy font loading and sizing
- **Color utilities**: Named colors, RGB, gradients, and conditional coloring
- **Common widgets**: Progress bars, meters, graphs, tables, etc.
- **Refresh control**: Configurable update intervals or manual control
- **Error handling**: Graceful fallbacks for missing fonts, permissions, etc.
## Design Considerations
Each approach offers different benefits:
1. **Builder Pattern**: Familiar to Go developers, good for static layouts
2. **Declarative**: Clean separation of data and presentation, easy to test
3. **Immediate Mode**: Simple and direct, good for dynamic content
4. **Widget-based**: Highest level abstraction, fastest to build common dashboards
The final API could combine elements from multiple approaches, such as using the widget system from Concept 4 with the immediate mode drawing primitives from Concept 3 for custom widgets.

View File

@@ -1,3 +1,4 @@
// Package hdmistat provides the CLI commands for the hdmistat application
package hdmistat
import (

View File

@@ -2,9 +2,11 @@ package hdmistat
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"runtime"
"syscall"
"git.eeqj.de/sneak/hdmistat/internal/app"
@@ -27,7 +29,7 @@ func (c *CLI) newDaemonCmd() *cobra.Command {
Use: "daemon",
Short: "Run hdmistat as a daemon",
Long: `Run hdmistat as a daemon that displays system statistics on the framebuffer.`,
Run: func(cmd *cobra.Command, args []string) {
Run: func(cmd *cobra.Command, _ []string) {
c.runDaemon(cmd, framebufferDevice, configFile)
},
}
@@ -38,7 +40,7 @@ func (c *CLI) newDaemonCmd() *cobra.Command {
return cmd
}
func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string) {
func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, _ string) {
// Set up signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -52,6 +54,21 @@ func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string
cancel()
}()
// Set up debug signal handler for SIGUSR1
debugChan := make(chan os.Signal, 1)
signal.Notify(debugChan, syscall.SIGUSR1)
go func() {
for range debugChan {
c.log.Info("received SIGUSR1, dumping goroutines")
// Dump all goroutine stack traces to stderr
const megabyte = 1 << 20
buf := make([]byte, megabyte) // 1MB buffer
stackSize := runtime.Stack(buf, true)
fmt.Fprintf(os.Stderr, "\n=== GOROUTINE DUMP ===\n%s\n=== END GOROUTINE DUMP ===\n", buf[:stackSize])
}
}()
// Load configuration
cfg, err := config.Load(ctx)
if err != nil {
@@ -64,11 +81,13 @@ func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string
cfg.FramebufferDevice = framebufferDevice
}
// Update logger level
// Update logger level - use stderr for serial console visibility
c.log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: cfg.GetLogLevel(),
}))
c.log.Info("hdmistat daemon starting", "log_level", cfg.LogLevel, "framebuffer", cfg.FramebufferDevice)
// Create fx application
fxApp := fx.New(
fx.Provide(
@@ -87,14 +106,30 @@ func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string
},
// Provide collector
func(logger *slog.Logger) statcollector.Collector {
return statcollector.NewSystemCollector(logger)
func(lc fx.Lifecycle, logger *slog.Logger) statcollector.Collector {
collector := statcollector.NewSystemCollector(logger)
lc.Append(fx.Hook{
OnStop: func(_ context.Context) error {
collector.Stop()
return nil
},
})
return collector
},
// Provide renderer
func(font *truetype.Font, logger *slog.Logger, cfg *config.Config) *renderer.Renderer {
func(font *truetype.Font, logger *slog.Logger, disp display.Display) *renderer.Renderer {
r := renderer.NewRenderer(font, logger)
r.SetResolution(cfg.Width, cfg.Height)
// Get actual framebuffer resolution
if fbDisp, ok := disp.(*display.FramebufferDisplay); ok && fbDisp != nil {
width := int(fbDisp.GetWidth())
height := int(fbDisp.GetHeight())
logger.Info("using framebuffer resolution", "width", width, "height", height)
r.SetResolution(width, height)
}
return r
},
@@ -102,7 +137,7 @@ func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string
app.NewApp,
),
fx.Invoke(func(a *app.App) {
fx.Invoke(func(_ *app.App) {
// App will be started by fx lifecycle
}),
)

View File

@@ -9,6 +9,10 @@ import (
"github.com/spf13/cobra"
)
const (
percentMultiplier = 100.0
)
// newInfoCmd creates the info command
func (c *CLI) newInfoCmd() *cobra.Command {
return &cobra.Command{
@@ -19,7 +23,7 @@ func (c *CLI) newInfoCmd() *cobra.Command {
}
}
func (c *CLI) runInfo(cmd *cobra.Command, args []string) {
func (c *CLI) runInfo(_ *cobra.Command, _ []string) {
collector := statcollector.NewSystemCollector(c.log)
c.log.Info("collecting system information")
@@ -40,7 +44,7 @@ func (c *CLI) runInfo(cmd *cobra.Command, args []string) {
fmt.Printf("Total: %s\n", layout.FormatBytes(info.MemoryTotal))
fmt.Printf("Used: %s (%.1f%%)\n",
layout.FormatBytes(info.MemoryUsed),
float64(info.MemoryUsed)/float64(info.MemoryTotal)*100)
float64(info.MemoryUsed)/float64(info.MemoryTotal)*percentMultiplier)
fmt.Printf("Free: %s\n", layout.FormatBytes(info.MemoryFree))
fmt.Println()

View File

@@ -22,21 +22,24 @@ func (c *CLI) newInstallCmd() *cobra.Command {
const systemdUnit = `[Unit]
Description=HDMI Statistics Display Daemon
After=multi-user.target
Conflicts=getty@tty1.service
[Service]
Type=simple
ExecStart=%s daemon
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
StandardOutput=journal+console
StandardError=journal+console
SyslogIdentifier=hdmistat
Environment="HDMISTAT_LOG_LEVEL=debug"
Environment="GOTRACEBACK=all"
[Install]
WantedBy=multi-user.target
`
func (c *CLI) runInstall(cmd *cobra.Command, args []string) {
func (c *CLI) runInstall(_ *cobra.Command, _ []string) {
// Check if running as root
if os.Geteuid() != 0 {
c.log.Error("install command must be run as root")
@@ -63,7 +66,8 @@ func (c *CLI) runInstall(cmd *cobra.Command, args []string) {
unitContent := fmt.Sprintf(systemdUnit, hdmistatPath)
unitPath := "/etc/systemd/system/hdmistat.service"
err = os.WriteFile(unitPath, []byte(unitContent), 0644)
const fileMode = 0600
err = os.WriteFile(unitPath, []byte(unitContent), fileMode)
if err != nil {
c.log.Error("writing systemd unit file", "error", err)
os.Exit(1)
@@ -73,7 +77,8 @@ func (c *CLI) runInstall(cmd *cobra.Command, args []string) {
// Create config directory
configDir := "/etc/hdmistat"
err = os.MkdirAll(configDir, 0755)
const dirMode = 0750
err = os.MkdirAll(configDir, dirMode)
if err != nil {
c.log.Error("creating config directory", "error", err)
os.Exit(1)
@@ -84,20 +89,32 @@ func (c *CLI) runInstall(cmd *cobra.Command, args []string) {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
defaultConfig := `# hdmistat configuration file
# This file is optional - hdmistat will use sensible defaults if not present
# Uncomment and modify any settings you want to override
framebuffer_device: /dev/fb0
rotation_interval: 10s
update_interval: 1s
log_level: info
width: 1920
height: 1080
# Framebuffer device to use for display
#framebuffer_device: /dev/fb0
screens:
- overview
- top_cpu
- top_memory
# How often to rotate between screens
#rotation_interval: 10s
# How often to update the current screen
#update_interval: 1s
# Log level: debug, info, warn, error
#log_level: info
# Display resolution (auto-detected from framebuffer if not set)
#width: 1920
#height: 1080
# Screens to display in rotation order
#screens:
# - overview
# - top_cpu
# - top_memory
`
err = os.WriteFile(configPath, []byte(defaultConfig), 0644)
const configFileMode = 0600
err = os.WriteFile(configPath, []byte(defaultConfig), configFileMode)
if err != nil {
c.log.Error("writing default config", "error", err)
os.Exit(1)

View File

@@ -17,14 +17,15 @@ func (c *CLI) newStatusCmd() *cobra.Command {
}
}
func (c *CLI) runStatus(cmd *cobra.Command, args []string) {
func (c *CLI) runStatus(_ *cobra.Command, _ []string) {
// Check systemd service status
out, err := exec.Command("systemctl", "status", "hdmistat.service", "--no-pager").Output()
if err != nil {
// Service might not be installed
if exitErr, ok := err.(*exec.ExitError); ok {
fmt.Printf("hdmistat service status:\n%s", exitErr.Stderr)
if exitErr.ExitCode() == 4 {
const systemdServiceNotFoundCode = 4
if exitErr.ExitCode() == systemdServiceNotFoundCode {
fmt.Println("\nhdmistat service is not installed. Run 'sudo hdmistat install' to install it.")
}
} else {

442
internal/layout/draw.go Normal file
View File

@@ -0,0 +1,442 @@
// Package layout provides a simple API for creating text-based layouts
// that can be rendered to fbdraw grids for display in a carousel.
package layout
import (
"fmt"
"image/color"
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
"git.eeqj.de/sneak/hdmistat/internal/font"
)
// Font represents a bundled monospace font.
type Font int
const (
// PlexMono is IBM Plex Mono, a modern monospace font with good readability.
PlexMono Font = iota
// Terminus is a bitmap font optimized for long-term reading on terminals.
Terminus
// SourceCodePro is Adobe's Source Code Pro, designed for coding environments.
SourceCodePro
)
// Color returns a standard color by name
func Color(name string) color.Color {
switch name {
case "black":
return color.RGBA{0, 0, 0, 255}
case "white":
return color.RGBA{255, 255, 255, 255}
case "red":
return color.RGBA{255, 0, 0, 255}
case "green":
return color.RGBA{0, 255, 0, 255}
case "blue":
return color.RGBA{0, 0, 255, 255}
case "yellow":
return color.RGBA{255, 255, 0, 255}
case "cyan":
return color.RGBA{0, 255, 255, 255}
case "magenta":
return color.RGBA{255, 0, 255, 255}
case "orange":
return color.RGBA{255, 165, 0, 255}
case "purple":
return color.RGBA{128, 0, 128, 255}
case "gray10":
return color.RGBA{26, 26, 26, 255}
case "gray20":
return color.RGBA{51, 51, 51, 255}
case "gray30":
return color.RGBA{77, 77, 77, 255}
case "gray40":
return color.RGBA{102, 102, 102, 255}
case "gray50":
return color.RGBA{128, 128, 128, 255}
case "gray60":
return color.RGBA{153, 153, 153, 255}
case "gray70":
return color.RGBA{179, 179, 179, 255}
case "gray80":
return color.RGBA{204, 204, 204, 255}
case "gray90":
return color.RGBA{230, 230, 230, 255}
default:
return color.RGBA{255, 255, 255, 255} // Default to white
}
}
// Draw provides the drawing context for creating a layout.
// It maintains state for font, size, colors, and text styling.
type Draw struct {
// Drawing state
font Font
fontSize int
bold bool
italic bool
fgColor color.Color
bgColor color.Color
// Grid to render to
grid *fbdraw.CharGrid
// Cached dimensions
Width int
Height int
}
// NewDraw creates a new drawing context with the specified dimensions
func NewDraw(width, height int) *Draw {
grid := fbdraw.NewCharGrid(width, height)
return &Draw{
grid: grid,
Width: width,
Height: height,
fontSize: 14,
fgColor: color.RGBA{255, 255, 255, 255},
bgColor: color.RGBA{0, 0, 0, 255},
}
}
// Render returns the current grid for rendering by the carousel
func (d *Draw) Render() *fbdraw.CharGrid {
return d.grid
}
// Clear fills the entire display with black.
func (d *Draw) Clear() {
d.grid.Clear(color.RGBA{0, 0, 0, 255})
}
// ClearColor fills the entire display with the specified color.
func (d *Draw) ClearColor(c color.Color) {
// Fill all cells with spaces and the background color
for y := 0; y < d.Height; y++ {
for x := 0; x < d.Width; x++ {
weight := font.WeightRegular
if d.bold {
weight = font.WeightBold
}
d.grid.SetCell(x, y, ' ', d.fgColor, c, weight, d.italic)
}
}
}
// Font sets the current font for text operations.
func (d *Draw) Font(f Font) *Draw {
d.font = f
return d
}
// Size sets the current font size in points.
func (d *Draw) Size(points int) *Draw {
d.fontSize = points
d.grid.FontSize = float64(points)
return d
}
// Bold enables bold text rendering.
func (d *Draw) Bold() *Draw {
d.bold = true
return d
}
// Plain disables bold and italic text rendering.
func (d *Draw) Plain() *Draw {
d.bold = false
d.italic = false
return d
}
// Italic enables italic text rendering.
func (d *Draw) Italic() *Draw {
d.italic = true
return d
}
// Color sets the foreground color for text operations.
func (d *Draw) Color(c color.Color) *Draw {
d.fgColor = c
return d
}
// Background sets the background color for text operations.
func (d *Draw) Background(c color.Color) *Draw {
d.bgColor = c
return d
}
// Text draws text at the specified character coordinates.
func (d *Draw) Text(x, y int, format string, args ...interface{}) {
text := fmt.Sprintf(format, args...)
writer := fbdraw.NewGridWriter(d.grid)
writer.MoveAbs(x, y)
writer.SetColor(d.fgColor)
writer.SetBackground(d.bgColor)
if d.bold {
writer.SetWeight(font.WeightBold)
} else {
writer.SetWeight(font.WeightRegular)
}
writer.SetItalic(d.italic)
writer.Write(text)
}
// TextCenter draws centered text at the specified y coordinate.
func (d *Draw) TextCenter(x, y int, format string, args ...interface{}) {
text := fmt.Sprintf(format, args...)
// Calculate starting position for centered text
startX := x + (d.Width-len(text))/2
d.Text(startX, y, text)
}
// Grid creates a text grid region for simplified text layout.
// The grid uses the current font settings from the Draw context.
func (d *Draw) Grid(x, y, cols, rows int) *Grid {
return &Grid{
draw: d,
x: x,
y: y,
cols: cols,
rows: rows,
}
}
// Grid represents a rectangular text grid for structured text layout.
// All positions are in character cells, not pixels.
type Grid struct {
draw *Draw
x, y int
cols, rows int
// Grid-specific state that can override draw state
fgColor color.Color
bgColor color.Color
borderColor color.Color
hasBorder bool
}
// Write places text at the specified row and column within the grid.
// Text that exceeds the grid bounds is clipped.
func (g *Grid) Write(col, row int, format string, args ...interface{}) {
if row < 0 || row >= g.rows || col < 0 || col >= g.cols {
return
}
text := fmt.Sprintf(format, args...)
// Calculate absolute position
absX := g.x + col
absY := g.y + row
// Write text with clipping
writer := fbdraw.NewGridWriter(g.draw.grid)
writer.MoveAbs(absX, absY)
if g.fgColor != nil {
writer.SetColor(g.fgColor)
} else {
writer.SetColor(g.draw.fgColor)
}
if g.bgColor != nil {
writer.SetBackground(g.bgColor)
} else {
writer.SetBackground(g.draw.bgColor)
}
// Clip text to grid bounds
maxLen := g.cols - col
if len(text) > maxLen {
text = text[:maxLen]
}
writer.Write(text)
}
// WriteCenter centers text within the specified row.
func (g *Grid) WriteCenter(row int, format string, args ...interface{}) {
if row < 0 || row >= g.rows {
return
}
text := fmt.Sprintf(format, args...)
col := (g.cols - len(text)) / 2
if col < 0 {
col = 0
}
g.Write(col, row, text)
}
// Color sets the foreground color for subsequent Write operations.
func (g *Grid) Color(c color.Color) *Grid {
g.fgColor = c
return g
}
// Background sets the background color for the entire grid.
func (g *Grid) Background(c color.Color) *Grid {
g.bgColor = c
// Fill the grid area with the background color
for row := 0; row < g.rows; row++ {
for col := 0; col < g.cols; col++ {
weight := font.WeightRegular
if g.draw.bold {
weight = font.WeightBold
}
g.draw.grid.SetCell(g.x+col, g.y+row, ' ', g.draw.fgColor, c, weight, g.draw.italic)
}
}
return g
}
// Border draws a border around the grid in the specified color.
func (g *Grid) Border(c color.Color) *Grid {
g.borderColor = c
g.hasBorder = true
// Draw border using box drawing characters
writer := fbdraw.NewGridWriter(g.draw.grid)
writer.SetColor(c)
// Top border
writer.MoveAbs(g.x-1, g.y-1)
writer.Write("┌")
for i := 0; i < g.cols; i++ {
writer.Write("─")
}
writer.Write("┐")
// Side borders
for row := 0; row < g.rows; row++ {
writer.MoveAbs(g.x-1, g.y+row)
writer.Write("│")
writer.MoveAbs(g.x+g.cols, g.y+row)
writer.Write("│")
}
// Bottom border
writer.MoveAbs(g.x-1, g.y+g.rows)
writer.Write("└")
for i := 0; i < g.cols; i++ {
writer.Write("─")
}
writer.Write("┘")
return g
}
// RowBackground sets the background color for a specific row.
func (g *Grid) RowBackground(row int, c color.Color) {
if row < 0 || row >= g.rows {
return
}
for col := 0; col < g.cols; col++ {
g.draw.grid.SetCell(g.x+col, g.y+row, ' ', g.draw.fgColor, c, font.WeightRegular, false)
}
}
// RowColor sets the text color for an entire row.
func (g *Grid) RowColor(row int, c color.Color) {
if row < 0 || row >= g.rows {
return
}
// This would need to track row colors for subsequent writes
// For now, we'll just update existing text in the row
for col := 0; col < g.cols; col++ {
// Get current cell to preserve other attributes
if g.y+row < g.draw.grid.Height && g.x+col < g.draw.grid.Width {
cell := &g.draw.grid.Cells[g.y+row][g.x+col]
cell.Foreground = c
}
}
}
// Bar draws a horizontal progress bar within the grid cell.
func (g *Grid) Bar(col, row, width int, percent float64, c color.Color) {
if row < 0 || row >= g.rows || col < 0 || col >= g.cols {
return
}
// Ensure width doesn't exceed grid bounds
maxWidth := g.cols - col
if width > maxWidth {
width = maxWidth
}
writer := fbdraw.NewGridWriter(g.draw.grid)
writer.MoveAbs(g.x+col, g.y+row)
writer.SetColor(c)
writer.DrawMeter(percent, width)
}
// Bold enables bold text for subsequent Write operations.
func (g *Grid) Bold() *Grid {
// TODO: Track bold state for grid
return g
}
// Plain disables text styling for subsequent Write operations.
func (g *Grid) Plain() *Grid {
// TODO: Clear text styling for grid
return g
}
// Meter creates a text-based progress meter using Unicode block characters.
// The width parameter specifies the number of characters.
// Returns a string like "█████████░░░░░░" for 60% with width 15.
func Meter(percent float64, width int) string {
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := int(percent / 100.0 * float64(width))
result := ""
for i := 0; i < width; i++ {
if i < filled {
result += "█"
} else {
result += "░"
}
}
return result
}
// Bytes formats a byte count as a human-readable string.
// For example: 1234567890 becomes "1.2 GB".
func Bytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := uint64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// Heat returns a color between blue and red based on the value.
// 0.0 returns blue, 1.0 returns red, with gradients in between.
func Heat(value float64) color.Color {
if value < 0 {
value = 0
}
if value > 1 {
value = 1
}
// Simple linear interpolation between blue and red
r := uint8(255 * value)
g := uint8(0)
b := uint8(255 * (1 - value))
return color.RGBA{r, g, b, 255}
}
// RGB creates a color from red, green, and blue values (0-255).
func RGB(r, g, b uint8) color.Color {
return color.RGBA{r, g, b, 255}
}

View File

@@ -0,0 +1,64 @@
package layout_test
import (
"testing"
"time"
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
"git.eeqj.de/sneak/hdmistat/internal/layout"
)
// ExampleScreen shows how to create a screen that implements FrameGenerator
type ExampleScreen struct {
name string
fps float64
}
func (s *ExampleScreen) GenerateFrame(grid *fbdraw.CharGrid) error {
// Create a draw context with the grid dimensions
draw := layout.NewDraw(grid.Width, grid.Height)
// Clear the screen
draw.Clear()
// Draw a title
draw.Color(layout.Color("cyan")).Size(16).Bold()
draw.TextCenter(0, 2, "Example Screen: %s", s.name)
// Create a grid for structured layout
contentGrid := draw.Grid(5, 5, 70, 20)
contentGrid.Border(layout.Color("gray50"))
// Add some content
contentGrid.Color(layout.Color("white")).WriteCenter(1, "Current Time: %s", time.Now().Format("15:04:05"))
// Draw a progress bar
contentGrid.Color(layout.Color("green")).Bar(10, 5, 50, 75.0, layout.Color("green"))
// Add system stats
contentGrid.Color(layout.Color("yellow")).Write(2, 8, "CPU: %.1f%%", 42.5)
contentGrid.Color(layout.Color("orange")).Write(2, 9, "Memory: %s / %s", layout.Bytes(4*1024*1024*1024), layout.Bytes(16*1024*1024*1024))
// Return the rendered grid
*grid = *draw.Render()
return nil
}
func (s *ExampleScreen) FramesPerSecond() float64 {
return s.fps
}
func TestExampleUsage(t *testing.T) {
// Create carousel with terminal display for testing
display := fbdraw.NewTerminalDisplay(80, 25)
carousel := fbdraw.NewCarousel(display, 5*time.Second)
// Add screens
carousel.AddScreen(&ExampleScreen{name: "Dashboard", fps: 1.0})
carousel.AddScreen(&ExampleScreen{name: "System Monitor", fps: 2.0})
carousel.AddScreen(&ExampleScreen{name: "Network Stats", fps: 0.5})
// In a real application, you would run this in a goroutine
// ctx := context.Background()
// go carousel.Run(ctx)
}

View File

@@ -1,3 +1,4 @@
// Package layout provides canvas and drawing utilities for hdmistat
package layout
import (
@@ -12,6 +13,21 @@ import (
"golang.org/x/image/font"
)
const (
// Display constants
defaultDPI = 72
percentDivisor = 100.0
halfDivisor = 2
// Time constants
secondsPerDay = 86400
secondsPerHour = 3600
secondsPerMinute = 60
// Byte formatting constants
byteUnit = 1024
)
// Canvas provides a simple API for rendering text and graphics
type Canvas struct {
img *image.RGBA
@@ -24,14 +40,18 @@ type TextStyle struct {
Size float64
Color color.Color
Alignment Alignment
Bold bool
}
// Alignment for text rendering
type Alignment int
const (
// AlignLeft aligns text to the left
AlignLeft Alignment = iota
// AlignCenter centers text
AlignCenter
// AlignRight aligns text to the right
AlignRight
)
@@ -66,7 +86,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
}
ctx := freetype.NewContext()
ctx.SetDPI(72)
ctx.SetDPI(defaultDPI)
ctx.SetFont(c.font)
ctx.SetFontSize(style.Size)
ctx.SetClip(c.img.Bounds())
@@ -76,7 +96,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
// Calculate text bounds for alignment
opts := truetype.Options{
Size: style.Size,
DPI: 72,
DPI: defaultDPI,
}
face := truetype.NewFace(c.font, &opts)
bounds, _ := font.BoundString(face, text)
@@ -85,7 +105,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
x := pos.X
switch style.Alignment {
case AlignCenter:
x = pos.X - width.Round()/2
x = pos.X - width.Round()/halfDivisor
case AlignRight:
x = pos.X - width.Round()
}
@@ -131,7 +151,7 @@ func (c *Canvas) DrawProgress(x, y, width, height int, percent float64, fg, bg c
c.DrawBox(x, y, width, height, bg)
// Foreground
fillWidth := int(float64(width) * percent / 100.0)
fillWidth := int(float64(width) * percent / percentDivisor)
if fillWidth > 0 {
c.DrawBox(x, y, fillWidth, height, fg)
}
@@ -163,13 +183,12 @@ func (c *Canvas) Size() (width, height int) {
// FormatBytes formats byte counts for display
func FormatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
if bytes < byteUnit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := uint64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
div, exp := uint64(byteUnit), 0
for n := bytes / byteUnit; n >= byteUnit; n /= byteUnit {
div *= byteUnit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
@@ -178,9 +197,9 @@ func FormatBytes(bytes uint64) string {
// FormatDuration formats time durations for display
func FormatDuration(d float64) string {
seconds := int(d)
days := seconds / 86400
hours := (seconds % 86400) / 3600
minutes := (seconds % 3600) / 60
days := seconds / secondsPerDay
hours := (seconds % secondsPerDay) / secondsPerHour
minutes := (seconds % secondsPerHour) / secondsPerMinute
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)

View File

@@ -0,0 +1,95 @@
package layout
import (
"fmt"
"image/color"
)
const (
// Progress bar label positioning
labelTopOffset = 5
labelBottomOffset = 15
labelSizeReduction = 2
percentMultiplier = 100
percentTextOffset = 5
)
// ProgressBar draws a labeled progress bar
type ProgressBar struct {
X, Y int
Width, Height int
Value float64 // 0.0 to 1.0
Label string
LeftLabel string
RightLabel string
BarColor color.Color
BGColor color.Color
TextColor color.Color
LabelSize float64
}
// Draw renders the progress bar on the canvas
func (p *ProgressBar) Draw(canvas *Canvas) {
// Default colors
if p.BarColor == nil {
p.BarColor = color.RGBA{100, 200, 255, 255}
}
if p.BGColor == nil {
p.BGColor = color.RGBA{50, 50, 50, 255}
}
if p.TextColor == nil {
p.TextColor = color.RGBA{255, 255, 255, 255}
}
if p.LabelSize == 0 {
p.LabelSize = 14
}
// Ensure value is between 0 and 1
value := p.Value
if value < 0 {
value = 0
}
if value > 1 {
value = 1
}
// Draw background
canvas.DrawBox(p.X, p.Y, p.Width, p.Height, p.BGColor)
// Draw filled portion
filledWidth := int(float64(p.Width) * value)
if filledWidth > 0 {
canvas.DrawBox(p.X, p.Y, filledWidth, p.Height, p.BarColor)
}
// Draw label above bar if provided
if p.Label != "" {
labelStyle := TextStyle{Size: p.LabelSize, Color: p.TextColor}
_ = canvas.DrawText(p.Label, Point{X: p.X, Y: p.Y - labelTopOffset}, labelStyle)
}
// Draw left label
if p.LeftLabel != "" {
labelStyle := TextStyle{Size: p.LabelSize - labelSizeReduction, Color: p.TextColor}
_ = canvas.DrawText(p.LeftLabel, Point{X: p.X, Y: p.Y + p.Height + labelBottomOffset}, labelStyle)
}
// Draw right label
if p.RightLabel != "" {
labelStyle := TextStyle{Size: p.LabelSize - labelSizeReduction, Color: p.TextColor, Alignment: AlignRight}
_ = canvas.DrawText(p.RightLabel, Point{X: p.X + p.Width, Y: p.Y + p.Height + labelBottomOffset}, labelStyle)
}
// Draw percentage in center of bar
percentText := fmt.Sprintf("%.1f%%", value*percentMultiplier)
centerStyle := TextStyle{
Size: p.LabelSize,
Color: color.RGBA{255, 255, 255, 255},
Alignment: AlignCenter,
}
pt := Point{
X: p.X + p.Width/halfDivisor,
Y: p.Y + p.Height/halfDivisor + percentTextOffset,
}
_ = canvas.DrawText(percentText, pt, centerStyle)
}

259
internal/netmon/netmon.go Normal file
View File

@@ -0,0 +1,259 @@
// Package netmon provides network interface monitoring with historical data
package netmon
import (
"context"
"log/slog"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
psnet "github.com/shirou/gopsutil/v3/net"
)
const (
ringBufferSize = 60 // Keep 60 seconds of history
sampleInterval = time.Second
rateWindowSeconds = 5 // Window size for rate calculation
bitsPerByte = 8
)
// Sample represents a single point-in-time network sample
type Sample struct {
BytesSent uint64
BytesRecv uint64
Timestamp time.Time
}
// InterfaceStats holds the ring buffer for a single interface
type InterfaceStats struct {
samples [ringBufferSize]Sample
head int // Points to the oldest sample
count int // Number of valid samples
lastSample Sample
}
// Stats represents current stats for an interface
type Stats struct {
Name string
BytesSent uint64
BytesRecv uint64
BitsSentRate uint64 // bits per second
BitsRecvRate uint64 // bits per second
}
// FormatSentRate returns the send rate as a human-readable string
func (s *Stats) FormatSentRate() string {
return humanize.SI(float64(s.BitsSentRate), "bit/s")
}
// FormatRecvRate returns the receive rate as a human-readable string
func (s *Stats) FormatRecvRate() string {
return humanize.SI(float64(s.BitsRecvRate), "bit/s")
}
// Monitor tracks network statistics for all interfaces
type Monitor struct {
mu sync.RWMutex
interfaces map[string]*InterfaceStats
logger *slog.Logger
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// New creates a new network monitor
func New(logger *slog.Logger) *Monitor {
ctx, cancel := context.WithCancel(context.Background())
m := &Monitor{
interfaces: make(map[string]*InterfaceStats),
logger: logger,
ctx: ctx,
cancel: cancel,
}
return m
}
// Start begins monitoring network interfaces
func (m *Monitor) Start() {
m.wg.Add(1)
go m.monitorLoop()
}
// Stop stops the monitor
func (m *Monitor) Stop() {
m.cancel()
m.wg.Wait()
}
// GetStats returns current stats for all interfaces
func (m *Monitor) GetStats() []Stats {
m.mu.RLock()
defer m.mu.RUnlock()
var stats []Stats
for name, ifaceStats := range m.interfaces {
// Skip interfaces with no samples
if ifaceStats.count == 0 {
continue
}
// Calculate rates over available samples (up to 5 seconds)
rate := m.calculateRate(ifaceStats, rateWindowSeconds)
stats = append(stats, Stats{
Name: name,
BytesSent: ifaceStats.lastSample.BytesSent,
BytesRecv: ifaceStats.lastSample.BytesRecv,
BitsSentRate: uint64(rate.sentRate * bitsPerByte), // Convert to bits/sec
BitsRecvRate: uint64(rate.recvRate * bitsPerByte), // Convert to bits/sec
})
}
return stats
}
// GetStatsForInterface returns stats for a specific interface
func (m *Monitor) GetStatsForInterface(name string) (*Stats, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
ifaceStats, ok := m.interfaces[name]
if !ok || ifaceStats.count == 0 {
return nil, false
}
rate := m.calculateRate(ifaceStats, 5)
return &Stats{
Name: name,
BytesSent: ifaceStats.lastSample.BytesSent,
BytesRecv: ifaceStats.lastSample.BytesRecv,
BitsSentRate: uint64(rate.sentRate * 8), // Convert to bits/sec
BitsRecvRate: uint64(rate.recvRate * 8), // Convert to bits/sec
}, true
}
type rateInfo struct {
sentRate float64 // bytes per second
recvRate float64 // bytes per second
}
// calculateRate calculates the average rate over the last n seconds
func (m *Monitor) calculateRate(ifaceStats *InterfaceStats, seconds int) rateInfo {
if ifaceStats.count <= 1 {
return rateInfo{}
}
// Determine how many samples to use (up to requested seconds)
samplesToUse := seconds
if samplesToUse > ifaceStats.count-1 {
samplesToUse = ifaceStats.count - 1
}
if samplesToUse > ringBufferSize-1 {
samplesToUse = ringBufferSize - 1
}
// Get the most recent sample
newestIdx := (ifaceStats.head + ifaceStats.count - 1) % ringBufferSize
newest := ifaceStats.samples[newestIdx]
// Get the sample from n seconds ago
oldestIdx := (ifaceStats.head + ifaceStats.count - 1 - samplesToUse) % ringBufferSize
oldest := ifaceStats.samples[oldestIdx]
// Calculate time difference
timeDiff := newest.Timestamp.Sub(oldest.Timestamp).Seconds()
if timeDiff <= 0 {
return rateInfo{}
}
// Calculate rates
bytesSentDiff := float64(newest.BytesSent - oldest.BytesSent)
bytesRecvDiff := float64(newest.BytesRecv - oldest.BytesRecv)
return rateInfo{
sentRate: bytesSentDiff / timeDiff,
recvRate: bytesRecvDiff / timeDiff,
}
}
// monitorLoop continuously samples network statistics
func (m *Monitor) monitorLoop() {
defer m.wg.Done()
ticker := time.NewTicker(sampleInterval)
defer ticker.Stop()
// Take initial sample
m.takeSample()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C:
m.takeSample()
}
}
}
// takeSample captures current network statistics
func (m *Monitor) takeSample() {
counters, err := psnet.IOCounters(true)
if err != nil {
m.logger.Warn("failed to get network counters", "error", err)
return
}
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
currentInterfaces := make(map[string]bool)
for _, counter := range counters {
// Skip loopback and docker interfaces
if counter.Name == "lo" || strings.HasPrefix(counter.Name, "docker") {
continue
}
currentInterfaces[counter.Name] = true
// Get or create interface stats
ifaceStats, exists := m.interfaces[counter.Name]
if !exists {
ifaceStats = &InterfaceStats{}
m.interfaces[counter.Name] = ifaceStats
}
// Create new sample
sample := Sample{
BytesSent: counter.BytesSent,
BytesRecv: counter.BytesRecv,
Timestamp: now,
}
// Add to ring buffer
if ifaceStats.count < ringBufferSize {
// Buffer not full yet
idx := ifaceStats.count
ifaceStats.samples[idx] = sample
ifaceStats.count++
} else {
// Buffer is full, overwrite oldest
ifaceStats.samples[ifaceStats.head] = sample
ifaceStats.head = (ifaceStats.head + 1) % ringBufferSize
}
ifaceStats.lastSample = sample
}
// Remove interfaces that no longer exist
for name := range m.interfaces {
if !currentInterfaces[name] {
delete(m.interfaces, name)
}
}
}

View File

@@ -1,156 +1,222 @@
// Package renderer provides screen rendering implementations for hdmistat
package renderer
import (
"fmt"
"image/color"
"strings"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
"github.com/dustin/go-humanize"
)
// OverviewScreen displays system overview
type OverviewScreen struct{}
// NewOverviewScreen creates a new overview screen renderer
func NewOverviewScreen() *OverviewScreen {
return &OverviewScreen{}
}
// Name returns the name of this screen
func (s *OverviewScreen) Name() string {
return "System Overview"
}
// Render draws the overview screen to the provided canvas
func (s *OverviewScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
width, height := canvas.Size()
_, _ = canvas.Size()
// Colors
textColor := color.RGBA{255, 255, 255, 255}
headerColor := color.RGBA{100, 200, 255, 255}
dimColor := color.RGBA{150, 150, 150, 255}
// Styles
titleStyle := layout.TextStyle{Size: 48, Color: headerColor}
headerStyle := layout.TextStyle{Size: 24, Color: headerColor}
headerStyle := layout.TextStyle{Size: 18, Color: headerColor, Bold: true}
normalStyle := layout.TextStyle{Size: 18, Color: textColor}
smallStyle := layout.TextStyle{Size: 16, Color: dimColor}
y := 50
y := 120 // Start below header
// Title
_ = canvas.DrawText(info.Hostname, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: titleStyle.Size,
// Get short hostname
shortHostname := info.Hostname
if idx := strings.Index(shortHostname, "."); idx > 0 {
shortHostname = shortHostname[:idx]
}
// Title - left aligned at consistent position
titleText := fmt.Sprintf("%s: status", shortHostname)
_ = canvas.DrawText(titleText, layout.Point{X: 50, Y: y}, layout.TextStyle{
Size: 36, // Smaller than before
Color: titleStyle.Color,
Alignment: layout.AlignCenter,
})
y += 80
// Uptime
uptimeText := fmt.Sprintf("Uptime: %s", layout.FormatDuration(info.Uptime.Seconds()))
_ = canvas.DrawText(uptimeText, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: smallStyle.Size,
Color: smallStyle.Color,
Alignment: layout.AlignCenter,
Alignment: layout.AlignLeft,
})
y += 60
// Two column layout
leftX := 50
rightX := width/2 + 50
// Memory section (left)
_ = canvas.DrawText("MEMORY", layout.Point{X: leftX, Y: y}, headerStyle)
y += 35
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal) * 100
_ = canvas.DrawText(fmt.Sprintf("Total: %s", layout.FormatBytes(info.MemoryTotal)),
layout.Point{X: leftX, Y: y}, normalStyle)
y += 25
_ = canvas.DrawText(fmt.Sprintf("Used: %s (%.1f%%)", layout.FormatBytes(info.MemoryUsed), memUsedPercent),
layout.Point{X: leftX, Y: y}, normalStyle)
y += 25
_ = canvas.DrawText(fmt.Sprintf("Free: %s", layout.FormatBytes(info.MemoryFree)),
layout.Point{X: leftX, Y: y}, normalStyle)
y += 35
// Memory progress bar
canvas.DrawProgress(leftX, y, 400, 20, memUsedPercent,
color.RGBA{100, 200, 100, 255},
color.RGBA{50, 50, 50, 255})
// CPU section (right)
cpuY := y - 115
_ = canvas.DrawText("CPU", layout.Point{X: rightX, Y: cpuY}, headerStyle)
cpuY += 35
// Show per-core CPU usage
for i, percent := range info.CPUPercent {
if i >= 8 {
// Limit display to 8 cores
_ = canvas.DrawText(fmt.Sprintf("... and %d more cores", len(info.CPUPercent)-8),
layout.Point{X: rightX, Y: cpuY}, smallStyle)
break
}
_ = canvas.DrawText(fmt.Sprintf("Core %d:", i), layout.Point{X: rightX, Y: cpuY}, smallStyle)
canvas.DrawProgress(rightX+80, cpuY-12, 200, 15, percent,
color.RGBA{255, 100, 100, 255},
color.RGBA{50, 50, 50, 255})
cpuY += 20
}
y += 60
// Disk usage section
_ = canvas.DrawText("DISK USAGE", layout.Point{X: leftX, Y: y}, headerStyle)
y += 35
for i, disk := range info.DiskUsage {
if i >= 4 {
break // Limit to 4 disks
}
_ = canvas.DrawText(disk.Path, layout.Point{X: leftX, Y: y}, normalStyle)
usageText := fmt.Sprintf("%s / %s", layout.FormatBytes(disk.Used), layout.FormatBytes(disk.Total))
_ = canvas.DrawText(usageText, layout.Point{X: leftX + 200, Y: y}, smallStyle)
canvas.DrawProgress(leftX+400, y-12, 300, 15, disk.UsedPercent,
color.RGBA{200, 200, 100, 255},
color.RGBA{50, 50, 50, 255})
y += 30
}
// Standard bar dimensions
barWidth := 400
barHeight := 20
sectionSpacing := 60
// CPU section
_ = canvas.DrawText("CPU", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
// Network section
_ = canvas.DrawText("NETWORK", layout.Point{X: leftX, Y: y}, headerStyle)
y += 35
// Calculate average CPU usage
totalCPU := 0.0
for _, cpu := range info.CPUPercent {
totalCPU += cpu
}
avgCPU := totalCPU / float64(len(info.CPUPercent))
for i, net := range info.Network {
if i >= 3 {
break // Limit to 3 interfaces
}
_ = canvas.DrawText(net.Name, layout.Point{X: leftX, Y: y}, normalStyle)
// Draw composite CPU bar below header
cpuBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth, Height: barHeight,
Value: avgCPU / 100.0,
Label: fmt.Sprintf("%.1f%% average across %d cores", avgCPU, len(info.CPUPercent)),
LeftLabel: "0%",
RightLabel: "100%",
BarColor: color.RGBA{255, 100, 100, 255},
}
cpuBar.Draw(canvas)
y += sectionSpacing
if len(net.IPAddresses) > 0 {
_ = canvas.DrawText(net.IPAddresses[0], layout.Point{X: leftX + 150, Y: y}, smallStyle)
}
// Memory section
_ = canvas.DrawText("MEMORY", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
trafficText := fmt.Sprintf("TX: %s RX: %s",
layout.FormatBytes(net.BytesSent),
layout.FormatBytes(net.BytesRecv))
_ = canvas.DrawText(trafficText, layout.Point{X: leftX + 400, Y: y}, smallStyle)
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal)
memoryBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth, Height: barHeight,
Value: memUsedPercent,
Label: fmt.Sprintf("%s of %s", layout.FormatBytes(info.MemoryUsed), layout.FormatBytes(info.MemoryTotal)),
LeftLabel: "0B",
RightLabel: layout.FormatBytes(info.MemoryTotal),
BarColor: color.RGBA{100, 200, 100, 255},
}
memoryBar.Draw(canvas)
y += sectionSpacing
// Temperature section
if len(info.Temperature) > 0 {
_ = canvas.DrawText("TEMPERATURE", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
// Find the highest temperature
maxTemp := 0.0
maxSensor := ""
for sensor, temp := range info.Temperature {
if temp > maxTemp {
maxTemp = temp
maxSensor = sensor
}
}
// Temperature scale from 30°C to 99°C
tempValue := (maxTemp - 30.0) / (99.0 - 30.0)
if tempValue < 0 {
tempValue = 0
}
if tempValue > 1 {
tempValue = 1
}
tempBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth, Height: barHeight,
Value: tempValue,
Label: fmt.Sprintf("%s: %.1f°C", maxSensor, maxTemp),
LeftLabel: "30°C",
RightLabel: "99°C",
BarColor: color.RGBA{255, 150, 50, 255},
}
tempBar.Draw(canvas)
y += sectionSpacing
}
// Temperature section (bottom right)
if len(info.Temperature) > 0 {
tempY := height - 200
_ = canvas.DrawText("TEMPERATURE", layout.Point{X: rightX, Y: tempY}, headerStyle)
tempY += 35
// Disk usage section
_ = canvas.DrawText("DISK USAGE", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
for sensor, temp := range info.Temperature {
if tempY > height-50 {
break
for _, disk := range info.DiskUsage {
// Skip snap disks
if strings.HasPrefix(disk.Path, "/snap") {
continue
}
diskBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth, Height: barHeight,
Value: disk.UsedPercent / 100.0,
Label: fmt.Sprintf("%s: %s of %s", disk.Path, layout.FormatBytes(disk.Used), layout.FormatBytes(disk.Total)),
LeftLabel: "0B",
RightLabel: layout.FormatBytes(disk.Total),
BarColor: color.RGBA{200, 200, 100, 255},
}
diskBar.Draw(canvas)
y += 40
if y > 700 {
break // Don't overflow the screen
}
}
y += sectionSpacing
// Network section
if len(info.Network) > 0 {
_ = canvas.DrawText("NETWORK", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
for _, net := range info.Network {
// Network interface info
interfaceText := net.Name
if len(net.IPAddresses) > 0 {
interfaceText = fmt.Sprintf("%s (%s)", net.Name, net.IPAddresses[0])
}
_ = canvas.DrawText(interfaceText, layout.Point{X: 50, Y: y}, normalStyle)
y += 25
// Get link speed for scaling (default to 1 Gbps if unknown)
linkSpeed := net.LinkSpeed
if linkSpeed == 0 {
linkSpeed = 1000 * 1000 * 1000 // 1 Gbps in bits
}
// TX rate bar
txValue := float64(net.BitsSentRate) / float64(linkSpeed)
txBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth/2 - 10, Height: barHeight,
Value: txValue,
Label: fmt.Sprintf("↑ %s", net.FormatSentRate()),
LeftLabel: "0",
RightLabel: humanize.SI(float64(linkSpeed), "bit/s"),
BarColor: color.RGBA{100, 255, 100, 255},
}
txBar.Draw(canvas)
// RX rate bar
rxValue := float64(net.BitsRecvRate) / float64(linkSpeed)
rxBar := &layout.ProgressBar{
X: 50 + barWidth/2 + 10, Y: y,
Width: barWidth/2 - 10, Height: barHeight,
Value: rxValue,
Label: fmt.Sprintf("↓ %s", net.FormatRecvRate()),
LeftLabel: "0",
RightLabel: humanize.SI(float64(linkSpeed), "bit/s"),
BarColor: color.RGBA{100, 100, 255, 255},
}
rxBar.Draw(canvas)
y += 60
if y > 900 {
break // Don't overflow the screen
}
_ = canvas.DrawText(fmt.Sprintf("%s: %.1f°C", sensor, temp),
layout.Point{X: rightX, Y: tempY}, normalStyle)
tempY += 25
}
}

View File

@@ -4,24 +4,39 @@ import (
"fmt"
"image/color"
"sort"
"strings"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
)
const (
// Display constants
maxProcessNameLen = 30
maxUsernameLen = 12
topProcessCount = 20
cpuHighThreshold = 50.0
memoryHighRatio = 0.1
percentMultiplier = 100.0
halfDivisor = 2
)
// ProcessScreen displays top processes
type ProcessScreen struct {
SortBy string // "cpu" or "memory"
}
// NewProcessScreenCPU creates a new process screen sorted by CPU usage
func NewProcessScreenCPU() *ProcessScreen {
return &ProcessScreen{SortBy: "cpu"}
}
// NewProcessScreenMemory creates a new process screen sorted by memory usage
func NewProcessScreenMemory() *ProcessScreen {
return &ProcessScreen{SortBy: "memory"}
}
// Name returns the name of this screen
func (s *ProcessScreen) Name() string {
if s.SortBy == "cpu" {
return "Top Processes by CPU"
@@ -29,6 +44,7 @@ func (s *ProcessScreen) Name() string {
return "Top Processes by Memory"
}
// Render draws the process screen to the provided canvas
func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
width, _ := canvas.Size()
@@ -43,15 +59,27 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
normalStyle := layout.TextStyle{Size: 16, Color: textColor}
smallStyle := layout.TextStyle{Size: 14, Color: dimColor}
y := 50
y := 120 // Start below header - same as overview
// Title
_ = canvas.DrawText(s.Name(), layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: titleStyle.Size,
// Get short hostname
shortHostname := info.Hostname
if idx := strings.Index(shortHostname, "."); idx > 0 {
shortHostname = shortHostname[:idx]
}
// Title - left aligned at consistent position
titleText := ""
if s.SortBy == "cpu" {
titleText = fmt.Sprintf("%s: cpu", shortHostname)
} else {
titleText = fmt.Sprintf("%s: memory", shortHostname)
}
_ = canvas.DrawText(titleText, layout.Point{X: 50, Y: y}, layout.TextStyle{
Size: 36, // Same size as overview
Color: titleStyle.Color,
Alignment: layout.AlignCenter,
Alignment: layout.AlignLeft,
})
y += 70
y += 60
// Sort processes
processes := make([]statcollector.ProcessInfo, len(info.Processes))
@@ -81,19 +109,26 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
// Display top 20 processes
for i, proc := range processes {
if i >= 20 {
if i >= topProcessCount {
break
}
// Truncate long names
name := proc.Name
if len(name) > 30 {
name = name[:27] + "..."
if len(name) > maxProcessNameLen {
name = name[:maxProcessNameLen-3] + "..."
}
user := proc.Username
if len(user) > 12 {
user = user[:9] + "..."
if len(user) > maxUsernameLen {
user = user[:maxUsernameLen-3] + "..."
}
// Highlight bar for high usage (draw BEFORE text)
if s.SortBy == "cpu" && proc.CPUPercent > cpuHighThreshold {
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{100, 50, 50, 100})
} else if s.SortBy == "memory" && float64(proc.MemoryRSS)/float64(info.MemoryTotal) > memoryHighRatio {
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{50, 50, 100, 100})
}
_ = canvas.DrawText(fmt.Sprintf("%d", proc.PID), layout.Point{X: x, Y: y}, normalStyle)
@@ -102,13 +137,6 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
_ = canvas.DrawText(fmt.Sprintf("%.1f", proc.CPUPercent), layout.Point{X: x + 600, Y: y}, normalStyle)
_ = canvas.DrawText(layout.FormatBytes(proc.MemoryRSS), layout.Point{X: x + 700, Y: y}, normalStyle)
// Highlight bar for high usage
if s.SortBy == "cpu" && proc.CPUPercent > 50 {
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{100, 50, 50, 100})
} else if s.SortBy == "memory" && float64(proc.MemoryRSS)/float64(info.MemoryTotal) > 0.1 {
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{50, 50, 100, 100})
}
y += 25
}
@@ -127,9 +155,9 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
avgCPU,
layout.FormatBytes(info.MemoryUsed),
layout.FormatBytes(info.MemoryTotal),
float64(info.MemoryUsed)/float64(info.MemoryTotal)*100)
float64(info.MemoryUsed)/float64(info.MemoryTotal)*percentMultiplier)
_ = canvas.DrawText(footerText, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
_ = canvas.DrawText(footerText, layout.Point{X: width / halfDivisor, Y: y}, layout.TextStyle{
Size: smallStyle.Size,
Color: smallStyle.Color,
Alignment: layout.AlignCenter,

View File

@@ -2,7 +2,11 @@ package renderer
import (
"image"
"image/color"
"log/slog"
"os/exec"
"strings"
"time"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
@@ -43,9 +47,134 @@ func (r *Renderer) SetResolution(width, height int) {
func (r *Renderer) RenderScreen(screen Screen, info *statcollector.SystemInfo) (*image.RGBA, error) {
canvas := layout.NewCanvas(r.width, r.height, r.font, r.logger)
// Draw common header
r.drawHeader(canvas, info)
if err := screen.Render(canvas, info); err != nil {
return nil, err
}
return canvas.Image(), nil
}
// drawHeader draws the common header with system info
func (r *Renderer) drawHeader(canvas *layout.Canvas, _ *statcollector.SystemInfo) {
width, _ := canvas.Size()
headerColor := color.RGBA{150, 150, 150, 255}
headerStyle := layout.TextStyle{Size: 14, Color: headerColor, Bold: true}
// Get uname info
uname := "Unknown System"
if output, err := exec.Command("uname", "-a").Output(); err == nil {
uname = strings.TrimSpace(string(output))
// Truncate if too long
if len(uname) > 150 {
uname = uname[:147] + "..."
}
}
// Draw uname on left
_ = canvas.DrawText(uname, layout.Point{X: 20, Y: 20}, headerStyle)
// Check NTP sync status
ntpSynced := r.checkNTPSync()
var syncIndicator string
var syncColor color.Color
if ntpSynced {
syncIndicator = "*"
syncColor = color.RGBA{0, 255, 0, 255} // Green
} else {
syncIndicator = "?"
syncColor = color.RGBA{255, 0, 0, 255} // Red
}
// Time formats
now := time.Now()
utcTime := now.UTC().Format("Mon 2006-01-02 15:04:05 UTC")
localTime := now.Format("Mon 2006-01-02 15:04:05 MST")
// Draw times on the right with sync indicators
// For simplicity, we'll use a fixed position approach
// Draw UTC time
_ = canvas.DrawText(utcTime, layout.Point{X: width - 40, Y: 20}, layout.TextStyle{
Size: headerStyle.Size,
Color: color.RGBA{255, 255, 255, 255}, // White
Alignment: layout.AlignRight,
Bold: true,
})
// UTC sync indicators
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 280, Y: 20}, layout.TextStyle{
Size: headerStyle.Size,
Color: syncColor,
Bold: true,
})
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 20, Y: 20}, layout.TextStyle{
Size: headerStyle.Size,
Color: syncColor,
Bold: true,
})
// Draw local time
_ = canvas.DrawText(localTime, layout.Point{X: width - 40, Y: 35}, layout.TextStyle{
Size: headerStyle.Size,
Color: color.RGBA{255, 255, 255, 255}, // White
Alignment: layout.AlignRight,
Bold: true,
})
// Local sync indicators
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 280, Y: 35}, layout.TextStyle{
Size: headerStyle.Size,
Color: syncColor,
Bold: true,
})
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 20, Y: 35}, layout.TextStyle{
Size: headerStyle.Size,
Color: syncColor,
Bold: true,
})
// Get uptime command output
uptimeStr := "uptime unavailable"
if output, err := exec.Command("uptime").Output(); err == nil {
uptimeStr = strings.TrimSpace(string(output))
}
// Draw uptime on second line
_ = canvas.DrawText(uptimeStr, layout.Point{X: 20, Y: 40}, headerStyle)
// Draw horizontal rule with more space
canvas.DrawHLine(0, 70, width, color.RGBA{100, 100, 100, 255})
}
// checkNTPSync checks if the system clock is synchronized with NTP
func (r *Renderer) checkNTPSync() bool {
// Try timedatectl first (systemd systems)
if output, err := exec.Command("timedatectl", "status").Output(); err == nil {
outputStr := string(output)
// Look for "System clock synchronized: yes" or "NTP synchronized: yes"
if strings.Contains(outputStr, "synchronized: yes") {
return true
}
return false
}
// Try chronyc (chrony)
if output, err := exec.Command("chronyc", "tracking").Output(); err == nil {
outputStr := string(output)
// Look for "Leap status : Normal"
if strings.Contains(outputStr, "Leap status : Normal") {
return true
}
return false
}
// Try ntpstat (ntpd)
if err := exec.Command("ntpstat").Run(); err == nil {
// ntpstat returns 0 if synchronized
return true
}
// Default to not synced if we can't determine
return false
}

View File

@@ -0,0 +1,246 @@
package renderer
import (
"fmt"
"image/color"
"strings"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
"github.com/dustin/go-humanize"
)
// StatusScreen displays system status overview
type StatusScreen struct{}
// NewStatusScreen creates a new status screen
func NewStatusScreen() *StatusScreen {
return &StatusScreen{}
}
// Name returns the screen name
func (s *StatusScreen) Name() string {
return "System Status"
}
// Render renders the status screen
func (s *StatusScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
// Use consistent font size for entire screen
const fontSize = 16
// Colors
textColor := color.RGBA{255, 255, 255, 255}
dimColor := color.RGBA{150, 150, 150, 255}
// Styles
normalStyle := layout.TextStyle{Size: fontSize, Color: textColor}
dimStyle := layout.TextStyle{Size: fontSize, Color: dimColor}
// Get short hostname
shortHostname := info.Hostname
if idx := strings.Index(shortHostname, "."); idx > 0 {
shortHostname = shortHostname[:idx]
}
// Starting Y position (after header)
y := 150
// Title
titleText := fmt.Sprintf("%s: system status", shortHostname)
_ = canvas.DrawText(titleText, layout.Point{X: 16, Y: y}, normalStyle)
y += 40
// CPU section
cpuLabel := fmt.Sprintf("CPU: %.1f%% average across %d cores",
getAverageCPU(info.CPUPercent), len(info.CPUPercent))
_ = canvas.DrawText(cpuLabel, layout.Point{X: 16, Y: y}, normalStyle)
y += 25
// CPU progress bar
_ = canvas.DrawText("0%", layout.Point{X: 100, Y: y}, dimStyle)
drawProgressBar(canvas, 130, y-10, getAverageCPU(info.CPUPercent)/100.0, textColor)
_ = canvas.DrawText("100%", layout.Point{X: 985, Y: y}, dimStyle)
y += 40
// Memory section
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal) * 100.0
memLabel := fmt.Sprintf("MEMORY: %s of %s (%.1f%%)",
layout.FormatBytes(info.MemoryUsed),
layout.FormatBytes(info.MemoryTotal),
memUsedPercent)
_ = canvas.DrawText(memLabel, layout.Point{X: 16, Y: y}, normalStyle)
y += 25
// Memory progress bar
_ = canvas.DrawText("0B", layout.Point{X: 100, Y: y}, dimStyle)
drawProgressBar(canvas, 130, y-10, float64(info.MemoryUsed)/float64(info.MemoryTotal), textColor)
_ = canvas.DrawText(layout.FormatBytes(info.MemoryTotal), layout.Point{X: 985, Y: y}, dimStyle)
y += 40
// Temperature section
if len(info.Temperature) > 0 {
maxTemp, maxSensor := getMaxTemperature(info.Temperature)
tempLabel := fmt.Sprintf("TEMPERATURE: %.1f°C (%s)", maxTemp, maxSensor)
_ = canvas.DrawText(tempLabel, layout.Point{X: 16, Y: y}, normalStyle)
y += 25
// Temperature progress bar (30-99°C scale)
_ = canvas.DrawText("30°C", layout.Point{X: 90, Y: y}, dimStyle)
tempValue := (maxTemp - 30.0) / (99.0 - 30.0)
if tempValue < 0 {
tempValue = 0
}
if tempValue > 1 {
tempValue = 1
}
drawProgressBar(canvas, 130, y-10, tempValue, textColor)
_ = canvas.DrawText("99°C", layout.Point{X: 985, Y: y}, dimStyle)
y += 40
}
// Disk usage section
_ = canvas.DrawText("DISK USAGE:", layout.Point{X: 16, Y: y}, normalStyle)
y += 25
for _, disk := range info.DiskUsage {
// Skip snap disks
if strings.HasPrefix(disk.Path, "/snap") {
continue
}
diskLabel := fmt.Sprintf(" * %-12s %s of %s (%.1f%%)",
disk.Path,
layout.FormatBytes(disk.Used),
layout.FormatBytes(disk.Total),
disk.UsedPercent)
_ = canvas.DrawText(diskLabel, layout.Point{X: 16, Y: y}, normalStyle)
// Disk progress bar
_ = canvas.DrawText("0B", layout.Point{X: 470, Y: y}, dimStyle)
drawDiskProgressBar(canvas, 500, y-10, disk.UsedPercent/100.0, textColor)
_ = canvas.DrawText(layout.FormatBytes(disk.Total), layout.Point{X: 985, Y: y}, dimStyle)
y += 30
if y > 700 {
break // Don't overflow
}
}
// Network section
if len(info.Network) > 0 {
y += 15
_ = canvas.DrawText("NETWORK:", layout.Point{X: 16, Y: y}, normalStyle)
y += 25
for _, net := range info.Network {
// Interface header
interfaceText := fmt.Sprintf(" * %s", net.Name)
if len(net.IPAddresses) > 0 {
interfaceText = fmt.Sprintf(" * %s (%s):", net.Name, net.IPAddresses[0])
}
_ = canvas.DrawText(interfaceText, layout.Point{X: 16, Y: y}, normalStyle)
y += 25
// Get link speed for scaling (default to 1 Gbps if unknown)
linkSpeed := net.LinkSpeed
linkSpeedText := ""
if linkSpeed == 0 {
linkSpeed = 1000 * 1000 * 1000 // 1 Gbps in bits
linkSpeedText = "1G link"
} else {
linkSpeedText = fmt.Sprintf("%s link", humanize.SI(float64(linkSpeed), "bit/s"))
}
// Upload rate
upLabel := fmt.Sprintf(" ↑ %7s (%s)", net.FormatSentRate(), linkSpeedText)
_ = canvas.DrawText(upLabel, layout.Point{X: 16, Y: y}, normalStyle)
_ = canvas.DrawText("0 bit/s", layout.Point{X: 400, Y: y}, dimStyle)
drawNetworkProgressBar(canvas, 500, y-10, float64(net.BitsSentRate)/float64(linkSpeed), textColor)
_ = canvas.DrawText(humanize.SI(float64(linkSpeed), "bit/s"), layout.Point{X: 960, Y: y}, dimStyle)
y += 25
// Download rate
downLabel := fmt.Sprintf(" ↓ %7s", net.FormatRecvRate())
_ = canvas.DrawText(downLabel, layout.Point{X: 16, Y: y}, normalStyle)
_ = canvas.DrawText("0 bit/s", layout.Point{X: 400, Y: y}, dimStyle)
drawNetworkProgressBar(canvas, 500, y-10, float64(net.BitsRecvRate)/float64(linkSpeed), textColor)
_ = canvas.DrawText(humanize.SI(float64(linkSpeed), "bit/s"), layout.Point{X: 960, Y: y}, dimStyle)
y += 35
if y > 900 {
break
}
}
}
return nil
}
// drawProgressBar draws a progress bar matching the mockup style
func drawProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
const barWidth = 850
// Draw opening bracket
_ = canvas.DrawText("[", layout.Point{X: x, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
// Calculate fill
fillChars := int(value * 80)
emptyChars := 80 - fillChars
// Draw bar content
barContent := strings.Repeat("█", fillChars) + strings.Repeat("▒", emptyChars)
_ = canvas.DrawText(barContent, layout.Point{X: x + 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
// Draw closing bracket
_ = canvas.DrawText("]", layout.Point{X: x + barWidth - 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
}
// drawDiskProgressBar draws a smaller progress bar for disk usage
func drawDiskProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
const barWidth = 480
// Draw opening bracket
_ = canvas.DrawText("[", layout.Point{X: x, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
// Calculate fill (50 chars total)
fillChars := int(value * 50)
emptyChars := 50 - fillChars
// Draw bar content
barContent := strings.Repeat("█", fillChars) + strings.Repeat("▒", emptyChars)
_ = canvas.DrawText(barContent, layout.Point{X: x + 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
// Draw closing bracket
_ = canvas.DrawText("]", layout.Point{X: x + barWidth - 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
}
// drawNetworkProgressBar draws a progress bar for network rates
func drawNetworkProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
// Same as disk progress bar
drawDiskProgressBar(canvas, x, y, value, color)
}
// getAverageCPU calculates average CPU usage across all cores
func getAverageCPU(cpuPercents []float64) float64 {
if len(cpuPercents) == 0 {
return 0
}
total := 0.0
for _, cpu := range cpuPercents {
total += cpu
}
return total / float64(len(cpuPercents))
}
// getMaxTemperature finds the highest temperature and its sensor name
func getMaxTemperature(temps map[string]float64) (float64, string) {
maxTemp := 0.0
maxSensor := ""
for sensor, temp := range temps {
if temp > maxTemp {
maxTemp = temp
maxSensor = sensor
}
}
return maxTemp, maxSensor
}

View File

@@ -1,11 +1,17 @@
// Package statcollector provides system information collection
package statcollector
import (
"log/slog"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/hdmistat/internal/netmon"
"github.com/dustin/go-humanize"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
@@ -14,6 +20,17 @@ import (
"github.com/shirou/gopsutil/v3/process"
)
const (
// Process collection constants
maxProcesses = 100
processTimeout = 50 * time.Millisecond
processStableTime = 100 * time.Millisecond
msToSecondsDivisor = 1000
// Network constants
bitsPerMegabit = 1000 * 1000
)
// SystemInfo represents overall system information
type SystemInfo struct {
Hostname string
@@ -40,13 +57,23 @@ type DiskInfo struct {
// NetworkInfo represents network interface information
type NetworkInfo struct {
Name string
IPAddresses []string
LinkSpeed uint64
BytesSent uint64
BytesRecv uint64
PacketsSent uint64
PacketsRecv uint64
Name string
IPAddresses []string
LinkSpeed uint64
BytesSent uint64
BytesRecv uint64
BitsSentRate uint64 // bits per second
BitsRecvRate uint64 // bits per second
}
// FormatSentRate returns the send rate as a human-readable string
func (n *NetworkInfo) FormatSentRate() string {
return humanize.SI(float64(n.BitsSentRate), "bit/s")
}
// FormatRecvRate returns the receive rate as a human-readable string
func (n *NetworkInfo) FormatRecvRate() string {
return humanize.SI(float64(n.BitsRecvRate), "bit/s")
}
// ProcessInfo represents process information
@@ -67,15 +94,25 @@ type Collector interface {
// SystemCollector implements Collector
type SystemCollector struct {
logger *slog.Logger
lastNetStats map[string]psnet.IOCountersStat
netMonitor *netmon.Monitor
lastCollectTime time.Time
}
// NewSystemCollector creates a new system collector
func NewSystemCollector(logger *slog.Logger) *SystemCollector {
nm := netmon.New(logger)
nm.Start()
return &SystemCollector{
logger: logger,
lastNetStats: make(map[string]psnet.IOCountersStat),
logger: logger,
netMonitor: nm,
}
}
// Stop stops the system collector
func (c *SystemCollector) Stop() {
if c.netMonitor != nil {
c.netMonitor.Stop()
}
}
@@ -100,7 +137,13 @@ func (c *SystemCollector) Collect() (*SystemInfo, error) {
if err != nil {
c.logger.Warn("getting uptime", "error", err)
} else {
info.Uptime = time.Duration(uptimeSecs) * time.Second
if uptimeSecs > 0 {
// Convert uint64 to int64 safely to avoid overflow
maxInt64 := ^uint64(0) >> 1
if uptimeSecs <= maxInt64 {
info.Uptime = time.Duration(int64(uptimeSecs)) * time.Second
}
}
}
// Memory
@@ -160,37 +203,52 @@ func (c *SystemCollector) Collect() (*SystemInfo, error) {
}
}
// Network
// Network - get rates from network monitor
netStats := c.netMonitor.GetStats()
// Also get interface details for IP addresses
interfaces, err := psnet.Interfaces()
if err != nil {
c.logger.Warn("getting network interfaces", "error", err)
} else {
ioCounters, _ := psnet.IOCounters(true)
ioMap := make(map[string]psnet.IOCountersStat)
for _, counter := range ioCounters {
ioMap[counter.Name] = counter
}
// Create a map of interface names to IPs and link speeds
ifaceIPs := make(map[string][]string)
ifaceSpeeds := make(map[string]uint64)
for _, iface := range interfaces {
if iface.Name == "lo" || strings.HasPrefix(iface.Name, "docker") {
continue
}
netInfo := NetworkInfo{
Name: iface.Name,
}
// Get IP addresses
var ips []string
for _, addr := range iface.Addrs {
netInfo.IPAddresses = append(netInfo.IPAddresses, addr.Addr)
ips = append(ips, addr.Addr)
}
ifaceIPs[iface.Name] = ips
// Try to get link speed with ethtool
if speed := c.getLinkSpeed(iface.Name); speed > 0 {
ifaceSpeeds[iface.Name] = speed
}
}
// Combine network monitor stats with interface details
for _, stat := range netStats {
netInfo := NetworkInfo{
Name: stat.Name,
BytesSent: stat.BytesSent,
BytesRecv: stat.BytesRecv,
BitsSentRate: stat.BitsSentRate,
BitsRecvRate: stat.BitsRecvRate,
}
// Get stats
if stats, ok := ioMap[iface.Name]; ok {
netInfo.BytesSent = stats.BytesSent
netInfo.BytesRecv = stats.BytesRecv
netInfo.PacketsSent = stats.PacketsSent
netInfo.PacketsRecv = stats.PacketsRecv
// Add IP addresses if available
if ips, ok := ifaceIPs[stat.Name]; ok {
netInfo.IPAddresses = ips
}
// Add link speed if available
if speed, ok := ifaceSpeeds[stat.Name]; ok {
netInfo.LinkSpeed = speed
}
info.Network = append(info.Network, netInfo)
@@ -202,9 +260,43 @@ func (c *SystemCollector) Collect() (*SystemInfo, error) {
if err != nil {
c.logger.Warn("getting processes", "error", err)
} else {
// Limit to top processes to avoid hanging
processCount := 0
for _, p := range processes {
name, _ := p.Name()
cpuPercent, _ := p.CPUPercent()
if processCount >= maxProcesses {
break
}
// Skip kernel threads and very short-lived processes
name, err := p.Name()
if err != nil || name == "" {
continue
}
// Use CreateTime to skip very new processes that might not have stable stats
createTime, err := p.CreateTime()
if err != nil || time.Since(time.Unix(createTime/msToSecondsDivisor, 0)) < processStableTime {
continue
}
// Get CPU percent with timeout - this is the call that can hang
cpuPercent := 0.0
cpuChan := make(chan float64, 1)
go func() {
cpu, _ := p.CPUPercent()
cpuChan <- cpu
}()
select {
case cpu := <-cpuChan:
cpuPercent = cpu
case <-time.After(processTimeout):
// Skip this process if CPU sampling takes too long
c.logger.Debug("skipping process due to CPU timeout", "pid", p.Pid, "name", name)
continue
}
memInfo, _ := p.MemoryInfo()
username, _ := p.Username()
@@ -216,9 +308,36 @@ func (c *SystemCollector) Collect() (*SystemInfo, error) {
MemoryVMS: memInfo.VMS,
Username: username,
})
processCount++
}
}
c.lastCollectTime = time.Now()
return info, nil
}
// getLinkSpeed gets the link speed for an interface using ethtool
func (c *SystemCollector) getLinkSpeed(ifaceName string) uint64 {
// Run ethtool to get link speed
output, err := exec.Command("ethtool", ifaceName).Output()
if err != nil {
return 0
}
// Parse the output for speed
// Look for lines like "Speed: 1000Mb/s" or "Speed: 10000Mb/s"
speedRegex := regexp.MustCompile(`Speed:\s+(\d+)Mb/s`)
matches := speedRegex.FindSubmatch(output)
if len(matches) < 2 {
return 0
}
// Convert from Mb/s to bits/s
mbps, err := strconv.ParseUint(string(matches[1]), 10, 64)
if err != nil {
return 0
}
return mbps * bitsPerMegabit // Convert to bits per second
}