491 lines
11 KiB
Go
491 lines
11 KiB
Go
// Package layout provides a simple API for creating text-based layouts
|
|
// that can be rendered to fbdraw grids for display in a carousel.
|
|
//
|
|
//nolint:mnd
|
|
package layout
|
|
|
|
import (
|
|
"fmt"
|
|
"image/color"
|
|
|
|
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
|
|
"git.eeqj.de/sneak/hdmistat/internal/font"
|
|
)
|
|
|
|
// Font represents a bundled monospace font.
|
|
type Font int
|
|
|
|
const (
|
|
// PlexMono is IBM Plex Mono, a modern monospace font with good readability.
|
|
PlexMono Font = iota
|
|
// Terminus is a bitmap font optimized for long-term reading on terminals.
|
|
Terminus
|
|
// SourceCodePro is Adobe's Source Code Pro, designed for coding environments.
|
|
SourceCodePro
|
|
)
|
|
|
|
// String implements the Stringer interface for Font
|
|
func (f Font) String() string {
|
|
switch f {
|
|
case PlexMono:
|
|
return "PlexMono"
|
|
case Terminus:
|
|
return "Terminus"
|
|
case SourceCodePro:
|
|
return "SourceCodePro"
|
|
default:
|
|
return fmt.Sprintf("Font(%d)", int(f))
|
|
}
|
|
}
|
|
|
|
// Color returns a standard color by name
|
|
func Color(name string) color.Color {
|
|
switch name {
|
|
case "black":
|
|
return color.RGBA{0, 0, 0, 255}
|
|
case "white":
|
|
return color.RGBA{255, 255, 255, 255}
|
|
case "red":
|
|
return color.RGBA{255, 0, 0, 255}
|
|
case "green":
|
|
return color.RGBA{0, 255, 0, 255}
|
|
case "blue":
|
|
return color.RGBA{0, 0, 255, 255}
|
|
case "yellow":
|
|
return color.RGBA{255, 255, 0, 255}
|
|
case "cyan":
|
|
return color.RGBA{0, 255, 255, 255}
|
|
case "magenta":
|
|
return color.RGBA{255, 0, 255, 255}
|
|
case "orange":
|
|
return color.RGBA{255, 165, 0, 255}
|
|
case "purple":
|
|
return color.RGBA{128, 0, 128, 255}
|
|
case "gray10":
|
|
return color.RGBA{26, 26, 26, 255}
|
|
case "gray20":
|
|
return color.RGBA{51, 51, 51, 255}
|
|
case "gray30":
|
|
return color.RGBA{77, 77, 77, 255}
|
|
case "gray40":
|
|
return color.RGBA{102, 102, 102, 255}
|
|
case "gray50":
|
|
return color.RGBA{128, 128, 128, 255}
|
|
case "gray60":
|
|
return color.RGBA{153, 153, 153, 255}
|
|
case "gray70":
|
|
return color.RGBA{179, 179, 179, 255}
|
|
case "gray80":
|
|
return color.RGBA{204, 204, 204, 255}
|
|
case "gray90":
|
|
return color.RGBA{230, 230, 230, 255}
|
|
default:
|
|
return color.RGBA{255, 255, 255, 255} // Default to white
|
|
}
|
|
}
|
|
|
|
// Draw provides the drawing context for creating a layout.
|
|
// It maintains state for colors and text styling.
|
|
type Draw struct {
|
|
// Drawing state
|
|
font Font
|
|
bold bool
|
|
italic bool
|
|
fgColor color.Color
|
|
bgColor color.Color
|
|
|
|
// Grid to render to
|
|
grid *fbdraw.CharGrid
|
|
|
|
// Cached dimensions
|
|
Width int
|
|
Height int
|
|
}
|
|
|
|
// String implements the Stringer interface for Draw
|
|
func (d *Draw) String() string {
|
|
return fmt.Sprintf(
|
|
"Draw{Width:%d, Height:%d, Font:%v, Bold:%v, Italic:%v}",
|
|
d.Width,
|
|
d.Height,
|
|
d.font,
|
|
d.bold,
|
|
d.italic,
|
|
)
|
|
}
|
|
|
|
// NewDraw creates a new drawing context that will modify the provided grid
|
|
func NewDraw(grid *fbdraw.CharGrid) *Draw {
|
|
return &Draw{
|
|
grid: grid,
|
|
Width: grid.Width,
|
|
Height: grid.Height,
|
|
fgColor: color.RGBA{255, 255, 255, 255},
|
|
bgColor: color.RGBA{0, 0, 0, 255},
|
|
}
|
|
}
|
|
|
|
// Clear fills the entire display with black.
|
|
func (d *Draw) Clear() {
|
|
d.grid.Clear(color.RGBA{0, 0, 0, 255})
|
|
}
|
|
|
|
// ClearColor fills the entire display with the specified color.
|
|
func (d *Draw) ClearColor(c color.Color) {
|
|
// Fill all cells with spaces and the background color
|
|
for y := 0; y < d.Height; y++ {
|
|
for x := 0; x < d.Width; x++ {
|
|
weight := font.WeightRegular
|
|
if d.bold {
|
|
weight = font.WeightBold
|
|
}
|
|
d.grid.SetCell(x, y, ' ', d.fgColor, c, weight, d.italic)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Font sets the current font for text operations.
|
|
func (d *Draw) Font(f Font) *Draw {
|
|
d.font = f
|
|
return d
|
|
}
|
|
|
|
// Bold enables bold text rendering.
|
|
func (d *Draw) Bold() *Draw {
|
|
d.bold = true
|
|
return d
|
|
}
|
|
|
|
// Plain disables bold and italic text rendering.
|
|
func (d *Draw) Plain() *Draw {
|
|
d.bold = false
|
|
d.italic = false
|
|
return d
|
|
}
|
|
|
|
// Italic enables italic text rendering.
|
|
func (d *Draw) Italic() *Draw {
|
|
d.italic = true
|
|
return d
|
|
}
|
|
|
|
// Color sets the foreground color for text operations.
|
|
func (d *Draw) Color(c color.Color) *Draw {
|
|
d.fgColor = c
|
|
return d
|
|
}
|
|
|
|
// Background sets the background color for text operations.
|
|
func (d *Draw) Background(c color.Color) *Draw {
|
|
d.bgColor = c
|
|
return d
|
|
}
|
|
|
|
// Text draws text at the specified character coordinates.
|
|
func (d *Draw) Text(x, y int, format string, args ...interface{}) {
|
|
text := fmt.Sprintf(format, args...)
|
|
writer := fbdraw.NewGridWriter(d.grid)
|
|
writer.MoveAbs(x, y)
|
|
writer.SetColor(d.fgColor)
|
|
writer.SetBackground(d.bgColor)
|
|
if d.bold {
|
|
writer.SetWeight(font.WeightBold)
|
|
} else {
|
|
writer.SetWeight(font.WeightRegular)
|
|
}
|
|
writer.SetItalic(d.italic)
|
|
writer.Write("%s", text)
|
|
}
|
|
|
|
// TextCenter draws centered text at the specified y coordinate.
|
|
func (d *Draw) TextCenter(x, y int, format string, args ...interface{}) {
|
|
text := fmt.Sprintf(format, args...)
|
|
// Calculate starting position for centered text
|
|
startX := (d.Width - len(text)) / 2
|
|
if startX < 0 {
|
|
startX = 0
|
|
}
|
|
d.Text(startX, y, "%s", text)
|
|
}
|
|
|
|
// Grid creates a text grid region for simplified text layout.
|
|
// The grid uses the current font settings from the Draw context.
|
|
func (d *Draw) Grid(x, y, cols, rows int) *Grid {
|
|
return &Grid{
|
|
draw: d,
|
|
x: x,
|
|
y: y,
|
|
cols: cols,
|
|
rows: rows,
|
|
}
|
|
}
|
|
|
|
// Grid represents a rectangular text grid for structured text layout.
|
|
// All positions are in character cells, not pixels.
|
|
type Grid struct {
|
|
draw *Draw
|
|
x, y int
|
|
cols, rows int
|
|
|
|
// Grid-specific state that can override draw state
|
|
fgColor color.Color
|
|
bgColor color.Color
|
|
borderColor color.Color
|
|
hasBorder bool
|
|
}
|
|
|
|
// String implements the Stringer interface for Grid
|
|
func (g *Grid) String() string {
|
|
return fmt.Sprintf(
|
|
"Grid{Pos:(%d,%d), Size:%dx%d, Border:%v}",
|
|
g.x,
|
|
g.y,
|
|
g.cols,
|
|
g.rows,
|
|
g.hasBorder,
|
|
)
|
|
}
|
|
|
|
// Write places text at the specified row and column within the grid.
|
|
// Text that exceeds the grid bounds is clipped.
|
|
func (g *Grid) Write(col, row int, format string, args ...interface{}) {
|
|
if row < 0 || row >= g.rows || col < 0 || col >= g.cols {
|
|
return
|
|
}
|
|
text := fmt.Sprintf(format, args...)
|
|
|
|
// Calculate absolute position
|
|
absX := g.x + col
|
|
absY := g.y + row
|
|
|
|
// Write text with clipping
|
|
writer := fbdraw.NewGridWriter(g.draw.grid)
|
|
writer.MoveAbs(absX, absY)
|
|
if g.fgColor != nil {
|
|
writer.SetColor(g.fgColor)
|
|
} else {
|
|
writer.SetColor(g.draw.fgColor)
|
|
}
|
|
if g.bgColor != nil {
|
|
writer.SetBackground(g.bgColor)
|
|
} else {
|
|
writer.SetBackground(g.draw.bgColor)
|
|
}
|
|
|
|
// Clip text to grid bounds
|
|
maxLen := g.cols - col
|
|
if len(text) > maxLen {
|
|
text = text[:maxLen]
|
|
}
|
|
writer.Write("%s", text)
|
|
}
|
|
|
|
// WriteCenter centers text within the specified row.
|
|
func (g *Grid) WriteCenter(row int, format string, args ...interface{}) {
|
|
if row < 0 || row >= g.rows {
|
|
return
|
|
}
|
|
text := fmt.Sprintf(format, args...)
|
|
col := (g.cols - len(text)) / 2
|
|
if col < 0 {
|
|
col = 0
|
|
}
|
|
g.Write(col, row, "%s", text)
|
|
}
|
|
|
|
// Color sets the foreground color for subsequent Write operations.
|
|
func (g *Grid) Color(c color.Color) *Grid {
|
|
g.fgColor = c
|
|
return g
|
|
}
|
|
|
|
// Background sets the background color for the entire grid.
|
|
func (g *Grid) Background(c color.Color) *Grid {
|
|
g.bgColor = c
|
|
// Fill the grid area with the background color
|
|
for row := 0; row < g.rows; row++ {
|
|
for col := 0; col < g.cols; col++ {
|
|
weight := font.WeightRegular
|
|
if g.draw.bold {
|
|
weight = font.WeightBold
|
|
}
|
|
g.draw.grid.SetCell(
|
|
g.x+col,
|
|
g.y+row,
|
|
' ',
|
|
g.draw.fgColor,
|
|
c,
|
|
weight,
|
|
g.draw.italic,
|
|
)
|
|
}
|
|
}
|
|
return g
|
|
}
|
|
|
|
// Border draws a border around the grid in the specified color.
|
|
func (g *Grid) Border(c color.Color) *Grid {
|
|
g.borderColor = c
|
|
g.hasBorder = true
|
|
// Draw border using box drawing characters
|
|
writer := fbdraw.NewGridWriter(g.draw.grid)
|
|
writer.SetColor(c)
|
|
|
|
// Top border
|
|
writer.MoveAbs(g.x-1, g.y-1)
|
|
writer.Write("┌")
|
|
for i := 0; i < g.cols; i++ {
|
|
writer.Write("─")
|
|
}
|
|
writer.Write("┐")
|
|
|
|
// Side borders
|
|
for row := 0; row < g.rows; row++ {
|
|
writer.MoveAbs(g.x-1, g.y+row)
|
|
writer.Write("│")
|
|
writer.MoveAbs(g.x+g.cols, g.y+row)
|
|
writer.Write("│")
|
|
}
|
|
|
|
// Bottom border
|
|
writer.MoveAbs(g.x-1, g.y+g.rows)
|
|
writer.Write("└")
|
|
for i := 0; i < g.cols; i++ {
|
|
writer.Write("─")
|
|
}
|
|
writer.Write("┘")
|
|
|
|
return g
|
|
}
|
|
|
|
// RowBackground sets the background color for a specific row.
|
|
func (g *Grid) RowBackground(row int, c color.Color) {
|
|
if row < 0 || row >= g.rows {
|
|
return
|
|
}
|
|
for col := 0; col < g.cols; col++ {
|
|
g.draw.grid.SetCell(
|
|
g.x+col,
|
|
g.y+row,
|
|
' ',
|
|
g.draw.fgColor,
|
|
c,
|
|
font.WeightRegular,
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
|
|
// RowColor sets the text color for an entire row.
|
|
func (g *Grid) RowColor(row int, c color.Color) {
|
|
if row < 0 || row >= g.rows {
|
|
return
|
|
}
|
|
// This would need to track row colors for subsequent writes
|
|
// For now, we'll just update existing text in the row
|
|
for col := 0; col < g.cols; col++ {
|
|
// Get current cell to preserve other attributes
|
|
if g.y+row < g.draw.grid.Height && g.x+col < g.draw.grid.Width {
|
|
cell := &g.draw.grid.Cells[g.y+row][g.x+col]
|
|
cell.Foreground = c
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bar draws a horizontal progress bar within the grid cell.
|
|
func (g *Grid) Bar(col, row, width int, percent float64, c color.Color) {
|
|
if row < 0 || row >= g.rows || col < 0 || col >= g.cols {
|
|
return
|
|
}
|
|
|
|
// Ensure width doesn't exceed grid bounds
|
|
maxWidth := g.cols - col
|
|
if width > maxWidth {
|
|
width = maxWidth
|
|
}
|
|
|
|
writer := fbdraw.NewGridWriter(g.draw.grid)
|
|
writer.MoveAbs(g.x+col, g.y+row)
|
|
writer.SetColor(c)
|
|
writer.DrawMeter(percent, width)
|
|
}
|
|
|
|
// Bold enables bold text for subsequent Write operations.
|
|
func (g *Grid) Bold() *Grid {
|
|
// TODO: Track bold state for grid
|
|
return g
|
|
}
|
|
|
|
// Plain disables text styling for subsequent Write operations.
|
|
func (g *Grid) Plain() *Grid {
|
|
// TODO: Clear text styling for grid
|
|
return g
|
|
}
|
|
|
|
// Meter creates a text-based progress meter using Unicode block characters.
|
|
// The width parameter specifies the number of characters.
|
|
// Returns a string like "█████████░░░░░░" for 60% with width 15.
|
|
func Meter(percent float64, width int) string {
|
|
if percent < 0 {
|
|
percent = 0
|
|
}
|
|
if percent > 100 {
|
|
percent = 100
|
|
}
|
|
|
|
filled := int(percent / 100.0 * float64(width))
|
|
result := ""
|
|
|
|
for i := 0; i < width; i++ {
|
|
if i < filled {
|
|
result += "█"
|
|
} else {
|
|
result += "░"
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Bytes formats a byte count as a human-readable string.
|
|
// For example: 1234567890 becomes "1.2 GB".
|
|
func Bytes(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],
|
|
)
|
|
}
|
|
|
|
// Heat returns a color between blue and red based on the value.
|
|
// 0.0 returns blue, 1.0 returns red, with gradients in between.
|
|
func Heat(value float64) color.Color {
|
|
if value < 0 {
|
|
value = 0
|
|
}
|
|
if value > 1 {
|
|
value = 1
|
|
}
|
|
|
|
// Simple linear interpolation between blue and red
|
|
r := uint8(255 * value)
|
|
g := uint8(0)
|
|
b := uint8(255 * (1 - value))
|
|
|
|
return color.RGBA{r, g, b, 255}
|
|
}
|
|
|
|
// RGB creates a color from red, green, and blue values (0-255).
|
|
func RGB(r, g, b uint8) color.Color {
|
|
return color.RGBA{r, g, b, 255}
|
|
}
|