widget.mx raw

   1  // SPDX-License-Identifier: Unlicense OR MIT
   2  
   3  // Core widget types for Gio: Clickable, Bool, Float, Image.
   4  // Uses a simplified GioContext that does not require the full input router.
   5  
   6  package gio
   7  
   8  import (
   9  	"image"
  10  	"image/color"
  11  	"time"
  12  )
  13  
  14  // GioContext is the layout context passed to widget functions.
  15  // It carries ops, constraints, event data, text shaper, and display metrics.
  16  type GioContext struct {
  17  	// Ops is the op list being built for this frame.
  18  	Ops *OpList
  19  	// Constraints are the current layout constraints.
  20  	Constraints Constraints
  21  	// Metric is the display pixel density.
  22  	Metric Metric
  23  	// Source provides access to the input router for routed event delivery.
  24  	// Use Source.Event(filters...) to receive events for a registered tag.
  25  	Source Source
  26  	// PtrEvents is the raw pointer event list from the window.
  27  	// Populated only when not using the Router. Prefer Source for new widgets.
  28  	PtrEvents []AppPointerEvent
  29  	// KeyEvents is the raw key event list from the window.
  30  	// Populated only when not using the Router. Prefer Source for new widgets.
  31  	KeyEvents []AppKeyEvent
  32  	// TextEvents is the raw text input event list (typed chars, IME).
  33  	TextEvents []AppTextEvent
  34  	// Now is the approximate frame time (for animation timing).
  35  	Now time.Time
  36  	// Shaper is the text renderer.
  37  	Shaper Shaper
  38  	// Win is the application window, for clipboard and focus operations.
  39  	Win *Window
  40  }
  41  
  42  // GioWidget is a function that lays out content in a GioContext.
  43  type GioWidget func(gtx GioContext) Dimensions
  44  
  45  // NewGioContext constructs a GioContext for a frame using the full Router.
  46  // The router should have had Frame(ops) called before building the context.
  47  func NewGioContext(frame FrameEvent, ops *OpList, router *Router, shaper Shaper, win *Window) GioContext {
  48  	return GioContext{
  49  		Ops:         ops,
  50  		Constraints: Constraints{Max: frame.Size},
  51  		Metric:      frame.Metric,
  52  		Source:      router.Source(),
  53  		Now:         time.Now(),
  54  		Shaper:      shaper,
  55  		Win:         win,
  56  	}
  57  }
  58  
  59  // NewGioContextRaw constructs a GioContext without a Router (legacy/simple mode).
  60  // Widgets use gtx.PtrEvents and gtx.KeyEvents directly.
  61  func NewGioContextRaw(frame FrameEvent, ops *OpList, shaper Shaper, ptrEvents []AppPointerEvent, keyEvents []AppKeyEvent, win *Window) GioContext {
  62  	return GioContext{
  63  		Ops:         ops,
  64  		Constraints: Constraints{Max: frame.Size},
  65  		Metric:      frame.Metric,
  66  		PtrEvents:   ptrEvents,
  67  		KeyEvents:   keyEvents,
  68  		Now:         time.Now(),
  69  		Shaper:      shaper,
  70  		Win:         win,
  71  	}
  72  }
  73  
  74  // WriteClipboard copies text to the system clipboard via the window.
  75  func (c GioContext) WriteClipboard(text string) {
  76  	if c.Win != nil {
  77  		c.Win.WriteClipboard(text)
  78  	}
  79  }
  80  
  81  // ReadClipboard requests an async clipboard read. Paste events arrive via
  82  // Window.PasteRunes() on the next frame.
  83  func (c GioContext) ReadClipboard() {
  84  	if c.Win != nil {
  85  		c.Win.ReadClipboard()
  86  	}
  87  }
  88  
  89  // WithOps returns a copy of the context with a new ops list.
  90  func (c GioContext) WithOps(ops *OpList) GioContext {
  91  	c.Ops = ops
  92  	return c
  93  }
  94  
  95  // WithConstraints returns a copy with updated constraints.
  96  func (c GioContext) WithConstraints(cs Constraints) GioContext {
  97  	c.Constraints = cs
  98  	return c
  99  }
 100  
 101  // Dp converts dp to pixels.
 102  func (c GioContext) Dp(v Dp) int { return c.Metric.Dp(v) }
 103  
 104  // --- Clickable ---
 105  
 106  // Clickable is a rectangular area that detects pointer clicks.
 107  // Usage: call Layout(gtx, content) which registers the hit area and
 108  // processes events. Then call Clicked() to check if a click occurred.
 109  type Clickable struct {
 110  	click   GestureClick
 111  	history []WidgetPress
 112  	clicked bool
 113  }
 114  
 115  // WidgetPress records a pointer press.
 116  type WidgetPress struct {
 117  	Position  image.Point
 118  	Start     time.Time
 119  	End       time.Time
 120  	Cancelled bool
 121  }
 122  
 123  // WidgetClick reports a completed click.
 124  type WidgetClick struct {
 125  	NumClicks int
 126  }
 127  
 128  // Clicked reports if a click was completed since the last call.
 129  // Must call Layout(gtx, w) first this frame to register the hit area.
 130  func (b *Clickable) Clicked() bool {
 131  	c := b.clicked
 132  	b.clicked = false
 133  	return c
 134  }
 135  
 136  // Hovered reports whether the pointer is over the clickable.
 137  func (b *Clickable) Hovered() bool { return b.click.Hovered() }
 138  
 139  // Pressed reports whether the pointer is pressing.
 140  func (b *Clickable) Pressed() bool { return b.click.Pressed() }
 141  
 142  // History returns past presses.
 143  func (b *Clickable) History() []WidgetPress { return b.history }
 144  
 145  // Layout lays out the child widget, registers a hit area, and processes events.
 146  // The click detection area is defined by the child widget's dimensions.
 147  func (b *Clickable) Layout(gtx GioContext, w GioWidget) Dimensions {
 148  	// Record content so we can emit EventOp before it (Router sees area first).
 149  	m := Record(gtx.Ops)
 150  	dims := w(gtx)
 151  	call := m.Stop()
 152  
 153  	// Register hit area at the current transform in the op stream.
 154  	clip := ClipRect(image.Rect(0, 0, dims.Size.X, dims.Size.Y)).Push(gtx.Ops)
 155  	b.click.Add(gtx.Ops)
 156  	clip.Pop()
 157  
 158  	// Draw content on top of the registration.
 159  	call.Add(gtx.Ops)
 160  
 161  	// Process events routed by the Router (coordinates already in local space).
 162  	b.expireHistory(gtx.Now)
 163  	for {
 164  		e, ok := b.click.Update(gtx.Source)
 165  		if !ok {
 166  			break
 167  		}
 168  		switch e.Kind {
 169  		case ClickKindPress:
 170  			b.history = append(b.history, WidgetPress{Position: e.Position, Start: gtx.Now})
 171  		case ClickKindClick:
 172  			if l := len(b.history); l > 0 {
 173  				b.history[l-1].End = gtx.Now
 174  			}
 175  			b.clicked = true
 176  		case ClickKindCancel:
 177  			for i := range b.history {
 178  				if b.history[i].End.IsZero() {
 179  					b.history[i].End = gtx.Now
 180  					b.history[i].Cancelled = true
 181  				}
 182  			}
 183  		}
 184  	}
 185  	return dims
 186  }
 187  
 188  func (b *Clickable) expireHistory(now time.Time) {
 189  	for len(b.history) > 0 {
 190  		p := b.history[0]
 191  		if p.End.IsZero() || now.Sub(p.End) < time.Second {
 192  			break
 193  		}
 194  		b.history = b.history[1:]
 195  	}
 196  }
 197  
 198  // --- Bool (toggle) ---
 199  
 200  // Bool is a boolean toggle widget.
 201  type Bool struct {
 202  	Value   bool
 203  	click   Clickable
 204  	changed bool
 205  }
 206  
 207  // Changed reports whether the value changed this frame.
 208  func (b *Bool) Changed() bool {
 209  	v := b.changed
 210  	b.changed = false
 211  	return v
 212  }
 213  
 214  // Layout processes click events and toggles Value.
 215  func (b *Bool) Layout(gtx GioContext, w GioWidget) Dimensions {
 216  	dims := b.click.Layout(gtx, w)
 217  	if b.click.Clicked() {
 218  		b.Value = !b.Value
 219  		b.changed = true
 220  		gtx.Source.Execute(InvalidateCmd{})
 221  	}
 222  	return dims
 223  }
 224  
 225  // --- Float (slider) ---
 226  
 227  // Float is a slider widget that yields a value in [Min, Max].
 228  type Float struct {
 229  	Value float32
 230  	Min   float32
 231  	Max   float32
 232  
 233  	drag    GestureClick // used to detect press for absolute positioning
 234  	dragging bool
 235  	changed  bool
 236  }
 237  
 238  // Changed reports whether the value changed this frame.
 239  func (f *Float) Changed() bool {
 240  	v := f.changed
 241  	f.changed = false
 242  	return v
 243  }
 244  
 245  // Layout processes drag events and updates Value from the pointer position.
 246  func (f *Float) Layout(gtx GioContext, w GioWidget) Dimensions {
 247  	m := Record(gtx.Ops)
 248  	dims := w(gtx)
 249  	call := m.Stop()
 250  
 251  	clip := ClipRect(image.Rect(0, 0, dims.Size.X, dims.Size.Y)).Push(gtx.Ops)
 252  	EventOp(gtx.Ops, f)
 253  	clip.Pop()
 254  	call.Add(gtx.Ops)
 255  
 256  	for {
 257  		ev, ok := gtx.Source.Event(PointerFilter{
 258  			Target: f,
 259  			Kinds:  PointerPress | PointerDrag | PointerRelease | PointerCancel,
 260  		})
 261  		if !ok {
 262  			break
 263  		}
 264  		pe, ok := ev.(PointerEvent)
 265  		if !ok {
 266  			continue
 267  		}
 268  		switch pe.Kind {
 269  		case PointerPress:
 270  			f.dragging = true
 271  			f.updateValue(pe.Position.X, float32(dims.Size.X))
 272  			gtx.Source.Execute(InvalidateCmd{})
 273  		case PointerDrag:
 274  			if f.dragging {
 275  				f.updateValue(pe.Position.X, float32(dims.Size.X))
 276  				gtx.Source.Execute(InvalidateCmd{})
 277  			}
 278  		case PointerRelease, PointerCancel:
 279  			f.dragging = false
 280  		}
 281  	}
 282  	return dims
 283  }
 284  
 285  func (f *Float) updateValue(x, width float32) {
 286  	if width <= 0 {
 287  		return
 288  	}
 289  	t := x / width
 290  	if t < 0 {
 291  		t = 0
 292  	}
 293  	if t > 1 {
 294  		t = 1
 295  	}
 296  	newVal := f.Min + t*(f.Max-f.Min)
 297  	if newVal != f.Value {
 298  		f.Value = newVal
 299  		f.changed = true
 300  	}
 301  }
 302  
 303  // --- Image ---
 304  
 305  // WidgetImage displays an image.
 306  type WidgetImage struct {
 307  	Src image.Image
 308  }
 309  
 310  // Layout draws the image within the given constraints.
 311  func (img *WidgetImage) Layout(gtx GioContext) Dimensions {
 312  	if img.Src == nil {
 313  		return Dimensions{}
 314  	}
 315  	sz := img.Src.Bounds().Size()
 316  	max := gtx.Constraints.Max
 317  	if sz.X > max.X {
 318  		sz.X = max.X
 319  	}
 320  	if sz.Y > max.Y {
 321  		sz.Y = max.Y
 322  	}
 323  	clip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(gtx.Ops)
 324  	NewPaintImageOp(img.Src).Add(gtx.Ops)
 325  	PaintPaintOp{}.Add(gtx.Ops)
 326  	clip.Pop()
 327  	return Dimensions{Size: sz}
 328  }
 329  
 330  // --- Layout helpers ---
 331  
 332  // WidgetStack lays out widgets on top of each other.
 333  // Returns the max dimensions.
 334  func WidgetStack(gtx GioContext, widgets ...GioWidget) Dimensions {
 335  	max := image.Point{}
 336  	for _, w := range widgets {
 337  		dims := w(gtx)
 338  		if dims.Size.X > max.X {
 339  			max.X = dims.Size.X
 340  		}
 341  		if dims.Size.Y > max.Y {
 342  			max.Y = dims.Size.Y
 343  		}
 344  	}
 345  	return Dimensions{Size: max}
 346  }
 347  
 348  // FillRect fills a rectangle with a solid color.
 349  func FillRect(ops *OpList, rect image.Rectangle, c color.NRGBA) {
 350  	if rect.Empty() {
 351  		return
 352  	}
 353  	stack := ClipRect(rect).Push(ops)
 354  	PaintColorOp{Color: c}.Add(ops)
 355  	PaintPaintOp{}.Add(ops)
 356  	stack.Pop()
 357  }
 358  
 359  // DrawTextAt renders a text line at the given position using the GioContext's shaper.
 360  // Returns the rendered dimensions.
 361  func DrawTextAt(gtx GioContext, font Font, size float32, txt string, x, y, maxW int) image.Point {
 362  	if gtx.Shaper == nil {
 363  		return image.Point{}
 364  	}
 365  	img := gtx.Shaper.RenderToImage(font, size, txt, int32(maxW))
 366  	if img == nil {
 367  		return image.Point{}
 368  	}
 369  	sz := img.Bounds().Size()
 370  	stack := OpOffset(image.Pt(x, y)).Push(gtx.Ops)
 371  	clip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(gtx.Ops)
 372  	NewPaintImageOp(img).Add(gtx.Ops)
 373  	PaintPaintOp{}.Add(gtx.Ops)
 374  	clip.Pop()
 375  	stack.Pop()
 376  	return sz
 377  }
 378  
 379  // WidgetInset wraps a widget in uniform padding.
 380  func WidgetInset(gtx GioContext, paddingPx int, w GioWidget) Dimensions {
 381  	p := paddingPx
 382  	inner := gtx.WithConstraints(Constraints{
 383  		Min: image.Pt(
 384  			max0(gtx.Constraints.Min.X-2*p, 0),
 385  			max0(gtx.Constraints.Min.Y-2*p, 0),
 386  		),
 387  		Max: image.Pt(
 388  			max0(gtx.Constraints.Max.X-2*p, 0),
 389  			max0(gtx.Constraints.Max.Y-2*p, 0),
 390  		),
 391  	})
 392  	stack := OpOffset(image.Pt(p, p)).Push(gtx.Ops)
 393  	dims := w(inner)
 394  	stack.Pop()
 395  	return Dimensions{Size: image.Pt(dims.Size.X+2*p, dims.Size.Y+2*p)}
 396  }
 397  
 398  func max0(a, b int) int {
 399  	if a > b {
 400  		return a
 401  	}
 402  	return b
 403  }
 404  
 405  // --- Label ---
 406  
 407  // TextAlign specifies horizontal text alignment.
 408  type TextAlign uint8
 409  
 410  const (
 411  	TextAlignStart  TextAlign = iota // left in LTR
 412  	TextAlignCenter
 413  	TextAlignEnd // right in LTR
 414  )
 415  
 416  // Label renders static text with word-wrap and optional line limit.
 417  // Color defaults to opaque black (zero value of color.NRGBA with A=0
 418  // is treated as fully opaque black via the zero check below).
 419  type Label struct {
 420  	MaxLines int
 421  	Align    TextAlign
 422  	Color    color.NRGBA // text color; zero = black
 423  }
 424  
 425  // Layout renders txt using the given font and size into gtx.
 426  // Returns Dimensions of the rendered text block.
 427  func (l Label) Layout(gtx GioContext, font Font, sizeSp float32, txt string) Dimensions {
 428  	if gtx.Shaper == nil || txt == "" {
 429  		return Dimensions{}
 430  	}
 431  	maxW := int32(gtx.Constraints.Max.X)
 432  	lines := wrapText(gtx.Shaper, font, sizeSp, txt, maxW)
 433  	if l.MaxLines > 0 && len(lines) > l.MaxLines {
 434  		lines = lines[:l.MaxLines]
 435  	}
 436  	totalH := 0
 437  	maxLineW := 0
 438  	for _, ln := range lines {
 439  		if ln.w > maxLineW {
 440  			maxLineW = ln.w
 441  		}
 442  		totalH += ln.h
 443  	}
 444  	y := 0
 445  	for _, ln := range lines {
 446  		xOff := 0
 447  		switch l.Align {
 448  		case TextAlignCenter:
 449  			xOff = (maxLineW - ln.w) / 2
 450  		case TextAlignEnd:
 451  			xOff = maxLineW - ln.w
 452  		}
 453  		if ln.img != nil {
 454  			img := ln.img
 455  			tc := l.Color
 456  			if tc.R != 0 || tc.G != 0 || tc.B != 0 || tc.A != 0 {
 457  				pix := []byte{:len(ln.img.Pix)}
 458  				copy(pix, ln.img.Pix)
 459  				for i := 0; i+3 < len(pix); i += 4 {
 460  					if pix[i+3] == 0 {
 461  						continue
 462  					}
 463  					pix[i+0] = tc.R
 464  					pix[i+1] = tc.G
 465  					pix[i+2] = tc.B
 466  				}
 467  				tinted := image.NewRGBA(ln.img.Bounds())
 468  				copy(tinted.Pix, pix)
 469  				img = tinted
 470  			}
 471  			stack := OpOffset(image.Pt(xOff, y)).Push(gtx.Ops)
 472  			clip := ClipRect(image.Rect(0, 0, int(ln.w), int(ln.h))).Push(gtx.Ops)
 473  			NewPaintImageOp(img).Add(gtx.Ops)
 474  			PaintPaintOp{}.Add(gtx.Ops)
 475  			clip.Pop()
 476  			stack.Pop()
 477  		}
 478  		y += ln.h
 479  	}
 480  	return Dimensions{Size: image.Pt(maxLineW, totalH)}
 481  }
 482  
 483  type textLine struct {
 484  	img *image.RGBA // nil for empty lines
 485  	w   int
 486  	h   int
 487  }
 488  
 489  // wrapText splits txt into lines that fit within maxW pixels.
 490  // Uses a greedy word-wrap algorithm with the browser shaper for measurement.
 491  func wrapText(sh Shaper, font Font, sizeSp float32, txt string, maxW int32) []textLine {
 492  	var lines []textLine
 493  	// Split on explicit newlines first.
 494  	paragraphs := splitLines(txt)
 495  	for _, para := range paragraphs {
 496  		if para == "" {
 497  			// Empty paragraph: emit a blank line of the right height.
 498  			_, h := sh.MeasureLine(font, sizeSp, "M")
 499  			lines = append(lines, textLine{h: int(h)})
 500  			continue
 501  		}
 502  		words := splitWords(para)
 503  		start := 0
 504  		for start < len(words) {
 505  			// Greedily extend the current line.
 506  			end := start + 1
 507  			lineStr := words[start]
 508  			for end < len(words) {
 509  				candidate := lineStr | " " | words[end]
 510  				w, _ := sh.MeasureLine(font, sizeSp, candidate)
 511  				if maxW > 0 && w > maxW {
 512  					break
 513  				}
 514  				lineStr = candidate
 515  				end++
 516  			}
 517  			img := sh.RenderToImage(font, sizeSp, lineStr, maxW)
 518  			ln := textLine{}
 519  			if img != nil {
 520  				ln.img = img
 521  				ln.w = img.Bounds().Dx()
 522  				ln.h = img.Bounds().Dy()
 523  			} else {
 524  				_, h := sh.MeasureLine(font, sizeSp, "M")
 525  				ln.h = int(h)
 526  			}
 527  			lines = append(lines, ln)
 528  			start = end
 529  		}
 530  	}
 531  	return lines
 532  }
 533  
 534  // splitLines splits on \n without allocating via strings package.
 535  func splitLines(s string) []string {
 536  	var out []string
 537  	i := 0
 538  	for j := 0; j < len(s); j++ {
 539  		if s[j] == '\n' {
 540  			out = append(out, s[i:j])
 541  			i = j + 1
 542  		}
 543  	}
 544  	out = append(out, s[i:])
 545  	return out
 546  }
 547  
 548  // splitWords splits on whitespace, returning non-empty tokens.
 549  func splitWords(s string) []string {
 550  	var out []string
 551  	start := -1
 552  	for i := 0; i < len(s); i++ {
 553  		c := s[i]
 554  		isSpace := c == ' ' || c == '\t' || c == '\r'
 555  		if !isSpace && start < 0 {
 556  			start = i
 557  		} else if isSpace && start >= 0 {
 558  			out = append(out, s[start:i])
 559  			start = -1
 560  		}
 561  	}
 562  	if start >= 0 {
 563  		out = append(out, s[start:])
 564  	}
 565  	return out
 566  }
 567  
 568  // --- Enum ---
 569  
 570  // RadioGroup is a set of mutually-exclusive options.
 571  // Value is the index of the currently selected option (-1 = none).
 572  type RadioGroup struct {
 573  	Value   int
 574  	changed bool
 575  	clicks  []Clickable
 576  }
 577  
 578  // Changed reports whether the value changed this frame.
 579  func (e *RadioGroup) Changed() bool {
 580  	v := e.changed
 581  	e.changed = false
 582  	return v
 583  }
 584  
 585  // Layout lays out n options using the provided widget function.
 586  // The function receives the option index and whether it is selected.
 587  func (e *RadioGroup) Layout(gtx GioContext, n int, option func(gtx GioContext, index int, selected bool) Dimensions) Dimensions {
 588  	for len(e.clicks) < n {
 589  		e.clicks = append(e.clicks, Clickable{})
 590  	}
 591  	maxW, totalH := 0, 0
 592  	for i := 0; i < n; i++ {
 593  		stack := OpOffset(image.Pt(0, totalH)).Push(gtx.Ops)
 594  		inner := gtx.WithConstraints(Constraints{Max: image.Pt(
 595  			gtx.Constraints.Max.X,
 596  			gtx.Constraints.Max.Y-totalH,
 597  		)})
 598  		idx := i
 599  		// Wrap the option rendering in Clickable.Layout so the Router sees the hit area.
 600  		dims := e.clicks[i].Layout(inner, func(gtx GioContext) Dimensions {
 601  			return option(gtx, idx, e.Value == idx)
 602  		})
 603  		stack.Pop()
 604  		if e.clicks[i].Clicked() {
 605  			if e.Value != i {
 606  				e.Value = i
 607  				e.changed = true
 608  				inner.Source.Execute(InvalidateCmd{})
 609  			}
 610  		}
 611  		if dims.Size.X > maxW {
 612  			maxW = dims.Size.X
 613  		}
 614  		totalH += dims.Size.Y
 615  	}
 616  	return Dimensions{Size: image.Pt(maxW, totalH)}
 617  }
 618  
 619  // --- Scrollbar ---
 620  
 621  // Scrollbar renders a thin scrollbar track + indicator.
 622  // It does not own scroll state; the caller provides viewport positions.
 623  // Uses raw PtrEvents for dragging (bypasses Router due to nested clip complexity).
 624  type Scrollbar struct {
 625  	dragging    bool
 626  	dragStartY  float32 // physical Y where drag started
 627  	trackRect   image.Rectangle
 628  	indRect     image.Rectangle
 629  	// screenY0 tracks the physical Y offset of the scrollbar's local origin.
 630  	screenY0    float32
 631  }
 632  
 633  const scrollbarThickPx = 6
 634  const scrollbarMinIndPx = 16
 635  
 636  // Layout draws the scrollbar and returns the scroll delta (in content pixels)
 637  // caused by user interaction this frame.
 638  // viewStart and viewEnd are in [0,1] relative to total content size.
 639  // contentPx is the total content length in pixels.
 640  func (s *Scrollbar) Layout(gtx GioContext, axis GestureAxis, viewStart, viewEnd, contentPx float32) (Dimensions, int) {
 641  	sz := gtx.Constraints.Max
 642  	var trackLen, thick int
 643  	var dims Dimensions
 644  	if axis == GestureAxisVertical {
 645  		thick = scrollbarThickPx
 646  		trackLen = sz.Y
 647  		s.trackRect = image.Rect(sz.X-thick, 0, sz.X, trackLen)
 648  		dims = Dimensions{Size: sz}
 649  	} else {
 650  		thick = scrollbarThickPx
 651  		trackLen = sz.X
 652  		s.trackRect = image.Rect(0, sz.Y-thick, trackLen, sz.Y)
 653  		dims = Dimensions{Size: sz}
 654  	}
 655  
 656  	// Draw track.
 657  	FillRect(gtx.Ops, s.trackRect, trackColor())
 658  
 659  	// Indicator bounds.
 660  	span := viewEnd - viewStart
 661  	if span < 0 {
 662  		span = 0
 663  	}
 664  	if span > 1 {
 665  		span = 1
 666  	}
 667  	indLen := int(span * float32(trackLen))
 668  	if indLen < scrollbarMinIndPx {
 669  		indLen = scrollbarMinIndPx
 670  	}
 671  	indStart := int(viewStart * float32(trackLen))
 672  	if indStart+indLen > trackLen {
 673  		indStart = trackLen - indLen
 674  	}
 675  	if indStart < 0 {
 676  		indStart = 0
 677  	}
 678  	if axis == GestureAxisVertical {
 679  		s.indRect = image.Rect(sz.X-thick, indStart, sz.X, indStart+indLen)
 680  	} else {
 681  		s.indRect = image.Rect(indStart, sz.Y-thick, indStart+indLen, sz.Y)
 682  	}
 683  	FillRect(gtx.Ops, s.indRect, indicatorColor())
 684  
 685  	// Direct PtrEvents hit-test for the scrollbar thumb drag.
 686  	// The Router's nested clip routing has complexity issues with scrollbars;
 687  	// using raw screen-space events with a known screen offset is more reliable.
 688  	scrollDelta := s.processRawDrag(gtx, axis, trackLen, contentPx)
 689  	return dims, scrollDelta
 690  }
 691  
 692  // processRawDrag handles scrollbar thumb drag using raw PtrEvents.
 693  // It detects press inside the indicator rect (in SCREEN coords = local + screenY0)
 694  // and computes content scroll delta from the drag distance.
 695  func (s *Scrollbar) processRawDrag(gtx GioContext, axis GestureAxis, trackLen int, contentPx float32) int {
 696  	if trackLen <= 0 || contentPx <= 0 {
 697  		return 0
 698  	}
 699  	// Determine the screen-space bounding box of the scrollbar track.
 700  	// The trackRect is in local coords. gtx.Metric gives DPR but not offset.
 701  	// Use gtx.PtrEvents: pointer coords are in physical screen pixels.
 702  	// The scrollbar's local origin is at (screenX, screenY0) from the section transform.
 703  	// We track the screen-space indicator: indRect offset by screen origin.
 704  	// Since we don't have the exact screen offset here, we use a self-calibrating
 705  	// approach: on first press, compare gtx.PtrEvents Y to indRect.Min.Y to
 706  	// determine the screen origin, then track relative movement.
 707  
 708  	scrollDelta := 0
 709  	for _, e := range gtx.PtrEvents {
 710  		switch e.Kind {
 711  		case WinPointerPress:
 712  			if e.Buttons&1 == 0 {
 713  				break
 714  			}
 715  			// Check if press is roughly in the track column.
 716  			// We accept any press near the right edge of the constraint area.
 717  			// sz.X - thick is the track left edge in local coords.
 718  			// In screen space it's shifted by the section x offset (≈12px).
 719  			// Accept presses within ±8px of the expected track x position.
 720  			screenTrackMinX := float32(gtx.Constraints.Max.X - scrollbarThickPx)
 721  			screenTrackMaxX := float32(gtx.Constraints.Max.X)
 722  			if e.X >= screenTrackMinX-20 && e.X <= screenTrackMaxX+20 {
 723  				s.dragging = true
 724  				s.dragStartY = e.Y
 725  				s.screenY0 = e.Y // calibrate: press Y is our anchor
 726  			}
 727  		case WinPointerMove:
 728  			if !s.dragging || e.Buttons&1 == 0 {
 729  				break
 730  			}
 731  			dy := e.Y - s.dragStartY
 732  			s.dragStartY = e.Y
 733  			// Scale indicator movement to content pixels (inverted: drag down = scroll down)
 734  			scrollDelta += int(dy * contentPx / float32(trackLen))
 735  		case WinPointerRelease, WinPointerCancel:
 736  			s.dragging = false
 737  		}
 738  	}
 739  	return scrollDelta
 740  }
 741  
 742  func trackColor() color.NRGBA    { return color.NRGBA{R: 0xe0, G: 0xe0, B: 0xe0, A: 0xff} }
 743  func indicatorColor() color.NRGBA { return color.NRGBA{R: 0x90, G: 0x90, B: 0x90, A: 0xff} }
 744  
 745  // --- StatefulList ---
 746  
 747  // StatefulList is a scrollable, virtualized list widget.
 748  // It owns scroll state and an optional scrollbar.
 749  type StatefulList struct {
 750  	Axis      GestureAxis // GestureAxisVertical or GestureAxisHorizontal
 751  	scroll    GestureScroll
 752  	offset    int // scroll offset in pixels
 753  	bar       Scrollbar
 754  	showBar   bool
 755  }
 756  
 757  // ScrollOffset returns the current pixel scroll offset.
 758  func (l *StatefulList) ScrollOffset() int { return l.offset }
 759  
 760  // ScrollTo sets the scroll offset directly.
 761  func (l *StatefulList) ScrollTo(px int) { l.offset = px }
 762  
 763  // Layout lays out count items using the item widget function.
 764  // item(gtx, i) should lay out item i and return its Dimensions.
 765  // totalContentPx is the total content size in pixels (for scrollbar).
 766  // Returns Dimensions of the list viewport.
 767  func (l *StatefulList) Layout(gtx GioContext, count int, totalContentPx int, item func(gtx GioContext, i int) Dimensions) Dimensions {
 768  	sz := gtx.Constraints.Max
 769  
 770  	// Register scroll handler over content area only (not the scrollbar strip),
 771  	// so the scroll handler doesn't compete with the scrollbar drag handler.
 772  	contentW := sz.X - scrollbarThickPx
 773  	if l.Axis == GestureAxisHorizontal {
 774  		contentW = sz.X
 775  	}
 776  	contentH := sz.Y
 777  	if l.Axis == GestureAxisHorizontal {
 778  		contentH = sz.Y - scrollbarThickPx
 779  	}
 780  	scrollClip := ClipRect(image.Rect(0, 0, contentW, contentH)).Push(gtx.Ops)
 781  	l.scroll.Add(gtx.Ops, l.Axis)
 782  	scrollClip.Pop()
 783  
 784  	// Full viewport clip for drawing items.
 785  	viewClip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(gtx.Ops)
 786  
 787  	// Accumulate scroll delta from Router.
 788  	delta := l.scroll.Update(gtx.Source, gtx.Metric, gtx.Now, l.Axis)
 789  	l.offset += delta
 790  	if l.offset < 0 {
 791  		l.offset = 0
 792  	}
 793  	maxOff := totalContentPx - sz.Y
 794  	if l.Axis == GestureAxisHorizontal {
 795  		maxOff = totalContentPx - sz.X
 796  	}
 797  	if maxOff < 0 {
 798  		maxOff = 0
 799  	}
 800  	if l.offset > maxOff {
 801  		l.offset = maxOff
 802  	}
 803  
 804  	// Layout visible items (still inside viewClip from above).
 805  	pos := -l.offset
 806  	for i := 0; i < count; i++ {
 807  		var itemSz image.Point
 808  		if l.Axis == GestureAxisVertical {
 809  			if pos >= sz.Y {
 810  				break
 811  			}
 812  		} else {
 813  			if pos >= sz.X {
 814  				break
 815  			}
 816  		}
 817  		var off image.Point
 818  		if l.Axis == GestureAxisVertical {
 819  			off = image.Pt(0, pos)
 820  		} else {
 821  			off = image.Pt(pos, 0)
 822  		}
 823  		offStack := OpOffset(off).Push(gtx.Ops)
 824  		childGtx := gtx.WithConstraints(Constraints{
 825  			Max: image.Pt(sz.X, sz.Y-pos),
 826  		})
 827  		if l.Axis == GestureAxisHorizontal {
 828  			childGtx = gtx.WithConstraints(Constraints{
 829  				Max: image.Pt(sz.X-pos, sz.Y),
 830  			})
 831  		}
 832  		dims := item(childGtx, i)
 833  		itemSz = dims.Size
 834  		offStack.Pop()
 835  		if l.Axis == GestureAxisVertical {
 836  			pos += itemSz.Y
 837  		} else {
 838  			pos += itemSz.X
 839  		}
 840  	}
 841  
 842  	viewClip.Pop()
 843  
 844  	// Draw scrollbar if content overflows.
 845  	l.showBar = totalContentPx > 0
 846  	if l.showBar && maxOff > 0 {
 847  		viewStart := float32(l.offset) / float32(totalContentPx)
 848  		viewEnd := float32(l.offset+sz.Y) / float32(totalContentPx)
 849  		if l.Axis == GestureAxisHorizontal {
 850  			viewEnd = float32(l.offset+sz.X) / float32(totalContentPx)
 851  		}
 852  		if viewEnd > 1 {
 853  			viewEnd = 1
 854  		}
 855  		barGtx := gtx
 856  		_, barScroll := l.bar.Layout(barGtx, l.Axis, viewStart, viewEnd, float32(totalContentPx))
 857  		l.offset += barScroll
 858  		if l.offset < 0 {
 859  			l.offset = 0
 860  		}
 861  		if l.offset > maxOff {
 862  			l.offset = maxOff
 863  		}
 864  	}
 865  
 866  	return Dimensions{Size: sz}
 867  }
 868  
 869  // --- Selectable ---
 870  
 871  // Selectable displays read-only text that can be copied.
 872  // Click to focus, Ctrl+C/Cmd+C to copy.
 873  type Selectable struct {
 874  	click   Clickable
 875  	focused bool
 876  }
 877  
 878  // Layout renders txt and handles copy key event.
 879  func (s *Selectable) Layout(gtx GioContext, font Font, sizeSp float32, txt string) Dimensions {
 880  	dims := s.click.Layout(gtx, func(gtx GioContext) Dimensions {
 881  		return Label{}.Layout(gtx, font, sizeSp, txt)
 882  	})
 883  
 884  	if s.click.Clicked() {
 885  		s.focused = true
 886  		if gtx.Win != nil {
 887  			gtx.Win.FocusTextArea()
 888  		}
 889  	}
 890  	if s.focused {
 891  		for _, ke := range gtx.KeyEvents {
 892  			if ke.Press && ke.Code == int32('C') &&
 893  				(ke.Mods&ModCtrlBit != 0 || ke.Mods&ModMetaBit != 0) {
 894  				gtx.WriteClipboard(txt)
 895  			}
 896  		}
 897  		// Focus underline.
 898  		FillRect(gtx.Ops, image.Rect(0, dims.Size.Y-2, dims.Size.X, dims.Size.Y),
 899  			color.NRGBA{R: 0x3F, G: 0x51, B: 0xB5, A: 0xFF})
 900  	}
 901  	return dims
 902  }
 903  
 904  // Focused reports whether this widget has focus.
 905  func (s *Selectable) Focused() bool { return s.focused }
 906  
 907  // Blur removes focus.
 908  func (s *Selectable) Blur() { s.focused = false }
 909  
 910  // --- Editor ---
 911  
 912  // Editor is a single-line text input widget.
 913  // Stores content as UTF-8 bytes (string = []byte in Moxie). Cursor is a byte index.
 914  type Editor struct {
 915  	value   string // current text as UTF-8
 916  	cursor  int    // byte position of cursor
 917  	focused bool
 918  	click   Clickable
 919  	Hint    string // placeholder text shown when empty
 920  }
 921  
 922  // Value returns the current text.
 923  func (e *Editor) Value() string { return e.value }
 924  
 925  // SetValue replaces the editor contents and moves cursor to end.
 926  func (e *Editor) SetValue(s string) {
 927  	e.value = s
 928  	e.cursor = len(s)
 929  }
 930  
 931  // cpToStr encodes a Unicode codepoint to a UTF-8 string without unicode/utf8.
 932  func cpToStr(cp int32) string {
 933  	if cp < 0x80 {
 934  		return string([]byte{byte(cp)})
 935  	}
 936  	if cp < 0x800 {
 937  		return string([]byte{
 938  			byte(0xC0 | (cp >> 6)),
 939  			byte(0x80 | (cp & 0x3F)),
 940  		})
 941  	}
 942  	if cp < 0x10000 {
 943  		return string([]byte{
 944  			byte(0xE0 | (cp >> 12)),
 945  			byte(0x80 | ((cp >> 6) & 0x3F)),
 946  			byte(0x80 | (cp & 0x3F)),
 947  		})
 948  	}
 949  	return string([]byte{
 950  		byte(0xF0 | (cp >> 18)),
 951  		byte(0x80 | ((cp >> 12) & 0x3F)),
 952  		byte(0x80 | ((cp >> 6) & 0x3F)),
 953  		byte(0x80 | (cp & 0x3F)),
 954  	})
 955  }
 956  
 957  // prevCharStart returns the byte index of the start of the UTF-8 sequence
 958  // immediately before bytePos in s.
 959  func prevCharStart(s string, bytePos int) int {
 960  	i := bytePos - 1
 961  	for i > 0 && s[i]&0xC0 == 0x80 {
 962  		i--
 963  	}
 964  	return i
 965  }
 966  
 967  // nextCharEnd returns the byte index of the end of the UTF-8 sequence
 968  // starting at bytePos in s.
 969  func nextCharEnd(s string, bytePos int) int {
 970  	i := bytePos + 1
 971  	for i < len(s) && s[i]&0xC0 == 0x80 {
 972  		i++
 973  	}
 974  	return i
 975  }
 976  
 977  // Focused reports whether the editor has keyboard focus.
 978  func (e *Editor) Focused() bool { return e.focused }
 979  
 980  // Layout renders the editor and processes events. Returns Dimensions.
 981  func (e *Editor) Layout(gtx GioContext, font Font, sizeSp float32) Dimensions {
 982  	const padX, padY = 6, 4
 983  	const cursorW = 2
 984  
 985  	maxW := gtx.Constraints.Max.X
 986  	if maxW <= 0 {
 987  		maxW = 200
 988  	}
 989  	// Use actual measured text height rather than sizeSp, since the canvas
 990  	// returns ascent+descent which is always larger than the CSS font size.
 991  	var measuredH int32
 992  	if gtx.Shaper != nil {
 993  		_, measuredH = gtx.Shaper.MeasureLine(font, sizeSp, "Mg")
 994  	}
 995  	lineH := int(measuredH)
 996  	if lineH <= 0 {
 997  		lineH = int(sizeSp) + 4
 998  	}
 999  	h := lineH + 2*padY
1000  	outerRect := image.Rect(0, 0, maxW, h)
1001  
1002  	// Register click area and process events.
1003  	e.click.Layout(gtx, func(gtx GioContext) Dimensions {
1004  		return Dimensions{Size: image.Pt(maxW, h)}
1005  	})
1006  	if e.click.Clicked() {
1007  		e.focused = true
1008  		if gtx.Win != nil {
1009  			gtx.Win.FocusTextArea()
1010  		}
1011  	}
1012  
1013  	if e.focused {
1014  		// Typed characters (from textarea input events).
1015  		for _, te := range gtx.TextEvents {
1016  			cp := te.Codepoint
1017  			if cp < 0x20 {
1018  				continue // skip control chars; handled below by key events
1019  			}
1020  			ch := cpToStr(cp)
1021  			e.value = e.value[:e.cursor] | ch | e.value[e.cursor:]
1022  			e.cursor += len(ch)
1023  		}
1024  		// Paste runes.
1025  		if gtx.Win != nil {
1026  			for _, cp := range gtx.Win.PasteRunes() {
1027  				if cp < 0x20 && cp != '\n' {
1028  					continue
1029  				}
1030  				ch := cpToStr(cp)
1031  				e.value = e.value[:e.cursor] | ch | e.value[e.cursor:]
1032  				e.cursor += len(ch)
1033  			}
1034  		}
1035  		// Key events.
1036  		for _, ke := range gtx.KeyEvents {
1037  			if !ke.Press {
1038  				continue
1039  			}
1040  			switch ke.Code {
1041  			case KeyBackspace:
1042  				if e.cursor > 0 {
1043  					prev := prevCharStart(e.value, e.cursor)
1044  					e.value = e.value[:prev] | e.value[e.cursor:]
1045  					e.cursor = prev
1046  				}
1047  			case KeyDelete:
1048  				if e.cursor < len(e.value) {
1049  					next := nextCharEnd(e.value, e.cursor)
1050  					e.value = e.value[:e.cursor] | e.value[next:]
1051  				}
1052  			case KeyArrowLeft:
1053  				if e.cursor > 0 {
1054  					e.cursor = prevCharStart(e.value, e.cursor)
1055  				}
1056  			case KeyArrowRight:
1057  				if e.cursor < len(e.value) {
1058  					e.cursor = nextCharEnd(e.value, e.cursor)
1059  				}
1060  			case KeyHome:
1061  				e.cursor = 0
1062  			case KeyEnd:
1063  				e.cursor = len(e.value)
1064  			case int32('C'):
1065  				if ke.Mods&ModCtrlBit != 0 || ke.Mods&ModMetaBit != 0 {
1066  					gtx.WriteClipboard(e.value)
1067  				}
1068  			case int32('X'):
1069  				if ke.Mods&ModCtrlBit != 0 || ke.Mods&ModMetaBit != 0 {
1070  					gtx.WriteClipboard(e.value)
1071  					e.value = ""
1072  					e.cursor = 0
1073  				}
1074  			case int32('V'):
1075  				if ke.Mods&ModCtrlBit != 0 || ke.Mods&ModMetaBit != 0 {
1076  					gtx.ReadClipboard()
1077  				}
1078  			case KeyEscape:
1079  				e.focused = false
1080  			}
1081  		}
1082  	}
1083  
1084  	// Background + border.
1085  	borderColor := color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF}
1086  	if e.focused {
1087  		borderColor = color.NRGBA{R: 0x3F, G: 0x51, B: 0xB5, A: 0xFF}
1088  	}
1089  	FillRect(gtx.Ops, outerRect, borderColor)
1090  	FillRect(gtx.Ops, image.Rect(1, 1, maxW-1, h-1), color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
1091  
1092  	// Text or hint.
1093  	txt := string(e.value)
1094  	textColor := color.NRGBA{A: 0xFF}
1095  	if txt == "" && e.Hint != "" {
1096  		txt = e.Hint
1097  		textColor = color.NRGBA{R: 0x9E, G: 0x9E, B: 0x9E, A: 0xFF}
1098  	}
1099  	if gtx.Shaper != nil && txt != "" {
1100  		img := gtx.Shaper.RenderToImage(font, sizeSp, txt, int32(maxW-2*padX))
1101  		if img != nil {
1102  			for i := 0; i+3 < len(img.Pix); i += 4 {
1103  				if img.Pix[i+3] == 0 {
1104  					continue
1105  				}
1106  				img.Pix[i+0] = textColor.R
1107  				img.Pix[i+1] = textColor.G
1108  				img.Pix[i+2] = textColor.B
1109  			}
1110  			esz := img.Bounds().Size()
1111  			// Center text vertically: (box height - text height) / 2
1112  			textY := (h - int(measuredH)) / 2
1113  			if textY < 0 {
1114  				textY = 0
1115  			}
1116  			stack := OpOffset(image.Pt(padX, textY)).Push(gtx.Ops)
1117  			eclip := ClipRect(image.Rect(0, 0, esz.X, esz.Y)).Push(gtx.Ops)
1118  			NewPaintImageOp(img).Add(gtx.Ops)
1119  			PaintPaintOp{}.Add(gtx.Ops)
1120  			eclip.Pop()
1121  			stack.Pop()
1122  		}
1123  	}
1124  
1125  	// Cursor (measure byte prefix up to cursor position).
1126  	if e.focused && gtx.Shaper != nil {
1127  		cx := padX
1128  		if e.cursor > 0 {
1129  			cw, _ := gtx.Shaper.MeasureLine(font, sizeSp, e.value[:e.cursor])
1130  			cx += int(cw)
1131  		}
1132  		textY := (h - int(measuredH)) / 2
1133  		if textY < 0 {
1134  			textY = 0
1135  		}
1136  		FillRect(gtx.Ops, image.Rect(cx, textY, cx+cursorW, textY+int(measuredH)),
1137  			color.NRGBA{R: 0x3F, G: 0x51, B: 0xB5, A: 0xFF})
1138  	}
1139  
1140  	return Dimensions{Size: image.Pt(maxW, h)}
1141  }
1142