# 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 ```go 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: ```go 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: ```go params := text.Parameters{ PxPerEm: fixed.I(gtx.Sp(size)), // size is unit.Sp } ``` **WRONG**: Don't use arbitrary multipliers like `1024 * pixelsPerDp`: ```go // 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. ```go // 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: ```go 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) ```go shp.LayoutString(params, "Hello, World!") ``` ### 3.2 Layout (for readers) ```go shp.Layout(params, strings.NewReader("Hello, World!")) ``` ## 4. Iterating Through Glyphs After layout, iterate through glyphs using `NextGlyph()`: ```go for { g, ok := shp.NextGlyph() if !ok { break } // Process glyph g } ``` ### 4.1 Glyph Fields ```go 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: ```go 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 - **Always** set `MinWidth`, `MaxWidth`, `MaxLines` or widths will be wrong ### 4.3 Getting Per-Character Widths For hit testing (mapping UI coordinates → byte offset), we need per-character cumulative widths: ```go 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 ```go 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: ```go 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**: ```go // 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): ```go // 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): ```go // 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: ```go 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: ```go 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`: ```go 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`: ```go // WRONG - glyphs will be at wrong position offX := float32(gtx.Dp(x)) offY := float32(gtx.Dp(y)) ``` **Mistake**: Subtracting `first.Y`: ```go // WRONG - pushes text off-screen offY := float32(gtx.Dp(y)) - float32(first.Y) ``` **Fix**: Add `first.X` and `first.Y` to the offset: ```go 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) ```go // 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 BottomBar The BottomBar uses `op.Offset` + `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 ## 9. Debugging Tips When text doesn't render correctly, add debug logging to trace: ```go 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`): ```go // 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: ```go // 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** ```go 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** ```go 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 Logic/Layout layer Renderer ───────── ───────────────── ───────── app.ConfigEvent EditorLayout(ui.Dp) gtx.Constraints (Px) → pixels → regions in Dp → converts Dp→Px → convert to Dp at boundaries ``` ### 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. **Renderer converts at boundaries**: The renderer captures `gtx.Constraints` once at the start of `Draw()` (before clips modify them), then passes this snapshot to all render functions. 3. **Explicit conversions only**: Use `ui.ToPx(dp, scale)` and `ui.ToDp(px, scale)` for conversions. Never write `int(reg.X * scale)` — the type system prevents this. ### BottomBar Positioning Example ```go func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) { reg := bb.Region() // Region in Dp // Convert region to pixels for positioning regXPx := r.toPx(reg.X) regWPx := r.toPx(reg.W) // Clamp right edge to window width rightXPx := regXPx + regWPx if rightXPx > Px(constraints.Max.X) { rightXPx = Px(constraints.Max.X) } barW := rightXPx - regXPx // Position at bottom of visible window (in pixels) drawYPx := Px(constraints.Max.Y) - r.toPx(Dp(24)) - r.toPx(Dp(10)) drawY := r.toDp(drawYPx) // All X positions use pixel arithmetic, converted back to Dp for text rendering cursorXDp := r.toDp(regXPx + r.toPx(Dp(8))) byteXDp := r.toDp(regXPx + barW/2 - r.toPx(Dp(60))) wordWrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(80))) } ``` ### Common Pitfalls - **Don't use `gtx.Constraints` in render functions**: They're modified by clips. Use the captured `WindowConstraints` snapshot. - **Don't mix Dp and Px**: The compiler will catch it. Use explicit conversions. - **Don't pass pixels as Dp to EditorLayout**: `main.go` must convert `app.ConfigEvent` pixels to Dp before passing to the logic layer. ## 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 - **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**. Renderer converts Dp→Px at Gio boundaries. - **Capture `gtx.Constraints` once at start of `Draw()`** — clips modify them. - **Use explicit conversions** (`ui.ToPx`, `ui.ToDp`) — never mix Dp and Px directly. - **Avoid** using `material.Label()` when you need precise glyph positions - **Reuse** glyph data for measurement, hit testing, and rendering - **Follow** Gio's `paintGlyph` as the reference implementation