189 lines
4.7 KiB
Go
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)
|
|
}
|