making lots of progress!
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/font"
|
||||
)
|
||||
|
||||
// Screen represents a single screen in the carousel
|
||||
@@ -21,6 +23,9 @@ type Carousel struct {
|
||||
screens []*Screen
|
||||
currentIndex int
|
||||
rotationInterval time.Duration
|
||||
fontSize float64
|
||||
gridWidth int
|
||||
gridHeight int
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
@@ -28,25 +33,104 @@ type Carousel struct {
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
const DefaultFontSize float64 = 24
|
||||
|
||||
// NewCarousel creates a new carousel
|
||||
func NewCarousel(display FramebufferDisplay, rotationInterval time.Duration) *Carousel {
|
||||
func NewCarousel(
|
||||
display FramebufferDisplay,
|
||||
rotationInterval time.Duration,
|
||||
) *Carousel {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Carousel{
|
||||
c := &Carousel{
|
||||
display: display,
|
||||
screens: make([]*Screen, 0),
|
||||
currentIndex: 0,
|
||||
rotationInterval: rotationInterval,
|
||||
fontSize: DefaultFontSize, // Default font size
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Calculate grid dimensions based on font size
|
||||
if err := c.calculateGridDimensions(); err != nil {
|
||||
// Log error but continue with default dimensions
|
||||
log.Printf("Warning: failed to calculate grid dimensions: %v", err)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// SetFontSize sets the font size and recalculates grid dimensions
|
||||
func (c *Carousel) SetFontSize(size float64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.fontSize = size
|
||||
return c.calculateGridDimensions()
|
||||
}
|
||||
|
||||
// calculateGridDimensions calculates grid size based on display and font
|
||||
func (c *Carousel) calculateGridDimensions() error {
|
||||
// Get pixel dimensions
|
||||
pixelWidth, pixelHeight := c.display.PixelSize()
|
||||
|
||||
// Calculate character dimensions first
|
||||
charWidth, charHeight, err := CalculateCharDimensions(
|
||||
font.FamilyIBMPlexMono,
|
||||
c.fontSize,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("calculating char dimensions: %w", err)
|
||||
}
|
||||
|
||||
// Log the calculated dimensions for debugging
|
||||
fmt.Printf(
|
||||
"Font size: %.0f, Char dimensions: %dx%d pixels\n",
|
||||
c.fontSize,
|
||||
charWidth,
|
||||
charHeight,
|
||||
)
|
||||
|
||||
// Calculate grid dimensions
|
||||
gridWidth, gridHeight, err := CalculateGridSize(
|
||||
pixelWidth, pixelHeight,
|
||||
font.FamilyIBMPlexMono, c.fontSize,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"Display: %dx%d pixels, Grid: %dx%d chars\n",
|
||||
pixelWidth,
|
||||
pixelHeight,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
)
|
||||
|
||||
c.gridWidth = gridWidth
|
||||
c.gridHeight = gridHeight
|
||||
|
||||
// Update display if it's an FBDisplay
|
||||
if fbDisplay, ok := c.display.(*FBDisplay); ok {
|
||||
fbDisplay.charWidth = charWidth
|
||||
fbDisplay.charHeight = charHeight
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddScreen adds a new screen to the carousel
|
||||
func (c *Carousel) AddScreen(name string, generator FrameGenerator) {
|
||||
func (c *Carousel) AddScreen(name string, generator FrameGenerator) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Initialize the generator with the calculated grid dimensions
|
||||
if err := generator.Init(c.gridWidth, c.gridHeight); err != nil {
|
||||
return fmt.Errorf("failed to initialize %s: %w", name, err)
|
||||
}
|
||||
|
||||
screen := &Screen{
|
||||
Name: name,
|
||||
Generator: generator,
|
||||
@@ -54,6 +138,7 @@ func (c *Carousel) AddScreen(name string, generator FrameGenerator) {
|
||||
}
|
||||
|
||||
c.screens = append(c.screens, screen)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the carousel
|
||||
@@ -62,15 +147,21 @@ func (c *Carousel) Run() error {
|
||||
return fmt.Errorf("no screens added to carousel")
|
||||
}
|
||||
|
||||
// Start rotation timer
|
||||
rotationTicker := time.NewTicker(c.rotationInterval)
|
||||
defer rotationTicker.Stop()
|
||||
|
||||
// Start with first screen
|
||||
if err := c.activateScreen(0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If no rotation, just wait for context cancellation
|
||||
if c.rotationInterval <= 0 {
|
||||
<-c.ctx.Done()
|
||||
return c.ctx.Err()
|
||||
}
|
||||
|
||||
// Start rotation timer
|
||||
rotationTicker := time.NewTicker(c.rotationInterval)
|
||||
defer rotationTicker.Stop()
|
||||
|
||||
// Main loop
|
||||
for {
|
||||
select {
|
||||
@@ -92,8 +183,31 @@ func (c *Carousel) Run() error {
|
||||
|
||||
// Stop stops the carousel
|
||||
func (c *Carousel) Stop() {
|
||||
// Cancel the context
|
||||
c.cancel()
|
||||
|
||||
// Stop the current screen
|
||||
c.mu.Lock()
|
||||
if c.currentIndex >= 0 && c.currentIndex < len(c.screens) {
|
||||
screen := c.screens[c.currentIndex]
|
||||
if screen.ticker != nil {
|
||||
screen.ticker.Stop()
|
||||
}
|
||||
// Close stop channel if it's still open
|
||||
select {
|
||||
case <-screen.stop:
|
||||
// Already closed
|
||||
default:
|
||||
close(screen.stop)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Wait for all goroutines
|
||||
c.wg.Wait()
|
||||
|
||||
// Close the display
|
||||
_ = c.display.Close()
|
||||
}
|
||||
|
||||
// activateScreen switches to the specified screen
|
||||
@@ -138,9 +252,17 @@ func (c *Carousel) activateScreen(index int) error {
|
||||
func (c *Carousel) runScreen(screen *Screen) {
|
||||
defer c.wg.Done()
|
||||
|
||||
// Get display size
|
||||
width, height := c.display.Size()
|
||||
grid := NewCharGrid(width, height)
|
||||
// Create grid with calculated dimensions
|
||||
grid := NewCharGrid(c.gridWidth, c.gridHeight)
|
||||
grid.FontSize = c.fontSize
|
||||
|
||||
// Set the character dimensions for proper rendering
|
||||
charWidth, charHeight, _ := CalculateCharDimensions(
|
||||
font.FamilyIBMPlexMono,
|
||||
c.fontSize,
|
||||
)
|
||||
grid.CharWidth = charWidth
|
||||
grid.CharHeight = charHeight
|
||||
|
||||
// Generate first frame immediately
|
||||
if err := screen.Generator.GenerateFrame(grid); err == nil {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd
|
||||
package fbdraw
|
||||
|
||||
import (
|
||||
@@ -216,6 +217,11 @@ func (d *FBDisplay) Size() (width, height int) {
|
||||
return
|
||||
}
|
||||
|
||||
// PixelSize returns the framebuffer dimensions in pixels
|
||||
func (d *FBDisplay) PixelSize() (width, height int) {
|
||||
return int(d.info.XRes), int(d.info.YRes)
|
||||
}
|
||||
|
||||
// Close closes the framebuffer
|
||||
func (d *FBDisplay) Close() error {
|
||||
if d.data != nil {
|
||||
@@ -234,76 +240,3 @@ func (d *FBDisplay) Close() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminalDisplay renders to the terminal using ANSI escape codes
|
||||
type TerminalDisplay struct {
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// NewTerminalDisplay creates a terminal display
|
||||
func NewTerminalDisplay(width, height int) *TerminalDisplay {
|
||||
return &TerminalDisplay{
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// Write renders a grid to the terminal
|
||||
func (d *TerminalDisplay) Write(grid *CharGrid) error {
|
||||
// Clear screen
|
||||
fmt.Print("\033[2J\033[H")
|
||||
|
||||
// Print ANSI representation
|
||||
fmt.Print(grid.ToANSI())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the terminal size in characters
|
||||
func (d *TerminalDisplay) Size() (width, height int) {
|
||||
return d.width, d.height
|
||||
}
|
||||
|
||||
// Close is a no-op for terminal display
|
||||
func (d *TerminalDisplay) Close() error {
|
||||
// Clear screen one last time
|
||||
fmt.Print("\033[2J\033[H")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogDisplay renders to a logger for debugging
|
||||
type LogDisplay struct {
|
||||
width int
|
||||
height int
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewLogDisplay creates a log display
|
||||
func NewLogDisplay(width, height int, logger *log.Logger) *LogDisplay {
|
||||
if logger == nil {
|
||||
logger = log.New(os.Stderr, "[fbdraw] ", log.LstdFlags)
|
||||
}
|
||||
|
||||
return &LogDisplay{
|
||||
width: width,
|
||||
height: height,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Write logs the grid as text
|
||||
func (d *LogDisplay) Write(grid *CharGrid) error {
|
||||
d.logger.Printf("=== Frame ===\n%s\n", grid.ToText())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the display size
|
||||
func (d *LogDisplay) Size() (width, height int) {
|
||||
return d.width, d.height
|
||||
}
|
||||
|
||||
// Close is a no-op for log display
|
||||
func (d *LogDisplay) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
77
internal/fbdraw/font_metrics.go
Normal file
77
internal/fbdraw/font_metrics.go
Normal file
@@ -0,0 +1,77 @@
|
||||
//nolint:mnd
|
||||
package fbdraw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/font"
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
)
|
||||
|
||||
// CalculateCharDimensions calculates the character dimensions for a given font and size
|
||||
func CalculateCharDimensions(
|
||||
fontFamily font.FontFamily,
|
||||
fontSize float64,
|
||||
) (charWidth, charHeight int, err error) {
|
||||
// Load a sample font to measure
|
||||
f, err := font.LoadFont(fontFamily, font.WeightRegular, false)
|
||||
if err != nil {
|
||||
// Fallback to built-in font
|
||||
f, err = truetype.Parse(goregular.TTF)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create a context to measure font metrics
|
||||
c := freetype.NewContext()
|
||||
c.SetFont(f)
|
||||
c.SetFontSize(fontSize)
|
||||
c.SetDPI(72)
|
||||
|
||||
// Get font face for measurements
|
||||
face := truetype.NewFace(f, &truetype.Options{
|
||||
Size: fontSize,
|
||||
DPI: 72,
|
||||
})
|
||||
|
||||
// For monospace fonts, get the advance width
|
||||
advance, _ := face.GlyphAdvance('M')
|
||||
charWidth = advance.Round() - 1 // Slightly tighter kerning
|
||||
|
||||
// Get line height from metrics
|
||||
metrics := face.Metrics()
|
||||
charHeight = metrics.Height.Round() + 3 // Add extra leading for better line spacing
|
||||
|
||||
fmt.Printf(
|
||||
"Font metrics: advance=%v (rounded=%d), height=%v (rounded=%d, with +3 leading)\n",
|
||||
advance,
|
||||
charWidth,
|
||||
metrics.Height,
|
||||
charHeight,
|
||||
)
|
||||
|
||||
return charWidth, charHeight, nil
|
||||
}
|
||||
|
||||
// CalculateGridSize calculates the grid dimensions that fit in the given pixel dimensions
|
||||
func CalculateGridSize(
|
||||
pixelWidth, pixelHeight int,
|
||||
fontFamily font.FontFamily,
|
||||
fontSize float64,
|
||||
) (gridWidth, gridHeight int, err error) {
|
||||
charWidth, charHeight, err := CalculateCharDimensions(
|
||||
fontFamily,
|
||||
fontSize,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
gridWidth = pixelWidth / charWidth
|
||||
gridHeight = pixelHeight / charHeight
|
||||
|
||||
return gridWidth, gridHeight, nil
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd
|
||||
package fbdraw
|
||||
|
||||
import (
|
||||
@@ -48,6 +49,18 @@ type Cell struct {
|
||||
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
|
||||
@@ -104,7 +117,13 @@ func NewCharGrid(width, height int) *CharGrid {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
@@ -119,7 +138,13 @@ func (g *CharGrid) SetCell(x, y int, r rune, fg, bg color.Color, weight font.Fon
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
@@ -142,7 +167,10 @@ func (g *CharGrid) Clear(bg color.Color) {
|
||||
}
|
||||
|
||||
// getFont retrieves a font from cache or loads it
|
||||
func (g *CharGrid) getFont(weight font.FontWeight, italic bool) (*truetype.Font, error) {
|
||||
func (g *CharGrid) getFont(
|
||||
weight font.FontWeight,
|
||||
italic bool,
|
||||
) (*truetype.Font, error) {
|
||||
key := fontKey{
|
||||
family: g.FontFamily,
|
||||
weight: weight,
|
||||
@@ -201,9 +229,11 @@ func (g *CharGrid) computeCharSize() error {
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -276,6 +306,11 @@ func (g *CharGrid) Render() (*image.RGBA, error) {
|
||||
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
|
||||
@@ -325,13 +360,15 @@ func (g *CharGrid) ToANSI() string {
|
||||
}
|
||||
|
||||
// Foreground color
|
||||
if r, g, b, _ := cell.Foreground.RGBA(); r != 0 || g != 0 || b != 0 {
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
@@ -423,13 +460,24 @@ func (w *GridWriter) SetItalic(italic bool) *GridWriter {
|
||||
// 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.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 {
|
||||
func (w *GridWriter) WriteLine(
|
||||
format string,
|
||||
args ...interface{},
|
||||
) *GridWriter {
|
||||
w.Write(format, args...)
|
||||
w.X = 0
|
||||
w.Y++
|
||||
@@ -451,6 +499,17 @@ func (w *GridWriter) Clear() *GridWriter {
|
||||
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 {
|
||||
|
||||
167
internal/fbdraw/header_wrapper.go
Normal file
167
internal/fbdraw/header_wrapper.go
Normal file
@@ -0,0 +1,167 @@
|
||||
//nolint:mnd
|
||||
package fbdraw
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HeaderWrapper wraps a FrameGenerator and adds a 3-line header
|
||||
type HeaderWrapper struct {
|
||||
wrapped FrameGenerator
|
||||
width int
|
||||
height int
|
||||
unameCache string
|
||||
lsbCache string
|
||||
}
|
||||
|
||||
// NewHeaderWrapper creates a new header wrapper around a FrameGenerator
|
||||
func NewHeaderWrapper(wrapped FrameGenerator) *HeaderWrapper {
|
||||
return &HeaderWrapper{
|
||||
wrapped: wrapped,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes both the wrapper and the wrapped generator
|
||||
func (h *HeaderWrapper) Init(width, height int) error {
|
||||
h.width = width
|
||||
h.height = height
|
||||
|
||||
// Cache uname output since it doesn't change
|
||||
// Get OS, hostname, kernel version, and architecture separately
|
||||
var parts []string
|
||||
|
||||
// OS name (e.g., "Linux", "Darwin")
|
||||
if output, err := exec.Command("uname", "-s").Output(); err == nil {
|
||||
parts = append(parts, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
// Hostname
|
||||
if output, err := exec.Command("uname", "-n").Output(); err == nil {
|
||||
parts = append(parts, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
// Kernel version
|
||||
if output, err := exec.Command("uname", "-r").Output(); err == nil {
|
||||
parts = append(parts, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
// Machine architecture
|
||||
if output, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||
parts = append(parts, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
h.unameCache = strings.Join(parts, " ")
|
||||
} else {
|
||||
h.unameCache = "Unknown System"
|
||||
}
|
||||
|
||||
// Get LSB release info
|
||||
if output, err := exec.Command("lsb_release", "-ds").Output(); err == nil {
|
||||
h.lsbCache = strings.TrimSpace(string(output))
|
||||
// Remove quotes if present
|
||||
h.lsbCache = strings.Trim(h.lsbCache, "\"")
|
||||
}
|
||||
|
||||
// Initialize wrapped generator with reduced height (minus 3 for header)
|
||||
return h.wrapped.Init(width, height-3)
|
||||
}
|
||||
|
||||
// GenerateFrame generates a frame with header and wrapped content
|
||||
func (h *HeaderWrapper) GenerateFrame(grid *CharGrid) error {
|
||||
// Create a temporary grid for the wrapped content
|
||||
contentGrid := NewCharGrid(h.width, h.height-3)
|
||||
// Copy font settings from main grid
|
||||
contentGrid.FontSize = grid.FontSize
|
||||
contentGrid.FontFamily = grid.FontFamily
|
||||
|
||||
// Let the wrapped generator fill its content
|
||||
if err := h.wrapped.GenerateFrame(contentGrid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Now we'll assemble the final grid
|
||||
// First, clear the entire grid
|
||||
grid.Clear(Black)
|
||||
|
||||
// Draw the header
|
||||
h.drawHeader(grid)
|
||||
|
||||
// Copy content from wrapped generator below the header
|
||||
for y := 0; y < contentGrid.Height; y++ {
|
||||
for x := 0; x < contentGrid.Width; x++ {
|
||||
if y < len(contentGrid.Cells) && x < len(contentGrid.Cells[y]) {
|
||||
grid.Cells[y+3][x] = contentGrid.Cells[y][x]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// drawHeader draws the 3-line header
|
||||
func (h *HeaderWrapper) drawHeader(grid *CharGrid) {
|
||||
writer := NewGridWriter(grid)
|
||||
|
||||
// Line 1: uname + lsb_release output (truncated if needed)
|
||||
writer.MoveAbs(0, 0)
|
||||
writer.SetColor(Gray60)
|
||||
sysInfo := h.unameCache
|
||||
if h.lsbCache != "" {
|
||||
sysInfo += " " + h.lsbCache
|
||||
}
|
||||
// Account for the UTC time on the right - never truncate time
|
||||
now := time.Now()
|
||||
utcTime := now.UTC().Format("Mon 2006-01-02 15:04:05 UTC")
|
||||
maxLen := h.width - len(utcTime) - 1
|
||||
if len(sysInfo) > maxLen {
|
||||
sysInfo = sysInfo[:maxLen-3] + "..."
|
||||
}
|
||||
writer.Write("%s", sysInfo)
|
||||
|
||||
// Check if local time is different from UTC
|
||||
localZone, offset := now.Zone()
|
||||
showLocalTime := offset != 0 // Only show local time if not UTC
|
||||
|
||||
// Line 2: uptime output
|
||||
writer.MoveAbs(0, 1)
|
||||
if output, err := exec.Command("uptime").Output(); err == nil {
|
||||
uptime := strings.TrimSpace(string(output))
|
||||
// Don't cut off at "user" - show the full uptime output
|
||||
maxLen := h.width - 1
|
||||
if showLocalTime {
|
||||
// Account for the local time on the right - never truncate time
|
||||
localTime := now.Format("Mon 2006-01-02 15:04:05 ") + localZone
|
||||
maxLen = h.width - len(localTime) - 1
|
||||
}
|
||||
if len(uptime) > maxLen {
|
||||
uptime = uptime[:maxLen-3] + "..."
|
||||
}
|
||||
writer.Write("%s", uptime)
|
||||
}
|
||||
|
||||
// Right side - UTC time (line 1) - always show full time
|
||||
writer.MoveAbs(h.width-len(utcTime), 0)
|
||||
writer.Write("%s", utcTime)
|
||||
|
||||
// Right side - Local time (line 2) - only show if different from UTC
|
||||
if showLocalTime {
|
||||
localTime := now.Format("Mon 2006-01-02 15:04:05 ") + localZone
|
||||
writer.MoveAbs(h.width-len(localTime), 1)
|
||||
writer.Write("%s", localTime)
|
||||
}
|
||||
|
||||
// Line 3: Horizontal rule
|
||||
writer.MoveAbs(0, 2)
|
||||
writer.SetColor(Gray30)
|
||||
for i := 0; i < h.width; i++ {
|
||||
writer.Write("─")
|
||||
}
|
||||
}
|
||||
|
||||
// FramesPerSecond returns the wrapped generator's frame rate
|
||||
func (h *HeaderWrapper) FramesPerSecond() float64 {
|
||||
return h.wrapped.FramesPerSecond()
|
||||
}
|
||||
65
internal/fbdraw/header_wrapper_test.go
Normal file
65
internal/fbdraw/header_wrapper_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package fbdraw_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
|
||||
)
|
||||
|
||||
// SimpleGenerator is a test generator
|
||||
type SimpleGenerator struct {
|
||||
width, height int
|
||||
}
|
||||
|
||||
func (s *SimpleGenerator) Init(width, height int) error {
|
||||
s.width = width
|
||||
s.height = height
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SimpleGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
|
||||
writer := fbdraw.NewGridWriter(grid)
|
||||
writer.MoveAbs(s.width/2-5, s.height/2)
|
||||
writer.SetColor(fbdraw.White)
|
||||
writer.Write("Test Content")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SimpleGenerator) FramesPerSecond() float64 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
func TestHeaderWrapper(t *testing.T) {
|
||||
// Create a simple generator
|
||||
simple := &SimpleGenerator{}
|
||||
|
||||
// Wrap it with header
|
||||
wrapped := fbdraw.NewHeaderWrapper(simple)
|
||||
|
||||
// Initialize with some dimensions
|
||||
if err := wrapped.Init(80, 25); err != nil {
|
||||
t.Fatalf("Failed to init: %v", err)
|
||||
}
|
||||
|
||||
// Generate a frame
|
||||
grid := fbdraw.NewCharGrid(80, 25)
|
||||
if err := wrapped.GenerateFrame(grid); err != nil {
|
||||
t.Fatalf("Failed to generate frame: %v", err)
|
||||
}
|
||||
|
||||
// Check that header exists (line 3 should have horizontal rule)
|
||||
hasRule := false
|
||||
for x := 0; x < 80; x++ {
|
||||
if grid.Cells[2][x].Rune == '─' {
|
||||
hasRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasRule {
|
||||
t.Error("Expected horizontal rule on line 3")
|
||||
}
|
||||
|
||||
// Log the output for visual inspection
|
||||
t.Logf("Generated frame with header:\n%s", grid.String())
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package fbdraw
|
||||
|
||||
// FrameGenerator generates frames for a screen
|
||||
type FrameGenerator interface {
|
||||
// Init is called once when the screen is added to the carousel
|
||||
// width and height are the character dimensions that frames will be requested at
|
||||
Init(width, height int) error
|
||||
|
||||
// GenerateFrame is called to render a new frame
|
||||
GenerateFrame(grid *CharGrid) error
|
||||
|
||||
@@ -17,6 +21,9 @@ type FramebufferDisplay interface {
|
||||
// Size returns the display dimensions in characters
|
||||
Size() (width, height int)
|
||||
|
||||
// PixelSize returns the display dimensions in pixels
|
||||
PixelSize() (width, height int)
|
||||
|
||||
// Close cleans up resources
|
||||
Close() error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user