hdmistat/internal/fbdraw/grid.go
2025-07-24 14:32:50 +02:00

491 lines
11 KiB
Go

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
}