Update shaper_usage.md with correct MinWidth/MaxWidth/MaxLines requirements and offset calculation

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Greg Pomerantz 2026-05-09 14:07:47 -04:00
parent ca34a570a9
commit f67cca1f4b

View File

@ -15,19 +15,16 @@ 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. **Important**: Always provide a font collection. Without it, the shaper may not load fonts correctly and will return zero-width glyphs.
## 2. Layout Parameters ## 2. Layout Parameters — CRITICAL
The `text.Parameters` struct controls how text is shaped: The `text.Parameters` struct controls how text is shaped. **Three fields are mandatory** for correct single-line text layout:
```go ```go
type Parameters struct { params := text.Parameters{
PxPerEm fixed.Int26_6 // Font size in device pixels PxPerEm: fixed.I(gtx.Sp(size)), // Font size in device pixels (REQUIRED)
Font font.Font // Specific font (zero value uses default) MinWidth: 0, // Minimum width (REQUIRED for single-line)
MaxWidth int // Maximum width for word wrap (0 = no limit) MaxWidth: availableWidth, // Maximum width (REQUIRED for single-line)
MinWidth int // Minimum width for word wrap MaxLines: 1, // Limit to one line (REQUIRED for single-line)
TextAlign text.Align // Text alignment (Left, Center, Right)
LineSpacing unit.Sp // Additional spacing between lines
WrapPolicy text.WrapPolicy // How to wrap text
} }
``` ```
@ -52,19 +49,45 @@ params := text.Parameters{
**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. **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 ### 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: Set `MaxWidth` to enable word wrap:
```go ```go
params := text.Parameters{ params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)), PxPerEm: fixed.I(gtx.Sp(size)),
MaxWidth: int(widthInPixels), MinWidth: 0,
WrapPolicy: text.WrapTrailingSpace, 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. The shaper automatically breaks text into lines at word boundaries when `MaxWidth` is set and `MaxLines` is 0 or unset.
## 3. Layout Functions ## 3. Layout Functions
@ -98,15 +121,16 @@ for {
```go ```go
type Glyph struct { type Glyph struct {
ID uint32 // Glyph ID ID GlyphID // Glyph ID
X fixed.Int26_6 // Dot position in document coordinates X fixed.Int26_6 // Dot position in document coordinates
Y int32 // Baseline position Y int32 // Baseline position (same for all glyphs on a line)
Ascent int32 // Line ascent Ascent fixed.Int26_6 // Line ascent
Descent int32 // Line descent Descent fixed.Int26_6 // Line descent
Advance fixed.Int26_6 // Logical width (horizontal advance) Advance fixed.Int26_6 // Logical width (horizontal advance)
Runes uint16 // Number of runes this glyph represents Runes uint16 // Number of runes this glyph represents
Offset fixed.Point26_6 // Glyph offset from (X, Y) Offset fixed.Point26_6 // Glyph offset from (X, Y)
Bounds image.Rectangle // Glyph bounds in device pixels Bounds fixed.Rectangle26_6 // Glyph bounds relative to dot
Flags Flags // FlagLineBreak, FlagRunBreak, FlagClusterBreak, etc.
} }
``` ```
@ -116,7 +140,12 @@ To get per-character cumulative widths:
```go ```go
func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int { 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) shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: 1000,
MaxLines: 1,
}, str)
var widths []int var widths []int
var cumWidth fixed.Int26_6 = 0 var cumWidth fixed.Int26_6 = 0
for { for {
@ -135,6 +164,7 @@ func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp)
- `g.Advance` is in `fixed.Int26_6` format (16.6 fixed-point) - `g.Advance` is in `fixed.Int26_6` format (16.6 fixed-point)
- Convert to int by shifting right by 6 bits: `int(cumWidth>>6)` - Convert to int by shifting right by 6 bits: `int(cumWidth>>6)`
- The cumulative width is in device pixels - The cumulative width is in device pixels
- **Always** set `MinWidth`, `MaxWidth`, `MaxLines` or widths will be wrong
### 4.3 Getting Per-Character Widths ### 4.3 Getting Per-Character Widths
@ -142,7 +172,12 @@ For hit testing (mapping UI coordinates → byte offset), we need per-character
```go ```go
func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int { 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) shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: 1000,
MaxLines: 1,
}, str)
var widths []int var widths []int
var cumWidth fixed.Int26_6 = 0 var cumWidth fixed.Int26_6 = 0
for { for {
@ -159,16 +194,25 @@ func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp)
## 5. Rendering Glyphs Directly ## 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. 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 Using Gio's textView Approach ### 5.1 Drawing Text — Match Gio's paintGlyph Exactly
```go ```go
func drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y unit.Dp, col color.NRGBA) { func drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y unit.Dp, col color.NRGBA) {
// Layout text // Layout text with width constraints
shp.LayoutString(text.Parameters{PxPerEm: fixed.I(gtx.Sp(size))}, str) shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: 1000,
MaxLines: 1,
}, str)
drawLineText(gtx, shp, x, y, col)
}
// Draw glyphs using the same approach as Gio's textView 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 var glyphs [32]text.Glyph
line := glyphs[:0] line := glyphs[:0]
for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() { for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() {
@ -181,12 +225,23 @@ func drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x,
if len(line) > 0 { if len(line) > 0 {
drawLine(gtx, shp, line, x, y, col) 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) { func drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y unit.Dp, col color.NRGBA) {
// Apply offset transform if len(line) == 0 {
off := f32.Point{X: float32(x), Y: float32(y)} return
t := op.Affine(f32.Affine2D{}.Offset(off)).Push(gtx.Ops) }
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 // Draw vector glyphs
path := shp.Shape(line) path := shp.Shape(line)
@ -204,7 +259,35 @@ func drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y unit
} }
``` ```
### 5.2 Avoiding Double-Shaping ### 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): **WRONG** (shapes text twice):
@ -229,58 +312,104 @@ drawText(gtx, shp, text, size, x, y, col)
This is acceptable because truncation is only needed when text doesn't fit, and the string is short (filename in StatusBar). 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 ## 6. Comparison with Gio's paintGlyph
Gio's textView (`widget/text.go`) demonstrates the correct approach: Gio's `paintGlyph` (`widget/label.go`) demonstrates the correct approach:
```go ```go
func (e *textView) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp) { func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []text.Glyph) ([]text.Glyph, bool) {
textSize := fixed.I(gtx.Sp(size)) visibleOrBefore := it.processGlyph(glyph, true)
if it.visible {
// Set parameters if len(line) == 0 {
e.params.PxPerEm = textSize it.lineOff = f32.Point{X: fixedToFloat(glyph.X), Y: float32(glyph.Y)}
e.params.MaxWidth = gtx.Constraints.Max.X .Sub(layout.FPt(it.viewport.Min))
// 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 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**: **Key insights**:
1. Layout is done once with `lt.Layout(e.params, r)` 1. `lineOff` is set on the first glyph: `(glyph.X, glyph.Y) - viewport.Min`
2. Glyphs are iterated with `NextGlyph()` and stored in `e.index` 2. `shaper.Shape(line)` returns a path relative to the first glyph
3. Rendering uses the stored glyph data, not a new shaper call 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. Common Pitfalls
### 7.1 Wrong PxPerEm Calculation ### 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` **Mistake**: Using arbitrary multipliers like `1024 * pixelsPerDp`
**Fix**: Use `fixed.I(gtx.Sp(size))` to get the font size in device pixels **Fix**: Use `fixed.I(gtx.Sp(size))` to get the font size in device pixels
### 7.2 Double-Shaping ### 7.3 Double-Shaping
**Mistake**: Calling `charWidths()` to measure, then `material.Label()` to render **Mistake**: Calling `charWidths()` to measure, then `material.Label()` to render
**Fix**: Use `shp.Draw()` to render the already-laid-out glyphs **Fix**: Use `shp.Shape()` to render the already-laid-out glyphs
### 7.3 Forgetting to Reset Shaper ### 7.4 Wrong Offset Calculation
**Mistake**: Not clearing the shaper between layouts **Mistake**: Using just `(x, y)` without adding `first.X`/`first.Y`:
**Fix**: `LayoutString()` and `Layout()` automatically reset the shaper state ```go
// WRONG - glyphs will be at wrong position
offX := float32(gtx.Dp(x))
offY := float32(gtx.Dp(y))
```
### 7.4 Not Converting Int26_6 to int **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 **Mistake**: Using `int(g.Advance)` directly without shifting
@ -292,14 +421,23 @@ func (e *textView) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, s
```go ```go
// Measure widths // Measure widths
widths := charWidths(gtx, shp, filename, fontSize) shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: availableWidth,
MaxLines: 1,
}, filename)
// Truncate if needed // Truncate if needed
widths := measureWidths(shp)
if widths[len(widths)-1] > availableWidth { if widths[len(widths)-1] > availableWidth {
// Find truncation point // Find truncation point
truncated = filename[:i] + "..."
} }
// Render using shaper.Draw() instead of material.Label() // Re-layout and render
shp.LayoutString(text.Parameters{...}, truncated)
drawLineText(gtx, shp, x, y, col)
``` ```
### 8.2 TextField (Editor Content) ### 8.2 TextField (Editor Content)
@ -316,17 +454,34 @@ For the TextField element, we'll use the same approach:
### 8.3 BottomBar ### 8.3 BottomBar
The BottomBar uses `material.Label()` for simple text that doesn't need precise positioning. This is acceptable because: 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 - The text is short and fixed
- We don't need per-character widths for hit testing - We don't need per-character widths for hit testing
- The performance impact is negligible - The performance impact is negligible
However, for consistency, we could also use `shp.Draw()` for BottomBar text. ## 9. Debugging Tips
## 9. Summary 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
## 10. Summary
- **Always** use `fixed.I(gtx.Sp(size))` for `PxPerEm` - **Always** use `fixed.I(gtx.Sp(size))` for `PxPerEm`
- **Always** call `shp.Draw()` after `LayoutString()`/`Layout()` to render - **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
- **Avoid** using `material.Label()` when you need precise glyph positions - **Avoid** using `material.Label()` when you need precise glyph positions
- **Reuse** glyph data for measurement, hit testing, and rendering - **Reuse** glyph data for measurement, hit testing, and rendering
- **Follow** Gio's textView as a reference implementation - **Follow** Gio's `paintGlyph` as the reference implementation