Pad/internal/ui/render.go
Greg Pomerantz 9476f5448a Update BottomBar to use drawText instead of material.Label
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-09 15:53:14 -04:00

323 lines
9.4 KiB
Go

package ui
import (
"image"
"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"
"gioui.org/widget/material"
"golang.org/x/image/math/fixed"
)
// Renderer consumes a slice of elements and draws them.
type Renderer struct {
theme Theme
shp *text.Shaper
}
// New creates a new Renderer.
func New(th Theme, shp *text.Shaper) *Renderer {
return &Renderer{theme: th, shp: shp}
}
// Draw iterates elements and draws each in slice order (back-to-front).
func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
for _, e := range elems {
if !e.Visible() {
continue
}
switch v := e.(type) {
case Label:
r.drawLabel(gtx, v)
case ListView:
r.drawListView(gtx, v)
case StatusBar:
r.drawStatusBar(gtx, v)
case BottomBar:
r.drawBottomBar(gtx, v)
case Button:
r.drawButton(gtx, v)
default:
// unknown element type, skip
}
}
}
func (r *Renderer) drawLabel(gtx layout.Context, l Label) {
reg := l.Region()
th := material.NewTheme()
th.Shaper = r.shp
size := l.FontSize
if size == 0 {
size = r.theme.FontSize
}
// Position label at region origin
gtx.Constraints.Min.X = int(reg.X)
gtx.Constraints.Min.Y = int(reg.Y)
material.Label(th, size, l.Text).Layout(gtx)
}
func (r *Renderer) drawListView(gtx layout.Context, lv ListView) {
reg := lv.Region()
th := material.NewTheme()
th.Shaper = r.shp
// Position list view at region origin
gtx.Constraints.Min.X = int(reg.X)
gtx.Constraints.Min.Y = int(reg.Y)
for _, item := range lv.Items {
material.Label(th, r.theme.FontSize, item.Text).Layout(gtx)
}
}
func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) {
reg := sb.Region()
th := material.NewTheme()
th.Shaper = r.shp
// Clip to status bar region
c := clip.Rect{
Min: image.Point{X: int(reg.X), Y: int(reg.Y)},
Max: image.Point{X: int(reg.X + reg.W), Y: int(reg.Y + reg.H)},
}.Push(gtx.Ops)
// Line 1: Filename (truncated with ellipsis if needed)
filenameLineY := reg.Y
// Truncate filename only if it doesn't fit
displayFilename := sb.Filename
availableWidth := int(reg.W - unit.Dp(8) - unit.Dp(40)) // left margin + right icons in DP
// Layout ellipsis once and get its width
th.Shaper.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
MinWidth: 0,
MaxWidth: 1000,
MaxLines: 1,
}, "...")
var ellipsisWidth int
for {
g, ok := th.Shaper.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
r.drawTruncatedText(gtx, th.Shaper, displayFilename, r.theme.FontSize, reg.X+unit.Dp(8), filenameLineY, availableWidth, spaceForText, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
// Line 2: Icons — positioned 20DP below filename baseline.
// This matches Gio's default line spacing (ascent+descent with LineHeightScale=1.0).
iconsLineY := filenameLineY + unit.Dp(20)
// Left side: Cut, Copy, Paste icons (always visible)
leftX := reg.X + unit.Dp(8)
iconY := iconsLineY
r.drawText(gtx, th.Shaper, "✂", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += unit.Dp(36)
r.drawText(gtx, th.Shaper, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += unit.Dp(36)
r.drawText(gtx, th.Shaper, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += unit.Dp(12)
// Right side: Conflict (conditional), Search (always visible)
rightX := reg.X + reg.W - unit.Dp(8)
if sb.ConflictIcon {
rightX -= unit.Dp(36)
r.drawText(gtx, th.Shaper, "⚠", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
}
rightX -= unit.Dp(36)
r.drawText(gtx, th.Shaper, "🔍", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
c.Pop()
}
func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar) {
reg := bb.Region()
// BottomBar text at reg.Y (top of bar), using drawText for consistent rendering.
// The BottomBar region is at the bottom of the screen with 10DP margin.
bottomY := reg.Y
// Left: Cursor position
cursorX := reg.X + unit.Dp(8)
r.drawText(gtx, r.shp, bb.CursorPos, r.theme.FontSize, cursorX, bottomY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
// Center: Byte position
byteX := reg.X + reg.W/2 - unit.Dp(60) // approximate center
r.drawText(gtx, r.shp, bb.BytePos, r.theme.FontSize, byteX, bottomY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
// Right: Word wrap button
wordWrapX := reg.X + reg.W - unit.Dp(80)
r.drawText(gtx, r.shp, "W:"+boolToString(bb.WordWrap), r.theme.FontSize, wordWrapX, bottomY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
}
func (r *Renderer) drawButton(gtx layout.Context, btn Button) {
reg := btn.Region()
th := material.NewTheme()
th.Shaper = r.shp
// Position button at region origin
gtx.Constraints.Min.X = int(reg.X)
gtx.Constraints.Min.Y = int(reg.Y)
material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx)
}
func (r *Renderer) drawIconButton(gtx layout.Context, th *material.Theme, icon string, x, y unit.Dp) {
// Position icon button at (x, y)
gtx.Constraints.Min.X = int(x)
gtx.Constraints.Min.Y = int(y)
material.Label(th, r.theme.FontSize, icon).Layout(gtx)
}
func boolToString(b bool) string {
if b {
return "On"
}
return "Off"
}
// drawTruncatedText layouts text, measures widths, truncates if needed, and draws in a single pass.
func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y unit.Dp, availableWidth, spaceForText int, col color.NRGBA) {
// Layout text with width constraints so the shaper doesn't wrap every character.
shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: availableWidth,
MaxLines: 1,
}, str)
// Measure widths and find truncation point
var widths []int
var cumWidth fixed.Int26_6
fullStr := str
for {
g, ok := shp.NextGlyph()
if !ok {
break
}
cumWidth += g.Advance
w := int(cumWidth >> 6)
widths = append(widths, w)
}
// Truncate if needed
if len(widths) > 0 && widths[len(widths)-1] > availableWidth {
// Find truncation point (from longest to shortest)
for i := len(widths)-1; i >= 0; i-- {
if widths[i] <= spaceForText {
fullStr = str[:i] + "..."
break
}
}
}
// Re-layout truncated text and draw glyphs directly
shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: availableWidth,
MaxLines: 1,
}, fullStr)
r.drawLineText(gtx, shp, x, y, col)
}
// drawText draws text using the same approach as Gio's textView:
// 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 unit.Dp, col color.NRGBA) {
// Layout text with width constraints so the shaper doesn't wrap every character.
shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: 1000,
MaxLines: 1,
}, str)
r.drawLineText(gtx, shp, x, y, col)
}
// drawLineText draws already-laid-out glyphs using shaper.Shape() and shaper.Bitmaps().
func (r *Renderer) 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 {
r.drawLine(gtx, shp, line, x, y, col)
line = line[:0]
}
}
if len(line) > 0 {
r.drawLine(gtx, shp, line, x, y, col)
}
call := m.Stop()
call.Add(gtx.Ops)
}
// 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 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()
}
// charWidths returns the cumulative width after each rune in str.
func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int {
// Match Gio's textView: PxPerEm = font size in device pixels
// Must set MinWidth/MaxWidth/MaxLines or shaper wraps every character.
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
}