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.
This commit is contained in:
191
internal/layout/layout.go
Normal file
191
internal/layout/layout.go
Normal file
@@ -0,0 +1,191 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user