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