Density (pure hi-DPI) was already scale-free: all bookkeeping is in density-dp and the scale enters only at the px<->dp boundary. But Android also has a second axis, the user font-size setting (PxPerSp = fontScale * PxPerDp), and the shaper draws baselines in sp. At a non-default font scale the rendered line pitch is 16.8*fontScale dp while every logic-side consumer used the raw 16.8 dp: taps would misplace by up to (fontScale-1) viewportfuls of lines and scroll clamping would stop short of the bottom. - ScaleEvent.FontScale + Frame.FontScale closed loop (main reads gtx.Metric, logic tracks it in State.fontScale). - EffectiveLineHeight()/EffectiveLineHeightAt(): the font-scale-applied line height, now used by every consumer (window start, sub-line remainder, tap mapping, scroll clamp, page size, cursor vertical move, menu position, chunk-prefetch fallbacks). - Renderer: GlyphLayout.LineHeight, caret, selection handles, and highlight all use the scaled ascent/line-height from gtx.Metric. - font_scale_test.go: 2000-pair tap property test at fontScale 1.3 with the glyph layout fabricated at the scaled pitch (independent ground truth), plus EffectiveLineHeight unit test. - On-device: tap markers landed on exactly the tapped line at font_scale 1.3 (fsline060/080/081) and 0.8 (fsline039); rendered pitch measured 57/35/44 px at 1.3/0.8/1.0 (matches 16.8*fs*2.625); settled-position window start k = floor(s/lh_eff) verified against the visible top line. - Docs: architecture.md 6.2 font-scale axis, README two-scale note + profiler 2s flush staleness note, development plan v10 Phase 11.
942 lines
31 KiB
Go
942 lines
31 KiB
Go
package ui
|
||
|
||
import (
|
||
"bytes"
|
||
"embed"
|
||
"image"
|
||
"image/color"
|
||
_ "image/png"
|
||
"sort"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"gioui.org/f32"
|
||
"gioui.org/gesture"
|
||
"gioui.org/io/event" // Import event package
|
||
"gioui.org/io/input"
|
||
"gioui.org/io/key"
|
||
"gioui.org/io/pointer"
|
||
"gioui.org/layout"
|
||
"gioui.org/op"
|
||
"gioui.org/op/clip"
|
||
"gioui.org/op/paint"
|
||
"gioui.org/text"
|
||
"gioui.org/unit"
|
||
"gioui.org/widget"
|
||
"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
|
||
|
||
// clickReg pairs a gesture.Click with its handler.
|
||
type clickReg struct {
|
||
click *gesture.Click
|
||
handler func(any)
|
||
// pressAt/pressPos record the current press (set on KindPress) so the
|
||
// per-frame long-press check knows how long the finger has been still.
|
||
pressAt time.Time
|
||
pressPos image.Point
|
||
longFired bool
|
||
}
|
||
|
||
// longPressDuration is how long a still press must hold before a long-press
|
||
// fires (Android uses ~500ms; 400ms feels snappier for text selection).
|
||
const longPressDuration = 400 * time.Millisecond
|
||
|
||
// longPressSlopPx is how far the finger may drift (px) before a pending
|
||
// long press is cancelled (that motion becomes a scroll/drag instead).
|
||
const longPressSlopPx = 8
|
||
|
||
// 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.
|
||
//
|
||
// The Renderer is owned by the main goroutine. It is the home of any state
|
||
// that Gio mutates during draw (e.g. the search bar's widget.Editor): such
|
||
// state must not live in the logic goroutine's State (architecture.md §1).
|
||
type Renderer struct {
|
||
theme Theme
|
||
shp *text.Shaper
|
||
scale float32 // px-per-Dp for the current draw pass; set in Draw
|
||
icons map[string]image.Image
|
||
clicks map[string]*clickReg
|
||
Keys map[string]keyReg // Exported Keys map
|
||
scrolls map[string]scrollReg
|
||
gioEditors map[string]*widget.Editor // main-owned widget editors by element ID
|
||
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
|
||
|
||
// IME dedup (main-owned, persistent across frames). Re-pushing an unchanged
|
||
// snippet or selection every frame resets the IME's composition and caret,
|
||
// which desyncs fast commits; push only on change, as widget.Editor does in
|
||
// updateSnippet and its selection gating. Keyed to the focused field's ID so
|
||
// it stays correct if a second TextField is ever added.
|
||
lastIMEField string
|
||
lastWasFocused bool
|
||
lastSnippet key.Snippet
|
||
lastSelStart int // window-relative rune index of last-pushed selection start; -1 = no selection
|
||
lastSelCaret int // window-relative rune index of last-pushed selection end/caret
|
||
|
||
// Long-press detection. pressProbe is a plain event tag observing raw
|
||
// pointer events inside the editor text region: gesture.Click reports
|
||
// nothing until release, so a long press (finger held still for
|
||
// longPressDuration) can only be detected this way. ppMoved cancels the
|
||
// pending long press once the finger leaves longPressSlopPx (it is a
|
||
// scroll/drag then, not a press). longPressID gates the long-press to the
|
||
// editor's click reg (browser rows etc. don't long-press). ppLast is in
|
||
// f32.Point because pointer.Event.Position is window-space f32.
|
||
pressProbe struct{}
|
||
ppLast f32.Point
|
||
ppActive bool
|
||
ppMoved bool
|
||
longPressID string
|
||
|
||
// Selection / caret drag handles (0 = start, 1 = end, 2 = body, 3 = caret
|
||
// handle). Registered clipped in drawWrappedText only while a selection or
|
||
// caret handle is visible; a gesture.Drag grabs the pointer once movement
|
||
// exceeds slop, which cancels the scroll and click handlers so a handle
|
||
// drag never fights a fling. selDragEmitting tracks whether a Drag event
|
||
// was delivered for the current gesture (so a plain tap on a handle does
|
||
// not emit a spurious SelectionDragEnd).
|
||
selDragStart gesture.Drag
|
||
selDragEnd gesture.Drag
|
||
selDragBody gesture.Drag
|
||
selDragCaret gesture.Drag
|
||
selDragsOn [4]bool
|
||
selDragEmitting [4]bool
|
||
selDragHandler func(any)
|
||
}
|
||
|
||
// New creates a new Renderer.
|
||
func New(th Theme, shp *text.Shaper) *Renderer {
|
||
r := &Renderer{
|
||
theme: th,
|
||
shp: shp,
|
||
icons: make(map[string]image.Image),
|
||
clicks: make(map[string]*clickReg),
|
||
Keys: make(map[string]keyReg),
|
||
scrolls: make(map[string]scrollReg),
|
||
gioEditors: make(map[string]*widget.Editor),
|
||
}
|
||
r.loadIcons()
|
||
return r
|
||
}
|
||
|
||
// RegisterGioEditor attaches a main-owned widget.Editor to an element ID.
|
||
// GioEditor elements with that ID render the widget during draw. Must be
|
||
// called from the main goroutine before the first frame.
|
||
func (r *Renderer) RegisterGioEditor(id string, ed *widget.Editor) {
|
||
r.gioEditors[id] = ed
|
||
}
|
||
|
||
// GioEditor returns the main-owned widget editor registered for id, if any.
|
||
func (r *Renderer) GioEditor(id string) (*widget.Editor, bool) {
|
||
ed, ok := r.gioEditors[id]
|
||
return ed, ok
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// toDp converts physical pixels to Dp using State'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).
|
||
// Draw renders the given elements. scale is the px-per-Dp factor for this
|
||
// draw pass, taken from the frame's view-state snapshot (the renderer never
|
||
// reads logic state directly).
|
||
func (r *Renderer) Draw(gtx layout.Context, elems []Element, scale float32) {
|
||
r.scale = scale
|
||
// 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{}}
|
||
r.clicks[id] = reg
|
||
}
|
||
reg.handler = interaction.Handler
|
||
// 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
|
||
case SelDrag:
|
||
// The renderer registers the actual gesture.Drag ops in
|
||
// drawWrappedText (it owns the handle geometry); this only records the
|
||
// logic handler that receives the drag events.
|
||
r.selDragHandler = interaction.Handler
|
||
}
|
||
}
|
||
|
||
// 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{}}
|
||
r.clicks[id] = reg
|
||
}
|
||
reg.handler = handler
|
||
// 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)
|
||
}
|
||
|
||
// PendingLongPress reports whether a press is currently held still on the
|
||
// editor (long-press armed but not yet fired). Gio renders on demand: with a
|
||
// stationary finger there are no pointer events, hence no frames, and the
|
||
// 400 ms threshold could never be checked. The main loop calls this and
|
||
// invalidates the window while it is true, keeping frames flowing until the
|
||
// long press fires or the finger moves up.
|
||
func (r *Renderer) PendingLongPress() bool {
|
||
reg, ok := r.clicks[r.longPressID]
|
||
return ok && reg.click.Pressed() && !reg.longFired && !r.ppMoved && !reg.pressAt.IsZero()
|
||
}
|
||
|
||
// CheckGestures checks all registered gestures and returns any events.
|
||
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
||
var events []InputEvent
|
||
// Long-press motion probe first: it must see the press/drag events before
|
||
// the click loop decides about a long press.
|
||
r.consumePressProbe(q)
|
||
for id, reg := range r.clicks {
|
||
// Drain every queued event for this gesture in this frame.
|
||
// gesture.Click returns one event per Update call, but on Android a
|
||
// tap's press and release routinely arrive in the same frame. Without
|
||
// draining, the release would sit unprocessed until the next redraw —
|
||
// which on an idle window may never come — and the tap is swallowed.
|
||
for {
|
||
evt, ok := reg.click.Update(q)
|
||
if !ok {
|
||
break
|
||
}
|
||
switch evt.Kind {
|
||
case gesture.KindPress:
|
||
reg.pressAt = time.Now()
|
||
reg.pressPos = evt.Position
|
||
reg.longFired = false
|
||
case gesture.KindClick:
|
||
if reg.longFired {
|
||
break // the press was consumed as a long press
|
||
}
|
||
if evt.NumClicks >= 2 {
|
||
events = append(events, InputEvent{
|
||
Handler: reg.handler,
|
||
Data: DoubleTapPoint{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
|
||
})
|
||
} else {
|
||
events = append(events, InputEvent{
|
||
Handler: reg.handler,
|
||
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
|
||
})
|
||
}
|
||
case gesture.KindCancel:
|
||
reg.pressAt = time.Time{}
|
||
reg.longFired = false
|
||
}
|
||
}
|
||
// Long press: the finger must still be down on the probed (editor)
|
||
// region, held still, for the long-press duration.
|
||
if id == r.longPressID && reg.click.Pressed() && !reg.longFired &&
|
||
!r.ppMoved && !reg.pressAt.IsZero() && time.Since(reg.pressAt) >= longPressDuration {
|
||
reg.longFired = true
|
||
events = append(events, InputEvent{
|
||
Handler: reg.handler,
|
||
Data: LongPressPoint{X: r.toDp(Px(reg.pressPos.X)), Y: r.toDp(Px(reg.pressPos.Y))},
|
||
})
|
||
}
|
||
}
|
||
// Selection / caret drags before scroll: a handle grab must win over fling.
|
||
drags := [4]*gesture.Drag{&r.selDragStart, &r.selDragEnd, &r.selDragBody, &r.selDragCaret}
|
||
for which, d := range drags {
|
||
if !r.selDragsOn[which] || r.selDragHandler == nil {
|
||
continue
|
||
}
|
||
// wasDragging is captured before Update: Update resets Dragging() on
|
||
// Release/Cancel, so it would read false afterwards.
|
||
wasDragging := d.Dragging()
|
||
// Drain all queued events for this drag in this frame (same
|
||
// one-event-per-Update rationale as the click loop above).
|
||
for {
|
||
e, ok := d.Update(m, q, gesture.Both)
|
||
if !ok {
|
||
break
|
||
}
|
||
switch e.Kind {
|
||
case pointer.Drag:
|
||
r.selDragEmitting[which] = true
|
||
events = append(events, InputEvent{
|
||
Handler: r.selDragHandler,
|
||
Data: SelectionDragEvent{Which: which, X: r.toDp(Px(e.Position.X)), Y: r.toDp(Px(e.Position.Y))},
|
||
})
|
||
case pointer.Release, pointer.Cancel:
|
||
if wasDragging && r.selDragEmitting[which] {
|
||
events = append(events, InputEvent{
|
||
Handler: r.selDragHandler,
|
||
Data: SelectionDragEnd{},
|
||
})
|
||
}
|
||
r.selDragEmitting[which] = false
|
||
}
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// consumePressProbe drains the raw pointer events of the long-press probe
|
||
// and updates ppLast/ppMoved. It runs before the click loop each frame.
|
||
func (r *Renderer) consumePressProbe(q input.Source) {
|
||
for {
|
||
evt, ok := q.Event(pointer.Filter{Target: r.pressProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel})
|
||
if !ok {
|
||
return
|
||
}
|
||
pe, ok := evt.(pointer.Event)
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch pe.Kind {
|
||
case pointer.Press:
|
||
r.ppLast = pe.Position
|
||
r.ppActive = true
|
||
r.ppMoved = false
|
||
case pointer.Drag:
|
||
if r.ppActive {
|
||
dx, dy := pe.Position.X-r.ppLast.X, pe.Position.Y-r.ppLast.Y
|
||
if dx*dx+dy*dy > float32(longPressSlopPx*longPressSlopPx) {
|
||
r.ppMoved = true
|
||
}
|
||
}
|
||
r.ppLast = pe.Position
|
||
case pointer.Release, pointer.Cancel:
|
||
r.ppMoved = false
|
||
r.ppActive = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
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 {
|
||
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{}}
|
||
r.clicks[interactive.ID()] = reg
|
||
}
|
||
reg.handler = interaction.Handler
|
||
reg.click.Add(gtx.Ops)
|
||
}
|
||
if interaction.Gesture == SelDrag {
|
||
// Store the logic handler; the drag ops themselves are added
|
||
// clipped in drawWrappedText where the handle geometry is known.
|
||
r.registerInteraction(interactive.ID(), interaction, gtx)
|
||
}
|
||
}
|
||
}
|
||
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 {
|
||
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{}}
|
||
r.clicks[interactive.ID()] = reg
|
||
}
|
||
reg.handler = interaction.Handler
|
||
}
|
||
// 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)
|
||
|
||
// 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, selStart, selEnd int, caretDrag bool) {
|
||
if str == "" {
|
||
return
|
||
}
|
||
|
||
// Fixed line height based on font size, not glyph metrics.
|
||
lineHeightSp := unit.Sp(float32(r.theme.FontSize) * LineHeightScale)
|
||
// User font-size setting (sp per dp). The shaper draws baselines at
|
||
// Sp(...) physical px, so the RENDERED line pitch in density-dp is
|
||
// lineHeightSp × fontScale. Every dp-space value below (line height,
|
||
// ascent) uses the scaled form so caret/handles/highlight follow the
|
||
// drawn glyphs; the logic side tracks the same factor via
|
||
// EffectiveLineHeight (ScaleEvent.FontScale).
|
||
fontScale := float32(1)
|
||
if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 {
|
||
fontScale = gtx.Metric.PxPerSp / gtx.Metric.PxPerDp
|
||
}
|
||
ascent := Dp(float32(r.theme.FontSize) * fontScale)
|
||
lineH := Dp(float32(lineHeightSp) * fontScale)
|
||
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
|
||
|
||
// Pass 1: collect glyph lines and per-glyph layout data (no drawing yet),
|
||
// so the selection highlight can be emitted before the text ops and render
|
||
// underneath it.
|
||
var lines [][]text.Glyph
|
||
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 = lineH
|
||
byteOffset := 0
|
||
layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset)
|
||
flushLine := func() {
|
||
lines = append(lines, append([]text.Glyph(nil), line...))
|
||
line = line[:0]
|
||
}
|
||
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))
|
||
layout.Y = append(layout.Y, Dp(float32(g.Y)/r.scale))
|
||
layout.Advance = append(layout.Advance, Dp(float32(g.Advance>>6)/r.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 {
|
||
flushLine()
|
||
if g.Flags&text.FlagLineBreak != 0 {
|
||
lineCount++
|
||
layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset)
|
||
}
|
||
}
|
||
}
|
||
if len(line) > 0 {
|
||
flushLine()
|
||
lineCount++
|
||
}
|
||
|
||
// Pass 2: selection highlight (translucent blue), one rect per covered
|
||
// glyph, emitted before the text so glyphs draw on top of it.
|
||
if selStart >= 0 && selEnd > selStart {
|
||
for i := range layout.ByteOffsets {
|
||
b0 := layout.ByteOffsets[i]
|
||
b1 := len(str)
|
||
if i+1 < len(layout.ByteOffsets) {
|
||
b1 = layout.ByteOffsets[i+1]
|
||
}
|
||
if b0 >= selEnd || b1 <= selStart {
|
||
continue
|
||
}
|
||
hx := reg.X + layout.X[i]
|
||
hy := reg.Y - scrollOffset + layout.Y[i] - ascent
|
||
hw := layout.Advance[i]
|
||
hh := lineH
|
||
rect := clip.Rect{
|
||
Min: image.Point{X: int(r.toPx(hx)), Y: int(r.toPx(hy))},
|
||
Max: image.Point{X: int(r.toPx(hx + hw)), Y: int(r.toPx(hy + hh))},
|
||
}.Op().Push(gtx.Ops)
|
||
paint.ColorOp{Color: color.NRGBA{R: 0x33, G: 0x99, B: 0xFF, A: 0x59}}.Add(gtx.Ops)
|
||
paint.PaintOp{}.Add(gtx.Ops)
|
||
rect.Pop()
|
||
}
|
||
}
|
||
|
||
// Pass 3: the text itself.
|
||
m := op.Record(gtx.Ops)
|
||
for _, ln := range lines {
|
||
r.drawLine(gtx, ln, reg.X, y, col)
|
||
}
|
||
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] - ascent
|
||
} 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] - ascent
|
||
}
|
||
}
|
||
|
||
// Draw the cursor (thin vertical bar)
|
||
cursorRegion := Region{
|
||
X: cursorX,
|
||
Y: cursorY,
|
||
W: Dp(2),
|
||
H: lineH, // line height (font-scale aware)
|
||
}
|
||
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
|
||
|
||
// Long-press probe and selection/caret drag handles. The probe op and the
|
||
// (clipped) drag registrations live in the text clip so they only respond
|
||
// inside the editor region.
|
||
r.selDragsOn = [4]bool{}
|
||
event.Op(gtx.Ops, r.pressProbe)
|
||
r.longPressID = "editor_text"
|
||
if r.selDragHandler != nil && (selStart >= 0 && selEnd > selStart || caretDrag) {
|
||
// handleAt mirrors the cursor computation above: window-relative byte
|
||
// offset -> screen Dp of the caret insertion point.
|
||
handleAt := func(byteOff int) (x, y Dp) {
|
||
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
||
return layout.ByteOffsets[i] >= byteOff
|
||
})
|
||
if idx < len(layout.ByteOffsets) {
|
||
return reg.X + layout.X[idx], reg.Y - scrollOffset + layout.Y[idx] - ascent
|
||
}
|
||
n := len(layout.X) - 1
|
||
return reg.X + layout.X[n] + layout.Advance[n], reg.Y - scrollOffset + layout.Y[n] - ascent
|
||
}
|
||
registerDrag := func(d *gesture.Drag, hx, hy Dp) {
|
||
hc := clip.Rect{
|
||
Min: image.Point{X: int(r.toPx(hx - 8)), Y: int(r.toPx(hy - 6))},
|
||
Max: image.Point{X: int(r.toPx(hx + 8)), Y: int(r.toPx(hy + lineH + 6))},
|
||
}.Push(gtx.Ops)
|
||
d.Add(gtx.Ops)
|
||
hc.Pop()
|
||
}
|
||
if selStart >= 0 && selEnd > selStart {
|
||
sx, sy := handleAt(selStart)
|
||
ex, ey := handleAt(selEnd)
|
||
registerDrag(&r.selDragStart, sx, sy)
|
||
r.selDragsOn[0] = true
|
||
registerDrag(&r.selDragEnd, ex, ey)
|
||
r.selDragsOn[1] = true
|
||
// Body: bounding box of the selected glyphs (only for 2+ glyphs; a
|
||
// single-glyph selection is already covered by its two handles).
|
||
var bx0, by0, bx1, by1 Dp
|
||
hasGlyph := false
|
||
for i := range layout.ByteOffsets {
|
||
b0 := layout.ByteOffsets[i]
|
||
b1 := len(str)
|
||
if i+1 < len(layout.ByteOffsets) {
|
||
b1 = layout.ByteOffsets[i+1]
|
||
}
|
||
if b0 >= selEnd || b1 <= selStart {
|
||
continue
|
||
}
|
||
gx := reg.X + layout.X[i]
|
||
gy := reg.Y - scrollOffset + layout.Y[i] - ascent
|
||
gw := layout.Advance[i]
|
||
gh := lineH
|
||
if !hasGlyph {
|
||
bx0, by0, bx1, by1 = gx, gy, gx+gw, gy+gh
|
||
hasGlyph = true
|
||
} else {
|
||
if gx < bx0 {
|
||
bx0 = gx
|
||
}
|
||
if gy < by0 {
|
||
by0 = gy
|
||
}
|
||
if gx+gw > bx1 {
|
||
bx1 = gx + gw
|
||
}
|
||
if gy+gh > by1 {
|
||
by1 = gy + gh
|
||
}
|
||
}
|
||
}
|
||
if hasGlyph {
|
||
bc := clip.Rect{
|
||
Min: image.Point{X: int(r.toPx(bx0)), Y: int(r.toPx(by0))},
|
||
Max: image.Point{X: int(r.toPx(bx1)), Y: int(r.toPx(by1))},
|
||
}.Push(gtx.Ops)
|
||
r.selDragBody.Add(gtx.Ops)
|
||
bc.Pop()
|
||
r.selDragsOn[2] = true
|
||
}
|
||
r.drawHandle(gtx, sx, sy, lineH)
|
||
r.drawHandle(gtx, ex, ey, lineH)
|
||
} else {
|
||
// Caret drag (long press on blank space): a single handle on the caret.
|
||
cx, cy := handleAt(cursorPos)
|
||
registerDrag(&r.selDragCaret, cx, cy)
|
||
r.selDragsOn[3] = true
|
||
r.drawHandle(gtx, cx, cy, lineH)
|
||
}
|
||
}
|
||
|
||
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]
|
||
}
|
||
}
|
||
|
||
// drawHandle draws a selection handle: a short vertical stem with a filled
|
||
// square foot at the line's bottom (v1 approximation of Android's circle).
|
||
func (r *Renderer) drawHandle(gtx layout.Context, x, y, lineH Dp) {
|
||
col := Color{R: 51, G: 153, B: 255, A: 255}
|
||
r.drawBg(gtx, Region{X: x - 1, Y: y, W: 2, H: lineH}, col)
|
||
r.drawBg(gtx, Region{X: x - 5, Y: y + lineH - 2, W: 10, H: 10}, col)
|
||
}
|
||
|
||
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()
|
||
}
|