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