Pad/doc/layout_rendering.md

23 KiB
Raw Blame History

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 — CRITICAL

The text.Parameters struct controls how text is shaped. Three fields are mandatory for correct single-line text layout:

params := text.Parameters{
    PxPerEm:    fixed.I(gtx.Sp(size)),  // Font size in device pixels (REQUIRED)
    MinWidth:   0,                       // Minimum width (REQUIRED for single-line)
    MaxWidth:   availableWidth,          // Maximum width (REQUIRED for single-line)
    MaxLines:   1,                       // Limit to one line (REQUIRED for single-line)
}

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 MinWidth/MaxWidth/MaxLines — REQUIRED

Without MinWidth, MaxWidth, and MaxLines, the shaper has no horizontal space constraint and will wrap every character into its own line. This is the most common mistake when using the shaper.

// WRONG - every character wraps to its own line
shp.LayoutString(text.Parameters{PxPerEm: fixed.I(gtx.Sp(size))}, str)

// CORRECT - text flows horizontally within constraints
shp.LayoutString(text.Parameters{
    PxPerEm:  fixed.I(gtx.Sp(size)),
    MinWidth: 0,
    MaxWidth: availableWidth,
    MaxLines: 1,
}, str)

Why: Gio's widget.Label sets these from gtx.Constraints:

  • MinWidth: cs.Min.X
  • MaxWidth: cs.Max.X
  • MaxLines: l.MaxLines

Without these, the shaper treats each character as a separate line with FlagLineBreak set.

2.3 Word Wrap

Set MaxWidth to enable word wrap:

params := text.Parameters{
    PxPerEm:    fixed.I(gtx.Sp(size)),
    MinWidth:   0,
    MaxWidth:   int(widthInPixels),
    MaxLines:   0,  // 0 = no limit, allows multi-line
    WrapPolicy: text.WrapHeuristically,
}

The shaper automatically breaks text into lines at word boundaries when MaxWidth is set and MaxLines is 0 or unset.

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      GlyphID         // Glyph ID
    X       fixed.Int26_6   // Dot position in document coordinates
    Y       int32           // Baseline position (same for all glyphs on a line)
    Ascent  fixed.Int26_6   // Line ascent
    Descent fixed.Int26_6   // 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  fixed.Rectangle26_6 // Glyph bounds relative to dot
    Flags   Flags           // FlagLineBreak, FlagRunBreak, FlagClusterBreak, etc.
}

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)),
        MinWidth: 0,
        MaxWidth: 1000,
        MaxLines: 1,
    }, 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 (not Dp!)
  • Always set MinWidth, MaxWidth, MaxLines or widths will be wrong

4.3 Measuring Text Width for Alignment

When you need the total text width for centering or right-alignment, sum the advances and convert to Dp:

var totalAdvance fixed.Int26_6
for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() {
    totalAdvance += g.Advance
}
// totalAdvance>>6 is in DEVICE PIXELS — must divide by scale to get Dp
textW_Dp := ui.Dp(float32(totalAdvance>>6) / scale.Scale())

Critical: totalAdvance>>6 gives device pixels, but UI regions (reg.W, reg.X) are in Dp. If you use the raw value as Dp, centering will appear roughly correct (off by factor of 2, halved by division in center formula) but right-alignment will have a visible gap. Always divide by the scale factor to convert device pixels → Dp before using with region coordinates.

Wrong (works for AlignStart, breaks AlignCenter/AlignEnd):

textW := ui.Dp(totalAdvance>>6)  // treats device pixels as Dp!

Correct:

textW := ui.Dp(float32(totalAdvance>>6) / r.scale.Scale())  // convert to Dp

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)),
        MinWidth: 0,
        MaxWidth: 1000,
        MaxLines: 1,
    }, 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 paintGlyph (widget/label.go) 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 — Match Gio's paintGlyph Exactly

func drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y unit.Dp, col color.NRGBA) {
    // Layout text with width constraints
    shp.LayoutString(text.Parameters{
        PxPerEm:  fixed.I(gtx.Sp(size)),
        MinWidth: 0,
        MaxWidth: 1000,
        MaxLines: 1,
    }, str)
    drawLineText(gtx, shp, x, y, col)
}

func drawLineText(gtx layout.Context, shp *text.Shaper, x, y unit.Dp, col color.NRGBA) {
    // Match Gio's textView: record into a macro for clipping
    m := op.Record(gtx.Ops)
    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)
    }
    call := m.Stop()
    call.Add(gtx.Ops)
}

func drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y unit.Dp, col color.NRGBA) {
    if len(line) == 0 {
        return
    }
    first := line[0]
    // shaper.Shape(line) returns a path where glyph positions are relative to
    // the first glyph. Offset by (x + first.X, y + first.Y) to place the line
    // at the desired document position. Matches Gio's paintGlyph:
    //   lineOff = (glyph.X, glyph.Y) - viewport.Min
    //   op.Affine(f32.Affine2D{}.Offset(lineOff))
    offX := float32(gtx.Dp(x)) + float32(first.X)/64.0
    offY := float32(gtx.Dp(y)) + float32(first.Y)
    t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).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 Offset Calculation Explained

The offset calculation is critical:

offX := float32(gtx.Dp(x)) + float32(first.X)/64.0
offY := float32(gtx.Dp(y)) + float32(first.Y)
  • x, y are the desired document position (in DP)
  • first.X is the first glyph's X position in fixed.Int26_6 (divide by 64 to get pixels)
  • first.Y is the first glyph's Y position (baseline) in pixels
  • shp.Shape(line) returns a path where glyph positions are relative to the first glyph
  • So we offset by (x + first.X, y + first.Y) to place the line at (x, y)

This matches Gio's paintGlyph exactly:

// Gio's paintGlyph:
if len(line) == 0 {
    it.lineOff = f32.Point{X: fixedToFloat(glyph.X), Y: float32(glyph.Y)}
        .Sub(layout.FPt(it.viewport.Min))
}
// ...
t := op.Affine(f32.Affine2D{}.Offset(it.lineOff)).Push(gtx.Ops)

Since viewport.Min = (0,0) in our case, lineOff = (glyph.X, glyph.Y).

5.3 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 paintGlyph

Gio's paintGlyph (widget/label.go) demonstrates the correct approach:

func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []text.Glyph) ([]text.Glyph, bool) {
    visibleOrBefore := it.processGlyph(glyph, true)
    if it.visible {
        if len(line) == 0 {
            it.lineOff = f32.Point{X: fixedToFloat(glyph.X), Y: float32(glyph.Y)}
                .Sub(layout.FPt(it.viewport.Min))
        }
        line = append(line, glyph)
    }
    if glyph.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 || !visibleOrBefore {
        t := op.Affine(f32.Affine2D{}.Offset(it.lineOff)).Push(gtx.Ops)
        path := shaper.Shape(line)
        outline := clip.Outline{Path: path}.Op().Push(gtx.Ops)
        it.material.Add(gtx.Ops)  // sets color
        paint.PaintOp{}.Add(gtx.Ops)
        outline.Pop()
        if call := shaper.Bitmaps(line); call != (op.CallOp{}) {
            call.Add(gtx.Ops)
        }
        t.Pop()
        line = line[:0]
    }
    return line, visibleOrBefore
}

Key insights:

  1. lineOff is set on the first glyph: (glyph.X, glyph.Y) - viewport.Min
  2. shaper.Shape(line) returns a path relative to the first glyph
  3. The offset places the first glyph at lineOff, and subsequent glyphs follow
  4. it.material is a pre-recorded color call; we use paint.ColorOp + paint.PaintOp instead
  5. Everything is wrapped in op.Record/m.Stop() for clipping

7. Common Pitfalls

7.1 Missing MinWidth/MaxWidth/MaxLines

Mistake: Calling LayoutString without width constraints:

shp.LayoutString(text.Parameters{PxPerEm: fixed.I(gtx.Sp(size))}, str)

Result: Every character wraps to its own line with FlagLineBreak set and X=0.

Fix: Always set MinWidth, MaxWidth, and MaxLines:

shp.LayoutString(text.Parameters{
    PxPerEm:  fixed.I(gtx.Sp(size)),
    MinWidth: 0,
    MaxWidth: availableWidth,
    MaxLines: 1,
}, str)

7.2 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.3 Double-Shaping

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

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

7.4 Wrong Offset Calculation

Mistake: Using just (x, y) without adding first.X/first.Y:

// WRONG - glyphs will be at wrong position
offX := float32(gtx.Dp(x))
offY := float32(gtx.Dp(y))

Mistake: Subtracting first.Y:

// WRONG - pushes text off-screen
offY := float32(gtx.Dp(y)) - float32(first.Y)

Fix: Add first.X and first.Y to the offset:

offX := float32(gtx.Dp(x)) + float32(first.X)/64.0
offY := float32(gtx.Dp(y)) + float32(first.Y)

7.5 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
shp.LayoutString(text.Parameters{
    PxPerEm:  fixed.I(gtx.Sp(size)),
    MinWidth: 0,
    MaxWidth: availableWidth,
    MaxLines: 1,
}, filename)

// Truncate if needed
widths := measureWidths(shp)
if widths[len(widths)-1] > availableWidth {
    // Find truncation point
    truncated = filename[:i] + "..."
}

