diff --git a/doc/shaper_usage.md b/doc/shaper_usage.md index 4c44db8..db17a11 100644 --- a/doc/shaper_usage.md +++ b/doc/shaper_usage.md @@ -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)), - MaxWidth: int(widthInPixels), - WrapPolicy: text.WrapTrailingSpace, + 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. +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 - X fixed.Int26_6 // Dot position in document coordinates - Y int32 // Baseline position - Ascent int32 // Line ascent - Descent int32 // Line descent - Advance fixed.Int26_6 // Logical width (horizontal advance) - Runes uint16 // Number of runes this glyph represents + 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 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