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.
192 lines
4.5 KiB
Go
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)
|
|
}
|