Pad/doc/shaper_usage.md
Greg Pomerantz 0f62ef32fa Update shaper_usage.md with correct glyph drawing approach
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-09 11:15:57 -04:00

9.1 KiB

Text Shaper Usage Guide

This document describes how to use the Gio text shaper correctly for text layout, measurement, and rendering. It serves as a reference to avoid common mistakes and ensure consistency across the codebase.

1. Creating a Shaper

import (
    "gioui.org/text"
    "gioui.org/x/gofont"
)

shp := text.NewShaper(text.WithCollection(gofont.Collection()))

Important: Always provide a font collection. Without it, the shaper may not load fonts correctly and will return zero-width glyphs.

2. Layout Parameters

The text.Parameters struct controls how text is shaped:

type Parameters struct {
    PxPerEm    fixed.Int26_6  // Font size in device pixels
    Font       font.Font      // Specific font (zero value uses default)
    MaxWidth   int            // Maximum width for word wrap (0 = no limit)
    MinWidth   int            // Minimum width for word wrap
    TextAlign  text.Align     // Text alignment (Left, Center, Right)
    LineSpacing unit.Sp       // Additional spacing between lines
    WrapPolicy text.WrapPolicy // How to wrap text
}

2.1 PxPerEm Calculation

CORRECT: Use gtx.Sp(size) to convert SP to device pixels:

params := text.Parameters{
    PxPerEm: fixed.I(gtx.Sp(size)),  // size is unit.Sp
}

WRONG: Don't use arbitrary multipliers like 1024 * pixelsPerDp:

// WRONG - this produces incorrect widths
params := text.Parameters{
    PxPerEm: fixed.Int26_6(1024 * pixelsPerDp),
}

Why: gtx.Sp(size) correctly converts SP to device pixels accounting for DPI scaling. The fixed.I() function converts the result to fixed.Int26_6 format.

2.2 Word Wrap

Set MaxWidth to enable word wrap:

params := text.Parameters{
    PxPerEm:  fixed.I(gtx.Sp(size)),
    MaxWidth: int(widthInPixels),
    WrapPolicy: text.WrapTrailingSpace,
}

The shaper automatically breaks text into lines at word boundaries when MaxWidth is set.

3. Layout Functions

3.1 LayoutString (for single strings)

shp.LayoutString(params, "Hello, World!")

3.2 Layout (for readers)

shp.Layout(params, strings.NewReader("Hello, World!"))

4. Iterating Through Glyphs

After layout, iterate through glyphs using NextGlyph():

for {
    g, ok := shp.NextGlyph()
    if !ok {
        break
    }
    // Process glyph g
}

4.1 Glyph Fields

type Glyph struct {
    ID      uint32        // Glyph ID
    X       fixed.Int26_6 // Dot position in document coordinates
    Y       int32         // Baseline position
    Ascent  int32         // Line ascent
    Descent int32         // Line descent
    Advance fixed.Int26_6 // Logical width (horizontal advance)
    Runes   uint16        // Number of runes this glyph represents
    Offset  fixed.Point26_6 // Glyph offset from (X, Y)
    Bounds  image.Rectangle // Glyph bounds in device pixels
}

4.2 Accumulating Widths

To get per-character cumulative widths:

func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int {
    shp.LayoutString(text.Parameters{PxPerEm: fixed.I(gtx.Sp(size))}, str)
    var widths []int
    var cumWidth fixed.Int26_6 = 0
    for {
        g, ok := shp.NextGlyph()
        if !ok {
            break
        }
        cumWidth += g.Advance
        widths = append(widths, int(cumWidth>>6)) // Convert from Int26_6 to int
    }
    return widths
}

Key points:

  • g.Advance is in fixed.Int26_6 format (16.6 fixed-point)
  • Convert to int by shifting right by 6 bits: int(cumWidth>>6)
  • The cumulative width is in device pixels

4.3 Getting Per-Character Widths

For hit testing (mapping UI coordinates → byte offset), we need per-character cumulative widths:

func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int {
    shp.LayoutString(text.Parameters{PxPerEm: fixed.I(gtx.Sp(size))}, str)
    var widths []int
    var cumWidth fixed.Int26_6 = 0
    for {
        g, ok := shp.NextGlyph()
        if !ok {
            break
        }
        cumWidth += g.Advance
        widths = append(widths, int(cumWidth>>6))
    }
    return widths
}

5. Rendering Glyphs Directly

Gio's textView draws glyphs using shaper.Shape() (for vector glyphs) and shaper.Bitmaps() (for bitmap glyphs like emoji). This is the correct approach to avoid double-shaping.

5.1 Drawing Text Using Gio's textView Approach

func drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y unit.Dp, col color.NRGBA) {
    // Layout text
    shp.LayoutString(text.Parameters{PxPerEm: fixed.I(gtx.Sp(size))}, str)

    // Draw glyphs using the same approach as Gio's textView
    var glyphs [32]text.Glyph
    line := glyphs[:0]
    for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() {
        line = append(line, g)
        if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
            drawLine(gtx, shp, line, x, y, col)
            line = line[:0]
        }
    }
    if len(line) > 0 {
        drawLine(gtx, shp, line, x, y, col)
    }
}

func drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y unit.Dp, col color.NRGBA) {
    // Apply offset transform
    off := f32.Point{X: float32(x), Y: float32(y)}
    t := op.Affine(f32.Affine2D{}.Offset(off)).Push(gtx.Ops)

    // Draw vector glyphs
    path := shp.Shape(line)
    outline := clip.Outline{Path: path}.Op().Push(gtx.Ops)
    paint.ColorOp{Color: col}.Add(gtx.Ops)
    paint.PaintOp{}.Add(gtx.Ops)
    outline.Pop()

    // Draw bitmap glyphs (emoji, etc.)
    if call := shp.Bitmaps(line); call != (op.CallOp{}) {
        call.Add(gtx.Ops)
    }

    t.Pop()
}

5.2 Avoiding Double-Shaping

WRONG (shapes text twice):

// First pass: measure
widths := charWidths(gtx, shp, text, size)

// Second pass: render (material.Label calls shaper again)
material.Label(th, size, text).Layout(gtx)

CORRECT (shapes text once for rendering):

// Single pass: layout and draw using shaper.Shape()
drawText(gtx, shp, text, size, x, y, col)

NOTE: For truncation, we may need to layout twice:

  1. First layout to measure widths and find truncation point
  2. Second layout to draw the truncated text

This is acceptable because truncation is only needed when text doesn't fit, and the string is short (filename in StatusBar).

6. Comparison with Gio's textView

Gio's textView (widget/text.go) demonstrates the correct approach:

func (e *textView) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp) {
    textSize := fixed.I(gtx.Sp(size))
    
    // Set parameters
    e.params.PxPerEm = textSize
    e.params.MaxWidth = gtx.Constraints.Max.X
    
    // Layout once
    lt.Layout(e.params, r)
    
    // Iterate through glyphs and store results
    for {
        g, ok := lt.NextGlyph()
        if !it.processGlyph(g, ok) {
            break
        }
        e.index.Glyph(g)  // Store glyph info for rendering
    }
}

Key insights:

  1. Layout is done once with lt.Layout(e.params, r)
  2. Glyphs are iterated with NextGlyph() and stored in e.index
  3. Rendering uses the stored glyph data, not a new shaper call

7. Common Pitfalls

7.1 Wrong PxPerEm Calculation

Mistake: Using arbitrary multipliers like 1024 * pixelsPerDp

Fix: Use fixed.I(gtx.Sp(size)) to get the font size in device pixels

7.2 Double-Shaping

Mistake: Calling charWidths() to measure, then material.Label() to render

Fix: Use shp.Draw() to render the already-laid-out glyphs

7.3 Forgetting to Reset Shaper

Mistake: Not clearing the shaper between layouts

Fix: LayoutString() and Layout() automatically reset the shaper state

7.4 Not Converting Int26_6 to int

Mistake: Using int(g.Advance) directly without shifting

Fix: Use int(g.Advance >> 6) to convert from fixed.Int26_6 to int

8. Usage in Pad

8.1 Filename Truncation (StatusBar)

// Measure widths
widths := charWidths(gtx, shp, filename, fontSize)

// Truncate if needed
if widths[len(widths)-1] > availableWidth {
    // Find truncation point
}

// Render using shaper.Draw() instead of material.Label()

8.2 TextField (Editor Content)

For the TextField element, we'll use the same approach:

  1. Layout text with MaxWidth for word wrap
  2. Iterate through glyphs to get per-character positions
  3. Use glyph positions for:
    • Hit testing (UI coordinates → byte offset)
    • Cursor positioning
    • Selection rendering
    • IME bridge sync

8.3 BottomBar

The BottomBar uses material.Label() for simple text that doesn't need precise positioning. This is acceptable because:

  • The text is short and fixed
  • We don't need per-character widths for hit testing
  • The performance impact is negligible

However, for consistency, we could also use shp.Draw() for BottomBar text.

9. Summary

  • Always use fixed.I(gtx.Sp(size)) for PxPerEm
  • Always call shp.Draw() after LayoutString()/Layout() to render
  • Avoid using material.Label() when you need precise glyph positions
  • Reuse glyph data for measurement, hit testing, and rendering
  • Follow Gio's textView as a reference implementation