Add ShapeText/DrawShapedText: single shaper call, zero-call drawing

- New shaper.go: ShapeText() returns ShapedText with measured width +
  stored PathSpec + BitmapsCall, DrawShapedText() draws with zero shaper calls
- drawStatusBar: ShapeText for filename + right icons, positions from widths
- drawBottomBar: ShapeText for cursor/bytepos/wrap, positions by alignment
- No separate measureText helper: ShapeText.Width provides measurements
- StatusBar uses IconLeft/IconRow string slices for icon configuration
- BottomBar uses BarAlignment enum for item positioning
This commit is contained in:
Greg Pomerantz 2026-05-12 12:06:28 -04:00
parent 5da60ac9b3
commit fa43bf2402
4 changed files with 244 additions and 99 deletions

7
.qwen/settings.json.orig Normal file
View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(pkill *)"
]
}
}

View File

@ -246,15 +246,15 @@ const (
) )
// StatusBar displays the top bar with filename, action icons, and conflict indicator. // StatusBar displays the top bar with filename, action icons, and conflict indicator.
// IconLeft contains icon names drawn left-aligned before the filename.
// IconRow contains icon names drawn right-aligned after the filename.
type StatusBar struct { type StatusBar struct {
id string id string
region Region region Region
visible bool visible bool
Filename string Filename string
FilenameExp bool IconLeft []string // icon names for left side (e.g., ["cut"])
CutCopy bool IconRow []string // icon names for right side (e.g., ["conflict", "search"])
Copy bool
Paste bool
ConflictIcon bool ConflictIcon bool
Search bool Search bool
} }
@ -269,23 +269,33 @@ func NewStatusBar(region Region, filename string, filenameExp bool, cutCopy, cop
region: region, region: region,
visible: true, visible: true,
Filename: filename, Filename: filename,
FilenameExp: filenameExp, IconLeft: []string{"cut"},
CutCopy: cutCopy, IconRow: []string{"search"},
Copy: copy,
Paste: paste,
ConflictIcon: conflictIcon, ConflictIcon: conflictIcon,
Search: search, Search: search,
} }
} }
// BarAlignment specifies how a bar item is positioned within its bar.
type BarAlignment int
const (
BarLeft BarAlignment = iota
BarCenter
BarRight
)
// BottomBar displays the bottom bar with cursor position, byte position, and word wrap toggle. // BottomBar displays the bottom bar with cursor position, byte position, and word wrap toggle.
type BottomBar struct { type BottomBar struct {
id string id string
region Region region Region
visible bool visible bool
CursorPos string CursorPos string
CursorAlign BarAlignment
BytePos string BytePos string
BytePosAlign BarAlignment
WordWrap bool WordWrap bool
WrapAlign BarAlignment
} }
func (bb BottomBar) Region() Region { return bb.region } func (bb BottomBar) Region() Region { return bb.region }
@ -295,11 +305,14 @@ func (bb BottomBar) ID() string { return bb.id }
// NewBottomBar creates a visible BottomBar element. // NewBottomBar creates a visible BottomBar element.
func NewBottomBar(region Region, cursorPos, bytePos string, wordWrap bool) BottomBar { func NewBottomBar(region Region, cursorPos, bytePos string, wordWrap bool) BottomBar {
return BottomBar{ return BottomBar{
region: region, region: region,
visible: true, visible: true,
CursorPos: cursorPos, CursorPos: cursorPos,
BytePos: bytePos, CursorAlign: BarLeft,
WordWrap: wordWrap, BytePos: bytePos,
BytePosAlign: BarCenter,
WordWrap: wordWrap,
WrapAlign: BarRight,
} }
} }

View File

