// SPDX-License-Identifier: Unlicense OR MIT package widget import ( "image" "math" "strings" "time" "unicode/utf8" "gioui.org/f32" "gioui.org/gesture" "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" "golang.org/x/image/math/fixed" ) // Editor implements an editable and scrollable text area. type Editor struct { Alignment text.Alignment // SingleLine force the text to stay on a single line. // SingleLine also sets the scrolling direction to // horizontal. SingleLine bool // Submit enabled translation of carriage return keys to SubmitEvents. // If not enabled, carriage returns are inserted as newlines in the text. Submit bool eventKey int font text.Font shaper text.Shaper textSize fixed.Int26_6 blinkStart time.Time focused bool rr editBuffer maxWidth int viewSize image.Point valid bool lines []text.Line shapes []line dims layout.Dimensions requestFocus bool caret struct { on bool scroll bool // xoff is the offset to the current caret // position when moving between lines. xoff fixed.Int26_6 } scroller gesture.Scroll scrollOff image.Point clicker gesture.Click // events is the list of events not yet processed. events []EditorEvent // prevEvents is the number of events from the previous frame. prevEvents int } type EditorEvent interface { isEditorEvent() } // A ChangeEvent is generated for every user change to the text. type ChangeEvent struct{} // A SubmitEvent is generated when Submit is set // and a carriage return key is pressed. type SubmitEvent struct { Text string } type line struct { offset f32.Point clip op.CallOp } const ( blinksPerSecond = 1 maxBlinkDuration = 10 * time.Second ) // Events returns available editor events. func (e *Editor) Events() []EditorEvent { events := e.events e.events = nil e.prevEvents = 0 return events } func (e *Editor) processEvents(gtx layout.Context) { // Flush events from before the previous Layout. n := copy(e.events, e.events[e.prevEvents:]) e.events = e.events[:n] e.prevEvents = n if e.shaper == nil { // Can't process events without a shaper. return } e.makeValid() e.processPointer(gtx) e.processKey(gtx) } func (e *Editor) makeValid() { if !e.valid { e.lines, e.dims = e.layoutText(e.shaper) e.valid = true } } func (e *Editor) processPointer(gtx layout.Context) { sbounds := e.scrollBounds() var smin, smax int var axis gesture.Axis if e.SingleLine { axis = gesture.Horizontal smin, smax = sbounds.Min.X, sbounds.Max.X } else { axis = gesture.Vertical smin, smax = sbounds.Min.Y, sbounds.Max.Y } sdist := e.scroller.Scroll(gtx.Metric, gtx, gtx.Now, axis) var soff int if e.SingleLine { e.scrollRel(sdist, 0) soff = e.scrollOff.X } else { e.scrollRel(0, sdist) soff = e.scrollOff.Y } for _, evt := range e.clicker.Events(gtx) { switch { case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse, evt.Type == gesture.TypeClick && evt.Source == pointer.Touch: e.blinkStart = gtx.Now e.moveCoord(image.Point{ X: int(math.Round(float64(evt.Position.X))), Y: int(math.Round(float64(evt.Position.Y))), }) e.requestFocus = true if e.scroller.State() != gesture.StateFlinging { e.caret.scroll = true } } } if (sdist > 0 && soff >= smax) || (sdist < 0 && soff <= smin) { e.scroller.Stop() } } func (e *Editor) processKey(gtx layout.Context) { if e.rr.Changed() { e.events = append(e.events, ChangeEvent{}) } for _, ke := range gtx.Events(&e.eventKey) { e.blinkStart = gtx.Now switch ke := ke.(type) { case key.FocusEvent: e.focused = ke.Focus case key.Event: if !e.focused { break } if e.Submit && (ke.Name == key.NameReturn || ke.Name == key.NameEnter) { if !ke.Modifiers.Contain(key.ModShift) { e.events = append(e.events, SubmitEvent{ Text: e.Text(), }) return } } if e.command(ke) { e.caret.scroll = true e.scroller.Stop() } case key.EditEvent: e.caret.scroll = true e.scroller.Stop() e.append(ke.Text) } if e.rr.Changed() { e.events = append(e.events, ChangeEvent{}) } } } func (e *Editor) command(k key.Event) bool { switch k.Name { case key.NameReturn, key.NameEnter: e.append("\n") case key.NameDeleteBackward: e.Delete(-1) case key.NameDeleteForward: e.Delete(1) case key.NameUpArrow: line, _, carX, _ := e.layoutCaret() e.caret.xoff = e.moveToLine(carX+e.caret.xoff, line-1) case key.NameDownArrow: line, _, carX, _ := e.layoutCaret() e.caret.xoff = e.moveToLine(carX+e.caret.xoff, line+1) case key.NameLeftArrow: e.Move(-1) case key.NameRightArrow: e.Move(1) case key.NamePageUp: e.movePages(-1) case key.NamePageDown: e.movePages(+1) case key.NameHome: e.moveStart() case key.NameEnd: e.moveEnd() default: return false } return true } // Focus requests the input focus for the Editor. func (e *Editor) Focus() { e.requestFocus = true } // Focused returns whether the editor is focused or not. func (e *Editor) Focused() bool { return e.focused } // Layout lays out the editor. func (e *Editor) Layout(gtx layout.Context, sh text.Shaper, font text.Font, size unit.Value) layout.Dimensions { textSize := fixed.I(gtx.Px(size)) if e.font != font || e.textSize != textSize { e.invalidate() e.font = font e.textSize = textSize } maxWidth := gtx.Constraints.Max.X if e.SingleLine { maxWidth = inf } if maxWidth != e.maxWidth { e.maxWidth = maxWidth e.invalidate() } if sh != e.shaper { e.shaper = sh e.invalidate() } e.processEvents(gtx) return e.layout(gtx) } func (e *Editor) layout(gtx layout.Context) layout.Dimensions { e.makeValid() e.viewSize = gtx.Constraints.Constrain(e.dims.Size) // Adjust scrolling for new viewport and layout. e.scrollRel(0, 0) if e.caret.scroll { e.caret.scroll = false e.scrollToCaret() } off := image.Point{ X: -e.scrollOff.X, Y: -e.scrollOff.Y, } clip := textPadding(e.lines) clip.Max = clip.Max.Add(e.viewSize) it := lineIterator{ Lines: e.lines, Clip: clip, Alignment: e.Alignment, Width: e.viewSize.X, Offset: off, } e.shapes = e.shapes[:0] for { _, _, layout, off, ok := it.Next() if !ok { break } path := e.shaper.Shape(e.font, e.textSize, layout) e.shapes = append(e.shapes, line{off, path}) } key.InputOp{Tag: &e.eventKey, Focus: e.requestFocus}.Add(gtx.Ops) e.requestFocus = false pointerPadding := gtx.Px(unit.Dp(4)) r := image.Rectangle{Max: e.viewSize} r.Min.X -= pointerPadding r.Min.Y -= pointerPadding r.Max.X += pointerPadding r.Max.X += pointerPadding pointer.Rect(r).Add(gtx.Ops) e.scroller.Add(gtx.Ops) e.clicker.Add(gtx.Ops) e.caret.on = false if e.focused { now := gtx.Now dt := now.Sub(e.blinkStart) blinking := dt < maxBlinkDuration const timePerBlink = time.Second / blinksPerSecond nextBlink := now.Add(timePerBlink/2 - dt%(timePerBlink/2)) if blinking { redraw := op.InvalidateOp{At: nextBlink} redraw.Add(gtx.Ops) } e.caret.on = e.focused && (!blinking || dt%timePerBlink < timePerBlink/2) } return layout.Dimensions{Size: e.viewSize, Baseline: e.dims.Baseline} } func (e *Editor) PaintText(gtx layout.Context) { clip := textPadding(e.lines) clip.Max = clip.Max.Add(e.viewSize) for _, shape := range e.shapes { stack := op.Push(gtx.Ops) op.TransformOp{}.Offset(shape.offset).Add(gtx.Ops) shape.clip.Add(gtx.Ops) paint.PaintOp{Rect: layout.FRect(clip).Sub(shape.offset)}.Add(gtx.Ops) stack.Pop() } } func (e *Editor) PaintCaret(gtx layout.Context) { if !e.caret.on { return } carWidth := fixed.I(gtx.Px(unit.Dp(1))) carLine, _, carX, carY := e.layoutCaret() stack := op.Push(gtx.Ops) carX -= carWidth / 2 carAsc, carDesc := -e.lines[carLine].Bounds.Min.Y, e.lines[carLine].Bounds.Max.Y carRect := image.Rectangle{ Min: image.Point{X: carX.Ceil(), Y: carY - carAsc.Ceil()}, Max: image.Point{X: carX.Ceil() + carWidth.Ceil(), Y: carY + carDesc.Ceil()}, } carRect = carRect.Add(image.Point{ X: -e.scrollOff.X, Y: -e.scrollOff.Y, }) clip := textPadding(e.lines) // Account for caret width to each side. whalf := (carWidth / 2).Ceil() if clip.Max.X < whalf { clip.Max.X = whalf } if clip.Min.X > -whalf { clip.Min.X = -whalf } clip.Max = clip.Max.Add(e.viewSize) carRect = clip.Intersect(carRect) if !carRect.Empty() { paint.PaintOp{Rect: layout.FRect(carRect)}.Add(gtx.Ops) } stack.Pop() } // Len is the length of the editor contents. func (e *Editor) Len() int { return e.rr.len() } // Text returns the contents of the editor. func (e *Editor) Text() string { return e.rr.String() } // SetText replaces the contents of the editor. func (e *Editor) SetText(s string) { e.rr = editBuffer{} e.caret.xoff = 0 e.prepend(s) } func (e *Editor) scrollBounds() image.Rectangle { var b image.Rectangle if e.SingleLine { if len(e.lines) > 0 { b.Min.X = align(e.Alignment, e.lines[0].Width, e.viewSize.X).Floor() if b.Min.X > 0 { b.Min.X = 0 } } b.Max.X = e.dims.Size.X + b.Min.X - e.viewSize.X } else { b.Max.Y = e.dims.Size.Y - e.viewSize.Y } return b } func (e *Editor) scrollRel(dx, dy int) { e.scrollAbs(e.scrollOff.X+dx, e.scrollOff.Y+dy) } func (e *Editor) scrollAbs(x, y int) { e.scrollOff.X = x e.scrollOff.Y = y b := e.scrollBounds() if e.scrollOff.X > b.Max.X { e.scrollOff.X = b.Max.X } if e.scrollOff.X < b.Min.X { e.scrollOff.X = b.Min.X } if e.scrollOff.Y > b.Max.Y { e.scrollOff.Y = b.Max.Y } if e.scrollOff.Y < b.Min.Y { e.scrollOff.Y = b.Min.Y } } func (e *Editor) moveCoord(pos image.Point) { var ( prevDesc fixed.Int26_6 carLine int y int ) for _, l := range e.lines { y += (prevDesc + l.Ascent).Ceil() prevDesc = l.Descent if y+prevDesc.Ceil() >= pos.Y+e.scrollOff.Y { break } carLine++ } x := fixed.I(pos.X + e.scrollOff.X) e.carXOff = 0 e.moveToLine(x, carLine) } func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) { e.rr.Reset() lines, _ := s.Layout(e.font, e.textSize, e.maxWidth, &e.rr) dims := linesDimens(lines) for i := 0; i < len(lines)-1; i++ { // To avoid layout flickering while editing, assume a soft newline takes // up all available space. if layout := lines[i].Layout; len(layout) > 0 { r := layout[len(layout)-1].Rune if r != '\n' { dims.Size.X = e.maxWidth break } } } return lines, dims } // CaretPos returns the line & column numbers of the caret. func (e *Editor) CaretPos() (line, col int) { line, col, _, _ = e.layoutCaret() return } // CaretCoords returns the coordinates of the caret, relative to the // editor itself. func (e *Editor) CaretCoords() f32.Point { _, _, x, y := e.layoutCaret() return f32.Pt(float32(x)/64, float32(y)) } func (e *Editor) layoutCaret() (carLine, carCol int, x fixed.Int26_6, y int) { var idx int var prevDesc fixed.Int26_6 loop: for carLine = 0; carLine < len(e.lines); carLine++ { l := e.lines[carLine] y += (prevDesc + l.Ascent).Ceil() prevDesc = l.Descent if carLine == len(e.lines)-1 || idx+l.Len > e.rr.caret { for _, g := range l.Layout { if idx == e.rr.caret { break loop } x += g.Advance idx += utf8.RuneLen(g.Rune) carCol++ } break } idx += l.Len } x += align(e.Alignment, e.lines[carLine].Width, e.viewSize.X) return } func (e *Editor) invalidate() { e.valid = false } // Delete runes from the caret position. The sign of runes specifies the // direction to delete: positive is forward, negative is backward. func (e *Editor) Delete(runes int) { e.rr.deleteRunes(runes) e.caret.xoff = 0 e.invalidate() } // Insert inserts text at the caret, moving the caret forward. func (e *Editor) Insert(s string) { e.append(s) e.caret.scroll = true e.invalidate() } func (e *Editor) append(s string) { if e.SingleLine { s = strings.ReplaceAll(s, "\n", "") } e.prepend(s) e.rr.caret += len(s) } func (e *Editor) prepend(s string) { e.rr.prepend(s) e.caret.xoff = 0 e.invalidate() } func (e *Editor) movePages(pages int) { _, _, carX, carY := e.layoutCaret() y := carY + pages*e.viewSize.Y var ( prevDesc fixed.Int26_6 carLine2 int ) y2 := e.lines[0].Ascent.Ceil() for i := 1; i < len(e.lines); i++ { if y2 >= y { break } l := e.lines[i] h := (prevDesc + l.Ascent).Ceil() prevDesc = l.Descent if y2+h-y >= y-y2 { break } y2 += h carLine2++ } e.caret.xoff = e.moveToLine(carX+e.caret.xoff, carLine2) } func (e *Editor) moveToLine(carX fixed.Int26_6, carLine2 int) fixed.Int26_6 { carLine, carCol, _, _ := e.layoutCaret() if carLine2 < 0 { carLine2 = 0 } if carLine2 >= len(e.lines) { carLine2 = len(e.lines) - 1 } // Move to start of line. for i := carCol - 1; i >= 0; i-- { _, s := e.rr.runeBefore(e.rr.caret) e.rr.caret -= s } if carLine2 != carLine { // Move to start of line2. if carLine2 > carLine { for i := carLine; i < carLine2; i++ { e.rr.caret += e.lines[i].Len } } else { for i := carLine - 1; i >= carLine2; i-- { e.rr.caret -= e.lines[i].Len } } } l2 := e.lines[carLine2] carX2 := align(e.Alignment, l2.Width, e.viewSize.X) // Only move past the end of the last line end := 0 if carLine2 < len(e.lines)-1 { end = 1 } // Move to rune closest to previous horizontal position. for i := 0; i < len(l2.Layout)-end; i++ { g := l2.Layout[i] if carX2 >= carX { break } if carX2+g.Advance-carX >= carX-carX2 { break } carX2 += g.Advance _, s := e.rr.runeAt(e.rr.caret) e.rr.caret += s } return carX - carX2 } // Move the caret: positive distance moves forward, negative distance moves // backward. func (e *Editor) Move(distance int) { e.rr.move(distance) e.caret.xoff = 0 } func (e *Editor) moveStart() { carLine, carCol, x, _ := e.layoutCaret() layout := e.lines[carLine].Layout for i := carCol - 1; i >= 0; i-- { _, s := e.rr.runeBefore(e.rr.caret) e.rr.caret -= s x -= layout[i].Advance } e.caret.xoff = -x } func (e *Editor) moveEnd() { carLine, carCol, x, _ := e.layoutCaret() l := e.lines[carLine] // Only move past the end of the last line end := 0 if carLine < len(e.lines)-1 { end = 1 } layout := l.Layout for i := carCol; i < len(layout)-end; i++ { adv := layout[i].Advance _, s := e.rr.runeAt(e.rr.caret) e.rr.caret += s x += adv } a := align(e.Alignment, l.Width, e.viewSize.X) e.caret.xoff = l.Width + a - x } func (e *Editor) scrollToCaret() { carLine, _, x, y := e.layoutCaret() l := e.lines[carLine] if e.SingleLine { var dist int if d := x.Floor() - e.scrollOff.X; d < 0 { dist = d } else if d := x.Ceil() - (e.scrollOff.X + e.viewSize.X); d > 0 { dist = d } e.scrollRel(dist, 0) } else { miny := y - l.Ascent.Ceil() maxy := y + l.Descent.Ceil() var dist int if d := miny - e.scrollOff.Y; d < 0 { dist = d } else if d := maxy - (e.scrollOff.Y + e.viewSize.Y); d > 0 { dist = d } e.scrollRel(0, dist) } } // NumLines returns the number of lines in the editor. func (e *Editor) NumLines() int { e.makeValid() return len(e.lines) } func (s ChangeEvent) isEditorEvent() {} func (s SubmitEvent) isEditorEvent() {}