Pad/internal/ui/render.go
Greg Pomerantz fa43bf2402 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
2026-05-12 12:06:28 -04:00

442 lines
12 KiB
Go

package ui
import (
"bytes"
"embed"
"image"
"image/color"
_ "image/png"
"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"
)
//go:embed icons/*.png
var iconFS embed.FS
// Renderer consumes a slice of elements and draws them.
type Renderer struct {
theme Theme
shp *text.Shaper
scale float32 // pixels per DP, from State.Scale
icons map[string]image.Image
}
// New creates a new Renderer.
func New(th Theme, shp *text.Shaper) *Renderer {
r := &Renderer{theme: th, shp: shp, scale: 1.0, icons: make(map[string]image.Image)}
r.loadIcons()
return r
}
// loadIcons loads PNG icons from the embedded filesystem.
func (r *Renderer) loadIcons() {
data, err := iconFS.ReadFile("icons/cut.png")
if err != nil {
return
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return
}
r.icons["cut"] = img
}
// SetScale updates the renderer's scale factor from State.Scale.
func (r *Renderer) SetScale(scale float32) {
r.scale = scale
}
// toPx converts Dp to physical pixels using the renderer's scale.
func (r *Renderer) toPx(dp Dp) Px {
return ToPx(dp, r.scale)
}
// toDp converts physical pixels to Dp using the renderer's scale.
func (r *Renderer) toDp(px Px) Dp {
return ToDp(px, r.scale)
}
// Draw iterates elements and draws each in slice order (back-to-front).
// It captures the initial constraints once at the start, then passes them
// to each render function for consistent positioning.
// The scale is set externally via SetScale() from State.Scale.
func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
// Capture initial constraints once — before any clips modify them.
// These are in physical pixels and serve as the window bounds for all positioning.
initialConstraints := WindowConstraints{
Min: gtx.Constraints.Min,
Max: gtx.Constraints.Max,
}
for _, e := range elems {
if !e.Visible() {
continue
}
switch v := e.(type) {
case Label:
r.drawLabel(gtx, v, initialConstraints)
case ListView:
r.drawListView(gtx, v, initialConstraints)
case StatusBar:
r.drawStatusBar(gtx, v, initialConstraints)
case BottomBar:
r.drawBottomBar(gtx, v, initialConstraints)
case Button:
r.drawButton(gtx, v, initialConstraints)
default:
// unknown element type, skip
}
}
}
func (r *Renderer) drawLabel(gtx layout.Context, l Label, _ WindowConstraints) {
reg := l.Region()
th := material.NewTheme()
th.Shaper = r.shp
size := l.FontSize
if size == 0 {
size = r.theme.FontSize
}
// Convert region to pixels for Gio interop
gtx.Constraints.Min.X = int(r.toPx(reg.X))
gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
material.Label(th, size, l.Text).Layout(gtx)
}
func (r *Renderer) drawListView(gtx layout.Context, lv ListView, _ WindowConstraints) {
reg := lv.Region()
th := material.NewTheme()
th.Shaper = r.shp
// Convert region to pixels for Gio interop
gtx.Constraints.Min.X = int(r.toPx(reg.X))
gtx.Constraints.Min.Y = int(r.toPx(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, _ WindowConstraints) {
reg := sb.Region()
// ── Draw background (outside clip) ──
bgColor := color.NRGBA{R: 230, G: 230, B: 230, A: 255}
bgClip := clip.Rect{
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))},
}.Push(gtx.Ops)
paint.ColorOp{Color: bgColor}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop()
// ── 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{
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))},
}.Push(gtx.Ops)
// Line 1: Left icons + filename
filenameLineY := reg.Y + Dp(2)
leftX := reg.X + marginDp
// Draw left icons
for _, name := range sb.IconLeft {
if img, ok := r.icons[name]; ok {
iconSize := Dp(16)
r.drawPng(gtx, img, leftX, filenameLineY, iconSize, iconSize, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += Dp(16) + iconSpacingDp
}
}
// Draw filename
r.drawTruncatedText(gtx, r.shp, displayFilename, lineHeight, leftX, filenameLineY, availableWidthPx, spaceForTextPx, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
// Line 2: Icons
iconsLineY := reg.Y + Dp(26)
// Left side: Cut icon
leftX = reg.X + Dp(8)
r.drawPng(gtx, r.icons["cut"], leftX, iconsLineY, Dp(16), Dp(16), color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += Dp(36)
r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += Dp(36)
r.drawText(gtx, r.shp, "⎘", r.theme.FontSize, leftX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
leftX += Dp(12)
// Right side: Conflict (conditional), Search (always visible)
rightX := reg.X + reg.W - Dp(8)
if sb.ConflictIcon {
rightX -= Dp(36)
r.drawText(gtx, r.shp, "⚠", r.theme.FontSize, rightX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
}
rightX -= Dp(36)
r.drawText(gtx, r.shp, "🔍", r.theme.FontSize, rightX, iconsLineY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
c.Pop()
}
func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) {
reg := bb.Region()
// Draw light gray background
bgColor := color.NRGBA{R: 230, G: 230, B: 230, A: 255}
bgClip := clip.Rect{
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))},
}.Push(gtx.Ops)
paint.ColorOp{Color: bgColor}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop()
// Use captured constraints for positioning
windowW := constraints.Max.X
windowH := constraints.Max.Y
regXPx := r.toPx(reg.X)
regWPx := r.toPx(reg.W)
rightXPx := regXPx + regWPx
if rightXPx > Px(windowW) {
rightXPx = Px(windowW)
}
barW := rightXPx - regXPx
bottomBarHeightPx := r.toPx(Dp(24))
marginPx := r.toPx(Dp(10))
drawYPx := Px(windowH) - bottomBarHeightPx - marginPx
drawY := r.toDp(drawYPx)
// ── Layout: shape all items ──
cursorShaped := r.ShapeText(gtx, bb.CursorPos, r.theme.FontSize)
bytePosShaped := r.ShapeText(gtx, bb.BytePos, r.theme.FontSize)
wrapText := "Wrap:" + boolToString(bb.WordWrap)
wrapShaped := r.ShapeText(gtx, wrapText, r.theme.FontSize)
// ── Position based on alignment ──
cursorXDp := r.toDp(regXPx + r.toPx(Dp(8)))
byteXDp := r.toDp(regXPx + barW/2 - Px(cursorShaped.Width/2))
wordWrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(8)) - Px(wrapShaped.Width))
// ── Render ──
r.DrawShapedText(gtx, cursorShaped, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
r.DrawShapedText(gtx, bytePosShaped, byteXDp, 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) {
reg := btn.Region()
th := material.NewTheme()
th.Shaper = r.shp
gtx.Constraints.Min.X = int(r.toPx(reg.X))
gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx)
}
func boolToString(b bool) string {
if b {
return "On"
}
return "Off"
}
// 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) {
shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: availableWidth,
MaxLines: 1,
}, str)
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)
}
if len(widths) > 0 && widths[len(widths)-1] > availableWidth {
for i := len(widths) - 1; i >= 0; i-- {
if widths[i] <= spaceForText {
fullStr = str[:i] + "..."
break
}
}
}
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 Gio's textView approach.
func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, col color.NRGBA) {
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 Dp, col color.NRGBA) {
xPx := r.toPx(x)
yPx := r.toPx(y)
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, xPx, yPx, col)
line = line[:0]
}
}
if len(line) > 0 {
r.drawLine(gtx, shp, line, xPx, yPx, col)
}
call := m.Stop()
call.Add(gtx.Ops)
}
// drawLine draws a line of glyphs using shaper.Shape() and shaper.Bitmaps().
func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y Px, col color.NRGBA) {
if len(line) == 0 {
return
}
first := line[0]
offX := float32(x) + float32(first.X)/64.0
offY := float32(y) + float32(first.Y)
t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops)
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()
if call := shp.Bitmaps(line); call != (op.CallOp{}) {
call.Add(gtx.Ops)
}
t.Pop()
}
// WindowConstraints tracks the initial window constraints captured at the start of Draw.
type WindowConstraints struct {
Min image.Point
Max image.Point
}
// drawPng draws a PNG image at the given position and size.
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, x, y Dp, width, height Dp, col color.NRGBA) {
if img == nil {
return
}
xPx := int(r.toPx(x))
yPx := int(r.toPx(y))
wPx := int(r.toPx(width))
hPx := int(r.toPx(height))
dst := image.Rect(xPx, yPx, xPx+wPx, yPx+hPx)
if col.A < 255 {
bgClip := clip.Rect(dst).Push(gtx.Ops)
paint.ColorOp{Color: col}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop()
}
stack := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops)
paint.NewImageOp(img).Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
stack.Pop()
}