// SPDX-License-Identifier: Unlicense OR MIT package widget import ( "bufio" "image" "io" "math" "strings" "time" "unicode" "unicode/utf8" "gioui.org/f32" "gioui.org/font" "gioui.org/gesture" "gioui.org/io/clipboard" "gioui.org/io/event" "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/io/semantic" "gioui.org/io/system" "gioui.org/io/transfer" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/text" "gioui.org/unit" ) // Editor implements an editable and scrollable text area. type Editor struct { // text manages the text buffer and provides shaping and cursor positioning // services. text textView // Alignment controls the alignment of text within the editor. Alignment text.Alignment // LineHeight determines the gap between baselines of text. If zero, a sensible // default will be used. LineHeight unit.Sp // LineHeightScale is multiplied by LineHeight to determine the final gap // between baselines. If zero, a sensible default will be used. LineHeightScale float32 // SingleLine force the text to stay on a single line. // SingleLine also sets the scrolling direction to // horizontal. SingleLine bool // ReadOnly controls whether the contents of the editor can be altered by // user interaction. If set to true, the editor will allow selecting text // and copying it interactively, but not modifying it. ReadOnly bool // Submit enabled translation of carriage return keys to SubmitEvents. // If not enabled, carriage returns are inserted as newlines in the text. Submit bool // Mask replaces the visual display of each rune in the contents with the given rune. // Newline characters are not masked. When non-zero, the unmasked contents // are accessed by Len, Text, and SetText. Mask rune // InputHint specifies the type of on-screen keyboard to be displayed. InputHint key.InputHint // MaxLen limits the editor content to a maximum length. Zero means no limit. MaxLen int // Filter is the list of characters allowed in the Editor. If Filter is empty, // all characters are allowed. Filter string // WrapPolicy configures how displayed text will be broken into lines. WrapPolicy text.WrapPolicy buffer *editBuffer // scratch is a byte buffer that is reused to efficiently read portions of text // from the textView. scratch []byte blinkStart time.Time // ime tracks the state relevant to input methods. ime struct { imeState scratch []byte } dragging bool dragger gesture.Drag scroller gesture.Scroll scrollCaret bool showCaret bool clicker gesture.Click // history contains undo history. history []modification // nextHistoryIdx is the index within the history of the next modification. This // is only not len(history) immediately after undo operations occur. It is framed as the "next" value // to make the zero value consistent. nextHistoryIdx int pending []EditorEvent } type offEntry struct { runes int bytes int } type imeState struct { selection struct { rng key.Range caret key.Caret } snippet key.Snippet start, end int } type maskReader struct { // rr is the underlying reader. rr io.RuneReader maskBuf [utf8.UTFMax]byte // mask is the utf-8 encoded mask rune. mask []byte // overflow contains excess mask bytes left over after the last Read call. overflow []byte } type selectionAction int const ( selectionExtend selectionAction = iota selectionClear ) func (m *maskReader) Reset(r io.Reader, mr rune) { m.rr = bufio.NewReader(r) n := utf8.EncodeRune(m.maskBuf[:], mr) m.mask = m.maskBuf[:n] } // Read reads from the underlying reader and replaces every // rune with the mask rune. func (m *maskReader) Read(b []byte) (n int, err error) { for len(b) > 0 { var replacement []byte if len(m.overflow) > 0 { replacement = m.overflow } else { var r rune r, _, err = m.rr.ReadRune() if err != nil { break } if r == '\n' { replacement = []byte{'\n'} } else { replacement = m.mask } } nn := copy(b, replacement) m.overflow = replacement[nn:] n += nn b = b[nn:] } return n, err } 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 } // A SelectEvent is generated when the user selects some text, or changes the // selection (e.g. with a shift-click), including if they remove the // selection. The selected text is not part of the event, on the theory that // it could be a relatively expensive operation (for a large editor), most // applications won't actually care about it, and those that do can call // Editor.SelectedText() (which can be empty). type SelectEvent struct{} const ( blinksPerSecond = 1 maxBlinkDuration = 10 * time.Second ) func (e *Editor) processEvents(gtx layout.Context) (ev EditorEvent, ok bool) { if len(e.pending) > 0 { out := e.pending[0] e.pending = e.pending[:copy(e.pending, e.pending[1:])] return out, true } selStart, selEnd := e.Selection() defer func() { afterSelStart, afterSelEnd := e.Selection() if selStart != afterSelStart || selEnd != afterSelEnd { if ok { e.pending = append(e.pending, SelectEvent{}) } else { ev = SelectEvent{} ok = true } } }() ev, ok = e.processPointer(gtx) if ok { return ev, ok } ev, ok = e.processKey(gtx) if ok { return ev, ok } return nil, false } func (e *Editor) processPointer(gtx layout.Context) (EditorEvent, bool) { sbounds := e.text.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 } var scrollX, scrollY pointer.ScrollRange textDims := e.text.FullDimensions() visibleDims := e.text.Dimensions() if e.SingleLine { scrollOffX := e.text.ScrollOff().X scrollX.Min = min(-scrollOffX, 0) scrollX.Max = max(0, textDims.Size.X-(scrollOffX+visibleDims.Size.X)) } else { scrollOffY := e.text.ScrollOff().Y scrollY.Min = -scrollOffY scrollY.Max = max(0, textDims.Size.Y-(scrollOffY+visibleDims.Size.Y)) } sdist := e.scroller.Update(gtx.Metric, gtx.Source, gtx.Now, axis, scrollX, scrollY) var soff int if e.SingleLine { e.text.ScrollRel(sdist, 0) soff = e.text.ScrollOff().X } else { e.text.ScrollRel(0, sdist) soff = e.text.ScrollOff().Y } for { evt, ok := e.clicker.Update(gtx.Source) if !ok { break } ev, ok := e.processPointerEvent(gtx, evt) if ok { return ev, ok } } for { evt, ok := e.dragger.Update(gtx.Metric, gtx.Source, gesture.Both) if !ok { break } ev, ok := e.processPointerEvent(gtx, evt) if ok { return ev, ok } } if (sdist > 0 && soff >= smax) || (sdist < 0 && soff <= smin) { e.scroller.Stop() } return nil, false } func (e *Editor) processPointerEvent(gtx layout.Context, ev event.Event) (EditorEvent, bool) { switch evt := ev.(type) { case gesture.ClickEvent: switch { case evt.Kind == gesture.KindPress && evt.Source == pointer.Mouse, evt.Kind == gesture.KindClick && evt.Source != pointer.Mouse: prevCaretPos, _ := e.text.Selection() e.blinkStart = gtx.Now e.text.MoveCoord(image.Point{ X: int(math.Round(float64(evt.Position.X))), Y: int(math.Round(float64(evt.Position.Y))), }) gtx.Execute(key.FocusCmd{Tag: e}) if !e.ReadOnly { gtx.Execute(key.SoftKeyboardCmd{Show: true}) } if e.scroller.State() != gesture.StateFlinging { e.scrollCaret = true } if evt.Modifiers == key.ModShift { start, end := e.text.Selection() // If they clicked closer to the end, then change the end to // where the caret used to be (effectively swapping start & end). if abs(end-start) < abs(start-prevCaretPos) { e.text.SetCaret(start, prevCaretPos) } } else { e.text.ClearSelection() } e.dragging = true // Process multi-clicks. switch { case evt.NumClicks == 2: e.text.MoveWord(-1, selectionClear) e.text.MoveWord(1, selectionExtend) e.dragging = false case evt.NumClicks >= 3: e.text.MoveLineStart(selectionClear) e.text.MoveLineEnd(selectionExtend) e.dragging = false } } case pointer.Event: release := false switch { case evt.Kind == pointer.Release && evt.Source == pointer.Mouse: release = true fallthrough case evt.Kind == pointer.Drag && evt.Source == pointer.Mouse: if e.dragging { e.blinkStart = gtx.Now e.text.MoveCoord(image.Point{ X: int(math.Round(float64(evt.Position.X))), Y: int(math.Round(float64(evt.Position.Y))), }) e.scrollCaret = true if release { e.dragging = false } } } } return nil, false } func condFilter(pred bool, f key.Filter) event.Filter { if pred { return f } else { return nil } } func (e *Editor) processKey(gtx layout.Context) (EditorEvent, bool) { if e.text.Changed() { return ChangeEvent{}, true } caret, _ := e.text.Selection() atBeginning := caret == 0 atEnd := caret == e.text.Len() if gtx.Locale.Direction.Progression() != system.FromOrigin { atEnd, atBeginning = atBeginning, atEnd } filters := []event.Filter{ key.FocusFilter{Target: e}, transfer.TargetFilter{Target: e, Type: "application/text"}, key.Filter{Focus: e, Name: key.NameEnter, Optional: key.ModShift}, key.Filter{Focus: e, Name: key.NameReturn, Optional: key.ModShift}, key.Filter{Focus: e, Name: "Z", Required: key.ModShortcut, Optional: key.ModShift}, key.Filter{Focus: e, Name: "C", Required: key.ModShortcut}, key.Filter{Focus: e, Name: "V", Required: key.ModShortcut}, key.Filter{Focus: e, Name: "X", Required: key.ModShortcut}, key.Filter{Focus: e, Name: "A", Required: key.ModShortcut}, key.Filter{Focus: e, Name: key.NameDeleteBackward, Optional: key.ModShortcutAlt | key.ModShift}, key.Filter{Focus: e, Name: key.NameDeleteForward, Optional: key.ModShortcutAlt | key.ModShift}, key.Filter{Focus: e, Name: key.NameHome, Optional: key.ModShortcut | key.ModShift}, key.Filter{Focus: e, Name: key.NameEnd, Optional: key.ModShortcut | key.ModShift}, key.Filter{Focus: e, Name: key.NamePageDown, Optional: key.ModShift}, key.Filter{Focus: e, Name: key.NamePageUp, Optional: key.ModShift}, condFilter(!atBeginning, key.Filter{Focus: e, Name: key.NameLeftArrow, Optional: key.ModShortcutAlt | key.ModShift}), condFilter(!atBeginning, key.Filter{Focus: e, Name: key.NameUpArrow, Optional: key.ModShortcutAlt | key.ModShift}), condFilter(!atEnd, key.Filter{Focus: e, Name: key.NameRightArrow, Optional: key.ModShortcutAlt | key.ModShift}), condFilter(!atEnd, key.Filter{Focus: e, Name: key.NameDownArrow, Optional: key.ModShortcutAlt | key.ModShift}), } // adjust keeps track of runes dropped because of MaxLen. var adjust int for { ke, ok := gtx.Event(filters...) if !ok { break } e.blinkStart = gtx.Now switch ke := ke.(type) { case key.FocusEvent: // Reset IME state. e.ime.imeState = imeState{} if ke.Focus && !e.ReadOnly { gtx.Execute(key.SoftKeyboardCmd{Show: true}) } case key.Event: if !gtx.Focused(e) || ke.State != key.Press { break } if !e.ReadOnly && e.Submit && (ke.Name == key.NameReturn || ke.Name == key.NameEnter) { if !ke.Modifiers.Contain(key.ModShift) { e.scratch = e.text.Text(e.scratch) return SubmitEvent{ Text: string(e.scratch), }, true } } e.scrollCaret = true e.scroller.Stop() ev, ok := e.command(gtx, ke) if ok { return ev, ok } case key.SnippetEvent: e.updateSnippet(gtx, ke.Start, ke.End) case key.EditEvent: if e.ReadOnly { break } e.scrollCaret = true e.scroller.Stop() s := ke.Text moves := 0 submit := false switch { case e.Submit: if i := strings.IndexByte(s, '\n'); i != -1 { submit = true moves += len(s) - i s = s[:i] } case e.SingleLine: s = strings.ReplaceAll(s, "\n", " ") } moves += e.replace(ke.Range.Start, ke.Range.End, s, true) adjust += utf8.RuneCountInString(ke.Text) - moves // Reset caret xoff. e.text.MoveCaret(0, 0) if submit { e.scratch = e.text.Text(e.scratch) submitEvent := SubmitEvent{ Text: string(e.scratch), } if e.text.Changed() { e.pending = append(e.pending, submitEvent) return ChangeEvent{}, true } return submitEvent, true } // Complete a paste event, initiated by Shortcut-V in Editor.command(). case transfer.DataEvent: e.scrollCaret = true e.scroller.Stop() content, err := io.ReadAll(ke.Open()) if err == nil { if e.Insert(string(content)) != 0 { return ChangeEvent{}, true } } case key.SelectionEvent: e.scrollCaret = true e.scroller.Stop() ke.Start -= adjust ke.End -= adjust adjust = 0 e.text.SetCaret(ke.Start, ke.End) } } if e.text.Changed() { return ChangeEvent{}, true } return nil, false } func (e *Editor) command(gtx layout.Context, k key.Event) (EditorEvent, bool) { direction := 1 if gtx.Locale.Direction.Progression() == system.TowardOrigin { direction = -1 } moveByWord := k.Modifiers.Contain(key.ModShortcutAlt) selAct := selectionClear if k.Modifiers.Contain(key.ModShift) { selAct = selectionExtend } if k.Modifiers.Contain(key.ModShortcut) { switch k.Name { // Initiate a paste operation, by requesting the clipboard contents; other // half is in Editor.processKey() under clipboard.Event. case "V": if !e.ReadOnly { gtx.Execute(clipboard.ReadCmd{Tag: e}) } // Copy or Cut selection -- ignored if nothing selected. case "C", "X": e.scratch = e.text.SelectedText(e.scratch) if text := string(e.scratch); text != "" { gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(text))}) if k.Name == "X" && !e.ReadOnly { if e.Delete(1) != 0 { return ChangeEvent{}, true } } } // Select all case "A": e.text.SetCaret(0, e.text.Len()) case "Z": if !e.ReadOnly { if k.Modifiers.Contain(key.ModShift) { if ev, ok := e.redo(); ok { return ev, ok } } else { if ev, ok := e.undo(); ok { return ev, ok } } } case key.NameHome: e.text.MoveTextStart(selAct) case key.NameEnd: e.text.MoveTextEnd(selAct) } return nil, false } switch k.Name { case key.NameReturn, key.NameEnter: if !e.ReadOnly { if e.Insert("\n") != 0 { return ChangeEvent{}, true } } case key.NameDeleteBackward: if !e.ReadOnly { if moveByWord { if e.deleteWord(-1) != 0 { return ChangeEvent{}, true } } else { if e.Delete(-1) != 0 { return ChangeEvent{}, true } } } case key.NameDeleteForward: if !e.ReadOnly { if moveByWord { if e.deleteWord(1) != 0 { return ChangeEvent{}, true } } else { if e.Delete(1) != 0 { return ChangeEvent{}, true } } } case key.NameUpArrow: e.text.MoveLines(-1, selAct) case key.NameDownArrow: e.text.MoveLines(+1, selAct) case key.NameLeftArrow: if moveByWord { e.text.MoveWord(-1*direction, selAct) } else { if selAct == selectionClear { e.text.ClearSelection() } e.text.MoveCaret(-1*direction, -1*direction*int(selAct)) } case key.NameRightArrow: if moveByWord { e.text.MoveWord(1*direction, selAct) } else { if selAct == selectionClear { e.text.ClearSelection() } e.text.MoveCaret(1*direction, int(selAct)*direction) } case key.NamePageUp: e.text.MovePages(-1, selAct) case key.NamePageDown: e.text.MovePages(+1, selAct) case key.NameHome: e.text.MoveLineStart(selAct) case key.NameEnd: e.text.MoveLineEnd(selAct) } return nil, false } // initBuffer should be invoked first in every exported function that accesses // text state. It ensures that the underlying text widget is both ready to use // and has its fields synced with the editor. func (e *Editor) initBuffer() { if e.buffer == nil { e.buffer = new(editBuffer) e.text.SetSource(e.buffer) } e.text.Alignment = e.Alignment e.text.LineHeight = e.LineHeight e.text.LineHeightScale = e.LineHeightScale e.text.SingleLine = e.SingleLine e.text.Mask = e.Mask e.text.WrapPolicy = e.WrapPolicy e.text.DisableSpaceTrim = true } // Update the state of the editor in response to input events. Update consumes editor // input events until there are no remaining events or an editor event is generated. // To fully update the state of the editor, callers should call Update until it returns // false. func (e *Editor) Update(gtx layout.Context) (EditorEvent, bool) { e.initBuffer() event, ok := e.processEvents(gtx) // Notify IME of selection if it changed. newSel := e.ime.selection start, end := e.text.Selection() newSel.rng = key.Range{ Start: start, End: end, } caretPos, carAsc, carDesc := e.text.CaretInfo() newSel.caret = key.Caret{ Pos: layout.FPt(caretPos), Ascent: float32(carAsc), Descent: float32(carDesc), } if newSel != e.ime.selection { e.ime.selection = newSel gtx.Execute(key.SelectionCmd{Tag: e, Range: newSel.rng, Caret: newSel.caret}) } e.updateSnippet(gtx, e.ime.start, e.ime.end) return event, ok } // Layout lays out the editor using the provided textMaterial as the paint material // for the text glyphs+caret and the selectMaterial as the paint material for the // selection rectangle. func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, textMaterial, selectMaterial op.CallOp) layout.Dimensions { for { _, ok := e.Update(gtx) if !ok { break } } e.text.Layout(gtx, lt, font, size) return e.layout(gtx, textMaterial, selectMaterial) } // updateSnippet queues a key.SnippetCmd if the snippet content or position // have changed. off and len are in runes. func (e *Editor) updateSnippet(gtx layout.Context, start, end int) { if start > end { start, end = end, start } length := e.text.Len() if start > length { start = length } if end > length { end = length } e.ime.start = start e.ime.end = end startOff := e.text.ByteOffset(start) endOff := e.text.ByteOffset(end) n := endOff - startOff if n > int64(len(e.ime.scratch)) { e.ime.scratch = make([]byte, n) } scratch := e.ime.scratch[:n] read, _ := e.text.ReadAt(scratch, startOff) if read != len(scratch) { panic("e.rr.Read truncated data") } newSnip := key.Snippet{ Range: key.Range{ Start: e.ime.start, End: e.ime.end, }, Text: e.ime.snippet.Text, } if string(scratch) != newSnip.Text { newSnip.Text = string(scratch) } if newSnip == e.ime.snippet { return } e.ime.snippet = newSnip gtx.Execute(key.SnippetCmd{Tag: e, Snippet: newSnip}) } func (e *Editor) layout(gtx layout.Context, textMaterial, selectMaterial op.CallOp) layout.Dimensions { // Adjust scrolling for new viewport and layout. e.text.ScrollRel(0, 0) if e.scrollCaret { e.scrollCaret = false e.text.ScrollToCaret() } visibleDims := e.text.Dimensions() defer clip.Rect(image.Rectangle{Max: visibleDims.Size}).Push(gtx.Ops).Pop() pointer.CursorText.Add(gtx.Ops) event.Op(gtx.Ops, e) key.InputHintOp{Tag: e, Hint: e.InputHint}.Add(gtx.Ops) e.scroller.Add(gtx.Ops) e.clicker.Add(gtx.Ops) e.dragger.Add(gtx.Ops) e.showCaret = false if gtx.Focused(e) { 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 { gtx.Execute(op.InvalidateCmd{At: nextBlink}) } e.showCaret = !blinking || dt%timePerBlink < timePerBlink/2 } semantic.Editor.Add(gtx.Ops) if e.Len() > 0 { e.paintSelection(gtx, selectMaterial) e.paintText(gtx, textMaterial) } if gtx.Enabled() { e.paintCaret(gtx, textMaterial) } return visibleDims } // paintSelection paints the contrasting background for selected text using the provided // material to set the painting material for the selection. func (e *Editor) paintSelection(gtx layout.Context, material op.CallOp) { e.initBuffer() if !gtx.Focused(e) { return } e.text.PaintSelection(gtx, material) } // paintText paints the text glyphs using the provided material to set the fill of the // glyphs. func (e *Editor) paintText(gtx layout.Context, material op.CallOp) { e.initBuffer() e.text.PaintText(gtx, material) } // paintCaret paints the text glyphs using the provided material to set the fill material // of the caret rectangle. func (e *Editor) paintCaret(gtx layout.Context, material op.CallOp) { e.initBuffer() if !e.showCaret || e.ReadOnly { return } e.text.PaintCaret(gtx, material) } // Len is the length of the editor contents, in runes. func (e *Editor) Len() int { e.initBuffer() return e.text.Len() } // Text returns the contents of the editor. func (e *Editor) Text() string { e.initBuffer() e.scratch = e.text.Text(e.scratch) return string(e.scratch) } func (e *Editor) SetText(s string) { e.initBuffer() if e.SingleLine { s = strings.ReplaceAll(s, "\n", " ") } e.replace(0, e.text.Len(), s, true) // Reset xoff and move the caret to the beginning. e.SetCaret(0, 0) } // CaretPos returns the line & column numbers of the caret. func (e *Editor) CaretPos() (line, col int) { e.initBuffer() return e.text.CaretPos() } // CaretCoords returns the coordinates of the caret, relative to the // editor itself. func (e *Editor) CaretCoords() f32.Point { e.initBuffer() return e.text.CaretCoords() } // Delete runes from the caret position. The sign of the argument specifies the // direction to delete: positive is forward, negative is backward. // // If there is a selection, it is deleted and counts as a single grapheme // cluster. func (e *Editor) Delete(graphemeClusters int) (deletedRunes int) { e.initBuffer() if graphemeClusters == 0 { return 0 } start, end := e.text.Selection() if start != end { graphemeClusters -= sign(graphemeClusters) } // Move caret by the target quantity of clusters. e.text.MoveCaret(0, graphemeClusters) // Get the new rune offsets of the selection. start, end = e.text.Selection() e.replace(start, end, "", true) // Reset xoff. e.text.MoveCaret(0, 0) e.ClearSelection() return end - start } func (e *Editor) Insert(s string) (insertedRunes int) { e.initBuffer() if e.SingleLine { s = strings.ReplaceAll(s, "\n", " ") } start, end := e.text.Selection() moves := e.replace(start, end, s, true) if end < start { start = end } // Reset xoff. e.text.MoveCaret(0, 0) e.SetCaret(start+moves, start+moves) e.scrollCaret = true return moves } // modification represents a change to the contents of the editor buffer. // It contains the necessary information to both apply the change and // reverse it, and is useful for implementing undo/redo. type modification struct { // StartRune is the inclusive index of the first rune // modified. StartRune int // ApplyContent is the data inserted at StartRune to // apply this operation. It overwrites len([]rune(ReverseContent)) runes. ApplyContent string // ReverseContent is the data inserted at StartRune to // apply this operation. It overwrites len([]rune(ApplyContent)) runes. ReverseContent string } // undo applies the modification at e.history[e.historyIdx] and decrements // e.historyIdx. func (e *Editor) undo() (EditorEvent, bool) { e.initBuffer() if len(e.history) < 1 || e.nextHistoryIdx == 0 { return nil, false } mod := e.history[e.nextHistoryIdx-1] replaceEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent) e.replace(mod.StartRune, replaceEnd, mod.ReverseContent, false) caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent) e.SetCaret(caretEnd, mod.StartRune) e.nextHistoryIdx-- return ChangeEvent{}, true } // redo applies the modification at e.history[e.historyIdx] and increments // e.historyIdx. func (e *Editor) redo() (EditorEvent, bool) { e.initBuffer() if len(e.history) < 1 || e.nextHistoryIdx == len(e.history) { return nil, false } mod := e.history[e.nextHistoryIdx] end := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent) e.replace(mod.StartRune, end, mod.ApplyContent, false) caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent) e.SetCaret(caretEnd, mod.StartRune) e.nextHistoryIdx++ return ChangeEvent{}, true } // replace the text between start and end with s. Indices are in runes. // It returns the number of runes inserted. // addHistory controls whether this modification is recorded in the undo // history. replace can modify text in positions unrelated to the cursor // position. func (e *Editor) replace(start, end int, s string, addHistory bool) int { length := e.text.Len() if start > end { start, end = end, start } start = min(start, length) end = min(end, length) replaceSize := end - start el := e.Len() var sc int idx := 0 for idx < len(s) { if e.MaxLen > 0 && el-replaceSize+sc >= e.MaxLen { s = s[:idx] break } _, n := utf8.DecodeRuneInString(s[idx:]) if e.Filter != "" && !strings.Contains(e.Filter, s[idx:idx+n]) { s = s[:idx] + s[idx+n:] continue } idx += n sc++ } if addHistory { deleted := make([]rune, 0, replaceSize) readPos := e.text.ByteOffset(start) for i := 0; i < replaceSize; i++ { ru, s, _ := e.text.ReadRuneAt(int64(readPos)) readPos += int64(s) deleted = append(deleted, ru) } if e.nextHistoryIdx < len(e.history) { e.history = e.history[:e.nextHistoryIdx] } e.history = append(e.history, modification{ StartRune: start, ApplyContent: s, ReverseContent: string(deleted), }) e.nextHistoryIdx++ } sc = e.text.Replace(start, end, s) newEnd := start + sc adjust := func(pos int) int { switch { case newEnd < pos && pos <= end: pos = newEnd case end < pos: diff := newEnd - end pos = pos + diff } return pos } e.ime.start = adjust(e.ime.start) e.ime.end = adjust(e.ime.end) return sc } // MoveCaret moves the caret (aka selection start) and the selection end // relative to their current positions. Positive distances moves forward, // negative distances moves backward. Distances are in grapheme clusters, // which closely match what users perceive as "characters" even when the // characters are multiple code points long. func (e *Editor) MoveCaret(startDelta, endDelta int) { e.initBuffer() e.text.MoveCaret(startDelta, endDelta) } // deleteWord deletes the next word(s) in the specified direction. // Unlike moveWord, deleteWord treats whitespace as a word itself. // Positive is forward, negative is backward. // Absolute values greater than one will delete that many words. // The selection counts as a single word. func (e *Editor) deleteWord(distance int) (deletedRunes int) { if distance == 0 { return } start, end := e.text.Selection() if start != end { deletedRunes = e.Delete(1) distance -= sign(distance) } if distance == 0 { return deletedRunes } // split the distance information into constituent parts to be // used independently. words, direction := distance, 1 if distance < 0 { words, direction = distance*-1, -1 } caret, _ := e.text.Selection() // atEnd if offset is at or beyond either side of the buffer. atEnd := func(runes int) bool { idx := caret + runes*direction return idx <= 0 || idx >= e.Len() } // next returns the appropriate rune given the direction and offset in runes). next := func(runes int) rune { idx := caret + runes*direction if idx < 0 { idx = 0 } else if idx > e.Len() { idx = e.Len() } off := e.text.ByteOffset(idx) var r rune if direction < 0 { r, _, _ = e.text.ReadRuneBefore(int64(off)) } else { r, _, _ = e.text.ReadRuneAt(int64(off)) } return r } runes := 1 for ii := 0; ii < words; ii++ { r := next(runes) wantSpace := unicode.IsSpace(r) for r := next(runes); unicode.IsSpace(r) == wantSpace && !atEnd(runes); r = next(runes) { runes += 1 } } deletedRunes += e.Delete(runes * direction) return deletedRunes } // SelectionLen returns the length of the selection, in runes; it is // equivalent to utf8.RuneCountInString(e.SelectedText()). func (e *Editor) SelectionLen() int { e.initBuffer() return e.text.SelectionLen() } // Selection returns the start and end of the selection, as rune offsets. // start can be > end. func (e *Editor) Selection() (start, end int) { e.initBuffer() return e.text.Selection() } // SetCaret moves the caret to start, and sets the selection end to end. start // and end are in runes, and represent offsets into the editor text. func (e *Editor) SetCaret(start, end int) { e.initBuffer() e.text.SetCaret(start, end) e.scrollCaret = true e.scroller.Stop() } // SelectedText returns the currently selected text (if any) from the editor. func (e *Editor) SelectedText() string { e.initBuffer() e.scratch = e.text.SelectedText(e.scratch) return string(e.scratch) } // ClearSelection clears the selection, by setting the selection end equal to // the selection start. func (e *Editor) ClearSelection() { e.initBuffer() e.text.ClearSelection() } // WriteTo implements io.WriterTo. func (e *Editor) WriteTo(w io.Writer) (int64, error) { e.initBuffer() return e.text.WriteTo(w) } // Seek implements io.Seeker. func (e *Editor) Seek(offset int64, whence int) (int64, error) { e.initBuffer() return e.text.Seek(offset, whence) } // Read implements io.Reader. func (e *Editor) Read(p []byte) (int, error) { e.initBuffer() return e.text.Read(p) } // Regions returns visible regions covering the rune range [start,end). func (e *Editor) Regions(start, end int, regions []Region) []Region { e.initBuffer() return e.text.Regions(start, end, regions) } func max(a, b int) int { if a > b { return a } return b } func min(a, b int) int { if a < b { return a } return b } func abs(n int) int { if n < 0 { return -n } return n } func sign(n int) int { switch { case n < 0: return -1 case n > 0: return 1 default: return 0 } } func (s ChangeEvent) isEditorEvent() {} func (s SubmitEvent) isEditorEvent() {} func (s SelectEvent) isEditorEvent() {}