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