From 09a5702735a0c9c33b4ffcc5f8a3800f18a83cc3 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 13 May 2026 10:19:17 +0300 Subject: [PATCH] feat: custom font loading pipeline + Mac Retina fix (v0.1.23) FontRegistry (global singleton, CSS weight matching, Inter fallback), StyledTextDrawer optional interface on Canvas + SceneCanvas, Plugin LoadFont auto-registers in GlobalFontRegistry, TextWidget.FontFamily() builder routes to StyledTextDrawer. Mac Retina fix via gg v0.46.9 (gg#308, @sverrehu). CJK IsCJK propagation via gg v0.46.8 (gg#304). 47 new tests. ARCHITECTURE.md + ROADMAP.md updated. --- CHANGELOG.md | 13 +- ROADMAP.md | 44 +++--- docs/ARCHITECTURE.md | 40 ++++- internal/render/canvas.go | 70 +++++++++ internal/render/canvas_test.go | 126 ++++++++++++++++ internal/render/fontregistry.go | 173 ++++++++++++++++++++++ internal/render/fontregistry_test.go | 213 +++++++++++++++++++++++++++ internal/render/scene_canvas.go | 91 ++++++++++++ internal/render/scene_canvas_test.go | 141 ++++++++++++++++++ plugin/assets.go | 47 +++++- plugin/assets_test.go | 58 ++++++++ plugin/context.go | 22 ++- plugin/context_test.go | 82 +++++++++++ primitives/text.go | 50 +++++++ primitives/text_test.go | 190 ++++++++++++++++++++++++ uitest/canvas.go | 31 +++- widget/canvas.go | 68 +++++++++ 17 files changed, 1421 insertions(+), 38 deletions(-) create mode 100644 internal/render/fontregistry.go create mode 100644 internal/render/fontregistry_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index eda17ef..f75482c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.22] — 2026-05-13 +## [0.1.23] — 2026-05-13 + +### Added + +- **Custom font loading pipeline** (TASK-UI-CJK-001, gg#304) — plugins can now load custom fonts via `ctx.Assets.LoadFont("name", data)` and they are used for rendering. Follows Flutter/Qt6/Iced universal pattern: global `FontRegistry` singleton with CSS weight matching and Inter fallback. +- **`FontRegistry`** (`internal/render/fontregistry.go`) — process-global, thread-safe (RWMutex) font registry. Pre-registers embedded Inter. Caches `*text.FontSource` by (family, weight, style). CSS weight matching via `theme/font.Registry`. +- **`StyledTextDrawer`** optional interface (`widget/canvas.go`) — `DrawStyledText(text, bounds, TextStyle)` + `MeasureStyledText(text, TextStyle)`. Implemented by both Canvas and SceneCanvas. Uses type assertion pattern consistent with `ArcStroker`, `SVGFiller`, `DeviceScaler`. +- **`TextWidget.FontFamily()`** builder method (`primitives/text.go`) — routes to `StyledTextDrawer` when custom font family set, falls back to regular `DrawText` with Inter. +- **Plugin → Registry wiring** — `MemoryAssetLoader.LoadFont()` auto-registers fonts in `GlobalFontRegistry()`. `NewDefaultPluginContext()` creates real `MemoryAssetLoader` instead of noop. +- **47 new tests** across fontregistry, canvas, scene_canvas, plugin, primitives, uitest packages. ### Fixed -- **gg v0.46.9** — fix Mac Retina rendering (gg#308, @sverrehu). `MarkDirty()` used logical pixel dimensions for texture upload region — on Retina (scale=2.0), only 1/4 of the pixmap was uploaded to the GPU texture. Regression from gg v0.45.4. Includes 3 HiDPI regression tests with `mockHiDPIProvider`. +- **gg v0.46.9** — fix Mac Retina rendering (gg#308, @sverrehu). `MarkDirty()` used logical pixel dimensions for texture upload region — on Retina (scale=2.0), only 1/4 of the pixmap was uploaded to the GPU texture. Regression from gg v0.45.4. Includes 3 HiDPI regression tests. ### Dependencies diff --git a/ROADMAP.md b/ROADMAP.md index fe8d0c7..fcc25b1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # gogpu/ui Roadmap -> **Version:** 0.1.20 (Enterprise Render Pipeline + Layer Tree Compositor) +> **Version:** 0.1.23 (Custom Font Pipeline + Mac Retina Fix + CJK) > **Updated:** May 2026 > **Go Version:** 1.25+ @@ -283,13 +283,17 @@ v1.0.0 → Production (when ready) | ScreenBounds | Screen-space coordinate transform for overlay positioning | | Event Coordinate Transform | ScrollView mouse/wheel coordinate transforms | | Inter Font Unicode | Full Unicode Inter 4.1 (Cyrillic, Greek, Vietnamese) | +| **Custom Font Loading Pipeline** | **FontRegistry (global singleton, CSS weight matching), StyledTextDrawer optional interface, Plugin→Registry wiring, TextWidget.FontFamily()** | +| **Mac Retina Fix** | **gg v0.46.9 MarkDirty() logical→physical pixel fix (gg#308, @sverrehu)** | +| **CJK IsCJK Propagation** | **gg v0.46.8 ShapedGlyph.IsCJK through scene/shaper paths (gg#304)** | **Remaining:** | Task | Description | Priority | |------|-------------|----------| -| **Damage-aware compositor** | **LoadOpLoad + partial blit (gg-level). Spinner GPU 8% → <3%** | **P0** | -| **Parent chain fix** | **BoxWidget SetParent → correct propagateDirtyUpward** | **P1** | +| **GPU spinner <3%** | **scheduler.SetOnDirty needsRedraw lifecycle optimization** | **P0** | +| **ListView hover rebuild** | **Painter pattern: hover = repaint, not widget rebuild (~15 LOC)** | **P1** | +| **Texture GC** | **Prune orphaned boundaryTextures entries (~20 LOC)** | **P1** | | Accessibility adapters | Platform-specific AT-SPI / UIA adapters | P1 | | RichText widget | Styled text with inline formatting, links | P2 | | NumberField widget | Numeric input with increment/decrement, ranges | P2 | @@ -348,16 +352,16 @@ Single-pass compositor (Flutter OffsetLayer / Chrome cc pattern): - **Pumper isolation**: ScheduleAnimationFrame only pumper trigger, data tickers don't restart 30fps - **34 integration tests**: multi-frame lifecycle, visibility matrix, damage rects, recording order -> **Note:** `ui/compositor/` package (Layer Tree: OffsetLayer, PictureLayer, ClipRectLayer, -> OpacityLayer, Compositor) is fully implemented and tested but **NOT connected to -> production pipeline**. Phase 7 per-boundary GPU textures replaced it — direct texture -> caching + blit is simpler. Layer Tree remains for future animated transforms/opacity. +### Phase 4: Layer Tree + Damage-Aware Compositor (ADR-007 Phase D, ADR-030) ✅ Done -### Phase 4: Damage-Aware Compositor — Next - -- **LoadOpLoad**: gg-level optimization — preserve previous framebuffer, blit only dirty regions -- **Partial present**: PresentWithDamage sends dirty rects to OS compositor -- **Expected result**: spinner GPU 8% → <3% (only 48×48 blit instead of full-screen) +- **Layer Tree compositor in production** — `compositor/` drives render loop (OffsetLayer, PictureLayer, ClipRectLayer, OpacityLayer) +- **Persistent Layer Tree** — `UpdateLayerTree()` reuses layers (97.9% fewer allocs, 613→13) +- **O(1) frame skip** — flat dirty boundary set replaces O(n) tree walk (45× faster) +- **Multi-rect damage** — per-draw dynamic scissor, ring buffer, 16-rect merge threshold +- **LoadOpLoad** — preserve previous framebuffer, blit only damage rects +- **Partial present** — `PresentWithDamage` sends dirty rects through full stack +- **Overlay boundary pipeline** — dropdown/dialog content via same Layer Tree +- **Remaining:** scheduler.SetOnDirty lifecycle → spinner GPU 10% → <3% ### Phase 5: Vello Compute Integration — Future @@ -367,11 +371,11 @@ Full Vello 9-stage compute pipeline for GPU-accelerated path rendering: ### Performance Targets -| Metric | Phase 2 | Phase 3 ✅ | Phase 4 | Phase 5 | -|--------|---------|-----------|---------|---------| -| GPU % (static UI) | 8% | **0%** | 0% | 0% | -| GPU % (spinner) | 8% | **8%** | <3% | <1% | -| GPU % (spinner offscreen) | 8% | **0%** | 0% | 0% | +| Metric | Phase 2 | Phase 3 ✅ | Phase 4 ✅ | Phase 5 | +|--------|---------|-----------|-----------|---------| +| GPU % (static UI) | 8% | **0%** | **0%** | 0% | +| GPU % (spinner) | 8% | 8% | **10%** (48×48 scissor) | <1% | +| GPU % (spinner offscreen) | 8% | **0%** | **0%** | 0% | | GPU readback | 0 | 0 | 0 | 0 | --- @@ -434,13 +438,13 @@ Full Vello 9-stage compute pipeline for GPU-accelerated path rendering: | Dependency | Version | Purpose | Status | |------------|---------|---------|--------| -| gogpu/gg | v0.46.4 | 2D rendering + scene.Scene | ✅ Integrated | +| gogpu/gg | v0.46.9 | 2D rendering + scene.Scene | ✅ Integrated | | gogpu/gpucontext | v0.18.0 | Shared interfaces | ✅ Integrated | -| gogpu/gogpu | v0.34.0 | Windowing (examples) | ✅ Integrated | +| gogpu/gogpu | v0.34.3 | Windowing (examples) | ✅ Integrated | | coregx/signals | v0.1.0 | State management | ✅ Integrated | | golang.org/x/image | v0.39.0 | Inter font (standard) | ✅ Integrated | -**Indirect:** go-text/typesetting v0.3.4, gogpu/gputypes v0.5.0, gogpu/wgpu v0.27.1, gogpu/naga v0.17.13, golang.org/x/text v0.36.0 +**Indirect:** go-text/typesetting v0.3.4, gogpu/gputypes v0.5.0, gogpu/wgpu v0.27.3, gogpu/naga v0.17.13, golang.org/x/text v0.36.0 --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 72c0d89..a74c4ff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -165,7 +165,7 @@ | Package | Purpose | Key Types | |---------|---------|-----------| -| `internal/render/` | Canvas, SceneCanvas, Renderer backed by gg | `Canvas`, `SceneCanvas`, `Renderer`, `SoftwareTarget`, `RenderConfig` | +| `internal/render/` | Canvas, SceneCanvas, FontRegistry, Renderer backed by gg | `Canvas`, `SceneCanvas`, `FontRegistry`, `Renderer`, `SoftwareTarget`, `RenderConfig` | | `internal/layout/` | Layout engines | `FlexContainer`, `VStack`, `HStack`, `GridContainer`, `Engine` | | `internal/focus/` | Focus manager implementation | `Manager`, `Shortcut`, `DrawFocusRing`, traversal helpers | | `internal/dirty/` | Dirty region tracking | `Tracker`, `Collector`, merge algorithm, partial repaints | @@ -303,6 +303,31 @@ Key design decisions: - Clip and transform use push/pop stacks (not Save/Restore) - PushTransform applies a translation offset (not a full matrix) +### Custom Font Support (StyledTextDrawer) + +Canvas supports custom fonts via the optional `StyledTextDrawer` interface: + +```go +// widget/canvas.go +type TextStyle struct { + FontFamily string + FontSize float32 + Bold bool + Italic bool + Color Color + Align TextAlign +} + +type StyledTextDrawer interface { + DrawStyledText(text string, bounds geometry.Rect, style TextStyle) + MeasureStyledText(text string, style TextStyle) float32 +} +``` + +Both `Canvas` and `SceneCanvas` implement `StyledTextDrawer`. Widgets use type assertion (`if sd, ok := canvas.(widget.StyledTextDrawer); ok { ... }`), consistent with `ArcStroker`, `SVGFiller`, `DeviceScaler`, and `TextModeController`. + +Font resolution uses a global `FontRegistry` (process-singleton, RWMutex) with CSS weight matching and Inter fallback. Plugins register fonts via `ctx.Assets.LoadFont("name", data)` which auto-registers in the global registry. This follows the universal pattern from Flutter (`FontCollection`), Qt6 (`QFontDatabase`), and Iced (`FontSystem`). + ### Focusable Interface `Focusable` (`widget/focusable.go`) is an opt-in interface for widgets that accept keyboard focus: @@ -879,7 +904,9 @@ Key functions: - Manages clip stack and transform stack internally - Clip intersection computed manually; visibility checked per draw call - Transform is translation-only (offset accumulation) -- Text rendering uses Inter font (Regular/Bold) via `gg/text.FontSource` +- Text rendering uses Inter font by default (Regular/Bold) via `gg/text.FontSource` +- Custom fonts resolved via global `FontRegistry` (CSS weight matching, `*text.FontSource` caching) +- Implements `StyledTextDrawer` for custom font rendering alongside standard `DrawText` - Color conversion: `widget.Color` (float32) to `gg.RGBA` (float64) via `ToGGColor`/`FromGGColor` ### Renderer @@ -1370,7 +1397,7 @@ Implements `widget.Widget`, `a11y.Accessible`, and `widget.Lifecycle` (for signa ### TextWidget -Renders text with configurable font size, color, bold, and alignment. +Renders text with configurable font size, color, bold, alignment, and optional font family. The `FontFamily(name)` builder method routes to `StyledTextDrawer` for custom font rendering when available, with Inter fallback. ### ImageWidget @@ -1395,7 +1422,8 @@ type Plugin interface { - `Manager` handles registration, dependency resolution, and initialization order - `PluginContext` provides access to widget registry, theme registry, and asset loader - `Dependency` declares required plugins with version constraints -- `AssetLoader` handles asset management for plugins +- `AssetLoader` handles asset management for plugins (fonts, icons, images) +- `MemoryAssetLoader.LoadFont()` auto-registers fonts in the global `FontRegistry` — loaded fonts are immediately available to all widgets via `StyledTextDrawer` --- @@ -1413,7 +1441,7 @@ The `registry/` package provides a global registry for widget factories: | Dependency | Purpose | Version | |------------|---------|---------| -| `github.com/gogpu/gg` | 2D graphics + scene.Scene tile-parallel rendering | v0.46.7 | +| `github.com/gogpu/gg` | 2D graphics + scene.Scene tile-parallel rendering | v0.46.9 | | `github.com/gogpu/gpucontext` | Window/Platform provider interfaces | v0.18.0 | | `github.com/gogpu/gogpu` | Application framework, windowing (examples only) | v0.34.3 | | `github.com/coregx/signals` | Reactive state management | v0.1.0 | @@ -1497,4 +1525,4 @@ All types in `geometry/` are small structs passed by value. Operations return ne --- -*This document reflects the actual codebase as of May 11, 2026 (v0.1.20 — Layer Tree compositor, O(1) frame skip, persistent tree, multi-rect damage, overlay boundary pipeline, software backend e2e tests, ~120 new tests).* +*This document reflects the actual codebase as of May 13, 2026 (v0.1.23 — custom font loading pipeline, FontRegistry, StyledTextDrawer, Mac Retina fix, CJK IsCJK fix).* diff --git a/internal/render/canvas.go b/internal/render/canvas.go index aee8ee5..a2a3c08 100644 --- a/internal/render/canvas.go +++ b/internal/render/canvas.go @@ -513,6 +513,73 @@ func (c *Canvas) MeasureText(s string, fontSize float32, bold bool) float32 { return float32(w) } +// DrawStyledText draws text within the given bounding rectangle using +// a font resolved from the global [FontRegistry]. This enables rendering +// with custom fonts (CJK, icon fonts, etc.) loaded via the plugin system. +// +// If the FontFamily is empty or not found, the default Inter font is used. +func (c *Canvas) DrawStyledText(s string, bounds geometry.Rect, style widget.TextStyle) { + if s == "" { + return + } + + bounds = c.applyTransform(bounds) + if !c.isVisible(bounds) { + return + } + + source := c.resolveStyledFont(style) + if source == nil { + return + } + + face := source.Face(float64(style.FontSize)) + c.dc.SetFont(face) + c.dc.SetRGBA(float64(style.Color.R), float64(style.Color.G), float64(style.Color.B), float64(style.Color.A)) + + // Calculate baseline Y by centering text vertically within bounds. + metrics := face.Metrics() + textHeight := metrics.Ascent + metrics.Descent + baselineY := math.Round(float64(bounds.Min.Y) + (float64(bounds.Height())-textHeight)/2 + metrics.Ascent) + + // Calculate x position based on alignment. + w, _ := c.dc.MeasureString(s) + available := float64(bounds.Width()) + x := float64(bounds.Min.X) + if w < available { + x += (available - w) * style.Align.Float64() + } + x = math.Round(x) + + c.dc.DrawString(s, x, baselineY) +} + +// MeasureStyledText returns the width in pixels of the given text string +// when rendered with the specified [widget.TextStyle]. The font is resolved +// from the global [FontRegistry]. +func (c *Canvas) MeasureStyledText(s string, style widget.TextStyle) float32 { + if s == "" { + return 0 + } + + source := c.resolveStyledFont(style) + if source == nil { + return float32(len([]rune(s))) * style.FontSize * 0.5 + } + + face := source.Face(float64(style.FontSize)) + c.dc.SetFont(face) + w, _ := c.dc.MeasureString(s) + return float32(w) +} + +// resolveStyledFont resolves a [text.FontSource] from a [widget.TextStyle] +// using the global [FontRegistry]. Falls back to the default Inter font +// when the family is empty or not found. +func (c *Canvas) resolveStyledFont(style widget.TextStyle) *text.FontSource { + return resolveStyledFontSource(style) +} + // DrawImage draws an image at the specified position. // // The image is composited using source-over blending via gg.DrawImage. @@ -716,3 +783,6 @@ var _ widget.Canvas = (*Canvas)(nil) // Verify Canvas implements widget.ArcStroker. var _ widget.ArcStroker = (*Canvas)(nil) + +// Verify Canvas implements widget.StyledTextDrawer. +var _ widget.StyledTextDrawer = (*Canvas)(nil) diff --git a/internal/render/canvas_test.go b/internal/render/canvas_test.go index fbaafed..7c7b398 100644 --- a/internal/render/canvas_test.go +++ b/internal/render/canvas_test.go @@ -467,6 +467,132 @@ func TestCanvas_TextModeController(t *testing.T) { } } +func TestCanvas_ImplementsStyledTextDrawer(t *testing.T) { + canvas := newTestCanvas(100, 100) + + var _ widget.StyledTextDrawer = canvas + _, ok := widget.Canvas(canvas).(widget.StyledTextDrawer) + if !ok { + t.Fatal("Canvas should implement widget.StyledTextDrawer") + } +} + +func TestCanvas_DrawStyledText_DefaultFont(t *testing.T) { + canvas := newTestCanvas(200, 100) + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{ + FontSize: 14, + Color: widget.ColorBlack, + Align: widget.TextAlignLeft, + } + + // Should not panic -- uses default Inter font. + canvas.DrawStyledText("Hello World", bounds, style) +} + +func TestCanvas_DrawStyledText_BoldFont(t *testing.T) { + canvas := newTestCanvas(200, 100) + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{ + FontSize: 14, + Bold: true, + Color: widget.ColorBlack, + Align: widget.TextAlignCenter, + } + + // Should not panic -- uses Inter Bold. + canvas.DrawStyledText("Bold Text", bounds, style) +} + +func TestCanvas_DrawStyledText_EmptyString(t *testing.T) { + canvas := newTestCanvas(200, 100) + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{FontSize: 14, Color: widget.ColorBlack} + + // Empty string should be a no-op. + canvas.DrawStyledText("", bounds, style) +} + +func TestCanvas_DrawStyledText_OutsideClip(t *testing.T) { + canvas := newTestCanvas(100, 100) + + // Bounds completely outside the canvas. + bounds := geometry.NewRect(500, 500, 100, 30) + style := widget.TextStyle{FontSize: 14, Color: widget.ColorBlack} + + // Should be culled silently. + canvas.DrawStyledText("Offscreen", bounds, style) +} + +func TestCanvas_DrawStyledText_ExplicitFamily(t *testing.T) { + canvas := newTestCanvas(200, 100) + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{ + FontFamily: "Inter", + FontSize: 16, + Color: widget.ColorBlack, + Align: widget.TextAlignRight, + } + + // Should resolve Inter by name. + canvas.DrawStyledText("Inter Font", bounds, style) +} + +func TestCanvas_DrawStyledText_UnknownFamily(t *testing.T) { + canvas := newTestCanvas(200, 100) + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{ + FontFamily: "NonExistentFont", + FontSize: 14, + Color: widget.ColorBlack, + } + + // Unknown family should fall back to Inter, not panic. + canvas.DrawStyledText("Fallback", bounds, style) +} + +func TestCanvas_MeasureStyledText_Default(t *testing.T) { + canvas := newTestCanvas(200, 100) + + style := widget.TextStyle{FontSize: 14, Color: widget.ColorBlack} + w := canvas.MeasureStyledText("Hello", style) + + if w <= 0 { + t.Errorf("MeasureStyledText(Hello) = %f, want > 0", w) + } +} + +func TestCanvas_MeasureStyledText_Empty(t *testing.T) { + canvas := newTestCanvas(200, 100) + + style := widget.TextStyle{FontSize: 14} + w := canvas.MeasureStyledText("", style) + + if w != 0 { + t.Errorf("MeasureStyledText('') = %f, want 0", w) + } +} + +func TestCanvas_MeasureStyledText_BoldWider(t *testing.T) { + canvas := newTestCanvas(200, 100) + + regular := widget.TextStyle{FontSize: 14} + bold := widget.TextStyle{FontSize: 14, Bold: true} + + wRegular := canvas.MeasureStyledText("Hello World", regular) + wBold := canvas.MeasureStyledText("Hello World", bold) + + // Both should be positive. + if wRegular <= 0 || wBold <= 0 { + t.Errorf("widths should be > 0: regular=%f, bold=%f", wRegular, wBold) + } +} + func BenchmarkCanvas_Clear(b *testing.B) { canvas := newTestCanvas(800, 600) color := widget.ColorWhite diff --git a/internal/render/fontregistry.go b/internal/render/fontregistry.go new file mode 100644 index 0000000..a07afca --- /dev/null +++ b/internal/render/fontregistry.go @@ -0,0 +1,173 @@ +package render + +import ( + "fmt" + "sync" + + "github.com/gogpu/gg/text" + "github.com/gogpu/ui/internal/render/fonts" + "github.com/gogpu/ui/theme/font" +) + +// defaultFontFamily is the embedded default font family name. +// Inter is pre-registered in every [FontRegistry] and used as fallback. +const defaultFontFamily = "Inter" + +// FontRegistry manages font families and creates cached [text.FontSource] +// instances for rendering. It bridges [font.Registry] (metadata + CSS weight +// matching) with gg's [text.FontSource] (rendering). +// +// FontRegistry is safe for concurrent use. +// +// Resolution chain: +// 1. Exact match: family + weight + style +// 2. CSS weight matching via [font.Registry.Resolve] +// 3. Fallback to embedded Inter (Regular or Bold) +// +// FontSource instances are cached by the raw font data pointer to avoid +// re-parsing identical font files. +type FontRegistry struct { + mu sync.RWMutex + + // metadata holds font family metadata and CSS weight matching logic. + metadata *font.Registry + + // sources caches *text.FontSource by a composite key of + // (family, weight, style) that was resolved. + sources map[sourceKey]*text.FontSource +} + +// sourceKey identifies a resolved font source in the cache. +type sourceKey struct { + family string + weight font.Weight + style font.Style +} + +// NewFontRegistry creates a new FontRegistry with embedded Inter as the +// default font family. +func NewFontRegistry() *FontRegistry { + r := &FontRegistry{ + metadata: font.NewRegistry(), + sources: make(map[sourceKey]*text.FontSource), + } + + // Pre-register embedded Inter font as the default family so that + // unset FontFamily fields resolve to Inter. + r.metadata.RegisterFamily(font.Family{ + Name: defaultFontFamily, + Faces: []font.Face{ + {Weight: font.Regular, Style: font.Normal, Data: fonts.InterRegular}, + {Weight: font.Bold, Style: font.Normal, Data: fonts.InterBold}, + }, + }) + + return r +} + +// Register adds a font face to the registry. +// +// The data parameter must contain valid TTF or OTF font data. It is stored +// by the underlying [font.Registry] which makes a defensive copy. +// +// Returns an error if the font data cannot be parsed into a [text.FontSource]. +func (r *FontRegistry) Register(family string, weight font.Weight, style font.Style, data []byte) error { + if len(data) == 0 { + return fmt.Errorf("fontregistry: empty font data for %s/%s/%s", family, weight, style) + } + + // Validate that the font data can be parsed before storing. + src, err := text.NewFontSource(data) + if err != nil { + return fmt.Errorf("fontregistry: invalid font data for %s/%s/%s: %w", family, weight, style, err) + } + + r.mu.Lock() + defer r.mu.Unlock() + + r.metadata.RegisterFamily(font.Family{ + Name: family, + Faces: []font.Face{ + {Weight: weight, Style: style, Data: data}, + }, + }) + + // Cache the pre-built FontSource for this exact key. + key := sourceKey{family: family, weight: weight, style: style} + r.sources[key] = src + + return nil +} + +// Resolve finds the best matching [text.FontSource] for the given family, +// weight, and style. Returns nil if no font can be resolved (should not +// happen in practice because Inter is always registered). +// +// Resolution uses CSS font-matching via [font.Registry.Resolve], then +// creates and caches a [text.FontSource] from the resolved data. +func (r *FontRegistry) Resolve(family string, weight font.Weight, style font.Style) *text.FontSource { + r.mu.RLock() + key := sourceKey{family: family, weight: weight, style: style} + if src, ok := r.sources[key]; ok { + r.mu.RUnlock() + return src + } + r.mu.RUnlock() + + // Resolve font data via CSS weight matching. + data, ok := r.metadata.Resolve(family, weight, style) + if !ok { + // Fall back to default family (Inter). + data, ok = r.metadata.Resolve(defaultFontFamily, weight, style) + if !ok { + return nil + } + } + + // Create FontSource from resolved data. + src, err := text.NewFontSource(data) + if err != nil { + return nil + } + + // Cache for future lookups. + r.mu.Lock() + // Double-check: another goroutine might have created it. + if existing, ok := r.sources[key]; ok { + r.mu.Unlock() + return existing + } + r.sources[key] = src + r.mu.Unlock() + + return src +} + +// HasFamily reports whether the given family name is registered. +func (r *FontRegistry) HasFamily(family string) bool { + return r.metadata.HasFamily(family) +} + +// FamilyNames returns a sorted list of all registered family names. +func (r *FontRegistry) FamilyNames() []string { + return r.metadata.FamilyNames() +} + +// globalFontRegistry is the process-wide font registry singleton. +// Initialized lazily via [GlobalFontRegistry]. +var ( + globalRegistryOnce sync.Once + globalRegistry *FontRegistry +) + +// GlobalFontRegistry returns the process-wide [FontRegistry] singleton. +// +// The global registry is created lazily on first call with embedded Inter +// pre-registered. Plugin font loading and Canvas rendering both use this +// singleton to share font data. +func GlobalFontRegistry() *FontRegistry { + globalRegistryOnce.Do(func() { + globalRegistry = NewFontRegistry() + }) + return globalRegistry +} diff --git a/internal/render/fontregistry_test.go b/internal/render/fontregistry_test.go new file mode 100644 index 0000000..4766154 --- /dev/null +++ b/internal/render/fontregistry_test.go @@ -0,0 +1,213 @@ +package render + +import ( + "sync" + "testing" + + "github.com/gogpu/ui/internal/render/fonts" + "github.com/gogpu/ui/theme/font" +) + +func TestNewFontRegistry_InterPreRegistered(t *testing.T) { + r := NewFontRegistry() + + if !r.HasFamily("Inter") { + t.Fatal("NewFontRegistry should pre-register Inter family") + } + + names := r.FamilyNames() + found := false + for _, n := range names { + if n == "Inter" { + found = true + break + } + } + if !found { + t.Errorf("FamilyNames() = %v, want to contain Inter", names) + } +} + +func TestFontRegistry_Resolve_InterRegular(t *testing.T) { + r := NewFontRegistry() + + src := r.Resolve("Inter", font.Regular, font.Normal) + if src == nil { + t.Fatal("Resolve(Inter, Regular, Normal) returned nil") + } +} + +func TestFontRegistry_Resolve_InterBold(t *testing.T) { + r := NewFontRegistry() + + src := r.Resolve("Inter", font.Bold, font.Normal) + if src == nil { + t.Fatal("Resolve(Inter, Bold, Normal) returned nil") + } +} + +func TestFontRegistry_Resolve_FallbackToInter(t *testing.T) { + r := NewFontRegistry() + + // Unknown family should fall back to Inter. + src := r.Resolve("NonExistent", font.Regular, font.Normal) + if src == nil { + t.Fatal("Resolve for unknown family should fall back to Inter, got nil") + } +} + +func TestFontRegistry_Register_ValidFont(t *testing.T) { + r := NewFontRegistry() + + // Register Inter data under a custom family name. + err := r.Register("TestFamily", font.Regular, font.Normal, fonts.InterRegular) + if err != nil { + t.Fatalf("Register valid font data: %v", err) + } + + if !r.HasFamily("TestFamily") { + t.Fatal("HasFamily(TestFamily) should be true after Register") + } + + src := r.Resolve("TestFamily", font.Regular, font.Normal) + if src == nil { + t.Fatal("Resolve(TestFamily) should return a FontSource after Register") + } +} + +func TestFontRegistry_Register_EmptyData(t *testing.T) { + r := NewFontRegistry() + + err := r.Register("Empty", font.Regular, font.Normal, nil) + if err == nil { + t.Fatal("Register with nil data should return error") + } + + err = r.Register("Empty", font.Regular, font.Normal, []byte{}) + if err == nil { + t.Fatal("Register with empty data should return error") + } +} + +func TestFontRegistry_Register_InvalidData(t *testing.T) { + r := NewFontRegistry() + + err := r.Register("Bad", font.Regular, font.Normal, []byte("not a font")) + if err == nil { + t.Fatal("Register with invalid font data should return error") + } +} + +func TestFontRegistry_Register_MultipleWeights(t *testing.T) { + r := NewFontRegistry() + + if err := r.Register("Multi", font.Regular, font.Normal, fonts.InterRegular); err != nil { + t.Fatalf("Register regular: %v", err) + } + if err := r.Register("Multi", font.Bold, font.Normal, fonts.InterBold); err != nil { + t.Fatalf("Register bold: %v", err) + } + + regular := r.Resolve("Multi", font.Regular, font.Normal) + bold := r.Resolve("Multi", font.Bold, font.Normal) + + if regular == nil || bold == nil { + t.Fatal("Both regular and bold should resolve for Multi family") + } + + // They should be different FontSource instances (different font data). + if regular == bold { + t.Error("Regular and Bold should resolve to different FontSources") + } +} + +func TestFontRegistry_Resolve_CSSWeightFallback(t *testing.T) { + r := NewFontRegistry() + + // Register only Bold for a family. + if err := r.Register("BoldOnly", font.Bold, font.Normal, fonts.InterBold); err != nil { + t.Fatalf("Register: %v", err) + } + + // Request Regular — CSS weight matching should fall back to Bold. + src := r.Resolve("BoldOnly", font.Regular, font.Normal) + if src == nil { + t.Fatal("CSS weight fallback should resolve to Bold when Regular unavailable") + } +} + +func TestFontRegistry_Resolve_Caching(t *testing.T) { + r := NewFontRegistry() + + // First resolve creates and caches. + src1 := r.Resolve("Inter", font.Regular, font.Normal) + // Second resolve should return the cached instance. + src2 := r.Resolve("Inter", font.Regular, font.Normal) + + if src1 != src2 { + t.Error("Resolve should return cached FontSource on subsequent calls") + } +} + +func TestFontRegistry_Concurrent(t *testing.T) { + r := NewFontRegistry() + + var wg sync.WaitGroup + const goroutines = 10 + + // Concurrent reads. + for range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + _ = r.Resolve("Inter", font.Regular, font.Normal) + _ = r.HasFamily("Inter") + _ = r.FamilyNames() + }() + } + + // Concurrent write + reads. + for i := range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + // Register with unique names to avoid racing on same entry. + _ = r.Register("ConcFamily", font.Weight(400+i*100), font.Normal, fonts.InterRegular) + }() + } + + wg.Wait() +} + +func TestGlobalFontRegistry_Singleton(t *testing.T) { + r1 := GlobalFontRegistry() + r2 := GlobalFontRegistry() + + if r1 != r2 { + t.Error("GlobalFontRegistry should return the same singleton") + } + + if r1 == nil { + t.Fatal("GlobalFontRegistry should not return nil") + } + + if !r1.HasFamily("Inter") { + t.Error("Global registry should have Inter pre-registered") + } +} + +func TestFontRegistry_FamilyNames_Sorted(t *testing.T) { + r := NewFontRegistry() + + // Register additional families. + _ = r.Register("Zebra", font.Regular, font.Normal, fonts.InterRegular) + _ = r.Register("Alpha", font.Regular, font.Normal, fonts.InterRegular) + + names := r.FamilyNames() + for i := 1; i < len(names); i++ { + if names[i-1] > names[i] { + t.Errorf("FamilyNames not sorted: %v", names) + break + } + } +} diff --git a/internal/render/scene_canvas.go b/internal/render/scene_canvas.go index 81968e4..b2b485f 100644 --- a/internal/render/scene_canvas.go +++ b/internal/render/scene_canvas.go @@ -10,6 +10,7 @@ import ( "github.com/gogpu/gg/scene" "github.com/gogpu/gg/text" "github.com/gogpu/ui/geometry" + "github.com/gogpu/ui/theme/font" "github.com/gogpu/ui/widget" ) @@ -388,6 +389,93 @@ func (c *SceneCanvas) MeasureText(s string, fontSize float32, bold bool) float32 return float32(len([]rune(s))) * fontSize * 0.5 } +// DrawStyledText draws text within the given bounding rectangle using +// a font resolved from the global [FontRegistry]. This enables rendering +// with custom fonts (CJK, icon fonts, etc.) loaded via the plugin system. +// +// If the FontFamily is empty or not found, the default Inter font is used. +func (c *SceneCanvas) DrawStyledText(s string, bounds geometry.Rect, style widget.TextStyle) { + if s == "" { + return + } + + bounds = c.applyTransform(bounds) + if !c.isVisible(bounds) { + return + } + + if bounds.Width() <= 0 || bounds.Height() <= 0 { + return + } + + source := resolveStyledFontSource(style) + if source == nil { + return + } + + face := source.Face(float64(style.FontSize)) + + // Calculate baseline Y by centering text vertically within bounds. + metrics := face.Metrics() + textHeight := metrics.Ascent + metrics.Descent + baselineY := math.Round(float64(bounds.Min.Y) + (float64(bounds.Height())-textHeight)/2 + metrics.Ascent) + + // Calculate x position based on alignment. + tw, _ := text.Measure(s, face) + available := float64(bounds.Width()) + x := float64(bounds.Min.X) + if tw < available { + x += (available - tw) * style.Align.Float64() + } + x = math.Round(x) + + // Record text as vector glyph outlines into the scene. + brush := scene.SolidBrush(ToGGColor(style.Color)) + _ = c.sc.DrawText(s, face, float32(x), float32(baselineY), brush) +} + +// MeasureStyledText returns the width in pixels of the given text string +// when rendered with the specified [widget.TextStyle]. The font is resolved +// from the global [FontRegistry]. +func (c *SceneCanvas) MeasureStyledText(s string, style widget.TextStyle) float32 { + if s == "" { + return 0 + } + + source := resolveStyledFontSource(style) + if source != nil { + face := source.Face(float64(style.FontSize)) + w, _ := text.Measure(s, face) + return float32(w) + } + + // Fallback: approximate with average character width. + return float32(len([]rune(s))) * style.FontSize * 0.5 +} + +// resolveStyledFontSource resolves a [text.FontSource] from a [widget.TextStyle] +// using the global [FontRegistry]. Shared by Canvas and SceneCanvas. +func resolveStyledFontSource(style widget.TextStyle) *text.FontSource { + reg := GlobalFontRegistry() + + family := style.FontFamily + if family == "" { + family = defaultFontFamily + } + + weight := font.Regular + if style.Bold { + weight = font.Bold + } + + fontStyle := font.Normal + if style.Italic { + fontStyle = font.Italic + } + + return reg.Resolve(family, weight, fontStyle) +} + // DrawImage draws an image at the specified position. func (c *SceneCanvas) DrawImage(img image.Image, at geometry.Point) { if img == nil { @@ -729,3 +817,6 @@ var _ widget.SVGFiller = (*SceneCanvas)(nil) // Verify SceneCanvas implements widget.SVGRenderer. var _ widget.SVGRenderer = (*SceneCanvas)(nil) + +// Verify SceneCanvas implements widget.StyledTextDrawer. +var _ widget.StyledTextDrawer = (*SceneCanvas)(nil) diff --git a/internal/render/scene_canvas_test.go b/internal/render/scene_canvas_test.go index 868df91..b874877 100644 --- a/internal/render/scene_canvas_test.go +++ b/internal/render/scene_canvas_test.go @@ -920,3 +920,144 @@ func TestSceneCanvas_RenderSVG_Scale2_CacheHit(t *testing.T) { t.Error("second RenderSVG call at same scale should produce a cache hit") } } + +// --- StyledTextDrawer interface --- + +func TestSceneCanvas_ImplementsStyledTextDrawer(t *testing.T) { + var _ widget.StyledTextDrawer = (*SceneCanvas)(nil) +} + +func TestSceneCanvas_DrawStyledText_DefaultFont(t *testing.T) { + sc := scene.NewScene() + c := NewSceneCanvas(sc, 200, 100) + defer c.Close() + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{ + FontSize: 14, + Color: widget.ColorBlack, + Align: widget.TextAlignLeft, + } + + // Should not panic -- uses default Inter font. + c.DrawStyledText("Hello World", bounds, style) + + if sc.IsEmpty() { + t.Error("scene should have content after DrawStyledText") + } +} + +func TestSceneCanvas_DrawStyledText_Bold(t *testing.T) { + sc := scene.NewScene() + c := NewSceneCanvas(sc, 200, 100) + defer c.Close() + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{ + FontSize: 14, + Bold: true, + Color: widget.ColorBlack, + Align: widget.TextAlignCenter, + } + + c.DrawStyledText("Bold Text", bounds, style) + + if sc.IsEmpty() { + t.Error("scene should have content after DrawStyledText with bold") + } +} + +func TestSceneCanvas_DrawStyledText_Empty(t *testing.T) { + sc := scene.NewScene() + c := NewSceneCanvas(sc, 200, 100) + defer c.Close() + + style := widget.TextStyle{FontSize: 14, Color: widget.ColorBlack} + c.DrawStyledText("", geometry.NewRect(10, 10, 180, 30), style) + + if !sc.IsEmpty() { + t.Error("scene should be empty after DrawStyledText with empty string") + } +} + +func TestSceneCanvas_DrawStyledText_OutsideClip(t *testing.T) { + sc := scene.NewScene() + c := NewSceneCanvas(sc, 100, 100) + defer c.Close() + + // Bounds completely outside the canvas. + bounds := geometry.NewRect(500, 500, 100, 30) + style := widget.TextStyle{FontSize: 14, Color: widget.ColorBlack} + + c.DrawStyledText("Offscreen", bounds, style) + + if !sc.IsEmpty() { + t.Error("scene should be empty after DrawStyledText with offscreen bounds") + } +} + +func TestSceneCanvas_DrawStyledText_ExplicitFamily(t *testing.T) { + sc := scene.NewScene() + c := NewSceneCanvas(sc, 200, 100) + defer c.Close() + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{ + FontFamily: "Inter", + FontSize: 16, + Color: widget.ColorBlack, + Align: widget.TextAlignRight, + } + + c.DrawStyledText("Inter Font", bounds, style) + + if sc.IsEmpty() { + t.Error("scene should have content after DrawStyledText with Inter family") + } +} + +func TestSceneCanvas_DrawStyledText_UnknownFamily(t *testing.T) { + sc := scene.NewScene() + c := NewSceneCanvas(sc, 200, 100) + defer c.Close() + + bounds := geometry.NewRect(10, 10, 180, 30) + style := widget.TextStyle{ + FontFamily: "NonExistentFont", + FontSize: 14, + Color: widget.ColorBlack, + } + + // Unknown family should fall back to Inter. + c.DrawStyledText("Fallback", bounds, style) + + if sc.IsEmpty() { + t.Error("scene should have content after DrawStyledText with unknown family (fallback)") + } +} + +func TestSceneCanvas_MeasureStyledText_Default(t *testing.T) { + sc := scene.NewScene() + c := NewSceneCanvas(sc, 200, 100) + defer c.Close() + + style := widget.TextStyle{FontSize: 14, Color: widget.ColorBlack} + w := c.MeasureStyledText("Hello", style) + + if w <= 0 { + t.Errorf("MeasureStyledText(Hello) = %f, want > 0", w) + } +} + +func TestSceneCanvas_MeasureStyledText_Empty(t *testing.T) { + sc := scene.NewScene() + c := NewSceneCanvas(sc, 200, 100) + defer c.Close() + + style := widget.TextStyle{FontSize: 14} + w := c.MeasureStyledText("", style) + + if w != 0 { + t.Errorf("MeasureStyledText('') = %f, want 0", w) + } +} diff --git a/plugin/assets.go b/plugin/assets.go index dc3be58..f111969 100644 --- a/plugin/assets.go +++ b/plugin/assets.go @@ -86,17 +86,30 @@ func (n *noopAssetLoader) LoadImage(_ string, _ []byte) error { // Verify noopAssetLoader implements AssetLoader. var _ AssetLoader = (*noopAssetLoader)(nil) +// FontRegisterer is called when a font is loaded via [MemoryAssetLoader] +// to register it with the rendering pipeline's font registry. +// +// The name parameter is the family name (e.g., "NotoSansCJK"), and data +// is the raw TTF/OTF bytes. Implementations should register the font so +// that [widget.StyledTextDrawer] can resolve it by name. +type FontRegisterer func(name string, data []byte) error + // MemoryAssetLoader is a simple in-memory implementation of AssetLoader. // // It stores all loaded assets in memory and provides methods to // retrieve them. This is useful for testing and simple applications. // +// When a [FontRegisterer] is set, LoadFont additionally registers the +// font with the rendering pipeline so that widgets using +// [widget.StyledTextDrawer] can resolve custom fonts by family name. +// // MemoryAssetLoader is thread-safe. type MemoryAssetLoader struct { - mu sync.RWMutex - fonts map[string][]byte - icons map[string][]byte - images map[string][]byte + mu sync.RWMutex + fonts map[string][]byte + icons map[string][]byte + images map[string][]byte + fontRegisterer FontRegisterer } // NewMemoryAssetLoader creates a new MemoryAssetLoader. @@ -108,7 +121,26 @@ func NewMemoryAssetLoader() *MemoryAssetLoader { } } +// SetFontRegisterer sets a callback that is invoked when [LoadFont] is +// called, bridging plugin font loading to the rendering pipeline. +// +// Example: +// +// loader := plugin.NewMemoryAssetLoader() +// loader.SetFontRegisterer(func(name string, data []byte) error { +// return render.GlobalFontRegistry().Register(name, font.Regular, font.Normal, data) +// }) +func (m *MemoryAssetLoader) SetFontRegisterer(fn FontRegisterer) { + m.mu.Lock() + defer m.mu.Unlock() + m.fontRegisterer = fn +} + // LoadFont implements AssetLoader. +// +// If a [FontRegisterer] has been set via [SetFontRegisterer], LoadFont +// additionally registers the font with the rendering pipeline so that +// widgets using [widget.StyledTextDrawer] can render text with it. func (m *MemoryAssetLoader) LoadFont(name string, data []byte) error { m.mu.Lock() defer m.mu.Unlock() @@ -118,6 +150,13 @@ func (m *MemoryAssetLoader) LoadFont(name string, data []byte) error { copy(copied, data) m.fonts[name] = copied + // Register with the rendering pipeline if a registerer is set. + if m.fontRegisterer != nil { + if err := m.fontRegisterer(name, copied); err != nil { + return err + } + } + return nil } diff --git a/plugin/assets_test.go b/plugin/assets_test.go index 9fd44a3..a9c4361 100644 --- a/plugin/assets_test.go +++ b/plugin/assets_test.go @@ -2,6 +2,7 @@ package plugin import ( "bytes" + "fmt" "sync" "testing" ) @@ -296,3 +297,60 @@ func TestMemoryAssetLoaderNilData(t *testing.T) { t.Errorf("Expected empty data, got %v", data) } } + +// TestMemoryAssetLoaderFontRegisterer tests that LoadFont calls the registerer. +func TestMemoryAssetLoaderFontRegisterer(t *testing.T) { + loader := NewMemoryAssetLoader() + + var registeredName string + var registeredData []byte + loader.SetFontRegisterer(func(name string, data []byte) error { + registeredName = name + registeredData = data + return nil + }) + + fontData := []byte("fake-font-data") + if err := loader.LoadFont("TestFont", fontData); err != nil { + t.Fatalf("LoadFont failed: %v", err) + } + + if registeredName != "TestFont" { + t.Errorf("registerer name = %q, want TestFont", registeredName) + } + if !bytes.Equal(registeredData, fontData) { + t.Error("registerer data should match loaded font data") + } +} + +// TestMemoryAssetLoaderFontRegisterer_Error tests that registerer errors propagate. +func TestMemoryAssetLoaderFontRegisterer_Error(t *testing.T) { + loader := NewMemoryAssetLoader() + loader.SetFontRegisterer(func(_ string, _ []byte) error { + return errForTesting + }) + + err := loader.LoadFont("Bad", []byte("data")) + if err == nil { + t.Fatal("LoadFont should propagate registerer error") + } + + // Font should still be stored in memory (store happens before register). + _, ok := loader.GetFont("Bad") + if !ok { + t.Error("Font data should still be stored despite registerer error") + } +} + +// TestMemoryAssetLoaderFontRegisterer_NilRegisterer tests no panic without registerer. +func TestMemoryAssetLoaderFontRegisterer_NilRegisterer(t *testing.T) { + loader := NewMemoryAssetLoader() + // No SetFontRegisterer call -- should not panic. + + if err := loader.LoadFont("Test", []byte("data")); err != nil { + t.Fatalf("LoadFont without registerer should succeed: %v", err) + } +} + +// errForTesting is a sentinel error for tests. +var errForTesting = fmt.Errorf("test error") diff --git a/plugin/context.go b/plugin/context.go index 4c2ebf6..c39cb25 100644 --- a/plugin/context.go +++ b/plugin/context.go @@ -1,9 +1,11 @@ package plugin import ( + "github.com/gogpu/ui/internal/render" "github.com/gogpu/ui/layout" "github.com/gogpu/ui/registry" "github.com/gogpu/ui/theme" + "github.com/gogpu/ui/theme/font" ) // PluginContext provides access to UI registries for plugin initialization. @@ -95,16 +97,30 @@ func NewPluginContext( ctx.Layouts = layout.GlobalRegistry() } if ctx.Assets == nil { - ctx.Assets = &noopAssetLoader{} + loader := NewMemoryAssetLoader() + loader.SetFontRegisterer(func(name string, data []byte) error { + return render.GlobalFontRegistry().Register(name, font.Regular, font.Normal, data) + }) + ctx.Assets = loader } return ctx } -// NewDefaultPluginContext creates a PluginContext with global registries. +// NewDefaultPluginContext creates a PluginContext with global registries +// and a [MemoryAssetLoader] wired to the global font registry. +// +// Fonts loaded via [AssetLoader.LoadFont] are automatically registered +// with the rendering pipeline's [render.GlobalFontRegistry], making them +// available to widgets that use [widget.StyledTextDrawer] (e.g., +// [primitives.TextWidget] with [primitives.TextWidget.FontFamily]). // // This is the standard context used when initializing plugins through // the global plugin manager. func NewDefaultPluginContext() *PluginContext { - return NewPluginContext(nil, nil, nil, nil) + loader := NewMemoryAssetLoader() + loader.SetFontRegisterer(func(name string, data []byte) error { + return render.GlobalFontRegistry().Register(name, font.Regular, font.Normal, data) + }) + return NewPluginContext(nil, nil, nil, loader) } diff --git a/plugin/context_test.go b/plugin/context_test.go index 8dfa2a5..025e50f 100644 --- a/plugin/context_test.go +++ b/plugin/context_test.go @@ -3,6 +3,7 @@ package plugin import ( "testing" + "github.com/gogpu/ui/internal/render" "github.com/gogpu/ui/layout" "github.com/gogpu/ui/registry" "github.com/gogpu/ui/theme" @@ -127,3 +128,84 @@ func TestPluginContextUsage(t *testing.T) { t.Error("Theme should be registered") } } + +// TestNewDefaultPluginContext_AssetLoaderIsMemory verifies that the default +// context creates a MemoryAssetLoader (not a no-op) so that fonts are stored. +func TestNewDefaultPluginContext_AssetLoaderIsMemory(t *testing.T) { + ctx := NewDefaultPluginContext() + + loader, ok := ctx.Assets.(*MemoryAssetLoader) + if !ok { + t.Fatalf("Assets should be *MemoryAssetLoader, got %T", ctx.Assets) + } + + // Verify it's a real MemoryAssetLoader by checking icons (no registerer validation). + if err := loader.LoadIcon("test-icon", []byte("svg-data")); err != nil { + t.Fatalf("LoadIcon failed: %v", err) + } + + data, ok := loader.GetIcon("test-icon") + if !ok || len(data) == 0 { + t.Error("MemoryAssetLoader should store asset data") + } + + // LoadFont with invalid data should return an error from the registerer + // (font validation), proving the registerer is wired. + err := loader.LoadFont("bad-font", []byte("not-a-font")) + if err == nil { + t.Error("LoadFont with invalid data should fail due to wired font registerer validation") + } +} + +// TestNewPluginContext_NilAssets_CreatesWiredLoader verifies that passing nil +// for assets creates a MemoryAssetLoader with FontRegisterer connected to +// the global font registry. +func TestNewPluginContext_NilAssets_CreatesWiredLoader(t *testing.T) { + ctx := NewPluginContext(nil, nil, nil, nil) + + loader, ok := ctx.Assets.(*MemoryAssetLoader) + if !ok { + t.Fatalf("nil assets should create *MemoryAssetLoader, got %T", ctx.Assets) + } + + // The font registerer should be set. + if loader.fontRegisterer == nil { + t.Fatal("MemoryAssetLoader should have fontRegisterer wired") + } +} + +// TestNewDefaultPluginContext_FontRegistererWired verifies that fonts loaded +// via the default context's asset loader reach the global font registry. +func TestNewDefaultPluginContext_FontRegistererWired(t *testing.T) { + ctx := NewDefaultPluginContext() + reg := render.GlobalFontRegistry() + + // Load valid font data (use the embedded Inter as test data). + // We check that the registerer is called. Since the test font data + // may not be a real font file, we use a known-good approach: + // verify the registerer callback is set and invoked. + loader, ok := ctx.Assets.(*MemoryAssetLoader) + if !ok { + t.Fatalf("expected *MemoryAssetLoader, got %T", ctx.Assets) + } + + if loader.fontRegisterer == nil { + t.Fatal("fontRegisterer should be wired in default context") + } + + // Inter is already in the registry from initialization. + if !reg.HasFamily("Inter") { + t.Error("Global registry should have Inter pre-registered") + } +} + +// TestNewPluginContext_ExplicitAssets_NotOverridden verifies that when an +// explicit AssetLoader is provided, it is used as-is without wrapping. +func TestNewPluginContext_ExplicitAssets_NotOverridden(t *testing.T) { + loader := NewMemoryAssetLoader() + ctx := NewPluginContext(nil, nil, nil, loader) + + if ctx.Assets != loader { + t.Error("explicit AssetLoader should be used as-is") + } +} diff --git a/primitives/text.go b/primitives/text.go index c28f6aa..b87f2d9 100644 --- a/primitives/text.go +++ b/primitives/text.go @@ -24,10 +24,17 @@ type TextStyle struct { FontSize float32 Color widget.Color Bold bool + Italic bool Align TextAlign MaxLines int Overflow TextOverflow LineHeight float32 + + // FontFamily specifies a custom font family name (e.g., "NotoSansCJK"). + // When empty (the default), the embedded Inter font is used. + // Custom fonts must be registered via the plugin system or font registry + // before they can be referenced here. + FontFamily string } // TextWidget displays static or reactive text content. @@ -131,6 +138,28 @@ func (t *TextWidget) Bold() *TextWidget { return t } +// Italic enables italic font style. +func (t *TextWidget) Italic() *TextWidget { + t.style.Italic = true + return t +} + +// FontFamily sets a custom font family name. +// +// The font must be registered via the plugin system's [plugin.AssetLoader] +// or directly via the font registry before it can be used. When set, the +// widget will use [widget.StyledTextDrawer] to render text with the custom +// font, falling back to the standard [widget.Canvas.DrawText] with Inter +// if the canvas does not support styled text. +// +// Example: +// +// label := primitives.Text("CJK").FontFamily("NotoSansCJK").FontSize(18) +func (t *TextWidget) FontFamily(name string) *TextWidget { + t.style.FontFamily = name + return t +} + // Align sets horizontal text alignment. func (t *TextWidget) Align(a TextAlign) *TextWidget { t.style.Align = a @@ -198,6 +227,11 @@ func (t *TextWidget) Layout(_ widget.Context, constraints geometry.Constraints) // 1. Explicit color set via [TextWidget.Color] (always wins) // 2. ThemeProvider's OnSurface color (if a theme is active) // 3. [widget.ColorBlack] (fallback when no theme is set) +// +// When a custom [TextWidget.FontFamily] or [TextWidget.Italic] is set +// and the canvas supports [widget.StyledTextDrawer], the styled text +// path is used to render with the custom font. Otherwise, the standard +// [widget.Canvas.DrawText] is used with the default embedded font. func (t *TextWidget) Draw(ctx widget.Context, canvas widget.Canvas) { if !t.IsVisible() { return @@ -214,6 +248,22 @@ func (t *TextWidget) Draw(ctx widget.Context, canvas widget.Canvas) { } color := t.resolveColor(ctx) + + // Use StyledTextDrawer path when custom font family or italic is set. + if t.style.FontFamily != "" || t.style.Italic { + if sd, ok := canvas.(widget.StyledTextDrawer); ok { + sd.DrawStyledText(text, bounds, widget.TextStyle{ + FontFamily: t.style.FontFamily, + FontSize: t.style.FontSize, + Bold: t.style.Bold, + Italic: t.style.Italic, + Color: color, + Align: t.style.Align, + }) + return + } + } + canvas.DrawText(text, bounds, t.style.FontSize, color, t.style.Bold, t.style.Align) } diff --git a/primitives/text_test.go b/primitives/text_test.go index 7951271..a7e6f8f 100644 --- a/primitives/text_test.go +++ b/primitives/text_test.go @@ -2,8 +2,10 @@ package primitives_test import ( "fmt" + "image" "testing" + "github.com/gogpu/gg/scene" "github.com/gogpu/ui/a11y" "github.com/gogpu/ui/event" "github.com/gogpu/ui/geometry" @@ -12,6 +14,69 @@ import ( "github.com/gogpu/ui/widget" ) +// styledMockCanvas extends mockCanvas with StyledTextDrawer support. +// Used to test TextWidget.FontFamily rendering path. +type styledMockCanvas struct { + drawTextCount int + drawStyledTextCount int + lastText string + lastTextColor widget.Color + lastStyledText string + lastStyle widget.TextStyle +} + +func (c *styledMockCanvas) Clear(_ widget.Color) {} +func (c *styledMockCanvas) DrawRect(_ geometry.Rect, _ widget.Color) {} +func (c *styledMockCanvas) FillRectDirect(_ geometry.Rect, _ widget.Color) {} +func (c *styledMockCanvas) StrokeRect(_ geometry.Rect, _ widget.Color, _ float32) {} +func (c *styledMockCanvas) DrawRoundRect(_ geometry.Rect, _ widget.Color, _ float32) { +} +func (c *styledMockCanvas) StrokeRoundRect(_ geometry.Rect, _ widget.Color, _ float32, _ float32) { +} +func (c *styledMockCanvas) DrawCircle(_ geometry.Point, _ float32, _ widget.Color) {} +func (c *styledMockCanvas) StrokeCircle(_ geometry.Point, _ float32, _ widget.Color, _ float32) { +} +func (c *styledMockCanvas) StrokeArc(_ geometry.Point, _ float32, _, _ float64, _ widget.Color, _ float32) { +} +func (c *styledMockCanvas) DrawLine(_, _ geometry.Point, _ widget.Color, _ float32) {} +func (c *styledMockCanvas) DrawText(text string, _ geometry.Rect, _ float32, color widget.Color, _ bool, _ widget.TextAlign) { + c.drawTextCount++ + c.lastTextColor = color + c.lastText = text +} +func (c *styledMockCanvas) MeasureText(text string, fontSize float32, _ bool) float32 { + return float32(len([]rune(text))) * fontSize * 0.5 +} +func (c *styledMockCanvas) DrawImage(_ image.Image, _ geometry.Point) {} +func (c *styledMockCanvas) PushClip(_ geometry.Rect) {} +func (c *styledMockCanvas) PushClipRoundRect(_ geometry.Rect, _ float32) {} +func (c *styledMockCanvas) PopClip() {} +func (c *styledMockCanvas) PushTransform(_ geometry.Point) {} +func (c *styledMockCanvas) PopTransform() {} +func (c *styledMockCanvas) TransformOffset() geometry.Point { return geometry.Point{} } +func (c *styledMockCanvas) ScreenOriginBase() geometry.Point { return geometry.Point{} } +func (c *styledMockCanvas) ClipBounds() geometry.Rect { + return geometry.NewRect(0, 0, 10000, 10000) +} +func (c *styledMockCanvas) ReplayScene(_ *scene.Scene) {} + +// StyledTextDrawer implementation. +func (c *styledMockCanvas) DrawStyledText(text string, _ geometry.Rect, style widget.TextStyle) { + c.drawStyledTextCount++ + c.lastStyledText = text + c.lastStyle = style +} + +func (c *styledMockCanvas) MeasureStyledText(text string, style widget.TextStyle) float32 { + return float32(len([]rune(text))) * style.FontSize * 0.5 +} + +// Compile-time interface checks. +var ( + _ widget.Canvas = (*styledMockCanvas)(nil) + _ widget.StyledTextDrawer = (*styledMockCanvas)(nil) +) + // --- Text construction --- func TestTextStaticContent(t *testing.T) { @@ -647,3 +712,128 @@ func TestTextWidget_Unmount_CleansBindings(t *testing.T) { t.Error("signal change after unmount should not mark widget dirty") } } + +// --- FontFamily tests --- + +func TestTextFontFamily_Setter(t *testing.T) { + tw := primitives.Text("CJK").FontFamily("NotoSansCJK") + if tw.Style().FontFamily != "NotoSansCJK" { + t.Errorf("FontFamily = %q, want NotoSansCJK", tw.Style().FontFamily) + } +} + +func TestTextFontFamily_Default(t *testing.T) { + tw := primitives.Text("Hello") + if tw.Style().FontFamily != "" { + t.Errorf("default FontFamily = %q, want empty string", tw.Style().FontFamily) + } +} + +func TestTextFontFamily_Draw_UsesStyledTextDrawer(t *testing.T) { + tw := primitives.Text("CJK Text").FontFamily("NotoSansCJK").FontSize(16) + ctx := widget.NewContext() + canvas := &styledMockCanvas{} + + _ = tw.Layout(ctx, geometry.Loose(geometry.Sz(200, 100))) + tw.Draw(ctx, canvas) + + if canvas.drawStyledTextCount != 1 { + t.Errorf("DrawStyledText called %d times, want 1", canvas.drawStyledTextCount) + } + if canvas.drawTextCount != 0 { + t.Errorf("DrawText called %d times, want 0 (should use styled path)", canvas.drawTextCount) + } + if canvas.lastStyle.FontFamily != "NotoSansCJK" { + t.Errorf("FontFamily = %q, want NotoSansCJK", canvas.lastStyle.FontFamily) + } + if canvas.lastStyle.FontSize != 16 { + t.Errorf("FontSize = %f, want 16", canvas.lastStyle.FontSize) + } +} + +func TestTextFontFamily_Draw_FallsBackWhenNotSupported(t *testing.T) { + tw := primitives.Text("CJK Text").FontFamily("NotoSansCJK").FontSize(16) + ctx := widget.NewContext() + // Use the regular mockCanvas which does NOT implement StyledTextDrawer. + canvas := &mockCanvas{} + + _ = tw.Layout(ctx, geometry.Loose(geometry.Sz(200, 100))) + tw.Draw(ctx, canvas) + + // Should fall back to regular DrawText. + if canvas.drawTextCount != 1 { + t.Errorf("DrawText called %d times, want 1 (fallback path)", canvas.drawTextCount) + } +} + +func TestTextItalic_Draw_UsesStyledTextDrawer(t *testing.T) { + tw := primitives.Text("Italic").Italic().FontSize(14) + ctx := widget.NewContext() + canvas := &styledMockCanvas{} + + _ = tw.Layout(ctx, geometry.Loose(geometry.Sz(200, 100))) + tw.Draw(ctx, canvas) + + if canvas.drawStyledTextCount != 1 { + t.Errorf("DrawStyledText called %d times, want 1 for italic text", canvas.drawStyledTextCount) + } + if !canvas.lastStyle.Italic { + t.Error("Italic should be true in TextStyle") + } +} + +func TestTextNoFontFamily_Draw_UsesRegularPath(t *testing.T) { + tw := primitives.Text("Regular").FontSize(14) + ctx := widget.NewContext() + canvas := &styledMockCanvas{} + + _ = tw.Layout(ctx, geometry.Loose(geometry.Sz(200, 100))) + tw.Draw(ctx, canvas) + + // Without FontFamily or Italic, should use regular DrawText. + if canvas.drawTextCount != 1 { + t.Errorf("DrawText called %d times, want 1 for regular text", canvas.drawTextCount) + } + if canvas.drawStyledTextCount != 0 { + t.Errorf("DrawStyledText called %d times, want 0 for regular text", canvas.drawStyledTextCount) + } +} + +func TestTextFontFamily_FluentChaining(t *testing.T) { + tw := primitives.Text("Test"). + FontFamily("CustomFont"). + FontSize(18). + Bold(). + Italic(). + Color(widget.ColorRed) + + s := tw.Style() + if s.FontFamily != "CustomFont" { + t.Errorf("FontFamily = %q, want CustomFont", s.FontFamily) + } + if s.FontSize != 18 { + t.Errorf("FontSize = %f, want 18", s.FontSize) + } + if !s.Bold { + t.Error("Bold should be true") + } + if !s.Italic { + t.Error("Italic should be true") + } +} + +func TestTextFontFamily_Draw_PassesBoldFlag(t *testing.T) { + tw := primitives.Text("Bold CJK").FontFamily("NotoSansCJK").Bold().FontSize(14) + ctx := widget.NewContext() + canvas := &styledMockCanvas{} + + _ = tw.Layout(ctx, geometry.Loose(geometry.Sz(200, 100))) + tw.Draw(ctx, canvas) + + if canvas.drawStyledTextCount != 1 { + t.Fatalf("DrawStyledText called %d times, want 1", canvas.drawStyledTextCount) + } + if !canvas.lastStyle.Bold { + t.Error("Bold should be passed through TextStyle") + } +} diff --git a/uitest/canvas.go b/uitest/canvas.go index 7121296..4671e27 100644 --- a/uitest/canvas.go +++ b/uitest/canvas.go @@ -90,6 +90,13 @@ type DrawTextCall struct { Align widget.TextAlign } +// DrawStyledTextCall records a single DrawStyledText invocation. +type DrawStyledTextCall struct { + Text string + Bounds geometry.Rect + Style widget.TextStyle +} + // DrawImageCall records a single DrawImage invocation. type DrawImageCall struct { Image image.Image @@ -148,6 +155,9 @@ type MockCanvas struct { // Texts records arguments passed to DrawText. Texts []DrawTextCall + // StyledTexts records arguments passed to DrawStyledText. + StyledTexts []DrawStyledTextCall + // Images records arguments passed to DrawImage. Images []DrawImageCall @@ -254,6 +264,19 @@ func (c *MockCanvas) MeasureText(text string, fontSize float32, _ bool) float32 return float32(len([]rune(text))) * fontSize * 0.5 } +// DrawStyledText records all styled text drawing arguments. +func (c *MockCanvas) DrawStyledText(text string, bounds geometry.Rect, style widget.TextStyle) { + c.StyledTexts = append(c.StyledTexts, DrawStyledTextCall{ + Text: text, Bounds: bounds, Style: style, + }) +} + +// MeasureStyledText returns an approximate width for the given text. +// Uses average character width (0.5 * fontSize) for test predictability. +func (c *MockCanvas) MeasureStyledText(text string, style widget.TextStyle) float32 { + return float32(len([]rune(text))) * style.FontSize * 0.5 +} + // DrawImage records the image and position arguments. func (c *MockCanvas) DrawImage(img image.Image, at geometry.Point) { c.Images = append(c.Images, DrawImageCall{Image: img, At: at}) @@ -320,6 +343,7 @@ func (c *MockCanvas) Reset() { c.StrokeArcStyleds = c.StrokeArcStyleds[:0] c.Lines = c.Lines[:0] c.Texts = c.Texts[:0] + c.StyledTexts = c.StyledTexts[:0] c.Images = c.Images[:0] c.Clips = c.Clips[:0] c.ClipRoundRects = c.ClipRoundRects[:0] @@ -336,11 +360,12 @@ func (c *MockCanvas) TotalDrawCalls() int { return len(c.Clears) + len(c.Rects) + len(c.StrokeRects) + len(c.RoundRects) + len(c.StrokeRoundRects) + len(c.Circles) + len(c.StrokeCircles) + len(c.StrokeArcs) + - len(c.StrokeArcStyleds) + len(c.Lines) + len(c.Texts) + len(c.Images) + len(c.StrokeArcStyleds) + len(c.Lines) + len(c.Texts) + len(c.StyledTexts) + len(c.Images) } // Compile-time interface checks. var ( - _ widget.Canvas = (*MockCanvas)(nil) - _ widget.ArcStroker = (*MockCanvas)(nil) + _ widget.Canvas = (*MockCanvas)(nil) + _ widget.ArcStroker = (*MockCanvas)(nil) + _ widget.StyledTextDrawer = (*MockCanvas)(nil) ) diff --git a/widget/canvas.go b/widget/canvas.go index a2a381d..3227f96 100644 --- a/widget/canvas.go +++ b/widget/canvas.go @@ -321,6 +321,74 @@ type TextModeController interface { TextMode() TextMode } +// TextStyle specifies font properties for styled text rendering. +// +// TextStyle is used with [StyledTextDrawer] to render text with custom fonts +// loaded through the plugin system or font registry. When FontFamily is empty, +// the default embedded Inter font is used. +// +// Example: +// +// style := widget.TextStyle{ +// FontFamily: "NotoSansCJK", +// FontSize: 16, +// Bold: true, +// Color: widget.ColorBlack, +// Align: widget.TextAlignLeft, +// } +type TextStyle struct { + // FontFamily is the font family name (e.g., "Inter", "NotoSansCJK"). + // Empty string falls back to the default embedded font. + FontFamily string + + // FontSize is the font size in logical pixels. + FontSize float32 + + // Bold selects bold weight (700). When false, regular weight (400) is used. + Bold bool + + // Italic selects italic style. When false, normal style is used. + Italic bool + + // Color is the text color. + Color Color + + // Align controls horizontal text alignment within bounds. + Align TextAlign +} + +// StyledTextDrawer is an optional interface for canvases that support +// rendering text with custom fonts from the font registry. +// +// Use type assertion to check availability: +// +// if sd, ok := canvas.(widget.StyledTextDrawer); ok { +// sd.DrawStyledText("Hello", bounds, widget.TextStyle{ +// FontFamily: "NotoSansCJK", +// FontSize: 16, +// Color: widget.ColorBlack, +// }) +// width := sd.MeasureStyledText("Hello", widget.TextStyle{ +// FontFamily: "NotoSansCJK", +// FontSize: 16, +// }) +// } +// +// Widgets that need custom font support should check for this interface +// and fall back to [Canvas.DrawText] when it is not available. This follows +// the same optional interface pattern as [ArcStroker], [SVGFiller], and +// [TextModeController]. +type StyledTextDrawer interface { + // DrawStyledText draws text within the given bounding rectangle using + // the font specified in the TextStyle. The font is resolved from the + // global font registry using CSS weight matching. + DrawStyledText(text string, bounds geometry.Rect, style TextStyle) + + // MeasureStyledText returns the width in pixels of the given text + // when rendered with the specified TextStyle. + MeasureStyledText(text string, style TextStyle) float32 +} + // Color represents an RGBA color with float32 components. // // Each component is in the range [0, 1], where 0 is minimum intensity