Files
nebara-tui/internal/ui/sixel/sixel.go
T

189 lines
4.7 KiB
Go

// Package sixel provides SIXEL graphics decoding, cell synchronization,
// and terminal rendering for the Nebara OS TUI.
package sixel
import (
"bytes"
_ "embed"
"fmt"
"image/png"
"io"
"os"
"runtime"
"sync"
"github.com/gdamore/tcell/v2"
gosixel "github.com/mattn/go-sixel"
)
// DefaultLogoPNG contains the official Nebara OS PNG logo asset.
//
//go:embed logo.png
var DefaultLogoPNG []byte
const (
// DefaultLogoCellWidth is the number of character columns reserved for the logo.
// 221px image width / ~8.5-9px cell width ≈ 25-26 columns.
DefaultLogoCellWidth = 26
// DefaultLogoCellHeight is the number of character rows reserved for the logo.
// 19px image height fits in 1 row (with breathing room from the spacer below).
DefaultLogoCellHeight = 1
)
// LogoRenderer handles encoding and rendering of the SIXEL logo.
type LogoRenderer struct {
mu sync.Mutex
sixelData []byte
pixelWidth int
pixelHeight int
cellCols int
cellRows int
enabled bool
lastX int
lastY int
hasDrawn bool
needsDraw bool
}
// NewLogoRenderer initializes the renderer using the official PNG asset.
func NewLogoRenderer() (*LogoRenderer, error) {
return NewLogoRendererFromPNG(DefaultLogoPNG)
}
// NewLogoRendererFromPNG initializes the renderer from custom PNG bytes.
func NewLogoRendererFromPNG(pngBytes []byte) (*LogoRenderer, error) {
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return nil, fmt.Errorf("decode logo PNG: %w", err)
}
bounds := img.Bounds()
pw := bounds.Dx()
ph := bounds.Dy()
var buf bytes.Buffer
enc := gosixel.NewEncoder(&buf)
enc.Transparent = true
if err := enc.Encode(img); err != nil {
return nil, fmt.Errorf("encode SIXEL: %w", err)
}
return &LogoRenderer{
sixelData: buf.Bytes(),
pixelWidth: pw,
pixelHeight: ph,
cellCols: DefaultLogoCellWidth,
cellRows: DefaultLogoCellHeight,
enabled: true,
lastX: -1,
lastY: -1,
needsDraw: true,
}, nil
}
// SetEnabled toggles SIXEL rendering.
func (r *LogoRenderer) SetEnabled(enabled bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.enabled = enabled
r.needsDraw = true
}
// IsEnabled returns whether SIXEL rendering is currently active.
func (r *LogoRenderer) IsEnabled() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.enabled
}
// Invalidate forces a re-render of the SIXEL logo on the next draw cycle.
func (r *LogoRenderer) Invalidate() {
r.mu.Lock()
defer r.mu.Unlock()
r.needsDraw = true
}
// CellCols returns the number of terminal character cells the logo occupies.
func (r *LogoRenderer) CellCols() int {
return r.cellCols
}
// CellRows returns the number of terminal character rows the logo occupies.
func (r *LogoRenderer) CellRows() int {
return r.cellRows
}
// SixelDataLength returns the byte size of the cached SIXEL stream.
func (r *LogoRenderer) SixelDataLength() int {
return len(r.sixelData)
}
// Draw locks the tcell screen buffer region and outputs the SIXEL sequence to the TTY.
func (r *LogoRenderer) Draw(screen tcell.Screen, x, y int) error {
r.mu.Lock()
defer r.mu.Unlock()
if !r.enabled || len(r.sixelData) == 0 {
return nil
}
// 1. Lock the screen region in tcell buffer so tcell.Show() skips these cells.
// This prevents tcell from clearing or drawing characters over the logo.
screen.LockRegion(x, y, r.cellCols, r.cellRows, true)
// Check if we need to re-emit the SIXEL data to the terminal.
// We re-emit if position changed, or if explicitly invalidated (e.g. after resize/modal).
posChanged := (x != r.lastX || y != r.lastY)
if !r.hasDrawn || posChanged || r.needsDraw {
if err := r.emitSixel(x, y); err != nil {
return err
}
r.lastX = x
r.lastY = y
r.hasDrawn = true
r.needsDraw = false
}
return nil
}
// emitSixel writes the escape sequences and SIXEL payload to the active terminal.
func (r *LogoRenderer) emitSixel(x, y int) error {
tty, err := openTTY()
if err != nil {
return fmt.Errorf("open TTY: %w", err)
}
defer tty.Close()
// ANSI cursor positioning is 1-indexed: row = y + 1, col = x + 1.
// Sequence:
// \x1b[s : Save current cursor position
// \x1b[%d;%dH: Move to target (row, col)
// [SIXEL payload]
// \x1b[u : Restore cursor position
posSeq := fmt.Sprintf("\x1b[s\x1b[%d;%dH", y+1, x+1)
if _, err := io.WriteString(tty, posSeq); err != nil {
return err
}
if _, err := tty.Write(r.sixelData); err != nil {
return err
}
if _, err := io.WriteString(tty, "\x1b[u"); err != nil {
return err
}
return nil
}
// openTTY opens the active terminal output device directly, bypassing tcell text buffers.
func openTTY() (io.WriteCloser, error) {
if runtime.GOOS == "windows" {
return os.OpenFile("CONOUT$", os.O_WRONLY, 0)
}
return os.OpenFile("/dev/tty", os.O_WRONLY, 0)
}