package html
import (
"fmt"
"math"
"unicode/utf8"
"github.com/bnema/vev-vt/core"
)
type Renderer struct {
limits Limits
generation uint64
pending *transaction
committed core.Frame
// spare is a frame page no update or transaction references. Prepare
// copies a core.Frame source into it; Commit and Abort hand a page back.
spare core.Frame
cursor Cursor
initialized bool
// Encoder scratch reused across updates; never referenced by an Update.
ends []int
text []byte
rowStarts []int
}
// CellSource is the read-only semantic grid consumed by HTML rendering.
type CellSource = core.CellSource
type transactionState uint8
const (
transactionPending transactionState = iota
transactionCommitted
transactionAborted
)
type transaction struct {
renderer *Renderer
generation uint64
state transactionState
update Update
json []byte
frame core.Frame
cursor Cursor
}
// PreparedDraw owns one immutable update and its speculative renderer state.
type PreparedDraw struct {
tx *transaction
}
func New(options Options) (*Renderer, error) {
limits, err := normalizeLimits(options.Limits)
if err != nil {
return nil, err
}
return &Renderer{limits: limits, generation: 1}, nil
}
// Prepare validates and encodes a speculative update. The renderer advances
// only after Commit. Abort preserves the previous committed shadow.
func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, cursor Cursor) (*PreparedDraw, error) {
if r == nil {
return nil, fmt.Errorf("html: nil renderer")
}
if r.pending != nil {
return nil, ErrPendingDraw
}
frame, err := r.materializeCellSource(source)
if err != nil {
return nil, err
}
update, encoded, err := r.prepareUpdate(frame, damage, reset, &cursor)
if err != nil {
r.spare = frame
return nil, err
}
tx := &transaction{
renderer: r,
generation: r.generation,
state: transactionPending,
update: update,
json: encoded,
frame: frame,
cursor: cursor,
}
r.pending = tx
return &PreparedDraw{tx: tx}, nil
}
func (r *Renderer) prepareUpdate(frame core.Frame, damage []core.Damage, reset bool, cursor *Cursor) (Update, []byte, error) {
scratch, err := r.validateFrame(frame)
if err != nil {
return Update{}, nil, err
}
*cursor = normalizeWrapPendingCursor(*cursor, frame.Width)
if err := validateCursor(*cursor, frame.Width, frame.Height); err != nil {
return Update{}, nil, err
}
snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
update, err := r.buildUpdate(frame, scratch, snapshot, *cursor)
if err != nil {
return Update{}, nil, err
}
encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
if err != nil {
return Update{}, nil, err
}
return update, encoded, nil
}
// validateFrame checks limits and cell invariants. It returns a one-row
// scratch buffer that the caller may reuse for the rest of this Prepare.
func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {
if frame.Width <= 0 || frame.Height <= 0 {
return nil, fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height)
}
if frame.Width > math.MaxInt/frame.Height {
return nil, fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded)
}
cells := frame.Width * frame.Height
if cells > r.limits.MaxCells {
return nil, fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells)
}
if frame.Height > r.limits.MaxRowsPerUpdate {
return nil, fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate)
}
if err := frame.Validate(); err != nil {
return nil, fmt.Errorf("html: validate frame: %w", err)
}
scratch := make([]core.Cell, frame.Width)
for y := range frame.Height {
row := readRow(frame, y, scratch)
for x := 0; x < frame.Width; x++ {
cell := row[x]
if err := validateCoreStyle(cell.Style); err != nil {
return nil, fmt.Errorf("html: cell (%d,%d): %w", x, y, err)
}
if cell.Continuation {
if cell.Rune != 0 {
return nil, fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y)
}
if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 {
return nil, fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y)
}
if !cell.Style.Equal(row[x-1].Style) {
return nil, fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y)
}
continue
}
if cell.Rune == 0 {
continue
}
if !utf8.ValidRune(cell.Rune) {
return nil, fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y)
}
width := core.RuneWidth(cell.Rune)
switch width {
case 1:
case 2:
if x+1 >= frame.Width || !row[x+1].Continuation {
return nil, fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y)
}
case 0:
return nil, fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y)
default:
return nil, fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width)
}
}
}
return scratch, nil
}
// materializeCellSource reads a source into an owned frame. A core.Frame is
// copied into the renderer's spare page; any other source is copied cell by
// cell.
func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error) {
if source == nil {
return core.Frame{}, fmt.Errorf("html: nil cell source")
}
switch frame := source.(type) {
case core.Frame:
if err := frame.Validate(); err != nil {
return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
}
return r.copyFrame(frame), nil
case *core.Frame:
if frame == nil {
return core.Frame{}, fmt.Errorf("html: nil cell source")
}
if err := frame.Validate(); err != nil {
return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
}
return r.copyFrame(*frame), nil
}
width, height := source.Columns(), source.Rows()
if width <= 0 || height <= 0 {
return core.Frame{}, fmt.Errorf("html: invalid cell source size %dx%d", width, height)
}
clone := core.NewFrame(width, height)
for y := range height {
for x := range width {
clone.Set(x, y, source.Cell(x, y))
}
}
return clone, nil
}
// copyFrame copies a validated frame into the spare page, which the returned
// frame then owns. CopyFrom always succeeds here: src passed Validate.
func (r *Renderer) copyFrame(src core.Frame) core.Frame {
frame := r.spare
r.spare = core.Frame{}
frame.CopyFrom(src)
return frame
}
// maxRetainedRows bounds the row count whose encoder scratch the renderer
// keeps between updates.
const maxRetainedRows = 80
// normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
// produced after writing the last column with autowrap enabled onto the
// last visible column. It leaves every in-bounds cursor untouched.
func normalizeWrapPendingCursor(cursor Cursor, width int) Cursor {
if cursor.Column == width && cursor.Row >= 0 {
cursor.Column = width - 1
}
return cursor
}
func validateCursor(cursor Cursor, width, height int) error {
if cursor.Row < 0 || cursor.Row >= height || cursor.Column < 0 || cursor.Column >= width {
return fmt.Errorf("html: cursor (%d,%d) outside %dx%d frame", cursor.Column, cursor.Row, width, height)
}
if cursor.Style > 6 {
return fmt.Errorf("html: invalid cursor style %d", cursor.Style)
}
if !cursor.StyleSet && cursor.Style != 0 {
return fmt.Errorf("html: cursor style must be zero when unset")
}
return nil
}
func damageRequiresSnapshot(damage []core.Damage, width, height int) bool {
for _, item := range damage {
switch item.Kind {
case core.DamageText, core.DamageClear:
if item.X < 0 || item.Y < 0 || item.Width <= 0 || item.Height <= 0 || item.X > width-item.Width || item.Y > height-item.Height {
return true
}
case core.DamageScrollUp, core.DamageFullRedraw:
return true
default:
return true
}
}
return false
}
// readRow copies logical row y into buf and returns the filled prefix. It is
// the allocation-free equivalent of core.Frame.Row.
func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell {
row := buf[:frame.Width]
for x := range row {
row[x] = frame.Cell(x, y)
}
return row
}
func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) {
update := Update{
SchemaVersion: UpdateSchemaVersion,
Width: frame.Width,
Height: frame.Height,
Snapshot: snapshot,
Rows: make([]RowUpdate, 0),
Styles: make([]Style, 0),
Cursor: cursor,
}
styleIDs := make(map[Style]int)
// Every row appends its runs to cells and their text to text; ends[i]
// is the text offset after cells[i]. Rows and texts are sliced out once
// at the end, so an update costs one cell slice and one string.
// cells is owned by the update and sized for one full row (an
// incremental update usually changes one or a few rows). The other
// buffers are renderer scratch: the text is copied into one string.
cells := make([]CellUpdate, 0, frame.Width)
ends, text, rowStarts := r.ends[:0], r.text[:0], r.rowStarts[:0]
if cap(ends) < frame.Width {
ends = make([]int, 0, frame.Width)
text = make([]byte, 0, frame.Width)
}
if cap(rowStarts) < min(frame.Height, maxRetainedRows) {
rowStarts = make([]int, 0, min(frame.Height, maxRetainedRows))
}
defer func() {
// Keep scratch for frames up to the common 240x80 size only, so one
// huge snapshot does not pin its buffers for the renderer lifetime.
const maxRetainedCells = 240 * maxRetainedRows
if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= maxRetainedRows {
r.ends, r.text, r.rowStarts = ends, text, rowStarts
}
}()
for y := range frame.Height {
if !snapshot && core.RowsEqualAt(frame, y, r.committed, y) {
continue
}
row := readRow(frame, y, scratch)
if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
}
rowStarts = append(rowStarts, len(cells))
update.Rows = append(update.Rows, RowUpdate{Row: y})
var err error
cells, ends, text, err = encodeRow(row, cells, ends, text, &update.Styles, styleIDs, r.limits.MaxStyles)
if err != nil {
return Update{}, err
}
}
all := string(text)
offset := 0
for i := range cells {
cells[i].Text = all[offset:ends[i]]
offset = ends[i]
}
for i := range update.Rows {
next := len(cells)
if i+1 < len(rowStarts) {
next = rowStarts[i+1]
}
update.Rows[i].Cells = cells[rowStarts[i]:next:next]
}
return update, nil
}
// encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on
// the exact encoded size. The previous conservative estimate could reject
// compact updates well below the configured limit.
func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
// The hint never exceeds limit, so an oversized update cannot reserve its
// full estimate before the limit rejects it. limit+1 would overflow for
// math.MaxInt.
encoded, err := appendUpdateJSON(make([]byte, 0, min(estimateUpdateJSONSize(update), limit)), update)
if err != nil {
return nil, fmt.Errorf("html: encode update: %w", err)
}
if len(encoded) > limit {
return nil, fmt.Errorf("%w: generated update is %d bytes, limit is %d", ErrLimitExceeded, len(encoded), limit)
}
return encoded, nil
}
// runText returns the single-column printable ASCII text of cell, treating a
// blank cell as a space, or false when the cell cannot join a text run.
func runText(cell core.Cell) (byte, bool) {
switch {
case cell.Continuation:
return 0, false
case cell.Rune == 0:
return ' ', true
case cell.Rune >= 0x20 && cell.Rune < 0x7f:
return byte(cell.Rune), true
default:
return 0, false
}
}
// encodeRow appends one CellUpdate per text run of a row to cells, its text
// to text, and the text end offset to ends. Adjacent printable ASCII or blank
// cells with equal styles merge into one run whose Width equals its byte
// length. Every other cell (wide or non-ASCII) stays a single entry, so
// browsers never rely on a fallback font's advance for column alignment.
// Text fields are filled by the caller.
func encodeRow(row []core.Cell, cells []CellUpdate, ends []int, text []byte, styles *[]Style, styleIDs map[Style]int, maxStyles int) ([]CellUpdate, []int, []byte, error) {
for x := 0; x < len(row); x++ {
cell := row[x]
if cell.Continuation {
continue
}
style := styleFromCore(cell.Style)
styleID, ok := styleIDs[style]
if !ok {
if len(*styles) >= maxStyles {
return cells, ends, text, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles)
}
styleID = len(*styles)
styleIDs[style] = styleID
*styles = append(*styles, style)
}
if first, ok := runText(cell); ok {
start := x
text = append(text, first)
for x+1 < len(row) && row[x+1].Style.Equal(cell.Style) {
next, ok := runText(row[x+1])
if !ok {
break
}
text = append(text, next)
x++
}
cells = append(cells, CellUpdate{Column: start, Width: x - start + 1, Style: styleID})
} else {
text = utf8.AppendRune(text, cell.Rune)
cells = append(cells, CellUpdate{Column: x, Width: core.RuneWidth(cell.Rune), Style: styleID})
}
ends = append(ends, len(text))
}
return cells, ends, text, nil
}
func validateCoreStyle(style core.Style) error {
const knownAttrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough
if style.Attrs & ^knownAttrs != 0 {
return fmt.Errorf("unknown style attribute bits %#x", style.Attrs&^knownAttrs)
}
if style.UnderlineStyle > core.UnderlineDashed {
return fmt.Errorf("invalid underline style %d", style.UnderlineStyle)
}
if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > 255) {
return fmt.Errorf("invalid foreground index %d", style.Foreground)
}
if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > 255) {
return fmt.Errorf("invalid background index %d", style.Background)
}
if !style.HasUnderlineColorRGB && style.HasUnderlineColor && (style.UnderlineColor < 0 || style.UnderlineColor > 255) {
return fmt.Errorf("invalid underline color index %d", style.UnderlineColor)
}
return nil
}
func styleFromCore(style core.Style) Style {
return Style{
Bold: style.Bold,
Italic: style.Italic,
Inverse: style.Inverse,
Dim: style.Attrs&core.AttrDim != 0,
Blink: style.Attrs&core.AttrBlink != 0,
Strikethrough: style.Attrs&core.AttrStrikethrough != 0,
Underline: style.Attrs&core.AttrUnderline != 0,
UnderlineStyle: style.UnderlineStyle,
Foreground: colorFromCore(style.Foreground, style.HasForegroundRGB, style.ForegroundRGB),
Background: colorFromCore(style.Background, style.HasBackgroundRGB, style.BackgroundRGB),
UnderlineColor: underlineColorFromCore(style),
}
}
// Prepare validates every indexed color before these uint8 conversions.
func colorFromCore(index int, hasRGB bool, rgb core.RGB) Color {
if hasRGB {
return Color{Kind: ColorRGB, RGB: RGB{R: rgb.R, G: rgb.G, B: rgb.B}}
}
if index >= 0 {
return Color{Kind: ColorIndexed, Index: uint8(index)}
}
return Color{Kind: ColorDefault}
}
func underlineColorFromCore(style core.Style) Color {
if style.HasUnderlineColorRGB {
return Color{Kind: ColorRGB, RGB: RGB{R: style.UnderlineColorRGB.R, G: style.UnderlineColorRGB.G, B: style.UnderlineColorRGB.B}}
}
if style.HasUnderlineColor {
return Color{Kind: ColorIndexed, Index: uint8(style.UnderlineColor)}
}
return Color{Kind: ColorDefault}
}
// Update returns an owned copy that remains valid after later renderer work.
func (p *PreparedDraw) Update() Update {
if p == nil || p.tx == nil {
return Update{}
}
return cloneUpdate(p.tx.update)
}
// JSON returns an owned canonical JSON representation of Update.
func (p *PreparedDraw) JSON() []byte {
if p == nil || p.tx == nil {
return nil
}
return append([]byte(nil), p.tx.json...)
}
// Commit atomically advances the renderer shadow exactly once.
func (p *PreparedDraw) Commit() error {
if p == nil || p.tx == nil || p.tx.renderer == nil {
return ErrStaleDraw
}
tx := p.tx
if tx.state != transactionPending {
return ErrFinalizedDraw
}
r := tx.renderer
if tx.generation != r.generation || r.pending != tx {
return ErrStaleDraw
}
r.spare = r.committed
r.committed = tx.frame
r.cursor = tx.cursor
r.initialized = true
r.pending = nil
tx.state = transactionCommitted
return nil
}
// Abort finalizes the transaction without advancing the renderer shadow.
func (p *PreparedDraw) Abort() error {
if p == nil || p.tx == nil || p.tx.renderer == nil {
return ErrStaleDraw
}
tx := p.tx
if tx.state != transactionPending {
return ErrFinalizedDraw
}
r := tx.renderer
if tx.generation != r.generation || r.pending != tx {
return ErrStaleDraw
}
r.spare = tx.frame
r.pending = nil
tx.state = transactionAborted
return nil
}
// Reset invalidates any prepared draw and clears committed state.
func (r *Renderer) Reset() {
if r == nil {
return
}
r.generation++
r.pending = nil
r.committed = core.Frame{}
r.spare = core.Frame{}
r.cursor = Cursor{}
r.initialized = false
}