// Package layout provides canvas and drawing utilities for hdmistat 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" ) 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 font *truetype.Font logger *slog.Logger } // TextStyle defines text rendering parameters 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 ) // 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(defaultDPI) 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: defaultDPI, } 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()/halfDivisor 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 / percentDivisor) 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 { if bytes < byteUnit { return fmt.Sprintf("%d B", bytes) } 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]) } // FormatDuration formats time durations for display func FormatDuration(d float64) string { seconds := int(d) days := seconds / secondsPerDay hours := (seconds % secondsPerDay) / secondsPerHour minutes := (seconds % secondsPerHour) / secondsPerMinute 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) }