- JNI: open_file_in_termux via ACTION_SEND intent (text/plain + file:// uri), global context ref kept from registerFragment - impl_android.go: OpenFile(path) attaches current thread if needed - NewLogic takes openfunc; State.open + ui.OpenFile now func(string) - ChunkedBuffer.VisibleByteRange: word-wrap path using GlyphLayout.VisualLineStarts (+byteOffset, lineHeight, visual index param) - GlyphLayout gains LineHeight; drawWrappedText records VisualLineStarts - WordWrap default true; State.ByteOffset tracks first visible line - types: VisualLineIndex - scroll_fix_test.go (new) - debug prints left in place (WIP; cleanup in later phase)
602 lines
18 KiB
Go
602 lines
18 KiB
Go
package ui
|
||
|
||
import (
|
||
"bytes"
|
||
"embed"
|
||
"image"
|
||
"image/color"
|
||
_ "image/png"
|
||
"log"
|
||
"sort"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"gioui.org/f32"
|
||
"gioui.org/gesture"
|
||
"gioui.org/io/event" // Import event package
|
||
"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)
|
||
}
|
||
|
||
// keyReg pairs a handler for key events.
|
||
type keyReg struct {
|
||
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
|
||
Keys map[string]keyReg // Exported Keys map
|
||
scrolls map[string]scrollReg
|
||
displayLineCount int // number of display lines from last drawWrappedText
|
||
lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout)
|
||
glyphLayout GlyphLayout // captured per-glyph layout from last drawWrappedText
|
||
}
|
||
|
||
// 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),
|
||
Keys: make(map[string]keyReg),
|
||
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 id, reg := range r.clicks {
|
||
evt, ok := reg.click.Update(q)
|
||
if ok {
|
||
if evt.Kind == gesture.KindClick {
|
||
log.Printf("Renderer: Click detected on %s, pos=%v", id, evt.Position)
|
||
events = append(events, InputEvent{
|
||
Handler: reg.handler,
|
||
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
|
||
})
|
||
}
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// GlyphLayout returns the glyph layout data captured during the last
|
||
// drawWrappedText call. Used by the logic goroutine to position the cursor
|
||
// and navigate by glyph instead of byte offset.
|
||
func (r *Renderer) GlyphLayout() GlyphLayout {
|
||
log.Printf("VisualLineStarts = %v", r.glyphLayout.VisualLineStarts)
|
||
return r.glyphLayout
|
||
}
|
||
|
||
// 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 registering interactions and drawing.
|
||
// event.Op for key events must be within the clip so Gio routes events
|
||
// to this element's tag.
|
||
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)
|
||
// Register interactions inside the clip so event.Op is scoped to this region.
|
||
if interactive, ok := e.(Interactive); ok {
|
||
for _, interaction := range interactive.Interactions() {
|
||
if interaction.Gesture == KeyDown || interaction.Gesture == KeyUp {
|
||
log.Printf("register gesture for %s", interactive.ID())
|
||
event.Op(gtx.Ops, interactive.ID())
|
||
reg := keyReg{Handler: interaction.Handler}
|
||
r.Keys[interactive.ID()] = reg
|
||
break
|
||
}
|
||
}
|
||
for _, interaction := range interactive.Interactions() {
|
||
if interaction.Gesture == Scroll {
|
||
reg, ok := r.scrolls[interactive.ID()]
|
||
if !ok {
|
||
reg = scrollReg{scroll: &gesture.Scroll{}}
|
||
}
|
||
reg.scroll.Add(gtx.Ops)
|
||
reg.handler = interaction.Handler
|
||
r.scrolls[interactive.ID()] = reg
|
||
}
|
||
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
|
||
reg.click.Add(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 {
|
||
// Register input tag for key events
|
||
for _, interaction := range interactive.Interactions() {
|
||
if interaction.Gesture == KeyDown || interaction.Gesture == KeyUp {
|
||
log.Printf("register gesture for %s", interactive.ID())
|
||
event.Op(gtx.Ops, interactive.ID())
|
||
|
||
// Register handler
|
||
reg := keyReg{Handler: interaction.Handler}
|
||
r.Keys[interactive.ID()] = reg
|
||
break
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
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, cursorPos int) {
|
||
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
|
||
|
||
// Capture per-glyph layout data for cursor positioning and navigation.
|
||
var layout GlyphLayout
|
||
layout.LineHeight = Dp(float32(lineHeightSp))
|
||
byteOffset := 0
|
||
layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset)
|
||
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
|
||
// Record layout data for this glyph.
|
||
// g.X is in fixed.Int26_6 — shift >> 6 for device pixels, divide by scale for Dp.
|
||
// g.Y is the baseline in device pixels.
|
||
layout.ByteOffsets = append(layout.ByteOffsets, byteOffset)
|
||
layout.X = append(layout.X, Dp(float32(g.X>>6)/r.scale.Scale()))
|
||
layout.Y = append(layout.Y, Dp(float32(g.Y)/r.scale.Scale()))
|
||
layout.Advance = append(layout.Advance, Dp(float32(g.Advance>>6)/r.scale.Scale()))
|
||
|
||
// Advance byteOffset by g.Runes.
|
||
for i := uint16(0); i < g.Runes; i++ {
|
||
_, sz := utf8.DecodeRuneInString(str[byteOffset:])
|
||
byteOffset += sz
|
||
}
|
||
|
||
line = append(line, g)
|
||
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
|
||
r.drawLine(gtx, line, reg.X, y, col)
|
||
line = line[:0]
|
||
if g.Flags&text.FlagLineBreak != 0 {
|
||
lineCount++
|
||
layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset)
|
||
}
|
||
}
|
||
}
|
||
if len(line) > 0 {
|
||
r.drawLine(gtx, line, reg.X, y, col)
|
||
lineCount++
|
||
}
|
||
call := m.Stop()
|
||
call.Add(gtx.Ops)
|
||
|
||
// Determine cursor position from `layout` and `cursorPos`
|
||
var cursorX, cursorY Dp
|
||
if len(layout.ByteOffsets) == 0 {
|
||
cursorX = reg.X
|
||
cursorY = reg.Y
|
||
} else {
|
||
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
||
return layout.ByteOffsets[i] >= cursorPos
|
||
})
|
||
if idx < len(layout.ByteOffsets) {
|
||
cursorX = reg.X + layout.X[idx]
|
||
cursorY = reg.Y - scrollOffset + layout.Y[idx] - Dp(r.theme.FontSize)
|
||
} else {
|
||
cursorX = reg.X + layout.X[len(layout.X)-1] + layout.Advance[len(layout.Advance)-1]
|
||
cursorY = reg.Y - scrollOffset + layout.Y[len(layout.Y)-1] - Dp(r.theme.FontSize)
|
||
}
|
||
}
|
||
|
||
// Draw the cursor (thin vertical bar)
|
||
cursorRegion := Region{
|
||
X: cursorX,
|
||
Y: cursorY,
|
||
W: Dp(2),
|
||
H: Dp(r.theme.FontSize) * 1.2, // Use line height
|
||
}
|
||
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
|
||
|
||
textClip.Pop()
|
||
r.displayLineCount = lineCount
|
||
// Store captured layout; derive lastLineY from it.
|
||
r.glyphLayout = layout
|
||
if len(layout.Y) > 0 {
|
||
r.lastLineY = layout.Y[len(layout.Y)-1]
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|