491 lines
11 KiB
Go
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
|
|
}
|