hdmistat/internal/layout/layout.go
sneak 402c0797d5 Initial implementation of hdmistat - Linux framebuffer system stats display
Features:
- Beautiful system statistics display using IBM Plex Mono font
- Direct framebuffer rendering without X11/Wayland
- Multiple screens with automatic carousel rotation
- Real-time system monitoring (CPU, memory, disk, network, processes)
- Systemd service integration with install command
- Clean architecture using uber/fx dependency injection

Architecture:
- Cobra CLI with daemon, install, status, and info commands
- Modular design with separate packages for display, rendering, and stats
- Font embedding for zero runtime dependencies
- Layout API for clean text rendering
- Support for multiple screen types (overview, top CPU, top memory)

Technical details:
- Uses gopsutil for cross-platform system stats collection
- Direct Linux framebuffer access via memory mapping
- Anti-aliased text rendering with freetype
- Configurable screen rotation and update intervals
- Structured logging with slog
- Comprehensive test coverage and linting setup

This initial version provides a solid foundation for displaying rich
system information on resource-constrained devices like Raspberry Pis.
2025-07-23 12:55:42 +02:00

192 lines
4.5 KiB
Go

package layout
import (
"fmt"
"image"
"image/color"
"image/draw"
"log/slog"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
)
// Canvas provides a simple API for rendering text and graphics
type Canvas struct {
img *image.RGBA
font *truetype.Font
logger *slog.Logger
}
// TextStyle defines text rendering parameters
type TextStyle struct {
Size float64
Color color.Color
Alignment Alignment
}
// Alignment for text rendering
type Alignment int
const (
AlignLeft Alignment = iota
AlignCenter
AlignRight
)
// Point represents a 2D coordinate
type Point struct {
X, Y int
}
// NewCanvas creates a new canvas for drawing
func NewCanvas(width, height int, font *truetype.Font, logger *slog.Logger) *Canvas {
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Fill with black background
draw.Draw(img, img.Bounds(), &image.Uniform{color.Black}, image.Point{}, draw.Src)
return &Canvas{
img: img,
font: font,
logger: logger,
}
}
// Clear fills the canvas with a color
func (c *Canvas) Clear(col color.Color) {
draw.Draw(c.img, c.img.Bounds(), &image.Uniform{col}, image.Point{}, draw.Src)
}
// DrawText renders text at the specified position
func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
if style.Color == nil {
style.Color = color.White
}
ctx := freetype.NewContext()
ctx.SetDPI(72)
ctx.SetFont(c.font)
ctx.SetFontSize(style.Size)
ctx.SetClip(c.img.Bounds())
ctx.SetDst(c.img)
ctx.SetSrc(&image.Uniform{style.Color})
// Calculate text bounds for alignment
opts := truetype.Options{
Size: style.Size,
DPI: 72,
}
face := truetype.NewFace(c.font, &opts)
bounds, _ := font.BoundString(face, text)
width := bounds.Max.X - bounds.Min.X
x := pos.X
switch style.Alignment {
case AlignCenter:
x = pos.X - width.Round()/2
case AlignRight:
x = pos.X - width.Round()
}
pt := freetype.Pt(x, pos.Y)
_, err := ctx.DrawString(text, pt)
return err
}
// DrawTextMultiline renders multiple lines of text
func (c *Canvas) DrawTextMultiline(lines []string, pos Point, style TextStyle, lineSpacing float64) error {
y := pos.Y
for _, line := range lines {
if err := c.DrawText(line, Point{X: pos.X, Y: y}, style); err != nil {
return err
}
y += int(style.Size * lineSpacing)
}
return nil
}
// DrawBox draws a filled rectangle
func (c *Canvas) DrawBox(x, y, width, height int, col color.Color) {
rect := image.Rect(x, y, x+width, y+height)
draw.Draw(c.img, rect, &image.Uniform{col}, image.Point{}, draw.Src)
}
// DrawBorder draws a rectangle border
func (c *Canvas) DrawBorder(x, y, width, height, thickness int, col color.Color) {
// Top
c.DrawBox(x, y, width, thickness, col)
// Bottom
c.DrawBox(x, y+height-thickness, width, thickness, col)
// Left
c.DrawBox(x, y, thickness, height, col)
// Right
c.DrawBox(x+width-thickness, y, thickness, height, col)
}
// DrawProgress draws a progress bar
func (c *Canvas) DrawProgress(x, y, width, height int, percent float64, fg, bg color.Color) {
// Background
c.DrawBox(x, y, width, height, bg)
// Foreground
fillWidth := int(float64(width) * percent / 100.0)
if fillWidth > 0 {
c.DrawBox(x, y, fillWidth, height, fg)
}
// Border
c.DrawBorder(x, y, width, height, 1, color.Gray{128})
}
// DrawHLine draws a horizontal line
func (c *Canvas) DrawHLine(x, y, width int, col color.Color) {
c.DrawBox(x, y, width, 1, col)
}
// DrawVLine draws a vertical line
func (c *Canvas) DrawVLine(x, y, height int, col color.Color) {
c.DrawBox(x, y, 1, height, col)
}
// Image returns the underlying image
func (c *Canvas) Image() *image.RGBA {
return c.img
}
// Size returns the canvas dimensions
func (c *Canvas) Size() (width, height int) {
bounds := c.img.Bounds()
return bounds.Dx(), bounds.Dy()
}
// FormatBytes formats byte counts for display
func FormatBytes(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])
}
// 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
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
} else if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}