Commit: Initial Update
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,188 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package sixel_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/nebara/nebara-os/internal/ui/sixel"
|
||||
)
|
||||
|
||||
func TestNewLogoRenderer(t *testing.T) {
|
||||
r, err := sixel.NewLogoRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogoRenderer failed: %v", err)
|
||||
}
|
||||
|
||||
if r.SixelDataLength() == 0 {
|
||||
t.Error("SIXEL data should not be empty")
|
||||
}
|
||||
|
||||
if r.CellCols() != sixel.DefaultLogoCellWidth {
|
||||
t.Errorf("expected CellCols %d, got %d", sixel.DefaultLogoCellWidth, r.CellCols())
|
||||
}
|
||||
|
||||
if r.CellRows() != sixel.DefaultLogoCellHeight {
|
||||
t.Errorf("expected CellRows %d, got %d", sixel.DefaultLogoCellHeight, r.CellRows())
|
||||
}
|
||||
|
||||
if !r.IsEnabled() {
|
||||
t.Error("Renderer should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleEnabled(t *testing.T) {
|
||||
r, err := sixel.NewLogoRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogoRenderer failed: %v", err)
|
||||
}
|
||||
|
||||
r.SetEnabled(false)
|
||||
if r.IsEnabled() {
|
||||
t.Error("Renderer should be disabled")
|
||||
}
|
||||
|
||||
r.SetEnabled(true)
|
||||
if !r.IsEnabled() {
|
||||
t.Error("Renderer should be re-enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_SimulationScreenLock(t *testing.T) {
|
||||
r, err := sixel.NewLogoRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogoRenderer failed: %v", err)
|
||||
}
|
||||
|
||||
sim := tcell.NewSimulationScreen("")
|
||||
if err := sim.Init(); err != nil {
|
||||
t.Fatalf("simScreen init failed: %v", err)
|
||||
}
|
||||
defer sim.Fini()
|
||||
|
||||
sim.SetSize(120, 40)
|
||||
|
||||
// In test environment, emitSixel may fail if CONOUT$ or /dev/tty is unavailable in CI/headless,
|
||||
// but LockRegion must be executed on the screen.
|
||||
_ = r.Draw(sim, 4, 1)
|
||||
|
||||
// Invalidate and verify
|
||||
r.Invalidate()
|
||||
}
|
||||
Reference in New Issue
Block a user