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:
parent
ca34a570a9
commit
f67cca1f4b
|
|
@ -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.
|
||||
|
||||
## 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
|
||||
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
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
### 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:
|
||||
|
||||
```go
|
||||
params := text.Parameters{
|
||||
PxPerEm: fixed.I(gtx.Sp(size)),
|
||||
MinWidth: 0,
|
||||
MaxWidth: int(widthInPixels),
|
||||
WrapPolicy: text.WrapTrailingSpace,
|
||||
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
|
||||
|
||||
|
|
@ -98,15 +121,16 @@ for {
|
|||
|
||||
```go
|
||||
type Glyph struct {
|
||||
ID uint32 // Glyph ID
|
||||
ID GlyphID // Glyph ID
|
||||
X fixed.Int26_6 // Dot position in document coordinates
|
||||
Y int32 // Baseline position
|
||||
Ascent int32 // Line ascent
|
||||
Descent int32 // Line descent
|
||||
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 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
|
||||
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 cumWidth fixed.Int26_6 = 0
|
||||
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)
|
||||
- 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
|
||||
|
||||
|
|
@ -142,7 +172,12 @@ For hit testing (mapping UI coordinates → byte offset), we need per-character
|
|||
|
||||
```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))}, str)
|
||||
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 {
|
||||
|
|
@ -159,16 +194,25 @@ func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp)
|
|||
|
||||
## 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
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
line := glyphs[:0]
|
||||
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 {
|
||||
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) {
|
||||
// Apply offset transform
|
||||
off := f32.Point{X: float32(x), Y: float32(y)}
|
||||
t := op.Affine(f32.Affine2D{}.Offset(off)).Push(gtx.Ops)
|
||||
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)
|
||||
|
|
@ -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):
|
||||
|
||||
|
|
@ -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).
|
||||
|
||||
## 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
|
||||
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
|
||||
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))
|
||||
}
|
||||
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**:
|
||||
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
|
||||
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 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`
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
|
|
@ -292,14 +421,23 @@ func (e *textView) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, s
|
|||
|
||||
```go
|
||||
// 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
|
||||
widths := measureWidths(shp)
|
||||
if widths[len(widths)-1] > availableWidth {
|
||||
// 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)
|
||||
|
|
@ -316,17 +454,34 @@ For the TextField element, we'll use the same approach:
|
|||
|
||||
### 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
|
||||
- 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. 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** 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
|
||||
- **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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user