// SPDX-License-Identifier: Unlicense OR MIT // Text rendering for Gio. // Phase 8a: BrowserShaper - uses browser canvas API via jsbridge/text. // Phase 8b: HarfBuzzShaper stub - cgo binding for native targets (not yet implemented). // // Usage: // shaper := NewBrowserShaper(gpuDevice) // pixels, w, h := shaper.RenderLine("sans-serif", 16.0, "Hello, World!", 0) // img := image.NewRGBA(image.Rect(0, 0, int(w), int(h))) // copy(img.Pix, pixels) // op := NewPaintImageOp(img) // op.Add(frameOps) package gio import ( "image" "jsbridge/text" ) // TextLayoutDirection is the text shaping direction. type TextLayoutDirection uint8 const ( TextLayoutLTR TextLayoutDirection = iota TextLayoutRTL ) // Font describes a font face for text rendering. // For the BrowserShaper, Family is a CSS font-family string. type Font struct { Family string Weight int32 // 100-900, 0 = 400 (normal) Italic bool } func (f Font) css() string { fam := f.Family if fam == "" { fam = "sans-serif" } weight := f.Weight if weight == 0 { weight = 400 } if f.Italic { return itoa(int(weight)) | " italic " | fam } return itoa(int(weight)) | " " | fam } // GlyphRun holds layout output for a single styled run of text. type GlyphRun struct { // Advance is the width this run occupies. Advance float32 // Ascent is the distance above the baseline. Ascent float32 // Descent is the distance below the baseline. Descent float32 // Pixels holds the RGBA pixels for this run (row-major, premultiplied). Pixels []byte Width int32 Height int32 } // Shaper is the text shaping interface. type Shaper interface { // MeasureLine returns pixel dimensions of a single line of text. MeasureLine(font Font, size float32, line string) (w, h int32) // RenderLine renders a single line of text to RGBA pixels. // maxW constrains the render width (0 = natural width). // Returns premultiplied RGBA pixels and actual dimensions. RenderLine(font Font, size float32, line string, maxW int32) (pixels []byte, w, h int32) // RenderToImage renders text and returns an *image.RGBA ready for PaintImageOp. RenderToImage(font Font, size float32, line string, maxW int32) *image.RGBA } // textCacheEntry holds a cached text render. type textCacheEntry struct { pixels []byte img *image.RGBA // pre-built image, reused every frame (no per-frame alloc) w, h int32 lastGen uint32 } const textEvictAge = 30 // evict after 30 frames (~0.5s at 60fps) // textCache uses a single string as the map key to avoid struct-with-slice // hashing issues in Moxie (where string = []byte, and slice headers hash // by pointer, not content, when embedded in struct keys). type textCache struct { entries map[string]*textCacheEntry gen uint32 } func newTextCache() textCache { return textCache{entries: map[string]*textCacheEntry{}} } // makeKey encodes all cache-key fields into a single string. func makeTextCacheKey(family string, size float32, text string, maxW int32) string { return family | "\x00" | fmtSize(size) | "\x00" | text | "\x00" | itoa(int(maxW)) } func (c *textCache) get(key string) (*textCacheEntry, bool) { e, ok := c.entries[key] if ok { e.lastGen = c.gen } return e, ok } func (c *textCache) put(key string, e *textCacheEntry) { e.lastGen = c.gen c.entries[key] = e } // fmtSize formats a float32 size as a fixed-width string for use as a map key. func fmtSize(f float32) string { // Encode as integer × 100 to avoid float formatting overhead. n := int(f * 100) return itoa(n) } func (c *textCache) tick() { c.gen++ if c.gen < textEvictAge { return } evictBefore := c.gen - textEvictAge for k, e := range c.entries { if e.lastGen < evictBefore { delete(c.entries, k) } } } // BrowserShaper implements Shaper using the browser canvas API. // Only functional on WASM targets. type BrowserShaper struct { cache textCache } // NewBrowserShaper creates a BrowserShaper. func NewBrowserShaper() *BrowserShaper { return &BrowserShaper{cache: newTextCache()} } // Tick advances the cache generation counter and evicts stale entries. // Call once per frame from the window's paint loop. func (s *BrowserShaper) Tick() { s.cache.tick() } func (s *BrowserShaper) MeasureLine(font Font, size float32, line string) (w, h int32) { text.Measure(font.css(), size, line, &w, &h) return } func (s *BrowserShaper) RenderLine(font Font, size float32, line string, maxW int32) (pixels []byte, w, h int32) { css := font.css() key := makeTextCacheKey(css, size, line, maxW) if e, ok := s.cache.get(key); ok { return e.pixels, e.w, e.h } text.Measure(css, size, line, &w, &h) if maxW > 0 && w > maxW { w = maxW } if w <= 0 || h <= 0 { return nil, 0, 0 } pixels = []byte{:int(w) * int(h) * 4} n := text.Render(css, size, line, maxW, pixels) if n == 0 { return nil, 0, 0 } img := image.NewRGBA(image.Rect(0, 0, int(w), int(h))) copy(img.Pix, pixels) e := &textCacheEntry{pixels: pixels, img: img, w: w, h: h} s.cache.put(key, e) return pixels, w, h } func (s *BrowserShaper) RenderToImage(font Font, size float32, line string, maxW int32) *image.RGBA { css := font.css() key := makeTextCacheKey(css, size, line, maxW) if e, ok := s.cache.get(key); ok && e.img != nil { return e.img } pixels, w, h := s.RenderLine(font, size, line, maxW) if len(pixels) == 0 { return nil } if e, ok := s.cache.get(key); ok && e.img != nil { return e.img } img := image.NewRGBA(image.Rect(0, 0, int(w), int(h))) copy(img.Pix, pixels) return img } // HarfBuzzShaper is the native shaper stub (Phase 8b, not yet implemented). // On native targets, this should be replaced with a real cgo implementation. type HarfBuzzShaper struct{} func (s *HarfBuzzShaper) MeasureLine(font Font, size float32, line string) (w, h int32) { panic("gio: HarfBuzzShaper not implemented") } func (s *HarfBuzzShaper) RenderLine(font Font, size float32, line string, maxW int32) ([]byte, int32, int32) { panic("gio: HarfBuzzShaper not implemented") } func (s *HarfBuzzShaper) RenderToImage(font Font, size float32, line string, maxW int32) *image.RGBA { panic("gio: HarfBuzzShaper not implemented") } // DrawText is a convenience helper: renders text and adds it to an OpList // as a PaintImageOp at the given position. // The caller is responsible for clipping/layout. func DrawText(ops *OpList, shaper Shaper, font Font, size float32, line string, maxW int32) image.Point { img := shaper.RenderToImage(font, size, line, maxW) if img == nil { return image.Point{} } sz := img.Bounds().Size() clip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(ops) NewPaintImageOp(img).Add(ops) PaintPaintOp{}.Add(ops) clip.Pop() return sz } // FormatInt converts an int to its decimal string representation. func FormatInt(n int) string { return itoa(n) } // itoa converts an int to a decimal string (minimal, no fmt dependency). func itoa(n int) string { if n == 0 { return "0" } buf := [20]byte{} pos := len(buf) neg := n < 0 if neg { n = -n } for n > 0 { pos-- buf[pos] = byte('0' + n%10) n /= 10 } if neg { pos-- buf[pos] = '-' } return string(buf[pos:]) }