489 lines
15 KiB
Go
489 lines
15 KiB
Go
package ui
|
||
|
||
import (
|
||
"bytes"
|
||
"embed"
|
||
"fmt"
|
||
"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.
|
||
// The clip context must already be set to the element's bounds before calling this.
|
||
func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context) {
|
||
switch interaction.Gesture {
|
||
case Tap:
|
||
reg, ok := r.clicks[id]
|
||
if !ok {
|
||
reg = clickReg{click: &gesture.Click{}}
|
||
}
|
||
reg.handler = interaction.Handler
|
||
r.clicks[id] = reg
|
||
// Register click within current clip context
|
||
reg.click.Add(gtx.Ops)
|
||
case Scroll:
|
||
reg, ok := r.scrolls[id]
|
||
if !ok {
|
||
reg = scrollReg{scroll: &gesture.Scroll{}}
|
||
}
|
||
// Register scroll within current clip context
|
||
reg.scroll.Add(gtx.Ops)
|
||
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 to the specified region for click area
|
||
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.
|
||
// Should be called while the element's clip is active (e.g., inside a container
|
||
// or clippable element's Draw method).
|
||
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 {
|
||
if 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 {
|
||
fmt.Printf("Scroll detected: %v\n", delta)
|
||
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
|
||
}
|
||
|
||
// clippableElement is implemented by elements that need their own clip region
|
||
// around all their content and interaction registrations.
|
||
type clippableElement interface {
|
||
NeedsClip() bool
|
||
}
|
||
|
||
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
||
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 if clippable, ok := e.(clippableElement); ok && clippable.NeedsClip() {
|
||
// Clip to element bounds before drawing and registering interactions
|
||
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)
|
||
clipRect.Pop()
|
||
} else {
|
||
// Leaf element: register click handlers, then draw.
|
||
// For text elements, click registration happens inside Draw after shaping.
|
||
if interactive, ok := e.(Interactive); ok {
|
||
for _, interaction := range interactive.Interactions() {
|
||
// Set up click handler (but don't call Add for text elements)
|
||
if interaction.Gesture == Tap {
|
||
reg, ok := r.clicks[interactive.ID()]
|
||
if !ok {
|
||
reg = clickReg{click: &gesture.Click{}}
|
||
}
|
||
reg.handler = interaction.Handler
|
||
r.clicks[interactive.ID()] = reg
|
||
}
|
||
// For non-text elements, register click immediately
|
||
if _, isLabel := e.(Label); !isLabel {
|
||
elemClip := 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)
|
||
r.registerInteraction(interactive.ID(), interaction, gtx)
|
||
elemClip.Pop()
|
||
}
|
||
}
|
||
}
|
||
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
|
||
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)
|
||
|
||
// Register click within the text clip if this is an interactive label
|
||
if id != "" {
|
||
if reg, ok := r.clicks[id]; ok && reg.click != nil {
|
||
reg.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()
|
||
}
|