672 lines
21 KiB
Markdown
672 lines
21 KiB
Markdown
# 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.
|
|
|
|
## 9.6 Background Drawing with Clips
|
|
|
|
When drawing backgrounds (rectangles with solid colors), use `clip.Rect{...}.Push(gtx.Ops)` followed immediately by `clip.Pop()`. **Never use `op.Record`/`m.Stop()` macros for simple background clips** — the macro leaves the clip on the stack, preventing subsequent elements from drawing.
|
|
|
|
**Correct pattern**:
|
|
```go
|
|
bgClip := clip.Rect{
|
|
Min: image.Point{X: pxMinX, Y: pxMinY},
|
|
Max: image.Point{X: pxMaxX, Y: pxMaxY},
|
|
}.Push(gtx.Ops)
|
|
paint.ColorOp{Color: bgColor}.Add(gtx.Ops)
|
|
paint.PaintOp{}.Add(gtx.Ops)
|
|
bgClip.Pop() // Immediately pop — clip stack must be clean
|
|
```
|
|
|
|
**Wrong pattern** (leaves clip on stack):
|
|
```go
|
|
m := op.Record(gtx.Ops)
|
|
clip.Rect{...}.Op().Push(gtx.Ops) // Push inside macro
|
|
paint.ColorOp{...}.Add(gtx.Ops)
|
|
paint.PaintOp{}.Add(gtx.Ops)
|
|
call := m.Stop()
|
|
call.Add(gtx.Ops) // When replayed, clip is pushed but never popped!
|
|
```
|
|
|
|
**Why this matters**: When the StatusBar draws its background with a macro, the clip inside is pushed during macro replay but never popped. The BottomBar then draws while that stale clip is still active, causing it to be clipped out of visibility.
|
|
|
|
## 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
|