mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 15:45:38 +00:00
3d6cafa94d
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
854 lines
25 KiB
Go
854 lines
25 KiB
Go
package widget
|
|
|
|
import (
|
|
"bufio"
|
|
"image"
|
|
"io"
|
|
"math"
|
|
"slices"
|
|
"sort"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"gioui.org/f32"
|
|
"gioui.org/font"
|
|
"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)
|
|
}
|
|
|
|
// 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
|
|
// LineHeight controls the distance between the baselines of lines of text.
|
|
// If zero, a sensible default will be used.
|
|
LineHeight unit.Sp
|
|
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
|
|
// sensible default will be used.
|
|
LineHeightScale float32
|
|
// 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
|
|
// Truncator is the text that will be shown at the end of the final
|
|
// line if MaxLines is exceeded. Defaults to "…" if empty.
|
|
Truncator string
|
|
// WrapPolicy configures how displayed text will be broken into lines.
|
|
WrapPolicy text.WrapPolicy
|
|
// DisableSpaceTrim configures whether trailing whitespace on a line will have its
|
|
// width zeroed. Set to true for editors, but false for non-editable text.
|
|
DisableSpaceTrim 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
|
|
|
|
params text.Parameters
|
|
shaper *text.Shaper
|
|
seekCursor int64
|
|
rr textSource
|
|
maskReader maskReader
|
|
// graphemes tracks the indices of grapheme cluster boundaries within rr.
|
|
graphemes []int
|
|
// paragraphReader is used to populate graphemes.
|
|
paragraphReader graphemeReader
|
|
lastMask rune
|
|
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
|
|
}
|
|
|
|
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, bool) {
|
|
e.makeValid()
|
|
return e.index.closestToXY(x, y)
|
|
}
|
|
|
|
func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) (combinedPos, bool) {
|
|
// Find the closest existing rune position to the provided coordinates.
|
|
pos, atEndOfLine := e.closestToXY(x, y)
|
|
if atEndOfLine {
|
|
return pos, true
|
|
}
|
|
// Resolve cluster boundaries on either side of the rune position.
|
|
firstOption := e.moveByGraphemes(pos.runes, 0)
|
|
distance := 1
|
|
if firstOption > pos.runes {
|
|
distance = -1
|
|
}
|
|
secondOption := e.moveByGraphemes(firstOption, distance)
|
|
// Choose the closest grapheme cluster boundary to the desired point.
|
|
first := e.closestToRune(firstOption)
|
|
firstDist := absFixed(first.x - x)
|
|
second := e.closestToRune(secondOption)
|
|
secondDist := absFixed(second.x - x)
|
|
if firstDist > secondDist {
|
|
return second, false
|
|
} else {
|
|
return first, false
|
|
}
|
|
}
|
|
|
|
func absFixed(i fixed.Int26_6) fixed.Int26_6 {
|
|
if i < 0 {
|
|
return -i
|
|
}
|
|
return i
|
|
}
|
|
|
|
// MaxLines moves the cursor the specified number of lines vertically, ensuring
|
|
// that the resulting position is aligned to a grapheme cluster.
|
|
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, atEndOfLine := e.closestToXYGraphemes(x, pos.y)
|
|
e.caret.start = pos.runes
|
|
if atEndOfLine && pos.runes > 0 {
|
|
e.caret.start = pos.runes - 1
|
|
}
|
|
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)
|
|
}
|
|
|
|
// Layout the text, reshaping it as necessary.
|
|
func (e *textView) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp) {
|
|
if e.params.Locale != gtx.Locale {
|
|
e.params.Locale = gtx.Locale
|
|
e.invalidate()
|
|
}
|
|
textSize := fixed.I(gtx.Sp(size))
|
|
if e.params.Font != font || e.params.PxPerEm != textSize {
|
|
e.invalidate()
|
|
e.params.Font = font
|
|
e.params.PxPerEm = textSize
|
|
}
|
|
maxWidth := gtx.Constraints.Max.X
|
|
if e.SingleLine {
|
|
maxWidth = math.MaxInt
|
|
}
|
|
minWidth := gtx.Constraints.Min.X
|
|
if maxWidth != e.params.MaxWidth {
|
|
e.params.MaxWidth = maxWidth
|
|
e.invalidate()
|
|
}
|
|
if minWidth != e.params.MinWidth {
|
|
e.params.MinWidth = minWidth
|
|
e.invalidate()
|
|
}
|
|
if lt != e.shaper {
|
|
e.shaper = lt
|
|
e.invalidate()
|
|
}
|
|
if e.Mask != e.lastMask {
|
|
e.lastMask = e.Mask
|
|
e.invalidate()
|
|
}
|
|
if e.Alignment != e.params.Alignment {
|
|
e.params.Alignment = e.Alignment
|
|
e.invalidate()
|
|
}
|
|
if e.Truncator != e.params.Truncator {
|
|
e.params.Truncator = e.Truncator
|
|
e.invalidate()
|
|
}
|
|
if e.MaxLines != e.params.MaxLines {
|
|
e.params.MaxLines = e.MaxLines
|
|
e.invalidate()
|
|
}
|
|
if e.WrapPolicy != e.params.WrapPolicy {
|
|
e.params.WrapPolicy = e.WrapPolicy
|
|
e.invalidate()
|
|
}
|
|
if lh := fixed.I(gtx.Sp(e.LineHeight)); lh != e.params.LineHeight {
|
|
e.params.LineHeight = lh
|
|
e.invalidate()
|
|
}
|
|
if e.LineHeightScale != e.params.LineHeightScale {
|
|
e.params.LineHeightScale = e.LineHeightScale
|
|
e.invalidate()
|
|
}
|
|
if e.DisableSpaceTrim != e.params.DisableSpaceTrim {
|
|
e.params.DisableSpaceTrim = e.DisableSpaceTrim
|
|
e.invalidate()
|
|
}
|
|
|
|
e.makeValid()
|
|
|
|
if viewSize := e.calculateViewSize(gtx); viewSize != e.viewSize {
|
|
e.viewSize = viewSize
|
|
e.invalidate()
|
|
}
|
|
e.makeValid()
|
|
}
|
|
|
|
// PaintSelection clips and paints the visible text selection rectangles using
|
|
// the provided material to fill the rectangles.
|
|
func (e *textView) PaintSelection(gtx layout.Context, material op.CallOp) {
|
|
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)
|
|
material.Add(gtx.Ops)
|
|
paint.PaintOp{}.Add(gtx.Ops)
|
|
area.Pop()
|
|
}
|
|
}
|
|
|
|
// PaintText clips and paints the visible text glyph outlines using the provided
|
|
// material to fill the glyphs.
|
|
func (e *textView) PaintText(gtx layout.Context, material op.CallOp) {
|
|
m := op.Record(gtx.Ops)
|
|
viewport := image.Rectangle{
|
|
Min: e.scrollOff,
|
|
Max: e.viewSize.Add(e.scrollOff),
|
|
}
|
|
it := textIterator{
|
|
viewport: viewport,
|
|
material: material,
|
|
}
|
|
|
|
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 := max(gtx.Dp(1)/2, 1)
|
|
return carWidth2
|
|
}
|
|
|
|
// PaintCaret clips and paints the caret rectangle, adding material immediately
|
|
// before painting to set the appropriate paint material.
|
|
func (e *textView) PaintCaret(gtx layout.Context, material op.CallOp) {
|
|
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()
|
|
material.Add(gtx.Ops)
|
|
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 start byte of the rune at the given
|
|
// rune offset, clamped to the size of the text.
|
|
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. If the provided buf is large enough, it will
|
|
// be filled and returned. Otherwise a new buffer will be allocated.
|
|
// Callers can guarantee that buf is large enough by giving it capacity e.Len()*utf8.UTFMax.
|
|
func (e *textView) Text(buf []byte) []byte {
|
|
size := e.rr.Size()
|
|
if cap(buf) < int(size) {
|
|
buf = make([]byte, size)
|
|
}
|
|
buf = buf[:size]
|
|
e.Seek(0, io.SeekStart)
|
|
n, _ := io.ReadFull(e, buf)
|
|
buf = buf[:n]
|
|
return buf
|
|
}
|
|
|
|
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 = min(line.xOff.Floor(), 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
|
|
}
|
|
}
|
|
|
|
// MoveCoord moves the caret to the position closest to the provided
|
|
// point that is aligned to a grapheme cluster boundary.
|
|
func (e *textView) MoveCoord(pos image.Point) {
|
|
x := fixed.I(pos.X + e.scrollOff.X)
|
|
y := pos.Y + e.scrollOff.Y
|
|
p, _ := e.closestToXYGraphemes(x, y)
|
|
e.caret.start = p.runes
|
|
e.caret.xoff = 0
|
|
}
|
|
|
|
// Truncated returns whether the text in the textView is currently
|
|
// truncated due to a restriction on the number of lines.
|
|
func (e *textView) Truncated() bool {
|
|
return e.index.truncated
|
|
}
|
|
|
|
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.reset()
|
|
it := textIterator{viewport: image.Rectangle{Max: image.Point{X: math.MaxInt, Y: math.MaxInt}}}
|
|
if lt != nil {
|
|
lt.Layout(e.params, r)
|
|
for {
|
|
g, ok := lt.NextGlyph()
|
|
if !it.processGlyph(g, ok) {
|
|
break
|
|
}
|
|
e.index.Glyph(g)
|
|
}
|
|
} 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 := text.Glyph{Runes: 1, Flags: text.FlagClusterBreak}
|
|
_ = it.processGlyph(g, true)
|
|
e.index.Glyph(g)
|
|
}
|
|
}
|
|
e.paragraphReader.SetSource(e.rr)
|
|
e.graphemes = e.graphemes[:0]
|
|
for g := e.paragraphReader.Graphemes(); len(g) > 0; g = e.paragraphReader.Graphemes() {
|
|
if len(e.graphemes) > 0 && g[0] == e.graphemes[len(e.graphemes)-1] {
|
|
g = g[1:]
|
|
}
|
|
e.graphemes = append(e.graphemes, 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.
|
|
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
|
|
}
|
|
|
|
// MovePages moves the caret position by vertical pages of text, ensuring that
|
|
// the final position is aligned to a grapheme cluster boundary.
|
|
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.closestToXYGraphemes(x, y)
|
|
e.caret.start = pos.runes
|
|
e.caret.xoff = x - pos.x
|
|
e.updateSelection(selAct)
|
|
}
|
|
|
|
// moveByGraphemes returns the rune index resulting from moving the
|
|
// specified number of grapheme clusters from startRuneidx.
|
|
func (e *textView) moveByGraphemes(startRuneidx, graphemes int) int {
|
|
if len(e.graphemes) == 0 {
|
|
return startRuneidx
|
|
}
|
|
startGraphemeIdx, _ := slices.BinarySearch(e.graphemes, startRuneidx)
|
|
startGraphemeIdx = max(startGraphemeIdx+graphemes, 0)
|
|
startGraphemeIdx = min(startGraphemeIdx, len(e.graphemes)-1)
|
|
startRuneIdx := e.graphemes[startGraphemeIdx]
|
|
return e.closestToRune(startRuneIdx).runes
|
|
}
|
|
|
|
// clampCursorToGraphemes ensures that the final start/end positions of
|
|
// the cursor are on grapheme cluster boundaries.
|
|
func (e *textView) clampCursorToGraphemes() {
|
|
e.caret.start = e.moveByGraphemes(e.caret.start, 0)
|
|
e.caret.end = e.moveByGraphemes(e.caret.end, 0)
|
|
}
|
|
|
|
// 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
|
|
// better match the expectations of users than runes.
|
|
func (e *textView) MoveCaret(startDelta, endDelta int) {
|
|
e.caret.xoff = 0
|
|
e.caret.start = e.moveByGraphemes(e.caret.start, startDelta)
|
|
e.caret.end = e.moveByGraphemes(e.caret.end, endDelta)
|
|
}
|
|
|
|
// MoveTextStart moves the caret to the start of the text.
|
|
func (e *textView) MoveTextStart(selAct selectionAction) {
|
|
caret := e.closestToRune(e.caret.end)
|
|
e.caret.start = 0
|
|
e.caret.end = caret.runes
|
|
e.caret.xoff = -caret.x
|
|
e.updateSelection(selAct)
|
|
e.clampCursorToGraphemes()
|
|
}
|
|
|
|
// MoveTextEnd moves the caret to the end of the text.
|
|
func (e *textView) MoveTextEnd(selAct selectionAction) {
|
|
caret := e.closestToRune(math.MaxInt)
|
|
e.caret.start = caret.runes
|
|
e.caret.xoff = fixed.I(e.params.MaxWidth) - caret.x
|
|
e.updateSelection(selAct)
|
|
e.clampCursorToGraphemes()
|
|
}
|
|
|
|
// MoveLineStart moves the caret to the start of the current line, ensuring that the resulting
|
|
// cursor position is on a grapheme cluster boundary.
|
|
func (e *textView) MoveLineStart(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)
|
|
e.clampCursorToGraphemes()
|
|
}
|
|
|
|
// MoveLineEnd moves the caret to the end of the current line, ensuring that the resulting
|
|
// cursor position is on a grapheme cluster boundary.
|
|
func (e *textView) MoveLineEnd(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.params.MaxWidth) - caret.x
|
|
e.updateSelection(selAct)
|
|
e.clampCursorToGraphemes()
|
|
}
|
|
|
|
// 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.
|
|
// The final caret position will be aligned to a grapheme cluster boundary.
|
|
// BUG(whereswaldon): this method's definition of a "word" is currently
|
|
// whitespace-delimited. Languages that do not use whitespace to delimit
|
|
// words will experience counter-intuitive behavior when navigating by
|
|
// word.
|
|
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 range words {
|
|
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)
|
|
e.clampCursorToGraphemes()
|
|
}
|
|
|
|
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. Then
|
|
// the two ends are clamped to the nearest grapheme cluster boundary. 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
|
|
e.clampCursorToGraphemes()
|
|
}
|
|
|
|
// SelectedText returns the currently selected text (if any) from the editor,
|
|
// filling the provided byte slice if it is large enough or allocating and
|
|
// returning a new byte slice if the provided one is insufficient.
|
|
// Callers can guarantee that the buf is large enough by providing a buffer
|
|
// with capacity e.SelectionLen()*utf8.UTFMax.
|
|
func (e *textView) SelectedText(buf []byte) []byte {
|
|
startOff := e.runeOffset(e.caret.start)
|
|
endOff := e.runeOffset(e.caret.end)
|
|
start := min(startOff, endOff)
|
|
end := max(startOff, endOff)
|
|
if cap(buf) < end-start {
|
|
buf = make([]byte, end-start)
|
|
}
|
|
buf = buf[: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 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)
|
|
}
|