308 lines
8.1 KiB
Go
308 lines
8.1 KiB
Go
package ui
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"image"
|
|
"image/color"
|
|
_ "image/png"
|
|
|
|
"gioui.org/f32"
|
|
"gioui.org/gesture"
|
|
"gioui.org/io/input"
|
|
"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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
r.loadIcons()
|
|
return r
|
|
}
|
|
|
|
// loadIcons loads PNG icons from the embedded filesystem.
|
|
func (r *Renderer) loadIcons() {
|
|
for _, name := range []string{"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.
|
|
// Only Tap is supported for now.
|
|
func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context, region Region) {
|
|
if interaction.Gesture != Tap {
|
|
return
|
|
}
|
|
reg, ok := r.clicks[id]
|
|
if !ok {
|
|
reg = clickReg{click: &gesture.Click{}}
|
|
r.clicks[id] = reg
|
|
}
|
|
// click.Add() is now called inside drawText, within the text clip
|
|
reg.handler = interaction.Handler
|
|
r.clicks[id] = reg
|
|
}
|
|
|
|
// CheckGestures checks all registered gestures and returns any events.
|
|
func (r *Renderer) CheckGestures(q input.Source) []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,
|
|
})
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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()
|
|
}
|