From f99aff96eebd2a0b4d08558d1661dd9a2e352330 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Thu, 22 Dec 2022 15:00:56 -0500 Subject: [PATCH] widget: create standalone textView This commit adds a standalone state type for manipulating and displaying text. It reads text from a minimal interface, shapes it, tracks valid cursor positions, and provides sizing and scrolling services to higher-level widgets. My long term goal with these types is to export them to allow non-core widgets to build atop them, but I've left them private for now. Signed-off-by: Chris Waldon --- widget/editor.go | 4 +- widget/index.go | 29 +- widget/text.go | 735 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 755 insertions(+), 13 deletions(-) create mode 100644 widget/text.go diff --git a/widget/editor.go b/widget/editor.go index 1b796a49..9b0092d9 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -68,7 +68,7 @@ type Editor struct { maxWidth, minWidth int viewSize image.Point valid bool - regions []region + regions []Region dims layout.Dimensions requestFocus bool @@ -750,7 +750,7 @@ func (e *Editor) PaintSelection(gtx layout.Context) { defer clip.Rect(localViewport).Push(gtx.Ops).Pop() e.regions = e.index.locate(docViewport, e.caret.start, e.caret.end, e.regions) for _, region := range e.regions { - area := clip.Rect(region.bounds.Sub(e.scrollOff)).Push(gtx.Ops) + area := clip.Rect(region.Bounds).Push(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) area.Pop() } diff --git a/widget/index.go b/widget/index.go index 3c907bde..af092d7a 100644 --- a/widget/index.go +++ b/widget/index.go @@ -281,34 +281,38 @@ func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos { // makeRegion creates a text-aligned rectangle from start to end. The vertical // dimensions of the rectangle are derived from the provided line's ascent and // descent, and the y offset of the line's baseline is provided as y. -func makeRegion(line lineInfo, y int, start, end fixed.Int26_6) region { +func makeRegion(line lineInfo, y int, start, end fixed.Int26_6) Region { if start > end { start, end = end, start } dotStart := image.Pt(start.Round(), y) dotEnd := image.Pt(end.Round(), y) - return region{ - bounds: image.Rectangle{ + return Region{ + Bounds: image.Rectangle{ Min: dotStart.Sub(image.Point{Y: line.ascent.Ceil()}), Max: dotEnd.Add(image.Point{Y: line.descent.Floor()}), }, - baseline: line.descent.Floor(), + Baseline: line.descent.Floor(), } } -// region describes an area of interest within shaped text. -type region struct { - // bounds is the coordinates of the bounding box in document space. - bounds image.Rectangle - // baseline is the quantity of vertical pixels between the baseline and +// Region describes the position and baseline of an area of interest within +// shaped text. +type Region struct { + // Bounds is the coordinates of the bounding box relative to the containing + // widget. + Bounds image.Rectangle + // Baseline is the quantity of vertical pixels between the baseline and // the bottom of bounds. - baseline int + Baseline int } // locate returns highlight regions covering the glyphs that represent the runes in // [startRune,endRune). If the rects parameter is non-nil, locate will use it to // return results instead of allocating, provided that there is enough capacity. -func (g *glyphIndex) locate(viewport image.Rectangle, startRune, endRune int, rects []region) []region { +// The returned regions have their Bounds specified relative to the provided +// viewport. +func (g *glyphIndex) locate(viewport image.Rectangle, startRune, endRune int, rects []Region) []Region { if startRune > endRune { startRune, endRune = endRune, startRune } @@ -388,5 +392,8 @@ func (g *glyphIndex) locate(viewport image.Rectangle, startRune, endRune int, re } } } + for i := range rects { + rects[i].Bounds = rects[i].Bounds.Sub(viewport.Min) + } return rects } diff --git a/widget/text.go b/widget/text.go new file mode 100644 index 00000000..94466ed9 --- /dev/null +++ b/widget/text.go @@ -0,0 +1,735 @@ +package widget + +import ( + "bufio" + "image" + "io" + "math" + "sort" + "unicode" + "unicode/utf8" + + "gioui.org/f32" + "gioui.org/io/system" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + "golang.org/x/image/math/fixed" +) + +// textSource provides text data for use in widgets. If the underlying data type +// can fail due to I/O errors, it is the responsibility of that type to provide +// its own mechanism to surface and handle those errors. They will not always +// be returned by widgets using these functions. +type textSource interface { + io.ReaderAt + // Size returns the total length of the data in bytes. + Size() int64 + // Changed returns whether the contents have changed since the last call + // to Changed. + Changed() bool + // ReplaceRunes replaces runeCount runes starting at byteOffset within the + // data with the provided string. Implementations of read-only text sources + // are free to make this a no-op. + ReplaceRunes(byteOffset int64, runeCount int64, replacement string) +} + +type maskReader2 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 +} + +func (m *maskReader2) 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 *maskReader2) 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 +} + +// textView provides efficient shaping and indexing of interactive text. When provided +// with a TextSource, textView will shape and cache the runes within that source. +// It provides methods for configuring a viewport onto the shaped text which can +// be scrolled, and for configuring and drawing text selection boxes. +type textView struct { + Alignment text.Alignment + // SingleLine forces the text to stay on a single line. + // SingleLine also sets the scrolling direction to + // horizontal. + SingleLine bool + // MaxLines limits the shaped text to a specific quantity of shaped lines. + MaxLines int + // 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 + + font text.Font + shaper *text.Shaper + textSize fixed.Int26_6 + seekCursor int64 + rr textSource + maskReader maskReader2 + lastMask rune + maxWidth, minWidth int + viewSize image.Point + valid bool + regions []Region + dims layout.Dimensions + + // offIndex is an index of rune index to byte offsets. + offIndex []offEntry + + index glyphIndex + + caret struct { + // xoff is the offset to the current position when moving between lines. + xoff fixed.Int26_6 + // start is the current caret position in runes, and also the start position of + // selected text. end is the end position of selected text. If start + // == end, then there's no selection. Note that it's possible (and + // common) that the caret (start) is after the end, e.g. after + // Shift-DownArrow. + start int + end int + } + + scrollOff image.Point + + locale system.Locale +} + +func (e *textView) Changed() bool { + return e.rr.Changed() +} + +// Dimensions returns the dimensions of the visible text. +func (e *textView) Dimensions() layout.Dimensions { + basePos := e.dims.Size.Y - e.dims.Baseline + return layout.Dimensions{Size: e.viewSize, Baseline: e.viewSize.Y - basePos} +} + +// FullDimensions returns the dimensions of all shaped text, including +// text that isn't visible within the current viewport. +func (e *textView) FullDimensions() layout.Dimensions { + return e.dims +} + +// SetSource initializes the underlying data source for the Text. This +// must be done before invoking any other methods on Text. +func (e *textView) SetSource(source textSource) { + e.rr = source + e.invalidate() + e.seekCursor = 0 +} + +// ReadRuneAt reads the rune starting at the given byte offset, if any. +func (e *textView) ReadRuneAt(off int64) (rune, int, error) { + var buf [utf8.UTFMax]byte + b := buf[:] + n, err := e.rr.ReadAt(b, off) + b = b[:n] + r, s := utf8.DecodeRune(b) + return r, s, err +} + +// ReadRuneAt reads the run prior to the given byte offset, if any. +func (e *textView) ReadRuneBefore(off int64) (rune, int, error) { + var buf [utf8.UTFMax]byte + b := buf[:] + if off < utf8.UTFMax { + b = b[:off] + off = 0 + } else { + off -= utf8.UTFMax + } + n, err := e.rr.ReadAt(b, off) + b = b[:n] + r, s := utf8.DecodeLastRune(b) + return r, s, err +} + +func (e *textView) makeValid() { + if e.valid { + return + } + e.layoutText(e.shaper) + e.valid = true +} + +func (e *textView) closestToRune(runeIdx int) combinedPos { + e.makeValid() + pos, _ := e.index.closestToRune(runeIdx) + return pos +} + +func (e *textView) closestToLineCol(line, col int) combinedPos { + e.makeValid() + return e.index.closestToLineCol(screenPos{line: line, col: col}) +} + +func (e *textView) closestToXY(x fixed.Int26_6, y int) combinedPos { + e.makeValid() + return e.index.closestToXY(x, y) +} + +func (e *textView) MoveLines(distance int, selAct selectionAction) { + caretStart := e.closestToRune(e.caret.start) + x := caretStart.x + e.caret.xoff + // Seek to line. + pos := e.closestToLineCol(caretStart.lineCol.line+distance, 0) + pos = e.closestToXY(x, pos.y) + e.caret.start = pos.runes + e.caret.xoff = x - pos.x + e.updateSelection(selAct) +} + +// calculateViewSize determines the size of the current visible content, +// ensuring that even if there is no text content, some space is reserved +// for the caret. +func (e *textView) calculateViewSize(gtx layout.Context) image.Point { + base := e.dims.Size + if caretWidth := e.caretWidth(gtx); base.X < caretWidth { + base.X = caretWidth + } + return gtx.Constraints.Constrain(base) +} + +// Update the text, reshaping it as necessary. If not nil, eventHandling will be invoked after reshaping the text to +// allow parent widgets to adapt to any changes in text content or positioning. If eventHandling invalidates the +// Text, Update will ensure that it is valid again before returning. +func (e *textView) Update(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, eventHandling func(gtx layout.Context)) { + if e.locale != gtx.Locale { + e.locale = gtx.Locale + e.invalidate() + } + textSize := fixed.I(gtx.Sp(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 = math.MaxInt + } + minWidth := gtx.Constraints.Min.X + if maxWidth != e.maxWidth { + e.maxWidth = maxWidth + e.invalidate() + } + if minWidth != e.minWidth { + e.minWidth = minWidth + e.invalidate() + } + if lt != e.shaper { + e.shaper = lt + e.invalidate() + } + if e.Mask != e.lastMask { + e.lastMask = e.Mask + e.invalidate() + } + + e.makeValid() + if eventHandling != nil { + eventHandling(gtx) + e.makeValid() + } + + if viewSize := e.calculateViewSize(gtx); viewSize != e.viewSize { + e.viewSize = viewSize + e.invalidate() + } + e.makeValid() +} + +// PaintSelection paints the contrasting background for selected text. +func (e *textView) PaintSelection(gtx layout.Context) { + localViewport := image.Rectangle{Max: e.viewSize} + docViewport := image.Rectangle{Max: e.viewSize}.Add(e.scrollOff) + defer clip.Rect(localViewport).Push(gtx.Ops).Pop() + e.regions = e.index.locate(docViewport, e.caret.start, e.caret.end, e.regions) + for _, region := range e.regions { + area := clip.Rect(region.Bounds).Push(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + area.Pop() + } +} + +func (e *textView) PaintText(gtx layout.Context) { + m := op.Record(gtx.Ops) + viewport := image.Rectangle{ + Min: e.scrollOff, + Max: e.viewSize.Add(e.scrollOff), + } + it := textIterator{viewport: viewport} + + startGlyph := 0 + for _, line := range e.index.lines { + if line.descent.Ceil()+line.yOff >= viewport.Min.Y { + break + } + startGlyph += line.glyphs + } + var glyphs [32]text.Glyph + line := glyphs[:0] + for _, g := range e.index.glyphs[startGlyph:] { + var ok bool + if line, ok = it.paintGlyph(gtx, e.shaper, g, line); !ok { + break + } + } + + call := m.Stop() + viewport.Min = viewport.Min.Add(it.padding.Min) + viewport.Max = viewport.Max.Add(it.padding.Max) + defer clip.Rect(viewport.Sub(e.scrollOff)).Push(gtx.Ops).Pop() + call.Add(gtx.Ops) +} + +// caretWidth returns the width occupied by the caret for the current +// gtx. +func (e *textView) caretWidth(gtx layout.Context) int { + carWidth2 := gtx.Dp(1) / 2 + if carWidth2 < 1 { + carWidth2 = 1 + } + return carWidth2 +} + +func (e *textView) PaintCaret(gtx layout.Context) { + carWidth2 := e.caretWidth(gtx) + caretPos, carAsc, carDesc := e.CaretInfo() + + carRect := image.Rectangle{ + Min: caretPos.Sub(image.Pt(carWidth2, carAsc)), + Max: caretPos.Add(image.Pt(carWidth2, carDesc)), + } + cl := image.Rectangle{Max: e.viewSize} + carRect = cl.Intersect(carRect) + if !carRect.Empty() { + defer clip.Rect(carRect).Push(gtx.Ops).Pop() + paint.PaintOp{}.Add(gtx.Ops) + } +} + +func (e *textView) CaretInfo() (pos image.Point, ascent, descent int) { + caretStart := e.closestToRune(e.caret.start) + + ascent = caretStart.ascent.Ceil() + descent = caretStart.descent.Ceil() + + pos = image.Point{ + X: caretStart.x.Round(), + Y: caretStart.y, + } + pos = pos.Sub(e.scrollOff) + return +} + +// ByteOffset returns the offset of the start byte of the rune nearest +// to the rune at the given offset. If the given offset is before or +// after the text, it will be clamped to the first or last rune. +func (e *textView) ByteOffset(runeOffset int) int64 { + return int64(e.runeOffset(e.closestToRune(runeOffset).runes)) +} + +// Len is the length of the editor contents, in runes. +func (e *textView) Len() int { + e.makeValid() + return e.closestToRune(math.MaxInt).runes +} + +// Text returns the contents of the editor. +func (e *textView) Text() string { + e.Seek(0, io.SeekStart) + b, _ := io.ReadAll(e) + return string(b) +} + +func (e *textView) ScrollBounds() image.Rectangle { + var b image.Rectangle + if e.SingleLine { + if len(e.index.lines) > 0 { + line := e.index.lines[0] + b.Min.X = line.xOff.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 *textView) ScrollRel(dx, dy int) { + e.scrollAbs(e.scrollOff.X+dx, e.scrollOff.Y+dy) +} + +// ScrollOff returns the scroll offset of the text viewport. +func (e *textView) ScrollOff() image.Point { + return e.scrollOff +} + +func (e *textView) 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 *textView) MoveCoord(pos image.Point) { + x := fixed.I(pos.X + e.scrollOff.X) + y := pos.Y + e.scrollOff.Y + e.caret.start = e.closestToXY(x, y).runes + e.caret.xoff = 0 +} + +func (e *textView) layoutText(lt *text.Shaper) { + e.Seek(0, io.SeekStart) + var r io.Reader = e + if e.Mask != 0 { + e.maskReader.Reset(e, e.Mask) + r = &e.maskReader + } + e.index = glyphIndex{} + it := textIterator{viewport: image.Rectangle{Max: image.Point{X: math.MaxInt, Y: math.MaxInt}}} + if lt != nil { + lt.LayoutReader(text.Parameters{ + Font: e.font, + PxPerEm: e.textSize, + Alignment: e.Alignment, + MaxLines: e.MaxLines, + }, e.minWidth, e.maxWidth, e.locale, r) + for glyph, ok := it.processGlyph(lt.NextGlyph()); ok; glyph, ok = it.processGlyph(lt.NextGlyph()) { + e.index.Glyph(glyph) + } + } else { + // Make a fake glyph for every rune in the reader. + b := bufio.NewReader(r) + for _, _, err := b.ReadRune(); err != io.EOF; _, _, err = b.ReadRune() { + g, _ := it.processGlyph(text.Glyph{Runes: 1, Flags: text.FlagClusterBreak}, true) + e.index.Glyph(g) + + } + } + dims := layout.Dimensions{Size: it.bounds.Size()} + dims.Baseline = dims.Size.Y - it.baseline + e.dims = dims +} + +// CaretPos returns the line & column numbers of the caret. +func (e *textView) CaretPos() (line, col int) { + pos := e.closestToRune(e.caret.start) + return pos.lineCol.line, pos.lineCol.col +} + +// CaretCoords returns the coordinates of the caret, relative to the +// editor itself. +func (e *textView) CaretCoords() f32.Point { + pos := e.closestToRune(e.caret.start) + return f32.Pt(float32(pos.x)/64-float32(e.scrollOff.X), float32(pos.y-e.scrollOff.Y)) +} + +// indexRune returns the latest rune index and byte offset no later than r. +func (e *textView) indexRune(r int) offEntry { + // Initialize index. + if len(e.offIndex) == 0 { + e.offIndex = append(e.offIndex, offEntry{}) + } + i := sort.Search(len(e.offIndex), func(i int) bool { + entry := e.offIndex[i] + return entry.runes >= r + }) + // Return the entry guaranteed to be less than or equal to r. + if i > 0 { + i-- + } + return e.offIndex[i] +} + +// runeOffset returns the byte offset into e.rr of the r'th rune. +// r must be a valid rune index, usually returned by closestPosition. +func (e *textView) runeOffset(r int) int { + const runesPerIndexEntry = 50 + entry := e.indexRune(r) + lastEntry := e.offIndex[len(e.offIndex)-1].runes + for entry.runes < r { + if entry.runes > lastEntry && entry.runes%runesPerIndexEntry == runesPerIndexEntry-1 { + e.offIndex = append(e.offIndex, entry) + } + _, s, _ := e.ReadRuneAt(int64(entry.bytes)) + entry.bytes += s + entry.runes++ + } + return entry.bytes +} + +func (e *textView) invalidate() { + e.offIndex = e.offIndex[:0] + e.valid = false +} + +// 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 *textView) Replace(start, end int, s string) int { + if start > end { + start, end = end, start + } + startPos := e.closestToRune(start) + endPos := e.closestToRune(end) + startOff := e.runeOffset(startPos.runes) + replaceSize := endPos.runes - startPos.runes + sc := utf8.RuneCountInString(s) + newEnd := startPos.runes + sc + + e.rr.ReplaceRunes(int64(startOff), int64(replaceSize), s) + adjust := func(pos int) int { + switch { + case newEnd < pos && pos <= endPos.runes: + pos = newEnd + case endPos.runes < pos: + diff := newEnd - endPos.runes + pos = pos + diff + } + return pos + } + e.caret.start = adjust(e.caret.start) + e.caret.end = adjust(e.caret.end) + e.invalidate() + return sc +} + +func (e *textView) MovePages(pages int, selAct selectionAction) { + caret := e.closestToRune(e.caret.start) + x := caret.x + e.caret.xoff + y := caret.y + pages*e.viewSize.Y + pos := e.closestToXY(x, y) + e.caret.start = pos.runes + e.caret.xoff = x - pos.x + e.updateSelection(selAct) +} + +// 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 runes. +func (e *textView) MoveCaret(startDelta, endDelta int) { + e.caret.xoff = 0 + e.caret.start = e.closestToRune(e.caret.start + startDelta).runes + e.caret.end = e.closestToRune(e.caret.end + endDelta).runes +} + +func (e *textView) MoveStart(selAct selectionAction) { + caret := e.closestToRune(e.caret.start) + caret = e.closestToLineCol(caret.lineCol.line, 0) + e.caret.start = caret.runes + e.caret.xoff = -caret.x + e.updateSelection(selAct) +} + +func (e *textView) MoveEnd(selAct selectionAction) { + caret := e.closestToRune(e.caret.start) + caret = e.closestToLineCol(caret.lineCol.line, math.MaxInt) + e.caret.start = caret.runes + e.caret.xoff = fixed.I(e.maxWidth) - caret.x + e.updateSelection(selAct) +} + +// MoveWord moves the caret to the next word in the specified direction. +// Positive is forward, negative is backward. +// Absolute values greater than one will skip that many words. +func (e *textView) MoveWord(distance int, selAct selectionAction) { + // split the distance information into constituent parts to be + // used independently. + words, direction := distance, 1 + if distance < 0 { + words, direction = distance*-1, -1 + } + // atEnd if caret is at either side of the buffer. + caret := e.closestToRune(e.caret.start) + atEnd := func() bool { + return caret.runes == 0 || caret.runes == e.Len() + } + // next returns the appropriate rune given the direction. + next := func() (r rune) { + off := e.runeOffset(caret.runes) + if direction < 0 { + r, _, _ = e.ReadRuneBefore(int64(off)) + } else { + r, _, _ = e.ReadRuneAt(int64(off)) + } + return r + } + for ii := 0; ii < words; ii++ { + for r := next(); unicode.IsSpace(r) && !atEnd(); r = next() { + e.MoveCaret(direction, 0) + caret = e.closestToRune(e.caret.start) + } + e.MoveCaret(direction, 0) + caret = e.closestToRune(e.caret.start) + for r := next(); !unicode.IsSpace(r) && !atEnd(); r = next() { + e.MoveCaret(direction, 0) + caret = e.closestToRune(e.caret.start) + } + } + e.updateSelection(selAct) +} + +func (e *textView) ScrollToCaret() { + caret := e.closestToRune(e.caret.start) + if e.SingleLine { + var dist int + if d := caret.x.Floor() - e.scrollOff.X; d < 0 { + dist = d + } else if d := caret.x.Ceil() - (e.scrollOff.X + e.viewSize.X); d > 0 { + dist = d + } + e.ScrollRel(dist, 0) + } else { + miny := caret.y - caret.ascent.Ceil() + maxy := caret.y + caret.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) + } +} + +// SelectionLen returns the length of the selection, in runes; it is +// equivalent to utf8.RuneCountInString(e.SelectedText()). +func (e *textView) SelectionLen() int { + return abs(e.caret.start - e.caret.end) +} + +// Selection returns the start and end of the selection, as rune offsets. +// start can be > end. +func (e *textView) Selection() (start, end int) { + return e.caret.start, e.caret.end +} + +// 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 *textView) SetCaret(start, end int) { + e.caret.start = e.closestToRune(start).runes + e.caret.end = e.closestToRune(end).runes +} + +// SelectedText returns the currently selected text (if any) from the editor. +func (e *textView) SelectedText() string { + startOff := e.runeOffset(e.caret.start) + endOff := e.runeOffset(e.caret.end) + start := min(startOff, endOff) + end := max(startOff, endOff) + buf := make([]byte, end-start) + n, _ := e.rr.ReadAt(buf, int64(start)) + // There is no way to reasonably handle a read error here. We rely upon + // implementations of textSource to provide other ways to signal errors + // if the user cares about that, and here we use whatever data we were + // able to read. + return string(buf[:n]) +} + +func (e *textView) updateSelection(selAct selectionAction) { + if selAct == selectionClear { + e.ClearSelection() + } +} + +// ClearSelection clears the selection, by setting the selection end equal to +// the selection start. +func (e *textView) ClearSelection() { + e.caret.end = e.caret.start +} + +// WriteTo implements io.WriterTo. +func (e *textView) WriteTo(w io.Writer) (int64, error) { + e.Seek(0, io.SeekStart) + return io.Copy(w, struct{ io.Reader }{e}) +} + +// Seek implements io.Seeker. +func (e *textView) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + e.seekCursor = offset + case io.SeekCurrent: + e.seekCursor += offset + case io.SeekEnd: + e.seekCursor = e.rr.Size() + offset + } + return e.seekCursor, nil +} + +// Read implements io.Reader. +func (e *textView) Read(p []byte) (int, error) { + n, err := e.rr.ReadAt(p, e.seekCursor) + e.seekCursor += int64(n) + return n, err +} + +// ReadAt implements io.ReaderAt. +func (e *textView) ReadAt(p []byte, offset int64) (int, error) { + return e.rr.ReadAt(p, offset) +} + +// Regions returns visible regions covering the rune range [start,end). +func (e *textView) Regions(start, end int, regions []Region) []Region { + viewport := image.Rectangle{ + Min: e.scrollOff, + Max: e.viewSize.Add(e.scrollOff), + } + return e.index.locate(viewport, start, end, regions) +}