// Re-layout and render
shp.LayoutString(text.Parameters{...}, truncated)
drawLineText(gtx, shp, x, y, col)

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 StatusBar / BottomBar

The StatusBar and BottomBar are Container compositions of Label and Icon elements. Their text is rendered via the Renderer's drawTextdrawLineTextdrawLine pipeline (same as all other text). Truncation may require a double-layout (measure then draw truncated), which is acceptable because the strings are short.

9. Debugging Tips

When text doesn't render correctly, add debug logging to trace:

fmt.Printf("[DEBUG] glyph X=%d Y=%d advance=%d flags=%b\n",
    g.X, g.Y, g.Advance, g.Flags)
fmt.Printf("[DEBUG] offX=%f offY=%f\n", offX, offY)
fmt.Printf("[DEBUG] clip=(%d, %d, %d, %d)\n", clipMin.X, clipMin.Y, clipMax.X, clipMax.Y)

Look for:

  • All X=0: Missing MaxWidth/MinWidth/MaxLines
  • All FlagLineBreak set: Same as above
  • Text off-screen: Wrong offset calculation
  • Text clipped: Clip region doesn't include text bounds

9. Multi-line Text Spacing

When laying out multiple lines of text at different positions, you need to understand how Gio calculates baseline spacing.

9.1 Gio's Line Height Calculation

In gio/text/gotext.go (calculateYOffsets and LayoutRunes):

// First line baseline starts at the ascent height
currentY := lines[0].ascent.Ceil()

// Subsequent baselines are spaced by lineHeight
for i := range lines {
    if i > 0 {
        currentY += lines[i].lineHeight.Round()
    }
    lines[i].yOffset = currentY
}

// lineHeight = max(ascent + descent) * LineHeightScale
// Default LineHeightScale = 1.2
if params.LineHeight != 0 {
    maxHeight = params.LineHeight
}
if params.LineHeightScale == 0 {
    params.LineHeightScale = 1.2
}
maxHeight = floatToFixed(fixedToFloat(maxHeight) * params.LineHeightScale)

Key points:

  • First line baseline: ascent.Ceil() (not 0)
  • Baseline-to-baseline spacing: lineHeight * LineHeightScale
  • Default LineHeightScale: 1.2 (adds 20% extra padding)
  • LineHeightScale 1.0: tightest spacing, no extra padding

9.2 Positioning Multiple Lines

When drawing multiple lines at different positions (e.g., filename + icons in StatusBar), use the baseline spacing as your guide:

// Line 1: Filename at origin
filenameLineY := reg.Y

// Line 2: Icons — baseline spacing ≈ font size with LineHeightScale=1.0
// For 16SP font: baseline-to-baseline ≈ 20DP
iconsLineY := filenameLineY + unit.Dp(20)

Why 20DP? For a 16SP font on a typical display:

  • PxPerEm = 16 * 1.25 = 20 device pixels (2x display)
  • ascent + descent ≈ PxPerEm = 20
  • With LineHeightScale = 1.0: baseline spacing = 20DP

9.3 Controlling Spacing

You have two options to control line spacing:

Option A: Set LineHeightScale in LayoutString

shp.LayoutString(text.Parameters{
    PxPerEm:         fixed.I(gtx.Sp(size)),
    MinWidth:        0,
    MaxWidth:        availableWidth,
    MaxLines:        1,
    LineHeightScale: 1.0,  // Tight spacing, no extra padding
}, str)

Option B: Set LineHeight to a specific value

shp.LayoutString(text.Parameters{
    PxPerEm:   fixed.I(gtx.Sp(size)),
    MinWidth:  0,
    MaxWidth:  availableWidth,
    MaxLines:  1,
    LineHeight: fixed.I(gtx.Sp(18)),  // Fixed 18SP baseline spacing
}, str)

9.4 Common Mistakes

Mistake: Adding arbitrary spacing between lines without considering baseline positioning.

Fix: Remember that the shaper's Y value is the baseline, not the top of the text. The visual top of the text is at baseline - ascent. So the visual gap between two lines is:

visualGap = (line2Baseline - line1Baseline) - (ascent1 + ascent2)
          = lineHeight - (ascent1 + ascent2)

With LineHeightScale = 1.0: visualGap ≈ 0 (lines touch) With LineHeightScale = 1.2: visualGap ≈ 0.2 * lineHeight (20% padding)

9.5 Coordinate System and Type Safety

The project uses distinct Go types (ui.Dp and ui.Px) to prevent accidental mixing of coordinate units at compile time.

Architecture

