Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.
Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.
Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
involving newlines left it permanently inconsistent. Replaced with
newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
straddling a chunk boundary). Now rune-granular.
Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).
Full suite green under -race; on-device verified.
665 lines
21 KiB
Go
665 lines
21 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)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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{}}
|
||
}
|
||
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: 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 {
|
||
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{}}
|
||
}
|
||
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 {
|
||
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)
|
||
|
||
// 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) {
|
||
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
|
||
|
||
// 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 = Dp(float32(lineHeightSp))
|
||
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] - Dp(r.theme.FontSize)
|
||
hw := layout.Advance[i]
|
||
hh := Dp(r.theme.FontSize) * 1.2
|
||
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] - 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()
|
||
}
|