@ -128,7 +128,7 @@ func (r *Renderer) drawListView(gtx layout.Context, lv ListView, _ WindowConstra
func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar, _ WindowConstraints) { func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar, _ WindowConstraints) {
reg := sb.Region() reg := sb.Region()
// Draw light gray background // ── Draw background (outside clip) ──
bgColor := color.NRGBA{R: 230, G: 230, B: 230, A: 255} bgColor := color.NRGBA{R: 230, G: 230, B: 230, A: 255}
bgClip := clip.Rect{ bgClip := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))}, Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
@ -138,64 +138,111 @@ func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar, _ WindowConst
paint.PaintOp{}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop() bgClip.Pop()
// Clip to status bar region for text (convert Dp to pixels) // ── Layout: shape all items ──
marginDp := Dp(8)
iconSpacingDp := Dp(12)
lineHeight := r.theme.FontSize
// Shape filename
shapedFilename := r.ShapeText(gtx, sb.Filename, lineHeight)
// Measure left icons (PNG, fixed size)
var leftIconWidthPx int
for range sb.IconLeft {
leftIconWidthPx += int(r.toPx(Dp(16))) + int(r.toPx(iconSpacingDp))
}
// Measure right icons (text-based)
var rightIconWidthPx int
for _, name := range sb.IconRow {
w := r.ShapeText(gtx, name, lineHeight).Width
rightIconWidthPx += w + int(r.toPx(iconSpacingDp))
}
if sb.ConflictIcon {
w := r.ShapeText(gtx, "⚠", lineHeight).Width
rightIconWidthPx += w + int(r.toPx(iconSpacingDp))
}
// Available width for filename
totalRightPx := leftIconWidthPx + rightIconWidthPx + int(r.toPx(marginDp)*2)
availableWidthPx := int(r.toPx(reg.W)) - totalRightPx
if availableWidthPx < 0 {
availableWidthPx = 0
}
// Truncate filename if needed
displayFilename := sb.Filename
spaceForTextPx := availableWidthPx - int(r.toPx(Dp(30)))
if shapedFilename.Width > availableWidthPx && spaceForTextPx > 0 {
// Find truncation point
r.shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(lineHeight)),
MinWidth: 0,
MaxWidth: availableWidthPx,
MaxLines: 1,
}, sb.Filename)
var widths []int
var cumWidth fixed.Int26_6
for {
g, ok := r.shp.NextGlyph()
if !ok {
break
}
cumWidth += g.Advance
widths = append(widths, int(cumWidth>>6))
}
for i := len(widths) - 1; i >= 0; i-- {
if widths[i] <= spaceForTextPx {
displayFilename = sb.Filename[:i] + "..."
break
}
}
}
// ── Render ──
// Clip to status bar region
c := clip.Rect{ c := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))}, Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))}, Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops) }.Push(gtx.Ops)
// Line 1: Filename (top at reg.Y + 2dp) // Line 1: Left icons + filename
filenameLineY := reg.Y + Dp(2) filenameLineY := reg.Y + Dp(2)
leftX := reg.X + marginDp
// Truncate filename only if it doesn't fit // Draw left icons
displayFilename := sb.Filename for _, name := range sb.IconLeft {
availableWidth := int(r.toPx(reg.W - Dp(8) - Dp(40))) // left margin + right icons in DP if img, ok := r.icons[name]; ok {
iconSize := Dp(16)
// Layout ellipsis once and get its width r.drawPng(gtx, img, leftX, filenameLineY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
r.shp.LayoutString(text.Parameters{ leftX += Dp(16) + iconSpacingDp
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
MinWidth: 0,
MaxWidth: 1000,
MaxLines: 1,
}, "...")
var ellipsisWidth int
for {
g, ok := r.shp.NextGlyph()
if !ok {
break
} }
ellipsisWidth = int((g.X + g.Advance) >> 6)
} }
// Measure how wide "..." is, then find how many chars fit in availableWidth - ellipsisWidth
spaceForText := availableWidth - ellipsisWidth
// Layout filename, measure widths, and draw in a single pass // Draw filename
r.drawTruncatedText(gtx, r.shp, displayFilename, r.theme.FontSize, reg.X+Dp(8), filenameLineY, availableWidth, spaceForText, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) r.drawTruncatedText(gtx, r.shp, displayFilename, lineHeight, leftX, filenameLineY, availableWidthPx, spaceForTextPx, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
// Line 2: Icons (top at reg.Y + 26dp) // Line 2: Icons
iconsLineY := reg.Y + Dp(26) iconsLineY := reg.Y + Dp(26)
// Left side: Cut, Copy, Paste icons (always visible) // Left side: Cut icon
leftX := reg.X + Dp(8) leftX = reg.X + Dp(8)
iconY := iconsLineY r.drawPng(gtx, r.icons["cut"], leftX, iconsLineY, Dp(16), Dp(16), color.NRGBA{R: 0, G: 0, B: 0, A: 255})
iconSize := Dp(16)
// Draw PNG icons at their top-left position (iconY)
r.drawPng(gtx, r.icons["cut"], leftX, iconY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += Dp(36) leftX += Dp(36)
r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += Dp(36) leftX += Dp(36)
r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += Dp(12) leftX += Dp(12)
// Right side: Conflict (conditional), Search (always visible) // Right side: Conflict (conditional), Search (always visible)
rightX := reg.X + reg.W - Dp(8) rightX := reg.X + reg.W - Dp(8)
if sb.ConflictIcon { if sb.ConflictIcon {
rightX -= Dp(36) rightX -= Dp(36)
r.drawText(gtx, r.shp, "⚠", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) r.drawText(gtx, r.shp, "⚠", r.theme.FontSize, rightX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
} }
rightX -= Dp(36) rightX -= Dp(36)
r.drawText(gtx, r.shp, "🔍", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) r.drawText(gtx, r.shp, "🔍", r.theme.FontSize, rightX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
c.Pop() c.Pop()
} }
@ -213,45 +260,39 @@ func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints W
paint.PaintOp{}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop() bgClip.Pop()
// Use captured constraints for positioning — they are in pixels and // Use captured constraints for positioning
// represent the actual window bounds. Never use gtx.Constraints here
// because clips modify them.
windowW := constraints.Max.X windowW := constraints.Max.X
windowH := constraints.Max.Y windowH := constraints.Max.Y
// Convert region to pixels for positioning
regXPx := r.toPx(reg.X) regXPx := r.toPx(reg.X)
regWPx := r.toPx(reg.W) regWPx := r.toPx(reg.W)
// Clamp right edge to window width
rightXPx := regXPx + regWPx rightXPx := regXPx + regWPx
if rightXPx > Px(windowW) { if rightXPx > Px(windowW) {
rightXPx = Px(windowW) rightXPx = Px(windowW)
} }
barW := rightXPx - regXPx barW := rightXPx - regXPx
// Position BottomBar at bottom of visible window (in pixels)
bottomBarHeightPx := r.toPx(Dp(24)) bottomBarHeightPx := r.toPx(Dp(24))
marginPx := r.toPx(Dp(10)) marginPx := r.toPx(Dp(10))
drawYPx := Px(windowH) - bottomBarHeightPx - marginPx drawYPx := Px(windowH) - bottomBarHeightPx - marginPx
// Convert draw position back to Dp for text rendering
drawY := r.toDp(drawYPx) drawY := r.toDp(drawYPx)
// Left: Cursor position // ── Layout: shape all items ──
cursorXPx := regXPx + r.toPx(Dp(8)) cursorShaped := r.ShapeText(gtx, bb.CursorPos, r.theme.FontSize)
cursorXDp := r.toDp(cursorXPx) bytePosShaped := r.ShapeText(gtx, bb.BytePos, r.theme.FontSize)
r.drawText(gtx, r.shp, bb.CursorPos, r.theme.FontSize, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) wrapText := "Wrap:" + boolToString(bb.WordWrap)
wrapShaped := r.ShapeText(gtx, wrapText, r.theme.FontSize)
// Center: Byte position // ── Position based on alignment ──
byteXPx := regXPx + barW/2 - r.toPx(Dp(60)) cursorXDp := r.toDp(regXPx + r.toPx(Dp(8)))
byteXDp := r.toDp(byteXPx) byteXDp := r.toDp(regXPx + barW/2 - Px(cursorShaped.Width/2))
r.drawText(gtx, r.shp, bb.BytePos, r.theme.FontSize, byteXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) wordWrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(8)) - Px(wrapShaped.Width))
// Right: Word wrap button // ── Render ──
wordWrapXPx := regXPx + barW - r.toPx(Dp(80)) r.DrawShapedText(gtx, cursorShaped, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
wordWrapXDp := r.toDp(wordWrapXPx) r.DrawShapedText(gtx, bytePosShaped, byteXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
r.drawText(gtx, r.shp, "Wrap:"+boolToString(bb.WordWrap), r.theme.FontSize, wordWrapXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) r.DrawShapedText(gtx, wrapShaped, wordWrapXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
} }
func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) { func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) {
@ -259,7 +300,6 @@ func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraint
th := material.NewTheme() th := material.NewTheme()
th.Shaper = r.shp th.Shaper = r.shp
// Convert region to pixels for Gio interop
gtx.Constraints.Min.X = int(r.toPx(reg.X)) gtx.Constraints.Min.X = int(r.toPx(reg.X))
gtx.Constraints.Min.Y = int(r.toPx(reg.Y)) gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx) material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx)
@ -272,9 +312,8 @@ func boolToString(b bool) string {
return "Off" return "Off"
} }
// drawTruncatedText layouts text, measures widths, truncates if needed, and draws in a single pass. // drawTruncatedText layouts text, measures widths, truncates if needed, and draws.
func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, availableWidth, spaceForText int, col color.NRGBA) { func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, availableWidth, spaceForText int, col color.NRGBA) {
// Layout text with width constraints so the shaper doesn't wrap every character.
shp.LayoutString(text.Parameters{ shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)), PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0, MinWidth: 0,
@ -282,7 +321,6 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s
MaxLines: 1, MaxLines: 1,
}, str) }, str)
// Measure widths and find truncation point
var widths []int var widths []int
var cumWidth fixed.Int26_6 var cumWidth fixed.Int26_6
fullStr := str fullStr := str
@ -296,10 +334,8 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s
widths = append(widths, w) widths = append(widths, w)
} }
// Truncate if needed
if len(widths) > 0 && widths[len(widths)-1] > availableWidth { if len(widths) > 0 && widths[len(widths)-1] > availableWidth {
// Find truncation point (from longest to shortest) for i := len(widths) - 1; i >= 0; i-- {
for i := len(widths)-1; i >= 0; i-- {
if widths[i] <= spaceForText { if widths[i] <= spaceForText {
fullStr = str[:i] + "..." fullStr = str[:i] + "..."
break break
@ -307,7 +343,6 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s
} }
} }
// Re-layout truncated text and draw glyphs directly
shp.LayoutString(text.Parameters{ shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)), PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0, MinWidth: 0,
@ -317,10 +352,8 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s
r.drawLineText(gtx, shp, x, y, col) r.drawLineText(gtx, shp, x, y, col)
} }
// drawText draws text using the same approach as Gio's textView: // drawText draws text using Gio's textView approach.
// layout text, iterate glyphs, buffer into lines, and draw using shaper.Shape().
func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, col color.NRGBA) { func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, col color.NRGBA) {
// Layout text with width constraints so the shaper doesn't wrap every character.
shp.LayoutString(text.Parameters{ shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)), PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0, MinWidth: 0,
@ -332,11 +365,9 @@ func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, si
// drawLineText draws already-laid-out glyphs using shaper.Shape() and shaper.Bitmaps(). // drawLineText draws already-laid-out glyphs using shaper.Shape() and shaper.Bitmaps().
func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y Dp, col color.NRGBA) { func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y Dp, col color.NRGBA) {
// Convert Dp to pixels for glyph positioning
xPx := r.toPx(x) xPx := r.toPx(x)
yPx := r.toPx(y) yPx := r.toPx(y)
// Match Gio's textView: record into a macro for clipping
m := op.Record(gtx.Ops) m := op.Record(gtx.Ops)
var glyphs [32]text.Glyph var glyphs [32]text.Glyph
line := glyphs[:0] line := glyphs[:0]
@ -355,29 +386,21 @@ func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y Dp, c
} }
// drawLine draws a line of glyphs using shaper.Shape() and shaper.Bitmaps(). // drawLine draws a line of glyphs using shaper.Shape() and shaper.Bitmaps().
// Matches Gio's paintGlyph exactly: offset by (x + first.X, y + first.Y).
func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y Px, col color.NRGBA) { func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y Px, col color.NRGBA) {
if len(line) == 0 { if len(line) == 0 {
return return
} }
first := line[0] 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(x) + float32(first.X)/64.0 offX := float32(x) + float32(first.X)/64.0
offY := float32(y) + float32(first.Y) offY := float32(y) + float32(first.Y)
t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops) t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops)
// Draw vector glyphs
path := shp.Shape(line) path := shp.Shape(line)
outline := clip.Outline{Path: path}.Op().Push(gtx.Ops) outline := clip.Outline{Path: path}.Op().Push(gtx.Ops)
paint.ColorOp{Color: col}.Add(gtx.Ops) paint.ColorOp{Color: col}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)
outline.Pop() outline.Pop()
// Draw bitmap glyphs (emoji, etc.)
if call := shp.Bitmaps(line); call != (op.CallOp{}) { if call := shp.Bitmaps(line); call != (op.CallOp{}) {
call.Add(gtx.Ops) call.Add(gtx.Ops)
} }
@ -386,40 +409,31 @@ func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Gl
} }
// WindowConstraints tracks the initial window constraints captured at the start of Draw. // WindowConstraints tracks the initial window constraints captured at the start of Draw.
// All elements are positioned relative to these bounds to ensure consistency.
type WindowConstraints struct { type WindowConstraints struct {
Min image.Point Min image.Point
Max image.Point Max image.Point
} }
// drawPng draws a PNG image at the given position and size. // drawPng draws a PNG image at the given position and size.
// x, y are in Dp (top-left corner).
// width, height are in Dp (target size).
// col is the tint color — if alpha < 255, the image is tinted using blendOp.DstIn.
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, x, y Dp, width, height Dp, col color.NRGBA) { func (r *Renderer) drawPng(gtx layout.Context, img image.Image, x, y Dp, width, height Dp, col color.NRGBA) {
if img == nil { if img == nil {
return return
} }
// Convert Dp to pixels
xPx := int(r.toPx(x)) xPx := int(r.toPx(x))
yPx := int(r.toPx(y)) yPx := int(r.toPx(y))
wPx := int(r.toPx(width)) wPx := int(r.toPx(width))
hPx := int(r.toPx(height)) hPx := int(r.toPx(height))
// Compute destination rectangle
dst := image.Rect(xPx, yPx, xPx+wPx, yPx+hPx) dst := image.Rect(xPx, yPx, xPx+wPx, yPx+hPx)
// If tint color has alpha < 255, apply tint using blendOp.DstIn
if col.A < 255 { if col.A < 255 {
// Draw tinted rectangle over the image
bgClip := clip.Rect(dst).Push(gtx.Ops) bgClip := clip.Rect(dst).Push(gtx.Ops)
paint.ColorOp{Color: col}.Add(gtx.Ops) paint.ColorOp{Color: col}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop() bgClip.Pop()
} }
// Draw the image
stack := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops) stack := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops)
paint.NewImageOp(img).Add(gtx.Ops) paint.NewImageOp(img).Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)