main.go                State/Logic layer          Renderer
─────────              ─────────────────          ────────
app.ConfigEvent        EditorLayout(ui.Dp)        r.toPx(dp)
  PixelWidth/Px           → regions in Dp         r.toDp(px)
  → stored in State                                                      
ScaleEvent                                                     
  → stored in State

Key Rules

  1. Logic layer works exclusively in Dp: EditorLayout accepts ui.Dp dimensions and returns regions in ui.Dp. No pixel conversions in the layout layer.

  2. main.go converts pixels to Dp: Raw pixel dimensions from app.ConfigEvent are stored in State.PixelWidth/State.PixelHeight. The State.layout() method converts them to Dp using the current scale before calling EditorLayout.

  3. Renderer converts on demand: The Renderer holds a ScaleProvider interface (backed by *editor.State) and converts Dp↔Px via r.toPx(dp) / r.toDp(px) at Gio interop boundaries.

  4. Explicit conversions only: Use ui.ToPx(dp, scale) and ui.ToDp(px, scale) for conversions. The Dp and Px types are distinct named types — the compiler prevents mixing them directly.

StatusBar Composition Example

The StatusBar is built as a Container with child Label and Icon elements:

// In EditorLayout (state.go):
statusBar := ui.NewContainer(
    ui.Region{X: margin, Y: margin, W: screenWidth - margin*2, H: ui.Dp(52)},
    ui.Color{R: 230, G: 230, B: 230, A: 255},
    []ui.Element{
        ui.NewLabel("filename.txt", 14,
            ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)},
            ui.AlignStart),
        // Icons: Size=0 → auto-scale to fill region W×H
        ui.NewIcon("cut", ui.Region{X: 0, Y: ui.Dp(28), W: ui.Dp(24), H: ui.Dp(24)}, 0),
        ui.NewIcon("copy", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.Dp(24), H: ui.Dp(24)}, 0),
        ui.NewIcon("paste", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.Dp(24), H: ui.Dp(24)}, 0),
    },
)

The Container's region is in screen-space Dp. Child regions are relative to the Container's origin (the Renderer applies op.Offset for children). The Renderer converts all Dp values to pixels via r.toPx() when submitting Gio ops. Icon elements use Size=0 so the renderer auto-scales them to fill their 24×24 DP regions via an affine transform.

Common Pitfalls

  • Don't mix Dp and Px: The compiler will catch it. Use explicit ui.ToPx / ui.ToDp conversions.
  • Don't use gtx.Constraints in element Draw methods: They're modified by clips. The Renderer's toPx/toDp methods use the scale from State, not constraints.
  • Container children use relative regions: Child element regions are relative to the Container's origin, not screen space. The Renderer applies op.Offset automatically when drawing Container children.

9.6 Background Drawing with Clips

When drawing backgrounds (rectangles with solid colors), use clip.Rect{...}.Push(gtx.Ops) followed immediately by clip.Pop(). Never use op.Record/m.Stop() macros for simple background clips — the macro leaves the clip on the stack, preventing subsequent elements from drawing.

Correct pattern:

bgClip := clip.Rect{
    Min: image.Point{X: pxMinX, Y: pxMinY},
    Max: image.Point{X: pxMaxX, Y: pxMaxY},
}.Push(gtx.Ops)
paint.ColorOp{Color: bgColor}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop()  // Immediately pop — clip stack must be clean

Wrong pattern (leaves clip on stack):

m := op.Record(gtx.Ops)
clip.Rect{...}.Op().Push(gtx.Ops)  // Push inside macro
paint.ColorOp{...}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
call := m.Stop()
call.Add(gtx.Ops)  // When replayed, clip is pushed but never popped!

Why this matters: When the StatusBar draws its background with a macro, the clip inside is pushed during macro replay but never popped. The BottomBar then draws while that stale clip is still active, causing it to be clipped out of visibility.

10. Summary

  • Always use fixed.I(gtx.Sp(size)) for PxPerEm
  • Always set MinWidth, MaxWidth, MaxLines in LayoutString parameters
  • Always offset by (x + first.X, y + first.Y) in drawLine
  • Always wrap glyph drawing in op.Record/m.Stop() for clipping safety
  • Baseline spacing = lineHeight * LineHeightScale (default 1.2)
  • For tight multi-line layouts, use LineHeightScale: 1.0 or set explicit LineHeight
  • Visual gap between lines = baselineSpacing - (ascent1 + ascent2)
  • Logic layer works exclusively in Dp. State.layout() converts pixels→Dp. Renderer converts Dp→Px via r.toPx().
  • Explicit conversions (ui.ToPx, ui.ToDp) — Dp and Px are distinct named types.
  • Follow Gio's paintGlyph as the reference implementation for text rendering