checkpointing, heavy dev
This commit is contained in:
442
internal/layout/draw.go
Normal file
442
internal/layout/draw.go
Normal file
@@ -0,0 +1,442 @@
|
||||
// Package layout provides a simple API for creating text-based layouts
|
||||
// that can be rendered to fbdraw grids for display in a carousel.
|
||||
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
|
||||
)
|
||||
|
||||
// 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 font, size, colors, and text styling.
|
||||
type Draw struct {
|
||||
// Drawing state
|
||||
font Font
|
||||
fontSize int
|
||||
bold bool
|
||||
italic bool
|
||||
fgColor color.Color
|
||||
bgColor color.Color
|
||||
|
||||
// Grid to render to
|
||||
grid *fbdraw.CharGrid
|
||||
|
||||
// Cached dimensions
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// NewDraw creates a new drawing context with the specified dimensions
|
||||
func NewDraw(width, height int) *Draw {
|
||||
grid := fbdraw.NewCharGrid(width, height)
|
||||
return &Draw{
|
||||
grid: grid,
|
||||
Width: width,
|
||||
Height: height,
|
||||
fontSize: 14,
|
||||
fgColor: color.RGBA{255, 255, 255, 255},
|
||||
bgColor: color.RGBA{0, 0, 0, 255},
|
||||
}
|
||||
}
|
||||
|
||||
// Render returns the current grid for rendering by the carousel
|
||||
func (d *Draw) Render() *fbdraw.CharGrid {
|
||||
return d.grid
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Size sets the current font size in points.
|
||||
func (d *Draw) Size(points int) *Draw {
|
||||
d.fontSize = points
|
||||
d.grid.FontSize = float64(points)
|
||||
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(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 := x + (d.Width-len(text))/2
|
||||
d.Text(startX, y, 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
|
||||
}
|
||||
|
||||
// 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(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, 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}
|
||||
}
|
||||
64
internal/layout/example_test.go
Normal file
64
internal/layout/example_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package layout_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
|
||||
"git.eeqj.de/sneak/hdmistat/internal/layout"
|
||||
)
|
||||
|
||||
// ExampleScreen shows how to create a screen that implements FrameGenerator
|
||||
type ExampleScreen struct {
|
||||
name string
|
||||
fps float64
|
||||
}
|
||||
|
||||
func (s *ExampleScreen) GenerateFrame(grid *fbdraw.CharGrid) error {
|
||||
// Create a draw context with the grid dimensions
|
||||
draw := layout.NewDraw(grid.Width, grid.Height)
|
||||
|
||||
// Clear the screen
|
||||
draw.Clear()
|
||||
|
||||
// Draw a title
|
||||
draw.Color(layout.Color("cyan")).Size(16).Bold()
|
||||
draw.TextCenter(0, 2, "Example Screen: %s", s.name)
|
||||
|
||||
// Create a grid for structured layout
|
||||
contentGrid := draw.Grid(5, 5, 70, 20)
|
||||
contentGrid.Border(layout.Color("gray50"))
|
||||
|
||||
// Add some content
|
||||
contentGrid.Color(layout.Color("white")).WriteCenter(1, "Current Time: %s", time.Now().Format("15:04:05"))
|
||||
|
||||
// Draw a progress bar
|
||||
contentGrid.Color(layout.Color("green")).Bar(10, 5, 50, 75.0, layout.Color("green"))
|
||||
|
||||
// Add system stats
|
||||
contentGrid.Color(layout.Color("yellow")).Write(2, 8, "CPU: %.1f%%", 42.5)
|
||||
contentGrid.Color(layout.Color("orange")).Write(2, 9, "Memory: %s / %s", layout.Bytes(4*1024*1024*1024), layout.Bytes(16*1024*1024*1024))
|
||||
|
||||
// Return the rendered grid
|
||||
*grid = *draw.Render()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExampleScreen) FramesPerSecond() float64 {
|
||||
return s.fps
|
||||
}
|
||||
|
||||
func TestExampleUsage(t *testing.T) {
|
||||
// Create carousel with terminal display for testing
|
||||
display := fbdraw.NewTerminalDisplay(80, 25)
|
||||
carousel := fbdraw.NewCarousel(display, 5*time.Second)
|
||||
|
||||
// Add screens
|
||||
carousel.AddScreen(&ExampleScreen{name: "Dashboard", fps: 1.0})
|
||||
carousel.AddScreen(&ExampleScreen{name: "System Monitor", fps: 2.0})
|
||||
carousel.AddScreen(&ExampleScreen{name: "Network Stats", fps: 0.5})
|
||||
|
||||
// In a real application, you would run this in a goroutine
|
||||
// ctx := context.Background()
|
||||
// go carousel.Run(ctx)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package layout provides canvas and drawing utilities for hdmistat
|
||||
package layout
|
||||
|
||||
import (
|
||||
@@ -12,6 +13,21 @@ import (
|
||||
"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
|
||||
@@ -24,14 +40,18 @@ 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
|
||||
)
|
||||
|
||||
@@ -66,7 +86,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
|
||||
}
|
||||
|
||||
ctx := freetype.NewContext()
|
||||
ctx.SetDPI(72)
|
||||
ctx.SetDPI(defaultDPI)
|
||||
ctx.SetFont(c.font)
|
||||
ctx.SetFontSize(style.Size)
|
||||
ctx.SetClip(c.img.Bounds())
|
||||
@@ -76,7 +96,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
|
||||
// Calculate text bounds for alignment
|
||||
opts := truetype.Options{
|
||||
Size: style.Size,
|
||||
DPI: 72,
|
||||
DPI: defaultDPI,
|
||||
}
|
||||
face := truetype.NewFace(c.font, &opts)
|
||||
bounds, _ := font.BoundString(face, text)
|
||||
@@ -85,7 +105,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
|
||||
x := pos.X
|
||||
switch style.Alignment {
|
||||
case AlignCenter:
|
||||
x = pos.X - width.Round()/2
|
||||
x = pos.X - width.Round()/halfDivisor
|
||||
case AlignRight:
|
||||
x = pos.X - width.Round()
|
||||
}
|
||||
@@ -131,7 +151,7 @@ func (c *Canvas) DrawProgress(x, y, width, height int, percent float64, fg, bg c
|
||||
c.DrawBox(x, y, width, height, bg)
|
||||
|
||||
// Foreground
|
||||
fillWidth := int(float64(width) * percent / 100.0)
|
||||
fillWidth := int(float64(width) * percent / percentDivisor)
|
||||
if fillWidth > 0 {
|
||||
c.DrawBox(x, y, fillWidth, height, fg)
|
||||
}
|
||||
@@ -163,13 +183,12 @@ func (c *Canvas) Size() (width, height int) {
|
||||
|
||||
// FormatBytes formats byte counts for display
|
||||
func FormatBytes(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
if bytes < byteUnit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
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])
|
||||
@@ -178,9 +197,9 @@ func FormatBytes(bytes uint64) string {
|
||||
// FormatDuration formats time durations for display
|
||||
func FormatDuration(d float64) string {
|
||||
seconds := int(d)
|
||||
days := seconds / 86400
|
||||
hours := (seconds % 86400) / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
days := seconds / secondsPerDay
|
||||
hours := (seconds % secondsPerDay) / secondsPerHour
|
||||
minutes := (seconds % secondsPerHour) / secondsPerMinute
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||
|
||||
95
internal/layout/progressbar.go
Normal file
95
internal/layout/progressbar.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
const (
|
||||
// Progress bar label positioning
|
||||
labelTopOffset = 5
|
||||
labelBottomOffset = 15
|
||||
labelSizeReduction = 2
|
||||
percentMultiplier = 100
|
||||
percentTextOffset = 5
|
||||
)
|
||||
|
||||
// ProgressBar draws a labeled progress bar
|
||||
type ProgressBar struct {
|
||||
X, Y int
|
||||
Width, Height int
|
||||
Value float64 // 0.0 to 1.0
|
||||
Label string
|
||||
LeftLabel string
|
||||
RightLabel string
|
||||
BarColor color.Color
|
||||
BGColor color.Color
|
||||
TextColor color.Color
|
||||
LabelSize float64
|
||||
}
|
||||
|
||||
// Draw renders the progress bar on the canvas
|
||||
func (p *ProgressBar) Draw(canvas *Canvas) {
|
||||
// Default colors
|
||||
if p.BarColor == nil {
|
||||
p.BarColor = color.RGBA{100, 200, 255, 255}
|
||||
}
|
||||
if p.BGColor == nil {
|
||||
p.BGColor = color.RGBA{50, 50, 50, 255}
|
||||
}
|
||||
if p.TextColor == nil {
|
||||
p.TextColor = color.RGBA{255, 255, 255, 255}
|
||||
}
|
||||
if p.LabelSize == 0 {
|
||||
p.LabelSize = 14
|
||||
}
|
||||
|
||||
// Ensure value is between 0 and 1
|
||||
value := p.Value
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
if value > 1 {
|
||||
value = 1
|
||||
}
|
||||
|
||||
// Draw background
|
||||
canvas.DrawBox(p.X, p.Y, p.Width, p.Height, p.BGColor)
|
||||
|
||||
// Draw filled portion
|
||||
filledWidth := int(float64(p.Width) * value)
|
||||
if filledWidth > 0 {
|
||||
canvas.DrawBox(p.X, p.Y, filledWidth, p.Height, p.BarColor)
|
||||
}
|
||||
|
||||
// Draw label above bar if provided
|
||||
if p.Label != "" {
|
||||
labelStyle := TextStyle{Size: p.LabelSize, Color: p.TextColor}
|
||||
_ = canvas.DrawText(p.Label, Point{X: p.X, Y: p.Y - labelTopOffset}, labelStyle)
|
||||
}
|
||||
|
||||
// Draw left label
|
||||
if p.LeftLabel != "" {
|
||||
labelStyle := TextStyle{Size: p.LabelSize - labelSizeReduction, Color: p.TextColor}
|
||||
_ = canvas.DrawText(p.LeftLabel, Point{X: p.X, Y: p.Y + p.Height + labelBottomOffset}, labelStyle)
|
||||
}
|
||||
|
||||
// Draw right label
|
||||
if p.RightLabel != "" {
|
||||
labelStyle := TextStyle{Size: p.LabelSize - labelSizeReduction, Color: p.TextColor, Alignment: AlignRight}
|
||||
_ = canvas.DrawText(p.RightLabel, Point{X: p.X + p.Width, Y: p.Y + p.Height + labelBottomOffset}, labelStyle)
|
||||
}
|
||||
|
||||
// Draw percentage in center of bar
|
||||
percentText := fmt.Sprintf("%.1f%%", value*percentMultiplier)
|
||||
centerStyle := TextStyle{
|
||||
Size: p.LabelSize,
|
||||
Color: color.RGBA{255, 255, 255, 255},
|
||||
Alignment: AlignCenter,
|
||||
}
|
||||
pt := Point{
|
||||
X: p.X + p.Width/halfDivisor,
|
||||
Y: p.Y + p.Height/halfDivisor + percentTextOffset,
|
||||
}
|
||||
_ = canvas.DrawText(percentText, pt, centerStyle)
|
||||
}
|
||||
Reference in New Issue
Block a user