//nolint:mnd 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 } // String implements the Stringer interface for Cell func (c Cell) String() string { return fmt.Sprintf( "Cell{Rune:'%c', FG:%v, BG:%v, Weight:%s, Italic:%v}", c.Rune, c.Foreground, c.Background, c.Weight, c.Italic, ) } // 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) { // Only compute character dimensions if not already set if g.CharWidth == 0 || g.CharHeight == 0 { 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 } // String implements the Stringer interface, returning a text representation func (g *CharGrid) String() string { return g.ToText() } // 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 } // String implements the Stringer interface for GridWriter func (w *GridWriter) String() string { return fmt.Sprintf( "GridWriter{X:%d, Y:%d, Grid:%dx%d}", w.X, w.Y, w.Grid.Width, w.Grid.Height, ) } // 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 }