Pad/internal/ui/render.go
Greg Pomerantz ad3049dd22 Browser page: scroll + click coexist via register order fix
- Added Page state (BrowserPage/EditorPage) and SortMode
- BrowserLayout with 30 static entries, sort toggle, search placeholder
- ListView rows clickable via per-row RegisterClick
- Scroll gesture registered at START of ListView.Draw (before clicks)
  so click gestures registered later are checked first during hit test
- Unique IDs for interactive elements: editor_text, browser_list, search_bar
- ListView.Interactions() filters out Scroll (registered in Draw, not registerInteraction)
- RegisterScroll added to Renderer, called from element Draw methods
- lastLineYChan frame loop fixed: only re-layout if value changed
- Back icon added, editor status bar shows active filename
2026-05-27 12:14:25 -04:00

458 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ui
import (
"bytes"
"embed"
"image"
"image/color"
_ "image/png"
"time"
"gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/input"
"gioui.org/io/pointer"
"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"
)
// maxInt32 is a large value used as MaxWidth for single-line text layout.
const maxInt32 = 1<<31 - 1
//go:embed icons/*.png
var iconFS embed.FS
// ScaleProvider provides access to the current scale factor.
type ScaleProvider interface {
Scale() float32
}
// clickReg pairs a gesture.Click with its handler.
type clickReg struct {
click *gesture.Click
handler func(any)
}
// scrollReg pairs a gesture.Scroll with its handler.
type scrollReg struct {
scroll *gesture.Scroll
handler func(any)
}
// Renderer consumes a slice of elements and draws them.
type Renderer struct {
theme Theme
shp *text.Shaper
scale ScaleProvider
icons map[string]image.Image
clicks map[string]clickReg
scrolls map[string]scrollReg
displayLineCount int // number of display lines from last drawWrappedText
lastLineY Dp // last line baseline offset from text origin, in Dp
}
// New creates a new Renderer.
func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
r := &Renderer{
theme: th,
shp: shp,
scale: scale,
icons: make(map[string]image.Image),
clicks: make(map[string]clickReg),
scrolls: make(map[string]scrollReg),
}
r.loadIcons()
return r
}
// loadIcons loads PNG icons from the embedded filesystem.
func (r *Renderer) loadIcons() {
for _, name := range []string{"back", "cut", "copy", "paste"} {
data, err := iconFS.ReadFile("icons/" + name + ".png")
if err != nil {
continue
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
continue
}
r.icons[name] = img
}
}
// icon returns a loaded icon image by name, or nil if not found.
func (r *Renderer) icon(name string) image.Image {
return r.icons[name]
}
// toPx converts Dp to physical pixels using State's scale.
func (r *Renderer) toPx(dp Dp) Px {
return ToPx(dp, r.scale.Scale())
}
// toDp converts physical pixels to Dp using State's scale.
func (r *Renderer) toDp(px Px) Dp {
return ToDp(px, r.scale.Scale())
}
// Draw iterates elements and draws each in slice order (back-to-front).
func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
// Gio sets constraints to layout.Exact(windowSize), so Min==Max. Use (0,0) as Min.
winW := gtx.Constraints.Max.X
winH := gtx.Constraints.Max.Y
clipRect := clip.Rect{
Min: image.Point{X: 0, Y: 0},
Max: image.Point{X: winW, Y: winH},
}.Push(gtx.Ops)
for _, e := range elems {
if !e.Visible() {
continue
}
r.drawElement(gtx, e)
}
clipRect.Pop()
}
// registerInteraction registers a gesture for an element.
// Supports Tap and Scroll.
func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context, region Region) {
switch interaction.Gesture {
case Tap:
reg, ok := r.clicks[id]
if !ok {
reg = clickReg{click: &gesture.Click{}}
r.clicks[id] = reg
}
// click.Add() is called inside drawText, within the text clip
reg.handler = interaction.Handler
r.clicks[id] = reg
case Scroll:
reg, ok := r.scrolls[id]
if !ok {
reg = scrollReg{scroll: &gesture.Scroll{}}
r.scrolls[id] = reg
}
// Clip scroll gesture to element region
scrollClip := clip.Rect{
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
Max: image.Point{X: int(r.toPx(region.X + region.W)), Y: int(r.toPx(region.Y + region.H))},
}.Push(gtx.Ops)
reg.scroll.Add(gtx.Ops)
scrollClip.Pop()
reg.handler = interaction.Handler
r.scrolls[id] = reg
}
}
// RegisterClick registers a click gesture for a rectangular region.
// Used by ListView to register per-row click areas. The click.Add() call
// is made within a clip so only the region is clickable.
func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, handler func(any)) {
reg, ok := r.clicks[id]
if !ok {
reg = clickReg{click: &gesture.Click{}}
}
reg.handler = handler
r.clicks[id] = reg
// Clip click to row region
clickClip := clip.Rect{
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
Max: image.Point{X: int(r.toPx(region.X + region.W)), Y: int(r.toPx(region.Y + region.H))},
}.Push(gtx.Ops)
reg.click.Add(gtx.Ops)
clickClip.Pop()
}
// RegisterScroll registers a scroll gesture for a rectangular region.
// Called by elements during their Draw method, after paint operations.
// No clip is pushed — the scroll gesture is registered at the current area,
// similar to Gio's layout.List.
func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region, handler func(any)) {
reg, ok := r.scrolls[id]
if !ok {
reg = scrollReg{scroll: &gesture.Scroll{}}
}
reg.handler = handler
r.scrolls[id] = reg
reg.scroll.Add(gtx.Ops)
}
// CheckGestures checks all registered gestures and returns any events.
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
var events []InputEvent
for _, reg := range r.clicks {
evt, ok := reg.click.Update(q)
if ok && evt.Kind == gesture.KindClick {
events = append(events, InputEvent{
Handler: reg.handler,
Data: evt,
})
}
}
for _, reg := range r.scrolls {
// gesture.Scroll.Update returns scroll delta in pixels.
// ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet).
// With Min==Max==0, clampSplit consumes zero scroll.
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1<<30), Max: 1<<30})
if delta != 0 {
events = append(events, InputEvent{
Handler: reg.handler,
Data: delta,
})
}
}
return events
}
// DisplayLineCount returns the number of display lines from the last
// drawWrappedText call. Used by the main loop to report back to logic.
func (r *Renderer) DisplayLineCount() int {
return r.displayLineCount
}
// LastLineY returns the last line baseline offset from the text origin, in Dp.
// Used by logic to compute max scroll without off-by-one errors.
func (r *Renderer) LastLineY() Dp {
return r.lastLineY
}
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
// Register interactions before drawing
if interactive, ok := e.(Interactive); ok {
for _, interaction := range interactive.Interactions() {
r.registerInteraction(interactive.ID(), interaction, gtx, e.Region())
}
}
reg := e.Region()
if container, ok := e.(Container); ok {
// Clip to container bounds, draw background, then offset children
clipRect := 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)
e.Draw(gtx, r) // draw container background
offset := op.Offset(image.Pt(int(r.toPx(reg.X)), int(r.toPx(reg.Y)))).Push(gtx.Ops)
for _, child := range container.children {
r.drawElement(gtx, child)
}
offset.Pop()
clipRect.Pop()
} else {
// Leaf element: no self-clip (region may be zero-sized; clip is handled by parent container)
e.Draw(gtx, r)
}
}
func (r *Renderer) drawBg(gtx layout.Context, reg Region, col Color) {
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: color.NRGBA{R: col.R, G: col.G, B: col.B, A: col.A}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop()
}
func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, align TextAlign, col Color, id string) {
if str == "" {
return
}
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: maxInt32,
MaxLines: 1,
}
// Measure text width via glyph iteration (consumes iterator)
r.shp.LayoutString(params, str)
var totalAdvance fixed.Int26_6
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
totalAdvance += g.Advance
}
textW := Dp(float32(totalAdvance>>6) / r.scale.Scale())
// Compute aligned X position
var drawX Dp
switch align {
case AlignStart:
drawX = reg.X
case AlignCenter:
drawX = reg.X + (reg.W - textW) / 2
case AlignEnd:
drawX = reg.X + reg.W - textW
}
// Clip to just the text area, register click inside the clip
textClip := clip.Rect{
Min: image.Point{X: int(r.toPx(drawX)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(drawX + textW)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
if cr, ok := r.clicks[id]; ok && cr.click != nil {
cr.click.Add(gtx.Ops)
}
// Layout again (iterator consumed) and draw
r.shp.LayoutString(params, str)
r.drawLineText(gtx, drawX, reg.Y, col)
textClip.Pop()
}
// drawLineText iterates laid-out glyphs and draws each line using op.Record for clipping safety.
func (r *Renderer) drawLineText(gtx layout.Context, x, y Dp, col Color) {
m := op.Record(gtx.Ops)
var glyphs [32]text.Glyph
line := glyphs[:0]
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
line = append(line, g)
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
r.drawLine(gtx, line, x, y, col)
line = line[:0]
}
}
if len(line) > 0 {
r.drawLine(gtx, line, x, y, col)
}
call := m.Stop()
call.Add(gtx.Ops)
}
// drawLine draws a single line of glyphs at the given position.
// Matches Gio's paintGlyph: offset by (x + first.X, y + first.Y).
func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col Color) {
if len(line) == 0 {
return
}
first := line[0]
// Offset: desired document position + first glyph's relative position.
// first.X is in fixed.Int26_6 (divide by 64 for pixels), first.Y is in pixels.
offX := float32(gtx.Dp(unit.Dp(x))) + float32(first.X)/64.0
offY := float32(gtx.Dp(unit.Dp(y))) + float32(first.Y)
t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops)
// Draw vector glyphs
path := r.shp.Shape(line)
outline := clip.Outline{Path: path}.Op().Push(gtx.Ops)
paint.ColorOp{Color: color.NRGBA{R: col.R, G: col.G, B: col.B, A: col.A}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
outline.Pop()
// Draw bitmap glyphs (emoji, etc.)
if call := r.shp.Bitmaps(line); call != (op.CallOp{}) {
call.Add(gtx.Ops)
}
t.Pop()
}
// drawWrappedText shapes text once with word wrap and draws display lines inline.
// One LayoutString call — no double-shaping. The shaper handles word boundary
// detection via WrapHeuristically. Long words overflow the wrap width.
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
// of glyph metrics. The shaper's first.Y accounts for line spacing.
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp) {
if str == "" {
return
}
// Fixed line height based on font size, not glyph metrics
lineHeightSp := unit.Sp(float32(r.theme.FontSize) * LineHeightScale)
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
MinWidth: 0,
MaxWidth: int(r.toPx(wrapWidth)),
MaxLines: 0, // unlimited — wrap at MaxWidth
LineHeight: fixed.I(gtx.Sp(lineHeightSp)),
LineHeightScale: 1.0, // use LineHeight directly, don't scale
WrapPolicy: text.WrapHeuristically,
}
r.shp.LayoutString(params, str)
// Clip to TextField region so text doesn't spill into status/bottom bars
textClip := 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)
// Y position: region top minus scroll offset.
// The shaper's first.Y handles line spacing — each line's first.Y is
// ascent + lineHeight × lineIndex. drawLine adds first.Y to y,
// so passing the same y for all lines gives correct baseline spacing.
y := reg.Y - scrollOffset
col := Color{R: 0, G: 0, B: 0, A: 255} // black text
m := op.Record(gtx.Ops)
var glyphs [32]text.Glyph
line := glyphs[:0]
lineCount := 0
var lastGlyphY float32 // shaper Y value of last glyph drawn
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
line = append(line, g)
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
r.drawLine(gtx, line, reg.X, y, col)
lastGlyphY = float32(line[len(line)-1].Y)
line = line[:0]
if g.Flags&text.FlagLineBreak != 0 {
lineCount++
}
}
}
if len(line) > 0 {
r.drawLine(gtx, line, reg.X, y, col)
lastGlyphY = float32(line[len(line)-1].Y)
lineCount++
}
call := m.Stop()
call.Add(gtx.Ops)
textClip.Pop()
r.displayLineCount = lineCount
r.lastLineY = Dp(lastGlyphY / r.scale.Scale()) // pixels → Dp
}
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) {
if img == nil {
return
}
// Auto-size: if width or height is 0, use the region dimensions
w := width
h := height
if w == 0 && h == 0 {
w, h = reg.W, reg.H
} else if w == 0 {
w = h
} else if h == 0 {
h = w
}
xPx := int(r.toPx(reg.X))
yPx := int(r.toPx(reg.Y))
wPx := int(r.toPx(w))
hPx := int(r.toPx(h))
origW := img.Bounds().Dx()
origH := img.Bounds().Dy()
if origW == 0 || origH == 0 {
return
}
sx := float32(wPx) / float32(origW)
sy := float32(hPx) / float32(origH)
// Position, then scale so the image fills the target size
offset := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops)
scale := op.Affine(f32.Affine2D{}.Scale(f32.Pt(0, 0), f32.Pt(sx, sy))).Push(gtx.Ops)
paint.NewImageOp(img).Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
scale.Pop()
offset.Pop()
}