checkpointing, heavy dev
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
184
internal/fbdraw/EXAMPLE.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# fbdraw Carousel Example
|
||||
|
||||
This example demonstrates how to use the fbdraw carousel API to create a rotating display with multiple screens, each updating at its own frame rate.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
|
||||
"git.eeqj.de/sneak/hdmistat/internal/font"
|
||||
)
|
||||
|
||||
// SystemStatusGenerator generates frames showing system status
|
||||
type SystemStatusGenerator struct {
|
||||
frameCount int
|
||||
}
|
||||
|
||||
func (g *SystemStatusGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
|
||||
g.frameCount++
|
||||
w := fbdraw.NewGridWriter(grid)
|
||||
|
||||
// Clear and draw header
|
||||
w.Clear()
|
||||
w.SetColor(fbdraw.Cyan).SetWeight(font.WeightBold)
|
||||
w.MoveAbs(0, 0).WriteLine("=== SYSTEM STATUS ===")
|
||||
|
||||
// Animate with frame count
|
||||
w.SetColor(fbdraw.White).SetWeight(font.WeightRegular)
|
||||
w.MoveAbs(0, 2).WriteLine("Frame: %d", g.frameCount)
|
||||
w.MoveAbs(0, 3).WriteLine("Time: %s", time.Now().Format("15:04:05.000"))
|
||||
|
||||
// Animated CPU meter
|
||||
cpuUsage := 50 + 30*math.Sin(float64(g.frameCount)*0.1)
|
||||
w.MoveAbs(0, 5).Write("CPU: [")
|
||||
w.DrawMeter(cpuUsage, 20)
|
||||
w.Write("] %.1f%%", cpuUsage)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *SystemStatusGenerator) FramesPerSecond() float64 {
|
||||
return 15.0 // 15 FPS
|
||||
}
|
||||
|
||||
// NetworkMonitorGenerator shows network activity
|
||||
type NetworkMonitorGenerator struct {
|
||||
packets []float64
|
||||
}
|
||||
|
||||
func (g *NetworkMonitorGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
|
||||
w := fbdraw.NewGridWriter(grid)
|
||||
|
||||
// Update data
|
||||
if len(g.packets) > 50 {
|
||||
g.packets = g.packets[1:]
|
||||
}
|
||||
g.packets = append(g.packets, rand.Float64()*100)
|
||||
|
||||
// Draw
|
||||
w.Clear()
|
||||
w.SetColor(fbdraw.Green).SetWeight(font.WeightBold)
|
||||
w.MoveAbs(0, 0).WriteLine("=== NETWORK MONITOR ===")
|
||||
|
||||
// Draw graph
|
||||
w.SetColor(fbdraw.White).SetWeight(font.WeightRegular)
|
||||
for i, val := range g.packets {
|
||||
height := int(val / 10) // Scale to 0-10
|
||||
for y := 10; y > 10-height; y-- {
|
||||
w.MoveAbs(i+5, y).Write("█")
|
||||
}
|
||||
}
|
||||
|
||||
w.MoveAbs(0, 12).WriteLine("Packets/sec: %.0f", g.packets[len(g.packets)-1])
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *NetworkMonitorGenerator) FramesPerSecond() float64 {
|
||||
return 10.0 // 10 FPS
|
||||
}
|
||||
|
||||
// ProcessListGenerator shows top processes
|
||||
type ProcessListGenerator struct {
|
||||
updateCount int
|
||||
}
|
||||
|
||||
func (g *ProcessListGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
|
||||
g.updateCount++
|
||||
w := fbdraw.NewGridWriter(grid)
|
||||
|
||||
w.Clear()
|
||||
w.SetColor(fbdraw.Yellow).SetWeight(font.WeightBold)
|
||||
w.MoveAbs(0, 0).WriteLine("=== TOP PROCESSES ===")
|
||||
|
||||
// Table header
|
||||
w.MoveAbs(0, 2).SetColor(fbdraw.White).SetWeight(font.WeightBold)
|
||||
w.WriteLine("PID CPU% PROCESS")
|
||||
w.WriteLine("----- ----- ----------------")
|
||||
|
||||
// Fake process data
|
||||
w.SetWeight(font.WeightRegular)
|
||||
processes := []struct {
|
||||
pid int
|
||||
cpu float64
|
||||
name string
|
||||
}{
|
||||
{1234, 42.1 + float64(g.updateCount%10), "firefox"},
|
||||
{5678, 18.7, "vscode"},
|
||||
{9012, 8.3, "dockerd"},
|
||||
}
|
||||
|
||||
for i, p := range processes {
|
||||
if p.cpu > 30 {
|
||||
w.SetColor(fbdraw.Red)
|
||||
} else if p.cpu > 15 {
|
||||
w.SetColor(fbdraw.Yellow)
|
||||
} else {
|
||||
w.SetColor(fbdraw.White)
|
||||
}
|
||||
|
||||
w.MoveAbs(0, 4+i)
|
||||
w.WriteLine("%-5d %5.1f %s", p.pid, p.cpu, p.name)
|
||||
}
|
||||
|
||||
w.MoveAbs(0, 10).SetColor(fbdraw.Gray60)
|
||||
w.WriteLine("Update #%d", g.updateCount)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *ProcessListGenerator) FramesPerSecond() float64 {
|
||||
return 1.0 // 1 FPS - processes don't change that fast
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Initialize display (auto-detect framebuffer)
|
||||
display, err := fbdraw.NewFBDisplayAuto()
|
||||
if err != nil {
|
||||
// Fall back to terminal display
|
||||
display = fbdraw.NewTerminalDisplay(80, 25)
|
||||
}
|
||||
defer display.Close()
|
||||
|
||||
// Create carousel with 10 second rotation
|
||||
carousel := fbdraw.NewCarousel(display, 10*time.Second)
|
||||
|
||||
// Add screens with their generators
|
||||
carousel.AddScreen("System Status", &SystemStatusGenerator{})
|
||||
carousel.AddScreen("Network Monitor", &NetworkMonitorGenerator{})
|
||||
carousel.AddScreen("Process List", &ProcessListGenerator{})
|
||||
|
||||
// Start the carousel (blocks until interrupted)
|
||||
if err := carousel.Run(); err != nil {
|
||||
fmt.Printf("Carousel error: %v\n", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
1. **Multiple Display Types**: The example tries to auto-detect a framebuffer, falling back to terminal display if not available.
|
||||
|
||||
2. **Different Frame Rates**: Each screen updates at its own rate:
|
||||
- System Status: 15 FPS (smooth animations)
|
||||
- Network Monitor: 10 FPS (moderate updates)
|
||||
- Process List: 1 FPS (slow changing data)
|
||||
|
||||
3. **GridWriter API**: Shows various drawing operations:
|
||||
- `MoveAbs()` for absolute positioning
|
||||
- `Move()` for relative movement
|
||||
- `DrawMeter()` for progress bars with automatic coloring
|
||||
- `SetColor()`, `SetWeight()` for styling
|
||||
|
||||
4. **Carousel Management**: The carousel automatically:
|
||||
- Rotates screens every 10 seconds
|
||||
- Manages frame timing for each screen
|
||||
- Only renders the active screen
|
||||
|
||||
5. **Animation**: The system status screen demonstrates smooth animation using frame counting and sine waves.
|
||||
335
internal/fbdraw/README.md
Normal file
335
internal/fbdraw/README.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# fbdraw - Simple Framebuffer Drawing for Go
|
||||
|
||||
A high-level Go package for creating text-based displays on Linux framebuffers. Designed for system monitors, status displays, and embedded systems.
|
||||
|
||||
## API Design
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/example/fbdraw"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Auto-detect and initialize
|
||||
fb := fbdraw.Init()
|
||||
defer fb.Close()
|
||||
|
||||
// Main render loop
|
||||
fb.Loop(func(d *fbdraw.Draw) {
|
||||
// Clear screen
|
||||
d.Clear()
|
||||
|
||||
// Draw header - state is maintained between calls
|
||||
d.Font(fbdraw.PlexSans).Size(36).Bold()
|
||||
d.Color(fbdraw.White)
|
||||
d.TextCenter(d.Width/2, 50, "System Monitor")
|
||||
|
||||
// Switch to normal text
|
||||
d.Font(fbdraw.PlexMono).Size(14).Plain()
|
||||
|
||||
// Create a text grid for the main content area
|
||||
grid := d.Grid(20, 100, 80, 30) // x, y, cols, rows
|
||||
|
||||
// Write to the grid using row,col coordinates
|
||||
grid.Color(fbdraw.Green)
|
||||
grid.Write(0, 0, "Hostname:")
|
||||
grid.Write(0, 15, getHostname())
|
||||
|
||||
grid.Write(2, 0, "Uptime:")
|
||||
grid.Write(2, 15, getUptime())
|
||||
|
||||
grid.Write(4, 0, "Load Average:")
|
||||
if load := getLoad(); load > 2.0 {
|
||||
grid.Color(fbdraw.Red)
|
||||
}
|
||||
grid.Write(4, 15, "%.2f %.2f %.2f", getLoad())
|
||||
|
||||
// CPU section with meter characters
|
||||
grid.Color(fbdraw.Blue).Bold()
|
||||
grid.Write(8, 0, "CPU Usage:")
|
||||
grid.Plain()
|
||||
|
||||
cpus := getCPUPercents()
|
||||
for i, pct := range cpus {
|
||||
grid.Write(10+i, 0, "CPU%d [%s] %5.1f%%", i,
|
||||
fbdraw.Meter(pct, 20), pct)
|
||||
}
|
||||
|
||||
// Memory section
|
||||
grid.Color(fbdraw.Yellow).Bold()
|
||||
grid.Write(20, 0, "Memory:")
|
||||
grid.Plain().Color(fbdraw.White)
|
||||
|
||||
mem := getMemoryInfo()
|
||||
grid.Write(22, 0, "Used: %s / %s",
|
||||
fbdraw.Bytes(mem.Used), fbdraw.Bytes(mem.Total))
|
||||
grid.Write(23, 0, "Free: %s", fbdraw.Bytes(mem.Free))
|
||||
grid.Write(24, 0, "Cache: %s", fbdraw.Bytes(mem.Cache))
|
||||
|
||||
// Present the frame
|
||||
d.Present()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard Example with Multiple Grids
|
||||
|
||||
```go
|
||||
func renderDashboard(d *fbdraw.Draw) {
|
||||
d.Clear(fbdraw.Gray10) // Dark background
|
||||
|
||||
// Header
|
||||
d.Font(fbdraw.PlexSans).Size(48).Bold().Color(fbdraw.Cyan)
|
||||
d.TextCenter(d.Width/2, 60, "SERVER STATUS")
|
||||
|
||||
// Reset to default for grids
|
||||
d.Font(fbdraw.PlexMono).Size(16).Plain()
|
||||
|
||||
// Left panel - System Info
|
||||
leftGrid := d.Grid(20, 120, 40, 25)
|
||||
leftGrid.Background(fbdraw.Gray20)
|
||||
leftGrid.Border(fbdraw.Gray40)
|
||||
|
||||
leftGrid.Color(fbdraw.Green).Bold()
|
||||
leftGrid.WriteCenter(0, "SYSTEM")
|
||||
leftGrid.Plain().Color(fbdraw.White)
|
||||
|
||||
info := getSystemInfo()
|
||||
leftGrid.Write(2, 1, "OS: %s", info.OS)
|
||||
leftGrid.Write(3, 1, "Kernel: %s", info.Kernel)
|
||||
leftGrid.Write(4, 1, "Arch: %s", info.Arch)
|
||||
leftGrid.Write(6, 1, "Hostname: %s", info.Hostname)
|
||||
leftGrid.Write(7, 1, "Uptime: %s", info.Uptime)
|
||||
|
||||
// Right panel - Performance
|
||||
rightGrid := d.Grid(d.Width/2+20, 120, 40, 25)
|
||||
rightGrid.Background(fbdraw.Gray20)
|
||||
rightGrid.Border(fbdraw.Gray40)
|
||||
|
||||
rightGrid.Color(fbdraw.Orange).Bold()
|
||||
rightGrid.WriteCenter(0, "PERFORMANCE")
|
||||
rightGrid.Plain()
|
||||
|
||||
// CPU bars
|
||||
rightGrid.Color(fbdraw.Blue)
|
||||
cpus := getCPUCores()
|
||||
for i, cpu := range cpus {
|
||||
rightGrid.Write(2+i, 1, "CPU%d", i)
|
||||
rightGrid.Bar(2+i, 6, 30, cpu.Percent, fbdraw.Heat(cpu.Percent))
|
||||
}
|
||||
|
||||
// Memory meter
|
||||
rightGrid.Color(fbdraw.Purple)
|
||||
mem := getMemory()
|
||||
rightGrid.Write(12, 1, "Memory")
|
||||
rightGrid.Bar(12, 8, 28, mem.Percent, fbdraw.Green)
|
||||
rightGrid.Write(13, 8, "%s / %s",
|
||||
fbdraw.Bytes(mem.Used), fbdraw.Bytes(mem.Total))
|
||||
|
||||
// Bottom status bar
|
||||
statusGrid := d.Grid(0, d.Height-40, d.Width/12, 1)
|
||||
statusGrid.Background(fbdraw.Black)
|
||||
statusGrid.Color(fbdraw.Gray60).Size(12)
|
||||
statusGrid.Write(0, 1, "Updated: %s | Load: %.2f | Temp: %d°C | Net: %s",
|
||||
time.Now().Format("15:04:05"),
|
||||
getLoad1Min(),
|
||||
getCPUTemp(),
|
||||
getNetRate())
|
||||
}
|
||||
```
|
||||
|
||||
### Process Table Example
|
||||
|
||||
```go
|
||||
func renderProcessTable(d *fbdraw.Draw) {
|
||||
d.Clear()
|
||||
|
||||
// Header
|
||||
d.Font(fbdraw.PlexSans).Size(24).Bold()
|
||||
d.Text(20, 30, "Top Processes by CPU")
|
||||
|
||||
// Create table grid
|
||||
table := d.Grid(20, 70, 100, 40)
|
||||
table.Font(fbdraw.PlexMono).Size(14)
|
||||
|
||||
// Table headers
|
||||
table.Background(fbdraw.Gray30).Color(fbdraw.White).Bold()
|
||||
table.Write(0, 0, "PID")
|
||||
table.Write(0, 8, "USER")
|
||||
table.Write(0, 20, "PROCESS")
|
||||
table.Write(0, 60, "CPU%")
|
||||
table.Write(0, 70, "MEM%")
|
||||
table.Write(0, 80, "TIME")
|
||||
|
||||
// Reset style for data
|
||||
table.Background(fbdraw.Black).Plain()
|
||||
|
||||
// Process rows
|
||||
processes := getTopProcesses(38) // 38 rows of data
|
||||
for i, p := range processes {
|
||||
row := i + 2 // Skip header row and blank row
|
||||
|
||||
// Alternate row backgrounds
|
||||
if i%2 == 0 {
|
||||
table.RowBackground(row, fbdraw.Gray10)
|
||||
}
|
||||
|
||||
// Highlight high CPU usage
|
||||
if p.CPU > 50.0 {
|
||||
table.RowColor(row, fbdraw.Red)
|
||||
} else if p.CPU > 20.0 {
|
||||
table.RowColor(row, fbdraw.Yellow)
|
||||
} else {
|
||||
table.RowColor(row, fbdraw.Gray80)
|
||||
}
|
||||
|
||||
table.Write(row, 0, "%5d", p.PID)
|
||||
table.Write(row, 8, "%-8s", p.User)
|
||||
table.Write(row, 20, "%-38s", truncate(p.Name, 38))
|
||||
table.Write(row, 60, "%5.1f", p.CPU)
|
||||
table.Write(row, 70, "%5.1f", p.Memory)
|
||||
table.Write(row, 80, "%8s", p.Time)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### Bundled Fonts
|
||||
```go
|
||||
const (
|
||||
PlexMono = iota // IBM Plex Mono - great for tables/code
|
||||
PlexSans // IBM Plex Sans - clean headers
|
||||
Terminus // Terminus - sharp bitmap font
|
||||
)
|
||||
```
|
||||
|
||||
### Drawing State
|
||||
The Draw object maintains state between calls:
|
||||
- Current font, size, bold/italic
|
||||
- Foreground and background colors
|
||||
- Transformations
|
||||
|
||||
### Text Grids
|
||||
- Define rectangular regions with rows/columns
|
||||
- Automatic text positioning
|
||||
- Built-in backgrounds, borders, styling
|
||||
- Row/column operations
|
||||
|
||||
### Utilities
|
||||
```go
|
||||
// Format bytes nicely (1.2GB, 456MB, etc)
|
||||
fbdraw.Bytes(1234567890) // "1.2GB"
|
||||
|
||||
// Create text-based meter/progress bars
|
||||
fbdraw.Meter(75.5, 20) // "███████████████ "
|
||||
|
||||
// Color based on value
|
||||
fbdraw.Heat(temp) // Returns color from blue->green->yellow->red
|
||||
|
||||
// Truncate with ellipsis
|
||||
truncate("very long string", 10) // "very lo..."
|
||||
```
|
||||
|
||||
### Colors
|
||||
```go
|
||||
// Basic colors
|
||||
fbdraw.Black, White, Red, Green, Blue, Yellow, Cyan, Purple, Orange
|
||||
|
||||
// Grays
|
||||
fbdraw.Gray10, Gray20, Gray30, Gray40, Gray50, Gray60, Gray70, Gray80, Gray90
|
||||
|
||||
// Custom
|
||||
fbdraw.RGB(128, 200, 255)
|
||||
fbdraw.HSL(180, 0.5, 0.5)
|
||||
```
|
||||
|
||||
## Carousel API
|
||||
|
||||
The carousel system provides automatic screen rotation with independent frame rates for each screen.
|
||||
|
||||
### Core Interfaces
|
||||
|
||||
```go
|
||||
// FrameGenerator generates frames for a screen
|
||||
type FrameGenerator interface {
|
||||
// GenerateFrame is called to render a new frame
|
||||
GenerateFrame(grid *Grid) error
|
||||
|
||||
// FramesPerSecond returns the desired frame rate
|
||||
FramesPerSecond() float64
|
||||
}
|
||||
|
||||
// Display represents the output device (framebuffer, terminal, etc)
|
||||
type Display interface {
|
||||
// Write renders a grid to the display
|
||||
Write(grid *Grid) error
|
||||
|
||||
// Size returns the display dimensions in characters
|
||||
Size() (width, height int)
|
||||
|
||||
// Close cleans up resources
|
||||
Close() error
|
||||
}
|
||||
```
|
||||
|
||||
### Carousel Usage
|
||||
|
||||
```go
|
||||
// Create display
|
||||
display, err := fbdraw.NewDisplay("") // auto-detect
|
||||
|
||||
// Create carousel with rotation interval
|
||||
carousel := fbdraw.NewCarousel(display, 10*time.Second)
|
||||
|
||||
// Add screens
|
||||
carousel.AddScreen("System Status", &SystemStatusGenerator{})
|
||||
carousel.AddScreen("Network", &NetworkMonitorGenerator{})
|
||||
carousel.AddScreen("Processes", &ProcessListGenerator{})
|
||||
|
||||
// Run carousel (blocks)
|
||||
carousel.Run()
|
||||
```
|
||||
|
||||
### Screen Implementation
|
||||
|
||||
```go
|
||||
type MyScreenGenerator struct {
|
||||
// Internal state
|
||||
}
|
||||
|
||||
func (g *MyScreenGenerator) GenerateFrame(grid *Grid) error {
|
||||
w := NewGridWriter(grid)
|
||||
w.Clear()
|
||||
|
||||
// Draw your content
|
||||
w.MoveTo(0, 0).Write("Hello World")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *MyScreenGenerator) FramesPerSecond() float64 {
|
||||
return 10.0 // 10 FPS
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Independent frame rates**: Each screen can update at its own rate
|
||||
- **Automatic rotation**: Screens rotate on a timer
|
||||
- **Clean separation**: Generators just draw, carousel handles timing
|
||||
- **Resource efficient**: Only the active screen generates frames
|
||||
- **Graceful shutdown**: Handles signals properly
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
- **Immediate mode**: Draw calls happen immediately in your render function
|
||||
- **Stateful**: Common properties persist until changed
|
||||
- **Grid-based**: Most text UIs are grids - embrace it
|
||||
- **Batteries included**: Common fonts, colors, and utilities built-in
|
||||
- **Zero allocation**: Reuse buffers where possible for smooth updates
|
||||
- **Simple interfaces**: Easy to implement custom screens
|
||||
162
internal/fbdraw/carousel.go
Normal file
162
internal/fbdraw/carousel.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package fbdraw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Screen represents a single screen in the carousel
|
||||
type Screen struct {
|
||||
Name string
|
||||
Generator FrameGenerator
|
||||
ticker *time.Ticker
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// Carousel manages rotating between multiple screens
|
||||
type Carousel struct {
|
||||
display FramebufferDisplay
|
||||
screens []*Screen
|
||||
currentIndex int
|
||||
rotationInterval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewCarousel creates a new carousel
|
||||
func NewCarousel(display FramebufferDisplay, rotationInterval time.Duration) *Carousel {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Carousel{
|
||||
display: display,
|
||||
screens: make([]*Screen, 0),
|
||||
currentIndex: 0,
|
||||
rotationInterval: rotationInterval,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// AddScreen adds a new screen to the carousel
|
||||
func (c *Carousel) AddScreen(name string, generator FrameGenerator) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
screen := &Screen{
|
||||
Name: name,
|
||||
Generator: generator,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
|
||||
c.screens = append(c.screens, screen)
|
||||
}
|
||||
|
||||
// Run starts the carousel
|
||||
func (c *Carousel) Run() error {
|
||||
if len(c.screens) == 0 {
|
||||
return fmt.Errorf("no screens added to carousel")
|
||||
}
|
||||
|
||||
// Start rotation timer
|
||||
rotationTicker := time.NewTicker(c.rotationInterval)
|
||||
defer rotationTicker.Stop()
|
||||
|
||||
// Start with first screen
|
||||
if err := c.activateScreen(0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Main loop
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return c.ctx.Err()
|
||||
|
||||
case <-rotationTicker.C:
|
||||
// Move to next screen
|
||||
c.mu.Lock()
|
||||
nextIndex := (c.currentIndex + 1) % len(c.screens)
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.activateScreen(nextIndex); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the carousel
|
||||
func (c *Carousel) Stop() {
|
||||
c.cancel()
|
||||
c.wg.Wait()
|
||||
}
|
||||
|
||||
// activateScreen switches to the specified screen
|
||||
func (c *Carousel) activateScreen(index int) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Stop current screen if any
|
||||
if c.currentIndex >= 0 && c.currentIndex < len(c.screens) {
|
||||
close(c.screens[c.currentIndex].stop)
|
||||
if c.screens[c.currentIndex].ticker != nil {
|
||||
c.screens[c.currentIndex].ticker.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for current screen to stop
|
||||
c.wg.Wait()
|
||||
|
||||
// Start new screen
|
||||
c.currentIndex = index
|
||||
screen := c.screens[index]
|
||||
|
||||
// Calculate frame interval
|
||||
fps := screen.Generator.FramesPerSecond()
|
||||
if fps <= 0 {
|
||||
fps = 1 // Default to 1 FPS if invalid
|
||||
}
|
||||
frameInterval := time.Duration(float64(time.Second) / fps)
|
||||
|
||||
// Create new stop channel and ticker
|
||||
screen.stop = make(chan struct{})
|
||||
screen.ticker = time.NewTicker(frameInterval)
|
||||
|
||||
// Start frame generation goroutine
|
||||
c.wg.Add(1)
|
||||
go c.runScreen(screen)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runScreen runs a single screen's frame generation loop
|
||||
func (c *Carousel) runScreen(screen *Screen) {
|
||||
defer c.wg.Done()
|
||||
|
||||
// Get display size
|
||||
width, height := c.display.Size()
|
||||
grid := NewCharGrid(width, height)
|
||||
|
||||
// Generate first frame immediately
|
||||
if err := screen.Generator.GenerateFrame(grid); err == nil {
|
||||
_ = c.display.Write(grid)
|
||||
}
|
||||
|
||||
// Frame generation loop
|
||||
for {
|
||||
select {
|
||||
case <-screen.stop:
|
||||
return
|
||||
|
||||
case <-screen.ticker.C:
|
||||
if err := screen.Generator.GenerateFrame(grid); err == nil {
|
||||
_ = c.display.Write(grid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
309
internal/fbdraw/display.go
Normal file
309
internal/fbdraw/display.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package fbdraw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// FBDisplay renders to a Linux framebuffer device
|
||||
type FBDisplay struct {
|
||||
device string
|
||||
file *os.File
|
||||
info fbVarScreeninfo
|
||||
fixInfo fbFixScreeninfo
|
||||
data []byte
|
||||
charWidth int
|
||||
charHeight int
|
||||
}
|
||||
|
||||
// fbFixScreeninfo from linux/fb.h
|
||||
type fbFixScreeninfo struct {
|
||||
ID [16]byte
|
||||
SMEMStart uint64
|
||||
SMEMLen uint32
|
||||
Type uint32
|
||||
TypeAux uint32
|
||||
Visual uint32
|
||||
XPanStep uint16
|
||||
YPanStep uint16
|
||||
YWrapStep uint16
|
||||
_ uint16
|
||||
LineLength uint32
|
||||
MMIOStart uint64
|
||||
MMIOLen uint32
|
||||
Accel uint32
|
||||
Capabilities uint16
|
||||
Reserved [2]uint16
|
||||
}
|
||||
|
||||
// fbVarScreeninfo from linux/fb.h
|
||||
type fbVarScreeninfo struct {
|
||||
XRes uint32
|
||||
YRes uint32
|
||||
XResVirtual uint32
|
||||
YResVirtual uint32
|
||||
XOffset uint32
|
||||
YOffset uint32
|
||||
BitsPerPixel uint32
|
||||
Grayscale uint32
|
||||
Red fbBitfield
|
||||
Green fbBitfield
|
||||
Blue fbBitfield
|
||||
Transp fbBitfield
|
||||
NonStd uint32
|
||||
Activate uint32
|
||||
Height uint32
|
||||
Width uint32
|
||||
AccelFlags uint32
|
||||
PixClock uint32
|
||||
LeftMargin uint32
|
||||
RightMargin uint32
|
||||
UpperMargin uint32
|
||||
LowerMargin uint32
|
||||
HSyncLen uint32
|
||||
VSyncLen uint32
|
||||
Sync uint32
|
||||
VMode uint32
|
||||
Rotate uint32
|
||||
Colorspace uint32
|
||||
Reserved [4]uint32
|
||||
}
|
||||
|
||||
type fbBitfield struct {
|
||||
Offset uint32
|
||||
Length uint32
|
||||
Right uint32
|
||||
}
|
||||
|
||||
const (
|
||||
fbiogetVscreeninfo = 0x4600
|
||||
fbiogetFscreeninfo = 0x4602
|
||||
)
|
||||
|
||||
// NewFBDisplay creates a display for a specific framebuffer device
|
||||
func NewFBDisplay(device string) (*FBDisplay, error) {
|
||||
file, err := os.OpenFile(device, os.O_RDWR, 0) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening framebuffer %s: %w", device, err)
|
||||
}
|
||||
|
||||
display := &FBDisplay{
|
||||
device: device,
|
||||
file: file,
|
||||
}
|
||||
|
||||
// Get variable screen info
|
||||
if err := display.getScreenInfo(); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get fixed screen info
|
||||
if err := display.getFixedInfo(); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Memory map the framebuffer
|
||||
size := int(display.fixInfo.SMEMLen)
|
||||
display.data, err = syscall.Mmap(
|
||||
int(file.Fd()),
|
||||
0,
|
||||
size,
|
||||
syscall.PROT_READ|syscall.PROT_WRITE,
|
||||
syscall.MAP_SHARED,
|
||||
)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("mmap framebuffer: %w", err)
|
||||
}
|
||||
|
||||
// Calculate character dimensions (rough approximation)
|
||||
display.charWidth = 8
|
||||
display.charHeight = 16
|
||||
|
||||
return display, nil
|
||||
}
|
||||
|
||||
// NewFBDisplayAuto auto-detects and opens the framebuffer
|
||||
func NewFBDisplayAuto() (*FBDisplay, error) {
|
||||
// Try common framebuffer devices
|
||||
devices := []string{"/dev/fb0", "/dev/fb1", "/dev/graphics/fb0"}
|
||||
|
||||
for _, device := range devices {
|
||||
if _, err := os.Stat(device); err == nil {
|
||||
display, err := NewFBDisplay(device)
|
||||
if err == nil {
|
||||
return display, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no framebuffer device found")
|
||||
}
|
||||
|
||||
func (d *FBDisplay) getScreenInfo() error {
|
||||
_, _, errno := syscall.Syscall(
|
||||
syscall.SYS_IOCTL,
|
||||
d.file.Fd(),
|
||||
fbiogetVscreeninfo,
|
||||
uintptr(unsafe.Pointer(&d.info)), //nolint:gosec
|
||||
)
|
||||
if errno != 0 {
|
||||
return fmt.Errorf("ioctl FBIOGET_VSCREENINFO: %w", errno)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *FBDisplay) getFixedInfo() error {
|
||||
_, _, errno := syscall.Syscall(
|
||||
syscall.SYS_IOCTL,
|
||||
d.file.Fd(),
|
||||
fbiogetFscreeninfo,
|
||||
uintptr(unsafe.Pointer(&d.fixInfo)), //nolint:gosec
|
||||
)
|
||||
if errno != 0 {
|
||||
return fmt.Errorf("ioctl FBIOGET_FSCREENINFO: %w", errno)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write renders a grid to the framebuffer
|
||||
func (d *FBDisplay) Write(grid *CharGrid) error {
|
||||
// Render grid to image
|
||||
img, err := grid.Render()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rendering grid: %w", err)
|
||||
}
|
||||
|
||||
// Clear framebuffer
|
||||
for i := range d.data {
|
||||
d.data[i] = 0
|
||||
}
|
||||
|
||||
// Copy image to framebuffer
|
||||
bounds := img.Bounds()
|
||||
bytesPerPixel := int(d.info.BitsPerPixel / 8)
|
||||
lineLength := int(d.fixInfo.LineLength)
|
||||
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y && y < int(d.info.YRes); y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X && x < int(d.info.XRes); x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
|
||||
offset := y*lineLength + x*bytesPerPixel
|
||||
if offset+bytesPerPixel <= len(d.data) {
|
||||
// Assuming 32-bit BGRA format (most common)
|
||||
if bytesPerPixel == 4 {
|
||||
d.data[offset+0] = byte(b >> 8)
|
||||
d.data[offset+1] = byte(g >> 8)
|
||||
d.data[offset+2] = byte(r >> 8)
|
||||
d.data[offset+3] = 0xFF
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the display size in characters
|
||||
func (d *FBDisplay) Size() (width, height int) {
|
||||
width = int(d.info.XRes) / d.charWidth
|
||||
height = int(d.info.YRes) / d.charHeight
|
||||
return
|
||||
}
|
||||
|
||||
// Close closes the framebuffer
|
||||
func (d *FBDisplay) Close() error {
|
||||
if d.data != nil {
|
||||
if err := syscall.Munmap(d.data); err != nil {
|
||||
log.Printf("munmap error: %v", err)
|
||||
}
|
||||
d.data = nil
|
||||
}
|
||||
|
||||
if d.file != nil {
|
||||
if err := d.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.file = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminalDisplay renders to the terminal using ANSI escape codes
|
||||
type TerminalDisplay struct {
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// NewTerminalDisplay creates a terminal display
|
||||
func NewTerminalDisplay(width, height int) *TerminalDisplay {
|
||||
return &TerminalDisplay{
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// Write renders a grid to the terminal
|
||||
func (d *TerminalDisplay) Write(grid *CharGrid) error {
|
||||
// Clear screen
|
||||
fmt.Print("\033[2J\033[H")
|
||||
|
||||
// Print ANSI representation
|
||||
fmt.Print(grid.ToANSI())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the terminal size in characters
|
||||
func (d *TerminalDisplay) Size() (width, height int) {
|
||||
return d.width, d.height
|
||||
}
|
||||
|
||||
// Close is a no-op for terminal display
|
||||
func (d *TerminalDisplay) Close() error {
|
||||
// Clear screen one last time
|
||||
fmt.Print("\033[2J\033[H")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogDisplay renders to a logger for debugging
|
||||
type LogDisplay struct {
|
||||
width int
|
||||
height int
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewLogDisplay creates a log display
|
||||
func NewLogDisplay(width, height int, logger *log.Logger) *LogDisplay {
|
||||
if logger == nil {
|
||||
logger = log.New(os.Stderr, "[fbdraw] ", log.LstdFlags)
|
||||
}
|
||||
|
||||
return &LogDisplay{
|
||||
width: width,
|
||||
height: height,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Write logs the grid as text
|
||||
func (d *LogDisplay) Write(grid *CharGrid) error {
|
||||
d.logger.Printf("=== Frame ===\n%s\n", grid.ToText())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the display size
|
||||
func (d *LogDisplay) Size() (width, height int) {
|
||||
return d.width, d.height
|
||||
}
|
||||
|
||||
// Close is a no-op for log display
|
||||
func (d *LogDisplay) Close() error {
|
||||
return nil
|
||||
}
|
||||
76
internal/fbdraw/doc.go
Normal file
76
internal/fbdraw/doc.go
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Package fbdraw provides a simple, immediate-mode API for rendering
|
||||
monospaced text to Linux framebuffers.
|
||||
|
||||
# Basic Usage
|
||||
|
||||
The simplest way to create a display:
|
||||
|
||||
display, err := fbdraw.Init()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer display.Close()
|
||||
|
||||
display.Loop(func(d *fbdraw.Draw) {
|
||||
d.Clear()
|
||||
d.Font(fbdraw.PlexMono).Size(24).Bold()
|
||||
d.Color(fbdraw.White)
|
||||
d.TextCenter(d.Width/2, 50, "Hello, Framebuffer!")
|
||||
d.Present()
|
||||
})
|
||||
|
||||
# Working with Grids
|
||||
|
||||
For structured layouts, use the grid system:
|
||||
|
||||
grid := d.Grid(10, 10, 80, 25) // x, y, columns, rows
|
||||
grid.Border(fbdraw.White)
|
||||
|
||||
grid.Color(fbdraw.Green).Bold()
|
||||
grid.WriteCenter(0, "System Status")
|
||||
|
||||
grid.Plain().Color(fbdraw.White)
|
||||
grid.Write(2, 0, "CPU:")
|
||||
grid.Bar(2, 10, 30, cpuPercent, fbdraw.Heat(cpuPercent))
|
||||
|
||||
# Drawing State
|
||||
|
||||
The Draw object maintains state between calls:
|
||||
|
||||
d.Font(fbdraw.PlexMono).Size(14).Bold()
|
||||
d.Color(fbdraw.Green)
|
||||
d.Text(10, 10, "This is green bold text")
|
||||
d.Text(10, 30, "This is still green bold text")
|
||||
|
||||
d.Plain().Color(fbdraw.White)
|
||||
d.Text(10, 50, "This is white plain text")
|
||||
|
||||
# Performance
|
||||
|
||||
The package is designed for status displays that update at most a few times
|
||||
per second. It prioritizes ease of use over performance. Each Present() call
|
||||
updates the entire framebuffer.
|
||||
|
||||
# Fonts
|
||||
|
||||
Three monospace fonts are bundled:
|
||||
- PlexMono: IBM Plex Mono, excellent readability
|
||||
- Terminus: Classic bitmap terminal font
|
||||
- SourceCodePro: Adobe's coding font
|
||||
|
||||
# Colors
|
||||
|
||||
Common colors are provided as package variables (Black, White, Red, etc).
|
||||
Grays are available from Gray10 (darkest) to Gray90 (lightest).
|
||||
|
||||
# Error Handling
|
||||
|
||||
Most operations fail silently to keep the API simple. Use explicit error
|
||||
checks where needed:
|
||||
|
||||
if err := d.Present(); err != nil {
|
||||
// Handle framebuffer write error
|
||||
}
|
||||
*/
|
||||
package fbdraw
|
||||
490
internal/fbdraw/grid.go
Normal file
490
internal/fbdraw/grid.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package fbdraw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/font"
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
)
|
||||
|
||||
// Common colors
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
Black = color.RGBA{0, 0, 0, 255}
|
||||
White = color.RGBA{255, 255, 255, 255}
|
||||
Red = color.RGBA{255, 0, 0, 255}
|
||||
Green = color.RGBA{0, 255, 0, 255}
|
||||
Blue = color.RGBA{0, 0, 255, 255}
|
||||
Yellow = color.RGBA{255, 255, 0, 255}
|
||||
Cyan = color.RGBA{0, 255, 255, 255}
|
||||
Magenta = color.RGBA{255, 0, 255, 255}
|
||||
Orange = color.RGBA{255, 165, 0, 255}
|
||||
Purple = color.RGBA{128, 0, 128, 255}
|
||||
|
||||
// Grays
|
||||
Gray10 = color.RGBA{26, 26, 26, 255}
|
||||
Gray20 = color.RGBA{51, 51, 51, 255}
|
||||
Gray30 = color.RGBA{77, 77, 77, 255}
|
||||
Gray40 = color.RGBA{102, 102, 102, 255}
|
||||
Gray50 = color.RGBA{128, 128, 128, 255}
|
||||
Gray60 = color.RGBA{153, 153, 153, 255}
|
||||
Gray70 = color.RGBA{179, 179, 179, 255}
|
||||
Gray80 = color.RGBA{204, 204, 204, 255}
|
||||
Gray90 = color.RGBA{230, 230, 230, 255}
|
||||
)
|
||||
|
||||
// Cell represents a single character cell in the grid
|
||||
type Cell struct {
|
||||
Rune rune
|
||||
Foreground color.Color
|
||||
Background color.Color
|
||||
Weight font.FontWeight
|
||||
Italic bool
|
||||
}
|
||||
|
||||
// CharGrid represents a monospace character grid
|
||||
type CharGrid struct {
|
||||
Width int // Width in characters
|
||||
Height int // Height in characters
|
||||
Cells [][]Cell // 2D array [y][x]
|
||||
|
||||
// Font settings
|
||||
FontFamily font.FontFamily
|
||||
FontSize float64 // Points
|
||||
|
||||
// Computed values
|
||||
CharWidth int // Pixel width of a character
|
||||
CharHeight int // Pixel height of a character
|
||||
|
||||
// Rendering cache
|
||||
fontCache map[fontKey]*truetype.Font
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type fontKey struct {
|
||||
family font.FontFamily
|
||||
weight font.FontWeight
|
||||
italic bool
|
||||
}
|
||||
|
||||
// NewCharGrid creates a new character grid
|
||||
func NewCharGrid(width, height int) *CharGrid {
|
||||
// Create 2D array
|
||||
cells := make([][]Cell, height)
|
||||
for y := 0; y < height; y++ {
|
||||
cells[y] = make([]Cell, width)
|
||||
// Initialize with spaces and default colors
|
||||
for x := 0; x < width; x++ {
|
||||
cells[y][x] = Cell{
|
||||
Rune: ' ',
|
||||
Foreground: White,
|
||||
Background: Black,
|
||||
Weight: font.WeightRegular,
|
||||
Italic: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &CharGrid{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Cells: cells,
|
||||
FontFamily: font.FamilyIBMPlexMono,
|
||||
FontSize: 14,
|
||||
CharWidth: 8, // Will be computed based on font
|
||||
CharHeight: 16, // Will be computed based on font
|
||||
fontCache: make(map[fontKey]*truetype.Font),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCell sets a single cell's content
|
||||
func (g *CharGrid) SetCell(x, y int, r rune, fg, bg color.Color, weight font.FontWeight, italic bool) {
|
||||
if x < 0 || x >= g.Width || y < 0 || y >= g.Height {
|
||||
return
|
||||
}
|
||||
|
||||
g.Cells[y][x] = Cell{
|
||||
Rune: r,
|
||||
Foreground: fg,
|
||||
Background: bg,
|
||||
Weight: weight,
|
||||
Italic: italic,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteString writes a string starting at position (x, y)
|
||||
func (g *CharGrid) WriteString(x, y int, s string, fg, bg color.Color, weight font.FontWeight, italic bool) {
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
g.SetCell(x+i, y, r, fg, bg, weight, italic)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear clears the grid with the specified background color
|
||||
func (g *CharGrid) Clear(bg color.Color) {
|
||||
for y := 0; y < g.Height; y++ {
|
||||
for x := 0; x < g.Width; x++ {
|
||||
g.Cells[y][x] = Cell{
|
||||
Rune: ' ',
|
||||
Foreground: White,
|
||||
Background: bg,
|
||||
Weight: font.WeightRegular,
|
||||
Italic: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getFont retrieves a font from cache or loads it
|
||||
func (g *CharGrid) getFont(weight font.FontWeight, italic bool) (*truetype.Font, error) {
|
||||
key := fontKey{
|
||||
family: g.FontFamily,
|
||||
weight: weight,
|
||||
italic: italic,
|
||||
}
|
||||
|
||||
g.mu.RLock()
|
||||
if f, ok := g.fontCache[key]; ok {
|
||||
g.mu.RUnlock()
|
||||
return f, nil
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
|
||||
// Load font
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if f, ok := g.fontCache[key]; ok {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
f, err := font.LoadFont(g.FontFamily, weight, italic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g.fontCache[key] = f
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// computeCharSize computes the character cell size based on font metrics
|
||||
func (g *CharGrid) computeCharSize() error {
|
||||
f, err := g.getFont(font.WeightRegular, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use freetype to measure a typical character
|
||||
opts := truetype.Options{
|
||||
Size: g.FontSize,
|
||||
DPI: 72,
|
||||
}
|
||||
face := truetype.NewFace(f, &opts)
|
||||
|
||||
// Measure 'M' for width (typically widest regular character in monospace)
|
||||
bounds, _, _ := face.GlyphBounds('M')
|
||||
g.CharWidth = (bounds.Max.X - bounds.Min.X).Round()
|
||||
|
||||
// Use font metrics for height
|
||||
metrics := face.Metrics()
|
||||
g.CharHeight = (metrics.Ascent + metrics.Descent).Round()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render renders the grid to an image
|
||||
func (g *CharGrid) Render() (*image.RGBA, error) {
|
||||
// Ensure character dimensions are computed
|
||||
if err := g.computeCharSize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create image
|
||||
width := g.Width * g.CharWidth
|
||||
height := g.Height * g.CharHeight
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
// First pass: draw backgrounds
|
||||
for y := 0; y < g.Height; y++ {
|
||||
for x := 0; x < g.Width; x++ {
|
||||
cell := g.Cells[y][x]
|
||||
|
||||
// Draw background rectangle
|
||||
x0 := x * g.CharWidth
|
||||
y0 := y * g.CharHeight
|
||||
x1 := x0 + g.CharWidth
|
||||
y1 := y0 + g.CharHeight
|
||||
|
||||
for py := y0; py < y1; py++ {
|
||||
for px := x0; px < x1; px++ {
|
||||
img.Set(px, py, cell.Background)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: draw text
|
||||
ctx := freetype.NewContext()
|
||||
ctx.SetDPI(72)
|
||||
ctx.SetFont(nil) // Will be set per cell
|
||||
ctx.SetFontSize(g.FontSize)
|
||||
ctx.SetClip(img.Bounds())
|
||||
ctx.SetDst(img)
|
||||
|
||||
for y := 0; y < g.Height; y++ {
|
||||
for x := 0; x < g.Width; x++ {
|
||||
cell := g.Cells[y][x]
|
||||
|
||||
if cell.Rune == ' ' {
|
||||
continue // Skip spaces
|
||||
}
|
||||
|
||||
// Get font for this cell
|
||||
f, err := g.getFont(cell.Weight, cell.Italic)
|
||||
if err != nil {
|
||||
continue // Skip cells with font errors
|
||||
}
|
||||
|
||||
ctx.SetFont(f)
|
||||
ctx.SetSrc(image.NewUniform(cell.Foreground))
|
||||
|
||||
// Calculate text position
|
||||
// X: left edge of cell
|
||||
// Y: baseline (ascent from top of cell)
|
||||
opts := truetype.Options{
|
||||
Size: g.FontSize,
|
||||
DPI: 72,
|
||||
}
|
||||
face := truetype.NewFace(f, &opts)
|
||||
metrics := face.Metrics()
|
||||
|
||||
px := x * g.CharWidth
|
||||
py := y*g.CharHeight + metrics.Ascent.Round()
|
||||
|
||||
pt := freetype.Pt(px, py)
|
||||
_, _ = ctx.DrawString(string(cell.Rune), pt)
|
||||
}
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// ToText renders the grid as text for debugging/logging
|
||||
func (g *CharGrid) ToText() string {
|
||||
var sb strings.Builder
|
||||
|
||||
for y := 0; y < g.Height; y++ {
|
||||
for x := 0; x < g.Width; x++ {
|
||||
sb.WriteRune(g.Cells[y][x].Rune)
|
||||
}
|
||||
if y < g.Height-1 {
|
||||
sb.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ToANSI renders the grid as ANSI escape sequences for terminal display
|
||||
func (g *CharGrid) ToANSI() string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Track last colors to minimize escape sequences
|
||||
var lastFg, lastBg color.Color
|
||||
var lastWeight font.FontWeight
|
||||
var lastItalic bool
|
||||
|
||||
for y := 0; y < g.Height; y++ {
|
||||
for x := 0; x < g.Width; x++ {
|
||||
cell := g.Cells[y][x]
|
||||
|
||||
// Update styles if changed
|
||||
if cell.Foreground != lastFg || cell.Background != lastBg ||
|
||||
cell.Weight != lastWeight || cell.Italic != lastItalic {
|
||||
|
||||
// Reset
|
||||
sb.WriteString("\033[0m")
|
||||
|
||||
// Weight
|
||||
if cell.Weight == font.WeightBold ||
|
||||
cell.Weight == font.WeightExtraBold ||
|
||||
cell.Weight == font.WeightBlack {
|
||||
sb.WriteString("\033[1m") // Bold
|
||||
}
|
||||
|
||||
// Italic
|
||||
if cell.Italic {
|
||||
sb.WriteString("\033[3m")
|
||||
}
|
||||
|
||||
// Foreground color
|
||||
if r, g, b, _ := cell.Foreground.RGBA(); r != 0 || g != 0 || b != 0 {
|
||||
sb.WriteString(fmt.Sprintf("\033[38;2;%d;%d;%dm",
|
||||
r>>8, g>>8, b>>8))
|
||||
}
|
||||
|
||||
// Background color
|
||||
if r, g, b, _ := cell.Background.RGBA(); r != 0 || g != 0 || b != 0 {
|
||||
sb.WriteString(fmt.Sprintf("\033[48;2;%d;%d;%dm",
|
||||
r>>8, g>>8, b>>8))
|
||||
}
|
||||
|
||||
lastFg = cell.Foreground
|
||||
lastBg = cell.Background
|
||||
lastWeight = cell.Weight
|
||||
lastItalic = cell.Italic
|
||||
}
|
||||
|
||||
sb.WriteRune(cell.Rune)
|
||||
}
|
||||
|
||||
// Reset at end of line and add newline
|
||||
sb.WriteString("\033[0m\n")
|
||||
lastFg = nil
|
||||
lastBg = nil
|
||||
lastWeight = ""
|
||||
lastItalic = false
|
||||
}
|
||||
|
||||
// Final reset
|
||||
sb.WriteString("\033[0m")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GridWriter provides a convenient API for writing to a grid
|
||||
type GridWriter struct {
|
||||
Grid *CharGrid
|
||||
X, Y int // Current position
|
||||
Foreground color.Color
|
||||
Background color.Color
|
||||
Weight font.FontWeight
|
||||
Italic bool
|
||||
}
|
||||
|
||||
// NewGridWriter creates a new GridWriter
|
||||
func NewGridWriter(grid *CharGrid) *GridWriter {
|
||||
return &GridWriter{
|
||||
Grid: grid,
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Foreground: White,
|
||||
Background: Black,
|
||||
Weight: font.WeightRegular,
|
||||
Italic: false,
|
||||
}
|
||||
}
|
||||
|
||||
// MoveAbs moves the cursor to an absolute position
|
||||
func (w *GridWriter) MoveAbs(x, y int) *GridWriter {
|
||||
w.X = x
|
||||
w.Y = y
|
||||
return w
|
||||
}
|
||||
|
||||
// Move moves the cursor relative to the current position
|
||||
func (w *GridWriter) Move(dx, dy int) *GridWriter {
|
||||
w.X += dx
|
||||
w.Y += dy
|
||||
return w
|
||||
}
|
||||
|
||||
// SetColor sets the foreground color
|
||||
func (w *GridWriter) SetColor(c color.Color) *GridWriter {
|
||||
w.Foreground = c
|
||||
return w
|
||||
}
|
||||
|
||||
// SetBackground sets the background color
|
||||
func (w *GridWriter) SetBackground(c color.Color) *GridWriter {
|
||||
w.Background = c
|
||||
return w
|
||||
}
|
||||
|
||||
// SetWeight sets the font weight
|
||||
func (w *GridWriter) SetWeight(weight font.FontWeight) *GridWriter {
|
||||
w.Weight = weight
|
||||
return w
|
||||
}
|
||||
|
||||
// SetItalic sets italic style
|
||||
func (w *GridWriter) SetItalic(italic bool) *GridWriter {
|
||||
w.Italic = italic
|
||||
return w
|
||||
}
|
||||
|
||||
// Write writes a string at the current position
|
||||
func (w *GridWriter) Write(format string, args ...interface{}) *GridWriter {
|
||||
s := fmt.Sprintf(format, args...)
|
||||
w.Grid.WriteString(w.X, w.Y, s, w.Foreground, w.Background, w.Weight, w.Italic)
|
||||
w.X += len([]rune(s))
|
||||
return w
|
||||
}
|
||||
|
||||
// WriteLine writes a string and moves to the next line
|
||||
func (w *GridWriter) WriteLine(format string, args ...interface{}) *GridWriter {
|
||||
w.Write(format, args...)
|
||||
w.X = 0
|
||||
w.Y++
|
||||
return w
|
||||
}
|
||||
|
||||
// NewLine moves to the next line
|
||||
func (w *GridWriter) NewLine() *GridWriter {
|
||||
w.X = 0
|
||||
w.Y++
|
||||
return w
|
||||
}
|
||||
|
||||
// Clear clears the grid with the current background color
|
||||
func (w *GridWriter) Clear() *GridWriter {
|
||||
w.Grid.Clear(w.Background)
|
||||
w.X = 0
|
||||
w.Y = 0
|
||||
return w
|
||||
}
|
||||
|
||||
// DrawMeter draws a progress meter at the current position
|
||||
func (w *GridWriter) DrawMeter(percent float64, width int) *GridWriter {
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
|
||||
filled := int(percent / 100.0 * float64(width))
|
||||
|
||||
// Save original color
|
||||
origColor := w.Foreground
|
||||
|
||||
// Set color based on percentage
|
||||
if percent > 80 {
|
||||
w.SetColor(Red)
|
||||
} else if percent > 50 {
|
||||
w.SetColor(Yellow)
|
||||
} else {
|
||||
w.SetColor(Green)
|
||||
}
|
||||
|
||||
// Draw the meter
|
||||
for i := 0; i < width; i++ {
|
||||
if i < filled {
|
||||
w.Write("█")
|
||||
} else {
|
||||
w.Write("▒")
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original color
|
||||
w.SetColor(origColor)
|
||||
|
||||
return w
|
||||
}
|
||||
160
internal/fbdraw/grid_test.go
Normal file
160
internal/fbdraw/grid_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package fbdraw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/font"
|
||||
)
|
||||
|
||||
func ExampleGrid() {
|
||||
// Create a 80x25 character grid (standard terminal size)
|
||||
grid := NewGrid(80, 25)
|
||||
|
||||
// Create a writer for convenience
|
||||
w := NewGridWriter(grid)
|
||||
|
||||
// Clear with dark background
|
||||
w.SetBackground(Gray10).Clear()
|
||||
|
||||
// Draw header
|
||||
w.MoveTo(0, 0).
|
||||
SetBackground(Blue).
|
||||
SetColor(White).
|
||||
SetWeight(font.WeightBold).
|
||||
WriteLine(" System Monitor v1.0 ")
|
||||
|
||||
// Reset to normal style
|
||||
w.SetBackground(Black).SetWeight(font.WeightRegular)
|
||||
|
||||
// System info section
|
||||
w.MoveTo(2, 2).
|
||||
SetColor(Green).SetWeight(font.WeightBold).
|
||||
Write("SYSTEM INFO").
|
||||
SetWeight(font.WeightRegular).SetColor(White)
|
||||
|
||||
w.MoveTo(2, 4).Write("Hostname: ").SetColor(Cyan).Write("server01.example.com")
|
||||
w.MoveTo(2, 5).SetColor(White).Write("Uptime: ").SetColor(Yellow).Write("14 days, 3:42:15")
|
||||
w.MoveTo(2, 6).SetColor(White).Write("Load: ").SetColor(Red).Write("2.45 1.82 1.65")
|
||||
|
||||
// CPU meters
|
||||
w.MoveTo(2, 8).SetColor(Blue).SetWeight(font.WeightBold).Write("CPU USAGE")
|
||||
w.SetWeight(font.WeightRegular)
|
||||
|
||||
cpuValues := []float64{45.2, 78.9, 23.4, 91.5}
|
||||
for i, cpu := range cpuValues {
|
||||
w.MoveTo(2, 10+i)
|
||||
w.SetColor(White).Write("CPU%d [", i)
|
||||
|
||||
// Draw meter
|
||||
meterWidth := 20
|
||||
filled := int(cpu / 100.0 * float64(meterWidth))
|
||||
|
||||
// Choose color based on usage
|
||||
if cpu > 80 {
|
||||
w.SetColor(Red)
|
||||
} else if cpu > 50 {
|
||||
w.SetColor(Yellow)
|
||||
} else {
|
||||
w.SetColor(Green)
|
||||
}
|
||||
|
||||
for j := 0; j < meterWidth; j++ {
|
||||
if j < filled {
|
||||
w.Write("█")
|
||||
} else {
|
||||
w.Write("░")
|
||||
}
|
||||
}
|
||||
|
||||
w.SetColor(White).Write("] %5.1f%%", cpu)
|
||||
}
|
||||
|
||||
// Memory section
|
||||
w.MoveTo(45, 8).SetColor(Purple).SetWeight(font.WeightBold).Write("MEMORY")
|
||||
w.SetWeight(font.WeightRegular).SetColor(White)
|
||||
|
||||
w.MoveTo(45, 10).Write("Used: ").SetColor(Green).Write("4.2 GB / 16.0 GB")
|
||||
w.MoveTo(45, 11).SetColor(White).Write("Free: ").SetColor(Green).Write("11.8 GB")
|
||||
w.MoveTo(45, 12).SetColor(White).Write("Cache: ").SetColor(Blue).Write("2.1 GB")
|
||||
|
||||
// Process table
|
||||
w.MoveTo(2, 16).SetColor(Orange).SetWeight(font.WeightBold).Write("TOP PROCESSES")
|
||||
w.SetWeight(font.WeightRegular)
|
||||
|
||||
// Table header
|
||||
w.MoveTo(2, 18).SetBackground(Gray30).SetColor(White).SetWeight(font.WeightBold)
|
||||
w.Write(" PID USER PROCESS CPU% MEM% ")
|
||||
w.SetBackground(Black).SetWeight(font.WeightRegular)
|
||||
|
||||
// Table rows
|
||||
processes := []struct {
|
||||
pid int
|
||||
user string
|
||||
name string
|
||||
cpu float64
|
||||
mem float64
|
||||
}{
|
||||
{1234, "root", "systemd", 0.2, 0.1},
|
||||
{5678, "user", "firefox", 15.8, 12.4},
|
||||
{9012, "user", "vscode", 42.1, 8.7},
|
||||
}
|
||||
|
||||
for i, p := range processes {
|
||||
y := 19 + i
|
||||
|
||||
// Alternate row backgrounds
|
||||
if i%2 == 0 {
|
||||
w.SetBackground(Gray10)
|
||||
} else {
|
||||
w.SetBackground(Black)
|
||||
}
|
||||
|
||||
// Highlight high CPU
|
||||
if p.cpu > 40 {
|
||||
w.SetColor(Red)
|
||||
} else if p.cpu > 20 {
|
||||
w.SetColor(Yellow)
|
||||
} else {
|
||||
w.SetColor(Gray70)
|
||||
}
|
||||
|
||||
w.MoveTo(0, y)
|
||||
w.Write(" %5d %-8s %-25s %5.1f %5.1f ",
|
||||
p.pid, p.user, p.name, p.cpu, p.mem)
|
||||
}
|
||||
|
||||
// Output as text
|
||||
fmt.Println(grid.ToText())
|
||||
}
|
||||
|
||||
func TestGrid(t *testing.T) {
|
||||
grid := NewGrid(40, 10)
|
||||
w := NewGridWriter(grid)
|
||||
|
||||
// Test basic writing
|
||||
w.MoveTo(5, 2).Write("Hello, World!")
|
||||
|
||||
// Test colors and styles
|
||||
w.MoveTo(5, 4).
|
||||
SetColor(Red).SetWeight(font.WeightBold).
|
||||
Write("Bold Red Text")
|
||||
|
||||
// Test unicode
|
||||
w.MoveTo(5, 6).
|
||||
SetColor(Green).
|
||||
Write("Progress: [████████▒▒▒▒▒▒▒▒] 50%")
|
||||
|
||||
// Check text output
|
||||
text := grid.ToText()
|
||||
if text == "" {
|
||||
t.Error("Grid should not be empty")
|
||||
}
|
||||
|
||||
// Check specific cell
|
||||
cell := grid.Cells[2][5] // Row 2, Column 5 should be 'H'
|
||||
if cell.Rune != 'H' {
|
||||
t.Errorf("Expected 'H' at (5,2), got '%c'", cell.Rune)
|
||||
}
|
||||
}
|
||||
22
internal/fbdraw/interfaces.go
Normal file
22
internal/fbdraw/interfaces.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package fbdraw
|
||||
|
||||
// FrameGenerator generates frames for a screen
|
||||
type FrameGenerator interface {
|
||||
// GenerateFrame is called to render a new frame
|
||||
GenerateFrame(grid *CharGrid) error
|
||||
|
||||
// FramesPerSecond returns the desired frame rate
|
||||
FramesPerSecond() float64
|
||||
}
|
||||
|
||||
// FramebufferDisplay interface represents the output device
|
||||
type FramebufferDisplay interface {
|
||||
// Write renders a grid to the display
|
||||
Write(grid *CharGrid) error
|
||||
|
||||
// Size returns the display dimensions in characters
|
||||
Size() (width, height int)
|
||||
|
||||
// Close cleans up resources
|
||||
Close() error
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
93
internal/font/fonts/Source_Code_Pro/OFL.txt
Normal file
93
internal/font/fonts/Source_Code_Pro/OFL.txt
Normal 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.
|
||||
79
internal/font/fonts/Source_Code_Pro/README.txt
Normal file
79
internal/font/fonts/Source_Code_Pro/README.txt
Normal 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 aren’t 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.
|
||||
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Black.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Black.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Bold.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Bold.ttf
Normal file
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-BoldItalic.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-ExtraBold.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-ExtraBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-ExtraLight.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Italic.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Italic.ttf
Normal file
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Light.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Light.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Medium.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Medium.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Regular.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Regular.ttf
Normal file
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-SemiBold.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
94
internal/font/fonts/Terminus/OFL.TXT
Normal file
94
internal/font/fonts/Terminus/OFL.TXT
Normal 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.
|
||||
25792
internal/font/fonts/Terminus/ter-u12b.bdf
Normal file
25792
internal/font/fonts/Terminus/ter-u12b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
25792
internal/font/fonts/Terminus/ter-u12n.bdf
Normal file
25792
internal/font/fonts/Terminus/ter-u12n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
28504
internal/font/fonts/Terminus/ter-u14b.bdf
Normal file
28504
internal/font/fonts/Terminus/ter-u14b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
28504
internal/font/fonts/Terminus/ter-u14n.bdf
Normal file
28504
internal/font/fonts/Terminus/ter-u14n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
28504
internal/font/fonts/Terminus/ter-u14v.bdf
Normal file
28504
internal/font/fonts/Terminus/ter-u14v.bdf
Normal file
File diff suppressed because it is too large
Load Diff
31216
internal/font/fonts/Terminus/ter-u16b.bdf
Normal file
31216
internal/font/fonts/Terminus/ter-u16b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
31216
internal/font/fonts/Terminus/ter-u16n.bdf
Normal file
31216
internal/font/fonts/Terminus/ter-u16n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
31216
internal/font/fonts/Terminus/ter-u16v.bdf
Normal file
31216
internal/font/fonts/Terminus/ter-u16v.bdf
Normal file
File diff suppressed because it is too large
Load Diff
33928
internal/font/fonts/Terminus/ter-u18b.bdf
Normal file
33928
internal/font/fonts/Terminus/ter-u18b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
33928
internal/font/fonts/Terminus/ter-u18n.bdf
Normal file
33928
internal/font/fonts/Terminus/ter-u18n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
36640
internal/font/fonts/Terminus/ter-u20b.bdf
Normal file
36640
internal/font/fonts/Terminus/ter-u20b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
36640
internal/font/fonts/Terminus/ter-u20n.bdf
Normal file
36640
internal/font/fonts/Terminus/ter-u20n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
39352
internal/font/fonts/Terminus/ter-u22b.bdf
Normal file
39352
internal/font/fonts/Terminus/ter-u22b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
39352
internal/font/fonts/Terminus/ter-u22n.bdf
Normal file
39352
internal/font/fonts/Terminus/ter-u22n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
42064
internal/font/fonts/Terminus/ter-u24b.bdf
Normal file
42064
internal/font/fonts/Terminus/ter-u24b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
42064
internal/font/fonts/Terminus/ter-u24n.bdf
Normal file
42064
internal/font/fonts/Terminus/ter-u24n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
47488
internal/font/fonts/Terminus/ter-u28b.bdf
Normal file
47488
internal/font/fonts/Terminus/ter-u28b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
47488
internal/font/fonts/Terminus/ter-u28n.bdf
Normal file
47488
internal/font/fonts/Terminus/ter-u28n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
52912
internal/font/fonts/Terminus/ter-u32b.bdf
Normal file
52912
internal/font/fonts/Terminus/ter-u32b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
52912
internal/font/fonts/Terminus/ter-u32n.bdf
Normal file
52912
internal/font/fonts/Terminus/ter-u32n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
280
internal/framebufferdisplay/README.md
Normal file
280
internal/framebufferdisplay/README.md
Normal 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.
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package hdmistat provides the CLI commands for the hdmistat application
|
||||
package hdmistat
|
||||
|
||||
import (
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
442
internal/layout/draw.go
Normal 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}
|
||||
}
|
||||
64
internal/layout/example_test.go
Normal file
64
internal/layout/example_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
95
internal/layout/progressbar.go
Normal file
95
internal/layout/progressbar.go
Normal 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
259
internal/netmon/netmon.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
246
internal/renderer/status_screen.go
Normal file
246
internal/renderer/status_screen.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user