17 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 — 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.XMaxWidth: cs.Max.XMaxLines: 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.Advanceis infixed.Int26_6format (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,MaxLinesor widths will be wrong
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, yare the desired document position (in DP)first.Xis the first glyph's X position infixed.Int26_6(divide by 64 to get pixels)first.Yis the first glyph's Y position (baseline) in pixelsshp.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:
- First layout to measure widths and find truncation point
- 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:
lineOffis set on the first glyph:(glyph.X, glyph.Y) - viewport.Minshaper.Shape(line)returns a path relative to the first glyph- The offset places the first glyph at
lineOff, and subsequent glyphs follow it.materialis a pre-recorded color call; we usepaint.ColorOp+paint.PaintOpinstead- 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:
- Layout text with
MaxWidthfor word wrap - Iterate through glyphs to get per-character positions
- 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:
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: MissingMaxWidth/MinWidth/MaxLines - All
FlagLineBreakset: 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 = 20device 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)
10. Summary
- Always use
fixed.I(gtx.Sp(size))forPxPerEm - Always set
MinWidth,MaxWidth,MaxLinesinLayoutStringparameters - Always offset by
(x + first.X, y + first.Y)indrawLine - 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.0or set explicitLineHeight - Visual gap between lines =
baselineSpacing - (ascent1 + ascent2) - Avoid using
material.Label()when you need precise glyph positions - Reuse glyph data for measurement, hit testing, and rendering
- Follow Gio's
paintGlyphas the reference implementation