Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 24 additions & 20 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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+

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand All @@ -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 |

---
Expand Down Expand Up @@ -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

---

Expand Down
40 changes: 34 additions & 6 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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`

---

Expand All @@ -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 |
Expand Down Expand Up @@ -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.20Layer 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.23custom font loading pipeline, FontRegistry, StyledTextDrawer, Mac Retina fix, CJK IsCJK fix).*
70 changes: 70 additions & 0 deletions internal/render/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Loading
Loading