text.mx raw

   1  // SPDX-License-Identifier: Unlicense OR MIT
   2  
   3  // Text rendering for Gio.
   4  // Phase 8a: BrowserShaper - uses browser canvas API via jsbridge/text.
   5  // Phase 8b: HarfBuzzShaper stub - cgo binding for native targets (not yet implemented).
   6  //
   7  // Usage:
   8  //   shaper := NewBrowserShaper(gpuDevice)
   9  //   pixels, w, h := shaper.RenderLine("sans-serif", 16.0, "Hello, World!", 0)
  10  //   img := image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
  11  //   copy(img.Pix, pixels)
  12  //   op := NewPaintImageOp(img)
  13  //   op.Add(frameOps)
  14  
  15  package gio
  16  
  17  import (
  18  	"image"
  19  
  20  	"jsbridge/text"
  21  )
  22  
  23  // TextLayoutDirection is the text shaping direction.
  24  type TextLayoutDirection uint8
  25  
  26  const (
  27  	TextLayoutLTR TextLayoutDirection = iota
  28  	TextLayoutRTL
  29  )
  30  
  31  // Font describes a font face for text rendering.
  32  // For the BrowserShaper, Family is a CSS font-family string.
  33  type Font struct {
  34  	Family string
  35  	Weight int32  // 100-900, 0 = 400 (normal)
  36  	Italic bool
  37  }
  38  
  39  func (f Font) css() string {
  40  	fam := f.Family
  41  	if fam == "" {
  42  		fam = "sans-serif"
  43  	}
  44  	weight := f.Weight
  45  	if weight == 0 {
  46  		weight = 400
  47  	}
  48  	if f.Italic {
  49  		return itoa(int(weight)) | " italic " | fam
  50  	}
  51  	return itoa(int(weight)) | " " | fam
  52  }
  53  
  54  // GlyphRun holds layout output for a single styled run of text.
  55  type GlyphRun struct {
  56  	// Advance is the width this run occupies.
  57  	Advance float32
  58  	// Ascent is the distance above the baseline.
  59  	Ascent float32
  60  	// Descent is the distance below the baseline.
  61  	Descent float32
  62  	// Pixels holds the RGBA pixels for this run (row-major, premultiplied).
  63  	Pixels []byte
  64  	Width  int32
  65  	Height int32
  66  }
  67  
  68  // Shaper is the text shaping interface.
  69  type Shaper interface {
  70  	// MeasureLine returns pixel dimensions of a single line of text.
  71  	MeasureLine(font Font, size float32, line string) (w, h int32)
  72  	// RenderLine renders a single line of text to RGBA pixels.
  73  	// maxW constrains the render width (0 = natural width).
  74  	// Returns premultiplied RGBA pixels and actual dimensions.
  75  	RenderLine(font Font, size float32, line string, maxW int32) (pixels []byte, w, h int32)
  76  	// RenderToImage renders text and returns an *image.RGBA ready for PaintImageOp.
  77  	RenderToImage(font Font, size float32, line string, maxW int32) *image.RGBA
  78  }
  79  
  80  // textCacheEntry holds a cached text render.
  81  type textCacheEntry struct {
  82  	pixels  []byte
  83  	img     *image.RGBA // pre-built image, reused every frame (no per-frame alloc)
  84  	w, h    int32
  85  	lastGen uint32
  86  }
  87  
  88  const textEvictAge = 30 // evict after 30 frames (~0.5s at 60fps)
  89  
  90  // textCache uses a single string as the map key to avoid struct-with-slice
  91  // hashing issues in Moxie (where string = []byte, and slice headers hash
  92  // by pointer, not content, when embedded in struct keys).
  93  type textCache struct {
  94  	entries map[string]*textCacheEntry
  95  	gen     uint32
  96  }
  97  
  98  func newTextCache() textCache {
  99  	return textCache{entries: map[string]*textCacheEntry{}}
 100  }
 101  
 102  // makeKey encodes all cache-key fields into a single string.
 103  func makeTextCacheKey(family string, size float32, text string, maxW int32) string {
 104  	return family | "\x00" | fmtSize(size) | "\x00" | text | "\x00" | itoa(int(maxW))
 105  }
 106  
 107  func (c *textCache) get(key string) (*textCacheEntry, bool) {
 108  	e, ok := c.entries[key]
 109  	if ok {
 110  		e.lastGen = c.gen
 111  	}
 112  	return e, ok
 113  }
 114  
 115  func (c *textCache) put(key string, e *textCacheEntry) {
 116  	e.lastGen = c.gen
 117  	c.entries[key] = e
 118  }
 119  
 120  // fmtSize formats a float32 size as a fixed-width string for use as a map key.
 121  func fmtSize(f float32) string {
 122  	// Encode as integer × 100 to avoid float formatting overhead.
 123  	n := int(f * 100)
 124  	return itoa(n)
 125  }
 126  
 127  func (c *textCache) tick() {
 128  	c.gen++
 129  	if c.gen < textEvictAge {
 130  		return
 131  	}
 132  	evictBefore := c.gen - textEvictAge
 133  	for k, e := range c.entries {
 134  		if e.lastGen < evictBefore {
 135  			delete(c.entries, k)
 136  		}
 137  	}
 138  }
 139  
 140  // BrowserShaper implements Shaper using the browser canvas API.
 141  // Only functional on WASM targets.
 142  type BrowserShaper struct {
 143  	cache textCache
 144  }
 145  
 146  // NewBrowserShaper creates a BrowserShaper.
 147  func NewBrowserShaper() *BrowserShaper {
 148  	return &BrowserShaper{cache: newTextCache()}
 149  }
 150  
 151  // Tick advances the cache generation counter and evicts stale entries.
 152  // Call once per frame from the window's paint loop.
 153  func (s *BrowserShaper) Tick() {
 154  	s.cache.tick()
 155  }
 156  
 157  func (s *BrowserShaper) MeasureLine(font Font, size float32, line string) (w, h int32) {
 158  	text.Measure(font.css(), size, line, &w, &h)
 159  	return
 160  }
 161  
 162  func (s *BrowserShaper) RenderLine(font Font, size float32, line string, maxW int32) (pixels []byte, w, h int32) {
 163  	css := font.css()
 164  	key := makeTextCacheKey(css, size, line, maxW)
 165  	if e, ok := s.cache.get(key); ok {
 166  		return e.pixels, e.w, e.h
 167  	}
 168  	text.Measure(css, size, line, &w, &h)
 169  	if maxW > 0 && w > maxW {
 170  		w = maxW
 171  	}
 172  	if w <= 0 || h <= 0 {
 173  		return nil, 0, 0
 174  	}
 175  	pixels = []byte{:int(w) * int(h) * 4}
 176  	n := text.Render(css, size, line, maxW, pixels)
 177  	if n == 0 {
 178  		return nil, 0, 0
 179  	}
 180  	img := image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
 181  	copy(img.Pix, pixels)
 182  	e := &textCacheEntry{pixels: pixels, img: img, w: w, h: h}
 183  	s.cache.put(key, e)
 184  	return pixels, w, h
 185  }
 186  
 187  func (s *BrowserShaper) RenderToImage(font Font, size float32, line string, maxW int32) *image.RGBA {
 188  	css := font.css()
 189  	key := makeTextCacheKey(css, size, line, maxW)
 190  	if e, ok := s.cache.get(key); ok && e.img != nil {
 191  		return e.img
 192  	}
 193  	pixels, w, h := s.RenderLine(font, size, line, maxW)
 194  	if len(pixels) == 0 {
 195  		return nil
 196  	}
 197  	if e, ok := s.cache.get(key); ok && e.img != nil {
 198  		return e.img
 199  	}
 200  	img := image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
 201  	copy(img.Pix, pixels)
 202  	return img
 203  }
 204  
 205  // HarfBuzzShaper is the native shaper stub (Phase 8b, not yet implemented).
 206  // On native targets, this should be replaced with a real cgo implementation.
 207  type HarfBuzzShaper struct{}
 208  
 209  func (s *HarfBuzzShaper) MeasureLine(font Font, size float32, line string) (w, h int32) {
 210  	panic("gio: HarfBuzzShaper not implemented")
 211  }
 212  
 213  func (s *HarfBuzzShaper) RenderLine(font Font, size float32, line string, maxW int32) ([]byte, int32, int32) {
 214  	panic("gio: HarfBuzzShaper not implemented")
 215  }
 216  
 217  func (s *HarfBuzzShaper) RenderToImage(font Font, size float32, line string, maxW int32) *image.RGBA {
 218  	panic("gio: HarfBuzzShaper not implemented")
 219  }
 220  
 221  // DrawText is a convenience helper: renders text and adds it to an OpList
 222  // as a PaintImageOp at the given position.
 223  // The caller is responsible for clipping/layout.
 224  func DrawText(ops *OpList, shaper Shaper, font Font, size float32, line string, maxW int32) image.Point {
 225  	img := shaper.RenderToImage(font, size, line, maxW)
 226  	if img == nil {
 227  		return image.Point{}
 228  	}
 229  	sz := img.Bounds().Size()
 230  	clip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(ops)
 231  	NewPaintImageOp(img).Add(ops)
 232  	PaintPaintOp{}.Add(ops)
 233  	clip.Pop()
 234  	return sz
 235  }
 236  
 237  // FormatInt converts an int to its decimal string representation.
 238  func FormatInt(n int) string { return itoa(n) }
 239  
 240  // itoa converts an int to a decimal string (minimal, no fmt dependency).
 241  func itoa(n int) string {
 242  	if n == 0 {
 243  		return "0"
 244  	}
 245  	buf := [20]byte{}
 246  	pos := len(buf)
 247  	neg := n < 0
 248  	if neg {
 249  		n = -n
 250  	}
 251  	for n > 0 {
 252  		pos--
 253  		buf[pos] = byte('0' + n%10)
 254  		n /= 10
 255  	}
 256  	if neg {
 257  		pos--
 258  		buf[pos] = '-'
 259  	}
 260  	return string(buf[pos:])
 261  }
 262