// SPDX-License-Identifier: Unlicense OR MIT // Core widget types for Gio: Clickable, Bool, Float, Image. // Uses a simplified GioContext that does not require the full input router. package gio import ( "image" "image/color" "time" ) // GioContext is the layout context passed to widget functions. // It carries ops, constraints, event data, text shaper, and display metrics. type GioContext struct { // Ops is the op list being built for this frame. Ops *OpList // Constraints are the current layout constraints. Constraints Constraints // Metric is the display pixel density. Metric Metric // Source provides access to the input router for routed event delivery. // Use Source.Event(filters...) to receive events for a registered tag. Source Source // PtrEvents is the raw pointer event list from the window. // Populated only when not using the Router. Prefer Source for new widgets. PtrEvents []AppPointerEvent // KeyEvents is the raw key event list from the window. // Populated only when not using the Router. Prefer Source for new widgets. KeyEvents []AppKeyEvent // TextEvents is the raw text input event list (typed chars, IME). TextEvents []AppTextEvent // Now is the approximate frame time (for animation timing). Now time.Time // Shaper is the text renderer. Shaper Shaper // Win is the application window, for clipboard and focus operations. Win *Window } // GioWidget is a function that lays out content in a GioContext. type GioWidget func(gtx GioContext) Dimensions // NewGioContext constructs a GioContext for a frame using the full Router. // The router should have had Frame(ops) called before building the context. func NewGioContext(frame FrameEvent, ops *OpList, router *Router, shaper Shaper, win *Window) GioContext { return GioContext{ Ops: ops, Constraints: Constraints{Max: frame.Size}, Metric: frame.Metric, Source: router.Source(), Now: time.Now(), Shaper: shaper, Win: win, } } // NewGioContextRaw constructs a GioContext without a Router (legacy/simple mode). // Widgets use gtx.PtrEvents and gtx.KeyEvents directly. func NewGioContextRaw(frame FrameEvent, ops *OpList, shaper Shaper, ptrEvents []AppPointerEvent, keyEvents []AppKeyEvent, win *Window) GioContext { return GioContext{ Ops: ops, Constraints: Constraints{Max: frame.Size}, Metric: frame.Metric, PtrEvents: ptrEvents, KeyEvents: keyEvents, Now: time.Now(), Shaper: shaper, Win: win, } } // WriteClipboard copies text to the system clipboard via the window. func (c GioContext) WriteClipboard(text string) { if c.Win != nil { c.Win.WriteClipboard(text) } } // ReadClipboard requests an async clipboard read. Paste events arrive via // Window.PasteRunes() on the next frame. func (c GioContext) ReadClipboard() { if c.Win != nil { c.Win.ReadClipboard() } } // WithOps returns a copy of the context with a new ops list. func (c GioContext) WithOps(ops *OpList) GioContext { c.Ops = ops return c } // WithConstraints returns a copy with updated constraints. func (c GioContext) WithConstraints(cs Constraints) GioContext { c.Constraints = cs return c } // Dp converts dp to pixels. func (c GioContext) Dp(v Dp) int { return c.Metric.Dp(v) } // --- Clickable --- // Clickable is a rectangular area that detects pointer clicks. // Usage: call Layout(gtx, content) which registers the hit area and // processes events. Then call Clicked() to check if a click occurred. type Clickable struct { click GestureClick history []WidgetPress clicked bool } // WidgetPress records a pointer press. type WidgetPress struct { Position image.Point Start time.Time End time.Time Cancelled bool } // WidgetClick reports a completed click. type WidgetClick struct { NumClicks int } // Clicked reports if a click was completed since the last call. // Must call Layout(gtx, w) first this frame to register the hit area. func (b *Clickable) Clicked() bool { c := b.clicked b.clicked = false return c } // Hovered reports whether the pointer is over the clickable. func (b *Clickable) Hovered() bool { return b.click.Hovered() } // Pressed reports whether the pointer is pressing. func (b *Clickable) Pressed() bool { return b.click.Pressed() } // History returns past presses. func (b *Clickable) History() []WidgetPress { return b.history } // Layout lays out the child widget, registers a hit area, and processes events. // The click detection area is defined by the child widget's dimensions. func (b *Clickable) Layout(gtx GioContext, w GioWidget) Dimensions { // Record content so we can emit EventOp before it (Router sees area first). m := Record(gtx.Ops) dims := w(gtx) call := m.Stop() // Register hit area at the current transform in the op stream. clip := ClipRect(image.Rect(0, 0, dims.Size.X, dims.Size.Y)).Push(gtx.Ops) b.click.Add(gtx.Ops) clip.Pop() // Draw content on top of the registration. call.Add(gtx.Ops) // Process events routed by the Router (coordinates already in local space). b.expireHistory(gtx.Now) for { e, ok := b.click.Update(gtx.Source) if !ok { break } switch e.Kind { case ClickKindPress: b.history = append(b.history, WidgetPress{Position: e.Position, Start: gtx.Now}) case ClickKindClick: if l := len(b.history); l > 0 { b.history[l-1].End = gtx.Now } b.clicked = true case ClickKindCancel: for i := range b.history { if b.history[i].End.IsZero() { b.history[i].End = gtx.Now b.history[i].Cancelled = true } } } } return dims } func (b *Clickable) expireHistory(now time.Time) { for len(b.history) > 0 { p := b.history[0] if p.End.IsZero() || now.Sub(p.End) < time.Second { break } b.history = b.history[1:] } } // --- Bool (toggle) --- // Bool is a boolean toggle widget. type Bool struct { Value bool click Clickable changed bool } // Changed reports whether the value changed this frame. func (b *Bool) Changed() bool { v := b.changed b.changed = false return v } // Layout processes click events and toggles Value. func (b *Bool) Layout(gtx GioContext, w GioWidget) Dimensions { dims := b.click.Layout(gtx, w) if b.click.Clicked() { b.Value = !b.Value b.changed = true gtx.Source.Execute(InvalidateCmd{}) } return dims } // --- Float (slider) --- // Float is a slider widget that yields a value in [Min, Max]. type Float struct { Value float32 Min float32 Max float32 drag GestureClick // used to detect press for absolute positioning dragging bool changed bool } // Changed reports whether the value changed this frame. func (f *Float) Changed() bool { v := f.changed f.changed = false return v } // Layout processes drag events and updates Value from the pointer position. func (f *Float) Layout(gtx GioContext, w GioWidget) Dimensions { m := Record(gtx.Ops) dims := w(gtx) call := m.Stop() clip := ClipRect(image.Rect(0, 0, dims.Size.X, dims.Size.Y)).Push(gtx.Ops) EventOp(gtx.Ops, f) clip.Pop() call.Add(gtx.Ops) for { ev, ok := gtx.Source.Event(PointerFilter{ Target: f, Kinds: PointerPress | PointerDrag | PointerRelease | PointerCancel, }) if !ok { break } pe, ok := ev.(PointerEvent) if !ok { continue } switch pe.Kind { case PointerPress: f.dragging = true f.updateValue(pe.Position.X, float32(dims.Size.X)) gtx.Source.Execute(InvalidateCmd{}) case PointerDrag: if f.dragging { f.updateValue(pe.Position.X, float32(dims.Size.X)) gtx.Source.Execute(InvalidateCmd{}) } case PointerRelease, PointerCancel: f.dragging = false } } return dims } func (f *Float) updateValue(x, width float32) { if width <= 0 { return } t := x / width if t < 0 { t = 0 } if t > 1 { t = 1 } newVal := f.Min + t*(f.Max-f.Min) if newVal != f.Value { f.Value = newVal f.changed = true } } // --- Image --- // WidgetImage displays an image. type WidgetImage struct { Src image.Image } // Layout draws the image within the given constraints. func (img *WidgetImage) Layout(gtx GioContext) Dimensions { if img.Src == nil { return Dimensions{} } sz := img.Src.Bounds().Size() max := gtx.Constraints.Max if sz.X > max.X { sz.X = max.X } if sz.Y > max.Y { sz.Y = max.Y } clip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(gtx.Ops) NewPaintImageOp(img.Src).Add(gtx.Ops) PaintPaintOp{}.Add(gtx.Ops) clip.Pop() return Dimensions{Size: sz} } // --- Layout helpers --- // WidgetStack lays out widgets on top of each other. // Returns the max dimensions. func WidgetStack(gtx GioContext, widgets ...GioWidget) Dimensions { max := image.Point{} for _, w := range widgets { dims := w(gtx) if dims.Size.X > max.X { max.X = dims.Size.X } if dims.Size.Y > max.Y { max.Y = dims.Size.Y } } return Dimensions{Size: max} } // FillRect fills a rectangle with a solid color. func FillRect(ops *OpList, rect image.Rectangle, c color.NRGBA) { if rect.Empty() { return } stack := ClipRect(rect).Push(ops) PaintColorOp{Color: c}.Add(ops) PaintPaintOp{}.Add(ops) stack.Pop() } // DrawTextAt renders a text line at the given position using the GioContext's shaper. // Returns the rendered dimensions. func DrawTextAt(gtx GioContext, font Font, size float32, txt string, x, y, maxW int) image.Point { if gtx.Shaper == nil { return image.Point{} } img := gtx.Shaper.RenderToImage(font, size, txt, int32(maxW)) if img == nil { return image.Point{} } sz := img.Bounds().Size() stack := OpOffset(image.Pt(x, y)).Push(gtx.Ops) clip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(gtx.Ops) NewPaintImageOp(img).Add(gtx.Ops) PaintPaintOp{}.Add(gtx.Ops) clip.Pop() stack.Pop() return sz } // WidgetInset wraps a widget in uniform padding. func WidgetInset(gtx GioContext, paddingPx int, w GioWidget) Dimensions { p := paddingPx inner := gtx.WithConstraints(Constraints{ Min: image.Pt( max0(gtx.Constraints.Min.X-2*p, 0), max0(gtx.Constraints.Min.Y-2*p, 0), ), Max: image.Pt( max0(gtx.Constraints.Max.X-2*p, 0), max0(gtx.Constraints.Max.Y-2*p, 0), ), }) stack := OpOffset(image.Pt(p, p)).Push(gtx.Ops) dims := w(inner) stack.Pop() return Dimensions{Size: image.Pt(dims.Size.X+2*p, dims.Size.Y+2*p)} } func max0(a, b int) int { if a > b { return a } return b } // --- Label --- // TextAlign specifies horizontal text alignment. type TextAlign uint8 const ( TextAlignStart TextAlign = iota // left in LTR TextAlignCenter TextAlignEnd // right in LTR ) // Label renders static text with word-wrap and optional line limit. // Color defaults to opaque black (zero value of color.NRGBA with A=0 // is treated as fully opaque black via the zero check below). type Label struct { MaxLines int Align TextAlign Color color.NRGBA // text color; zero = black } // Layout renders txt using the given font and size into gtx. // Returns Dimensions of the rendered text block. func (l Label) Layout(gtx GioContext, font Font, sizeSp float32, txt string) Dimensions { if gtx.Shaper == nil || txt == "" { return Dimensions{} } maxW := int32(gtx.Constraints.Max.X) lines := wrapText(gtx.Shaper, font, sizeSp, txt, maxW) if l.MaxLines > 0 && len(lines) > l.MaxLines { lines = lines[:l.MaxLines] } totalH := 0 maxLineW := 0 for _, ln := range lines { if ln.w > maxLineW { maxLineW = ln.w } totalH += ln.h } y := 0 for _, ln := range lines { xOff := 0 switch l.Align { case TextAlignCenter: xOff = (maxLineW - ln.w) / 2 case TextAlignEnd: xOff = maxLineW - ln.w } if ln.img != nil { img := ln.img tc := l.Color if tc.R != 0 || tc.G != 0 || tc.B != 0 || tc.A != 0 { pix := []byte{:len(ln.img.Pix)} copy(pix, ln.img.Pix) for i := 0; i+3 < len(pix); i += 4 { if pix[i+3] == 0 { continue } pix[i+0] = tc.R pix[i+1] = tc.G pix[i+2] = tc.B } tinted := image.NewRGBA(ln.img.Bounds()) copy(tinted.Pix, pix) img = tinted } stack := OpOffset(image.Pt(xOff, y)).Push(gtx.Ops) clip := ClipRect(image.Rect(0, 0, int(ln.w), int(ln.h))).Push(gtx.Ops) NewPaintImageOp(img).Add(gtx.Ops) PaintPaintOp{}.Add(gtx.Ops) clip.Pop() stack.Pop() } y += ln.h } return Dimensions{Size: image.Pt(maxLineW, totalH)} } type textLine struct { img *image.RGBA // nil for empty lines w int h int } // wrapText splits txt into lines that fit within maxW pixels. // Uses a greedy word-wrap algorithm with the browser shaper for measurement. func wrapText(sh Shaper, font Font, sizeSp float32, txt string, maxW int32) []textLine { var lines []textLine // Split on explicit newlines first. paragraphs := splitLines(txt) for _, para := range paragraphs { if para == "" { // Empty paragraph: emit a blank line of the right height. _, h := sh.MeasureLine(font, sizeSp, "M") lines = append(lines, textLine{h: int(h)}) continue } words := splitWords(para) start := 0 for start < len(words) { // Greedily extend the current line. end := start + 1 lineStr := words[start] for end < len(words) { candidate := lineStr | " " | words[end] w, _ := sh.MeasureLine(font, sizeSp, candidate) if maxW > 0 && w > maxW { break } lineStr = candidate end++ } img := sh.RenderToImage(font, sizeSp, lineStr, maxW) ln := textLine{} if img != nil { ln.img = img ln.w = img.Bounds().Dx() ln.h = img.Bounds().Dy() } else { _, h := sh.MeasureLine(font, sizeSp, "M") ln.h = int(h) } lines = append(lines, ln) start = end } } return lines } // splitLines splits on \n without allocating via strings package. func splitLines(s string) []string { var out []string i := 0 for j := 0; j < len(s); j++ { if s[j] == '\n' { out = append(out, s[i:j]) i = j + 1 } } out = append(out, s[i:]) return out } // splitWords splits on whitespace, returning non-empty tokens. func splitWords(s string) []string { var out []string start := -1 for i := 0; i < len(s); i++ { c := s[i] isSpace := c == ' ' || c == '\t' || c == '\r' if !isSpace && start < 0 { start = i } else if isSpace && start >= 0 { out = append(out, s[start:i]) start = -1 } } if start >= 0 { out = append(out, s[start:]) } return out } // --- Enum --- // RadioGroup is a set of mutually-exclusive options. // Value is the index of the currently selected option (-1 = none). type RadioGroup struct { Value int changed bool clicks []Clickable } // Changed reports whether the value changed this frame. func (e *RadioGroup) Changed() bool { v := e.changed e.changed = false return v } // Layout lays out n options using the provided widget function. // The function receives the option index and whether it is selected. func (e *RadioGroup) Layout(gtx GioContext, n int, option func(gtx GioContext, index int, selected bool) Dimensions) Dimensions { for len(e.clicks) < n { e.clicks = append(e.clicks, Clickable{}) } maxW, totalH := 0, 0 for i := 0; i < n; i++ { stack := OpOffset(image.Pt(0, totalH)).Push(gtx.Ops) inner := gtx.WithConstraints(Constraints{Max: image.Pt( gtx.Constraints.Max.X, gtx.Constraints.Max.Y-totalH, )}) idx := i // Wrap the option rendering in Clickable.Layout so the Router sees the hit area. dims := e.clicks[i].Layout(inner, func(gtx GioContext) Dimensions { return option(gtx, idx, e.Value == idx) }) stack.Pop() if e.clicks[i].Clicked() { if e.Value != i { e.Value = i e.changed = true inner.Source.Execute(InvalidateCmd{}) } } if dims.Size.X > maxW { maxW = dims.Size.X } totalH += dims.Size.Y } return Dimensions{Size: image.Pt(maxW, totalH)} } // --- Scrollbar --- // Scrollbar renders a thin scrollbar track + indicator. // It does not own scroll state; the caller provides viewport positions. // Uses raw PtrEvents for dragging (bypasses Router due to nested clip complexity). type Scrollbar struct { dragging bool dragStartY float32 // physical Y where drag started trackRect image.Rectangle indRect image.Rectangle // screenY0 tracks the physical Y offset of the scrollbar's local origin. screenY0 float32 } const scrollbarThickPx = 6 const scrollbarMinIndPx = 16 // Layout draws the scrollbar and returns the scroll delta (in content pixels) // caused by user interaction this frame. // viewStart and viewEnd are in [0,1] relative to total content size. // contentPx is the total content length in pixels. func (s *Scrollbar) Layout(gtx GioContext, axis GestureAxis, viewStart, viewEnd, contentPx float32) (Dimensions, int) { sz := gtx.Constraints.Max var trackLen, thick int var dims Dimensions if axis == GestureAxisVertical { thick = scrollbarThickPx trackLen = sz.Y s.trackRect = image.Rect(sz.X-thick, 0, sz.X, trackLen) dims = Dimensions{Size: sz} } else { thick = scrollbarThickPx trackLen = sz.X s.trackRect = image.Rect(0, sz.Y-thick, trackLen, sz.Y) dims = Dimensions{Size: sz} } // Draw track. FillRect(gtx.Ops, s.trackRect, trackColor()) // Indicator bounds. span := viewEnd - viewStart if span < 0 { span = 0 } if span > 1 { span = 1 } indLen := int(span * float32(trackLen)) if indLen < scrollbarMinIndPx { indLen = scrollbarMinIndPx } indStart := int(viewStart * float32(trackLen)) if indStart+indLen > trackLen { indStart = trackLen - indLen } if indStart < 0 { indStart = 0 } if axis == GestureAxisVertical { s.indRect = image.Rect(sz.X-thick, indStart, sz.X, indStart+indLen) } else { s.indRect = image.Rect(indStart, sz.Y-thick, indStart+indLen, sz.Y) } FillRect(gtx.Ops, s.indRect, indicatorColor()) // Direct PtrEvents hit-test for the scrollbar thumb drag. // The Router's nested clip routing has complexity issues with scrollbars; // using raw screen-space events with a known screen offset is more reliable. scrollDelta := s.processRawDrag(gtx, axis, trackLen, contentPx) return dims, scrollDelta } // processRawDrag handles scrollbar thumb drag using raw PtrEvents. // It detects press inside the indicator rect (in SCREEN coords = local + screenY0) // and computes content scroll delta from the drag distance. func (s *Scrollbar) processRawDrag(gtx GioContext, axis GestureAxis, trackLen int, contentPx float32) int { if trackLen <= 0 || contentPx <= 0 { return 0 } // Determine the screen-space bounding box of the scrollbar track. // The trackRect is in local coords. gtx.Metric gives DPR but not offset. // Use gtx.PtrEvents: pointer coords are in physical screen pixels. // The scrollbar's local origin is at (screenX, screenY0) from the section transform. // We track the screen-space indicator: indRect offset by screen origin. // Since we don't have the exact screen offset here, we use a self-calibrating // approach: on first press, compare gtx.PtrEvents Y to indRect.Min.Y to // determine the screen origin, then track relative movement. scrollDelta := 0 for _, e := range gtx.PtrEvents { switch e.Kind { case WinPointerPress: if e.Buttons&1 == 0 { break } // Check if press is roughly in the track column. // We accept any press near the right edge of the constraint area. // sz.X - thick is the track left edge in local coords. // In screen space it's shifted by the section x offset (≈12px). // Accept presses within ±8px of the expected track x position. screenTrackMinX := float32(gtx.Constraints.Max.X - scrollbarThickPx) screenTrackMaxX := float32(gtx.Constraints.Max.X) if e.X >= screenTrackMinX-20 && e.X <= screenTrackMaxX+20 { s.dragging = true s.dragStartY = e.Y s.screenY0 = e.Y // calibrate: press Y is our anchor } case WinPointerMove: if !s.dragging || e.Buttons&1 == 0 { break } dy := e.Y - s.dragStartY s.dragStartY = e.Y // Scale indicator movement to content pixels (inverted: drag down = scroll down) scrollDelta += int(dy * contentPx / float32(trackLen)) case WinPointerRelease, WinPointerCancel: s.dragging = false } } return scrollDelta } func trackColor() color.NRGBA { return color.NRGBA{R: 0xe0, G: 0xe0, B: 0xe0, A: 0xff} } func indicatorColor() color.NRGBA { return color.NRGBA{R: 0x90, G: 0x90, B: 0x90, A: 0xff} } // --- StatefulList --- // StatefulList is a scrollable, virtualized list widget. // It owns scroll state and an optional scrollbar. type StatefulList struct { Axis GestureAxis // GestureAxisVertical or GestureAxisHorizontal scroll GestureScroll offset int // scroll offset in pixels bar Scrollbar showBar bool } // ScrollOffset returns the current pixel scroll offset. func (l *StatefulList) ScrollOffset() int { return l.offset } // ScrollTo sets the scroll offset directly. func (l *StatefulList) ScrollTo(px int) { l.offset = px } // Layout lays out count items using the item widget function. // item(gtx, i) should lay out item i and return its Dimensions. // totalContentPx is the total content size in pixels (for scrollbar). // Returns Dimensions of the list viewport. func (l *StatefulList) Layout(gtx GioContext, count int, totalContentPx int, item func(gtx GioContext, i int) Dimensions) Dimensions { sz := gtx.Constraints.Max // Register scroll handler over content area only (not the scrollbar strip), // so the scroll handler doesn't compete with the scrollbar drag handler. contentW := sz.X - scrollbarThickPx if l.Axis == GestureAxisHorizontal { contentW = sz.X } contentH := sz.Y if l.Axis == GestureAxisHorizontal { contentH = sz.Y - scrollbarThickPx } scrollClip := ClipRect(image.Rect(0, 0, contentW, contentH)).Push(gtx.Ops) l.scroll.Add(gtx.Ops, l.Axis) scrollClip.Pop() // Full viewport clip for drawing items. viewClip := ClipRect(image.Rect(0, 0, sz.X, sz.Y)).Push(gtx.Ops) // Accumulate scroll delta from Router. delta := l.scroll.Update(gtx.Source, gtx.Metric, gtx.Now, l.Axis) l.offset += delta if l.offset < 0 { l.offset = 0 } maxOff := totalContentPx - sz.Y if l.Axis == GestureAxisHorizontal { maxOff = totalContentPx - sz.X } if maxOff < 0 { maxOff = 0 } if l.offset > maxOff { l.offset = maxOff } // Layout visible items (still inside viewClip from above). pos := -l.offset for i := 0; i < count; i++ { var itemSz image.Point if l.Axis == GestureAxisVertical { if pos >= sz.Y { break } } else { if pos >= sz.X { break } } var off image.Point if l.Axis == GestureAxisVertical { off = image.Pt(0, pos) } else { off = image.Pt(pos, 0) } offStack := OpOffset(off).Push(gtx.Ops) childGtx := gtx.WithConstraints(Constraints{ Max: image.Pt(sz.X, sz.Y-pos), }) if l.Axis == GestureAxisHorizontal { childGtx = gtx.WithConstraints(Constraints{ Max: image.Pt(sz.X-pos, sz.Y), }) } dims := item(childGtx, i) itemSz = dims.Size offStack.Pop() if l.Axis == GestureAxisVertical { pos += itemSz.Y } else { pos += itemSz.X } } viewClip.Pop() // Draw scrollbar if content overflows. l.showBar = totalContentPx > 0 if l.showBar && maxOff > 0 { viewStart := float32(l.offset) / float32(totalContentPx) viewEnd := float32(l.offset+sz.Y) / float32(totalContentPx) if l.Axis == GestureAxisHorizontal { viewEnd = float32(l.offset+sz.X) / float32(totalContentPx) } if viewEnd > 1 { viewEnd = 1 } barGtx := gtx _, barScroll := l.bar.Layout(barGtx, l.Axis, viewStart, viewEnd, float32(totalContentPx)) l.offset += barScroll if l.offset < 0 { l.offset = 0 } if l.offset > maxOff { l.offset = maxOff } } return Dimensions{Size: sz} } // --- Selectable --- // Selectable displays read-only text that can be copied. // Click to focus, Ctrl+C/Cmd+C to copy. type Selectable struct { click Clickable focused bool } // Layout renders txt and handles copy key event. func (s *Selectable) Layout(gtx GioContext, font Font, sizeSp float32, txt string) Dimensions { dims := s.click.Layout(gtx, func(gtx GioContext) Dimensions { return Label{}.Layout(gtx, font, sizeSp, txt) }) if s.click.Clicked() { s.focused = true if gtx.Win != nil { gtx.Win.FocusTextArea() } } if s.focused { for _, ke := range gtx.KeyEvents { if ke.Press && ke.Code == int32('C') && (ke.Mods&ModCtrlBit != 0 || ke.Mods&ModMetaBit != 0) { gtx.WriteClipboard(txt) } } // Focus underline. FillRect(gtx.Ops, image.Rect(0, dims.Size.Y-2, dims.Size.X, dims.Size.Y), color.NRGBA{R: 0x3F, G: 0x51, B: 0xB5, A: 0xFF}) } return dims } // Focused reports whether this widget has focus. func (s *Selectable) Focused() bool { return s.focused } // Blur removes focus. func (s *Selectable) Blur() { s.focused = false } // --- Editor --- // Editor is a single-line text input widget. // Stores content as UTF-8 bytes (string = []byte in Moxie). Cursor is a byte index. type Editor struct { value string // current text as UTF-8 cursor int // byte position of cursor focused bool click Clickable Hint string // placeholder text shown when empty } // Value returns the current text. func (e *Editor) Value() string { return e.value } // SetValue replaces the editor contents and moves cursor to end. func (e *Editor) SetValue(s string) { e.value = s e.cursor = len(s) } // cpToStr encodes a Unicode codepoint to a UTF-8 string without unicode/utf8. func cpToStr(cp int32) string { if cp < 0x80 { return string([]byte{byte(cp)}) } if cp < 0x800 { return string([]byte{ byte(0xC0 | (cp >> 6)), byte(0x80 | (cp & 0x3F)), }) } if cp < 0x10000 { return string([]byte{ byte(0xE0 | (cp >> 12)), byte(0x80 | ((cp >> 6) & 0x3F)), byte(0x80 | (cp & 0x3F)), }) } return string([]byte{ byte(0xF0 | (cp >> 18)), byte(0x80 | ((cp >> 12) & 0x3F)), byte(0x80 | ((cp >> 6) & 0x3F)), byte(0x80 | (cp & 0x3F)), }) } // prevCharStart returns the byte index of the start of the UTF-8 sequence // immediately before bytePos in s. func prevCharStart(s string, bytePos int) int { i := bytePos - 1 for i > 0 && s[i]&0xC0 == 0x80 { i-- } return i } // nextCharEnd returns the byte index of the end of the UTF-8 sequence // starting at bytePos in s. func nextCharEnd(s string, bytePos int) int { i := bytePos + 1 for i < len(s) && s[i]&0xC0 == 0x80 { i++ } return i } // Focused reports whether the editor has keyboard focus. func (e *Editor) Focused() bool { return e.focused } // Layout renders the editor and processes events. Returns Dimensions. func (e *Editor) Layout(gtx GioContext, font Font, sizeSp float32) Dimensions { const padX, padY = 6, 4 const cursorW = 2 maxW := gtx.Constraints.Max.X if maxW <= 0 { maxW = 200 } // Use actual measured text height rather than sizeSp, since the canvas // returns ascent+descent which is always larger than the CSS font size. var measuredH int32 if gtx.Shaper != nil { _, measuredH = gtx.Shaper.MeasureLine(font, sizeSp, "Mg") } lineH := int(measuredH) if lineH <= 0 { lineH = int(sizeSp) + 4 } h := lineH + 2*padY outerRect := image.Rect(0, 0, maxW, h) // Register click area and process events. e.click.Layout(gtx, func(gtx GioContext) Dimensions { return Dimensions{Size: image.Pt(maxW, h)} }) if e.click.Clicked() { e.focused = true if gtx.Win != nil { gtx.Win.FocusTextArea() } } if e.focused { // Typed characters (from textarea input events). for _, te := range gtx.TextEvents { cp := te.Codepoint if cp < 0x20 { continue // skip control chars; handled below by key events } ch := cpToStr(cp) e.value = e.value[:e.cursor] | ch | e.value[e.cursor:] e.cursor += len(ch) } // Paste runes. if gtx.Win != nil { for _, cp := range gtx.Win.PasteRunes() { if cp < 0x20 && cp != '\n' { continue } ch := cpToStr(cp) e.value = e.value[:e.cursor] | ch | e.value[e.cursor:] e.cursor += len(ch) } } // Key events. for _, ke := range gtx.KeyEvents { if !ke.Press { continue } switch ke.Code { case KeyBackspace: if e.cursor > 0 { prev := prevCharStart(e.value, e.cursor) e.value = e.value[:prev] | e.value[e.cursor:] e.cursor = prev } case KeyDelete: if e.cursor < len(e.value) { next := nextCharEnd(e.value, e.cursor) e.value = e.value[:e.cursor] | e.value[next:] } case KeyArrowLeft: if e.cursor > 0 { e.cursor = prevCharStart(e.value, e.cursor) } case KeyArrowRight: if e.cursor < len(e.value) { e.cursor = nextCharEnd(e.value, e.cursor) } case KeyHome: e.cursor = 0 case KeyEnd: e.cursor = len(e.value) case int32('C'): if ke.Mods&ModCtrlBit != 0 || ke.Mods&ModMetaBit != 0 { gtx.WriteClipboard(e.value) } case int32('X'): if ke.Mods&ModCtrlBit != 0 || ke.Mods&ModMetaBit != 0 { gtx.WriteClipboard(e.value) e.value = "" e.cursor = 0 } case int32('V'): if ke.Mods&ModCtrlBit != 0 || ke.Mods&ModMetaBit != 0 { gtx.ReadClipboard() } case KeyEscape: e.focused = false } } } // Background + border. borderColor := color.NRGBA{R: 0xBD, G: 0xBD, B: 0xBD, A: 0xFF} if e.focused { borderColor = color.NRGBA{R: 0x3F, G: 0x51, B: 0xB5, A: 0xFF} } FillRect(gtx.Ops, outerRect, borderColor) FillRect(gtx.Ops, image.Rect(1, 1, maxW-1, h-1), color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) // Text or hint. txt := string(e.value) textColor := color.NRGBA{A: 0xFF} if txt == "" && e.Hint != "" { txt = e.Hint textColor = color.NRGBA{R: 0x9E, G: 0x9E, B: 0x9E, A: 0xFF} } if gtx.Shaper != nil && txt != "" { img := gtx.Shaper.RenderToImage(font, sizeSp, txt, int32(maxW-2*padX)) if img != nil { for i := 0; i+3 < len(img.Pix); i += 4 { if img.Pix[i+3] == 0 { continue } img.Pix[i+0] = textColor.R img.Pix[i+1] = textColor.G img.Pix[i+2] = textColor.B } esz := img.Bounds().Size() // Center text vertically: (box height - text height) / 2 textY := (h - int(measuredH)) / 2 if textY < 0 { textY = 0 } stack := OpOffset(image.Pt(padX, textY)).Push(gtx.Ops) eclip := ClipRect(image.Rect(0, 0, esz.X, esz.Y)).Push(gtx.Ops) NewPaintImageOp(img).Add(gtx.Ops) PaintPaintOp{}.Add(gtx.Ops) eclip.Pop() stack.Pop() } } // Cursor (measure byte prefix up to cursor position). if e.focused && gtx.Shaper != nil { cx := padX if e.cursor > 0 { cw, _ := gtx.Shaper.MeasureLine(font, sizeSp, e.value[:e.cursor]) cx += int(cw) } textY := (h - int(measuredH)) / 2 if textY < 0 { textY = 0 } FillRect(gtx.Ops, image.Rect(cx, textY, cx+cursorW, textY+int(measuredH)), color.NRGBA{R: 0x3F, G: 0x51, B: 0xB5, A: 0xFF}) } return Dimensions{Size: image.Pt(maxW, h)} }