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:
parent
5da60ac9b3
commit
fa43bf2402
7
.qwen/settings.json.orig
Normal file
7
.qwen/settings.json.orig
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pkill *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
111
internal/ui/shaper.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user