111
internal/ui/shaper.go Normal file
View File

@ -0,0 +1,111 @@
package ui
import (
"image/color"
"gioui.org/f32"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"golang.org/x/image/math/fixed"
)
// ShapedText holds layout results from a single ShapeText call.
// The PathSpec and BitmapsCall can be drawn without further shaper access.
type ShapedText struct {
Width int // total width in pixels
PathSpec clip.PathSpec // vector glyph path, ready to draw
BitmapsCall op.CallOp // bitmap glyph call (empty if none)
FirstX fixed.Int26_6 // first glyph X offset
FirstY int32 // first glyph Y offset
Text string
FontSize unit.Sp
}
// ShapeText layouts text, measures width, and returns a ShapedText
// with an immutable path ready for drawing. The shaper is called once.
func (r *Renderer) ShapeText(gtx layout.Context, str string, size unit.Sp) ShapedText {
r.shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: 1000,
MaxLines: 1,
}, str)
// Collect glyphs and measure width
var glyphs []text.Glyph
var cumWidth fixed.Int26_6
for {
g, ok := r.shp.NextGlyph()
if !ok {
break
}
cumWidth += g.Advance
glyphs = append(glyphs, g)
}
// Build lines and collect the last line's path spec
var lastSpec clip.PathSpec
var lastBitmaps op.CallOp
var firstX fixed.Int26_6
var firstY int32
var line []text.Glyph
for _, g := range glyphs {
if len(line) == 0 {
firstX = g.X
firstY = g.Y
}
line = append(line, g)
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
lastSpec = r.shp.Shape(line)
lastBitmaps = r.shp.Bitmaps(line)
line = line[:0]
}
}
if len(line) > 0 {
lastSpec = r.shp.Shape(line)
lastBitmaps = r.shp.Bitmaps(line)
}
return ShapedText{
Width: int(cumWidth >> 6),
PathSpec: lastSpec,
BitmapsCall: lastBitmaps,
FirstX: firstX,
FirstY: firstY,
Text: str,
FontSize: size,
}
}
// DrawShapedText draws a pre-shaped text at the given position.
// x, y are in Dp. Uses the stored path spec — zero shaper calls.
func (r *Renderer) DrawShapedText(gtx layout.Context, st ShapedText, x, y Dp, col color.NRGBA) {
xPx := r.toPx(x)
yPx := r.toPx(y)
m := op.Record(gtx.Ops)
// Compute offset: position + first glyph offset
offX := float32(xPx) + float32(st.FirstX)/64.0
offY := float32(yPx) + float32(st.FirstY)
t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops)
// Draw vector path
outline := clip.Outline{Path: st.PathSpec}.Op().Push(gtx.Ops)
paint.ColorOp{Color: col}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
outline.Pop()
// Draw bitmap glyphs
if st.BitmapsCall != (op.CallOp{}) {
st.BitmapsCall.Add(gtx.Ops)
}
t.Pop()
call := m.Stop()
call.Add(gtx.Ops)
}