M widget/buffer.go => widget/buffer.go +16 -74
@@ 4,7 4,6 @@ package widget
import (
"io"
- "strings"
"unicode/utf8"
"golang.org/x/text/runes"
@@ 24,6 23,8 @@ type editBuffer struct {
changed bool
}
+var _ textSource = (*editBuffer)(nil)
+
const minSpace = 5
func (e *editBuffer) Changed() bool {
@@ 56,10 57,10 @@ func (e *editBuffer) moveGap(caret, space int) {
if space < minSpace {
space = minSpace
}
- txt := make([]byte, e.len()+space)
+ txt := make([]byte, int(e.Size())+space)
// Expand to capacity.
txt = txt[:cap(txt)]
- gaplen := len(txt) - e.len()
+ gaplen := len(txt) - int(e.Size())
if caret > e.gapstart {
copy(txt, e.text[:e.gapstart])
copy(txt[caret+gaplen:], e.text[caret:])
@@ 84,83 85,38 @@ func (e *editBuffer) moveGap(caret, space int) {
}
}
-func (e *editBuffer) len() int {
- return len(e.text) - e.gapLen()
+func (e *editBuffer) Size() int64 {
+ return int64(len(e.text) - e.gapLen())
}
func (e *editBuffer) gapLen() int {
return e.gapend - e.gapstart
}
-func (e *editBuffer) Reset() {
- e.Seek(0, io.SeekStart)
-}
-
-// Seek implements io.Seeker
-func (e *editBuffer) Seek(offset int64, whence int) (ret int64, err error) {
- switch whence {
- case io.SeekStart:
- e.pos = int(offset)
- case io.SeekCurrent:
- e.pos += int(offset)
- case io.SeekEnd:
- e.pos = e.len() - int(offset)
- }
- if e.pos < 0 {
- e.pos = 0
- } else if e.pos > e.len() {
- e.pos = e.len()
- }
- return int64(e.pos), nil
-}
-
-func (e *editBuffer) Read(p []byte) (int, error) {
+func (e *editBuffer) ReadAt(p []byte, offset int64) (int, error) {
if len(p) == 0 {
return 0, nil
}
- if e.pos == e.len() {
+ if offset == e.Size() {
return 0, io.EOF
}
var total int
- if e.pos < e.gapstart {
- n := copy(p, e.text[e.pos:e.gapstart])
+ if offset < int64(e.gapstart) {
+ n := copy(p, e.text[offset:e.gapstart])
p = p[n:]
total += n
- e.pos += n
+ offset += int64(n)
}
- if e.pos >= e.gapstart {
- n := copy(p, e.text[e.pos+e.gapLen():])
+ if offset >= int64(e.gapstart) {
+ n := copy(p, e.text[offset+int64(e.gapLen()):])
total += n
- e.pos += n
}
return total, nil
}
-func (e *editBuffer) ReadRune() (rune, int, error) {
- if e.pos == e.len() {
- return 0, 0, io.EOF
- }
- r, s := e.runeAt(e.pos)
- e.pos += s
- return r, s, nil
-}
-
-// WriteTo implements io.WriterTo.
-func (e *editBuffer) WriteTo(w io.Writer) (int64, error) {
- n1, err := w.Write(e.text[:e.gapstart])
- if err != nil || n1 < e.gapstart {
- return int64(n1), err
- }
- n2, err := w.Write(e.text[e.gapend:])
- return int64(n1 + n2), err
-}
-
-func (e *editBuffer) String() string {
- var b strings.Builder
- b.Grow(e.len())
- b.Write(e.text[:e.gapstart])
- b.Write(e.text[e.gapend:])
- return b.String()
+func (e *editBuffer) ReplaceRunes(byteOffset, runeCount int64, s string) {
+ e.deleteRunes(int(byteOffset), int(runeCount))
+ e.prepend(int(byteOffset), s)
}
func (e *editBuffer) prepend(caret int, s string) {
@@ 173,17 129,3 @@ func (e *editBuffer) prepend(caret int, s string) {
e.gapstart += len(s)
e.changed = e.changed || len(s) > 0
}
-
-func (e *editBuffer) runeBefore(idx int) (rune, int) {
- if idx > e.gapstart {
- idx += e.gapLen()
- }
- return utf8.DecodeLastRune(e.text[:idx])
-}
-
-func (e *editBuffer) runeAt(idx int) (rune, int) {
- if idx >= e.gapstart {
- idx += e.gapLen()
- }
- return utf8.DecodeRune(e.text[idx:])
-}
M widget/editor.go => widget/editor.go +215 -590
@@ 3,10 3,10 @@
package widget
import (
+ "bufio"
"image"
"io"
"math"
- "sort"
"strings"
"time"
"unicode"
@@ 22,16 22,18 @@ import (
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
- "gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"golang.org/x/exp/constraints"
- "golang.org/x/image/math/fixed"
)
// 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
// SingleLine force the text to stay on a single line.
// SingleLine also sets the scrolling direction to
@@ 56,26 58,11 @@ type Editor struct {
// all characters are allowed.
Filter string
- eventKey int
- font text.Font
- shaper *text.Shaper
- textSize fixed.Int26_6
- blinkStart time.Time
- focused bool
- rr editBuffer
- maskReader maskReader
- lastMask rune
- maxWidth, minWidth int
- viewSize image.Point
- valid bool
- regions []Region
- dims layout.Dimensions
- requestFocus bool
-
- // offIndex is an index of rune index to byte offsets.
- offIndex []offEntry
-
- index glyphIndex
+ buffer *editBuffer
+ eventKey int
+ blinkStart time.Time
+ focused bool
+ requestFocus bool
// ime tracks the state relevant to input methods.
ime struct {
@@ 83,24 70,11 @@ type Editor struct {
scratch []byte
}
- caret struct {
- on bool
- scroll bool
- // 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
- }
-
- dragging bool
- dragger gesture.Drag
- scroller gesture.Scroll
- scrollOff image.Point
+ dragging bool
+ dragger gesture.Drag
+ scroller gesture.Scroll
+ scrollCaret bool
+ showCaret bool
clicker gesture.Click
@@ 108,9 82,6 @@ type Editor struct {
events []EditorEvent
// prevEvents is the number of events from the previous frame.
prevEvents int
-
- locale system.Locale
-
// history contains undo history.
history []modification
// nextHistoryIdx is the index within the history of the next modification. This
@@ 139,6 110,8 @@ type maskReader struct {
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
@@ 148,26 121,37 @@ const (
selectionClear
)
-func (m *maskReader) Reset(r io.RuneReader, mr rune) {
- m.rr = r
+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]
}
-// ReadRune reads a rune from the underlying reader and replaces every
+// Read reads from the underlying reader and replaces every
// rune with the mask rune.
-func (m *maskReader) ReadRune() (r rune, n int, err error) {
- r, _, err = m.rr.ReadRune()
- if err != nil {
- return
- }
- if r != '\n' {
- r, _ = utf8.DecodeRune(m.mask)
- n = len(m.mask)
- } else {
- n = 1
+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
+ return n, err
}
type EditorEvent interface {
@@ 210,29 194,17 @@ func (e *Editor) processEvents(gtx layout.Context) {
e.events = e.events[:n]
e.prevEvents = n
- if e.shaper == nil {
- // Can't process events without a shaper.
- return
- }
- oldStart, oldLen := min(e.caret.start, e.caret.end), e.SelectionLen()
+ oldStart, oldLen := min(e.text.Selection()), e.text.SelectionLen()
e.processPointer(gtx)
e.processKey(gtx)
// Queue a SelectEvent if the selection changed, including if it went away.
- if newStart, newLen := min(e.caret.start, e.caret.end), e.SelectionLen(); oldStart != newStart || oldLen != newLen {
+ if newStart, newLen := min(e.text.Selection()), e.text.SelectionLen(); oldStart != newStart || oldLen != newLen {
e.events = append(e.events, SelectEvent{})
}
}
-func (e *Editor) makeValid() {
- if e.valid {
- return
- }
- e.layoutText(e.shaper)
- e.valid = true
-}
-
func (e *Editor) processPointer(gtx layout.Context) {
- sbounds := e.scrollBounds()
+ sbounds := e.text.ScrollBounds()
var smin, smax int
var axis gesture.Axis
if e.SingleLine {
@@ 245,11 217,11 @@ func (e *Editor) processPointer(gtx layout.Context) {
sdist := e.scroller.Scroll(gtx.Metric, gtx, gtx.Now, axis)
var soff int
if e.SingleLine {
- e.scrollRel(sdist, 0)
- soff = e.scrollOff.X
+ e.text.ScrollRel(sdist, 0)
+ soff = e.text.ScrollOff().X
} else {
- e.scrollRel(0, sdist)
- soff = e.scrollOff.Y
+ e.text.ScrollRel(0, sdist)
+ soff = e.text.ScrollOff().Y
}
for _, evt := range e.clickDragEvents(gtx) {
switch evt := evt.(type) {
@@ 257,37 229,38 @@ func (e *Editor) processPointer(gtx layout.Context) {
switch {
case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse,
evt.Type == gesture.TypeClick && evt.Source != pointer.Mouse:
- prevCaretPos := e.caret.start
+ prevCaretPos, _ := e.text.Selection()
e.blinkStart = gtx.Now
- e.moveCoord(image.Point{
+ e.text.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
+ 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(e.caret.end-e.caret.start) < abs(e.caret.start-prevCaretPos) {
- e.caret.end = prevCaretPos
+ if abs(end-start) < abs(start-prevCaretPos) {
+ e.text.SetCaret(start, prevCaretPos)
}
} else {
- e.ClearSelection()
+ e.text.ClearSelection()
}
e.dragging = true
// Process multi-clicks.
switch {
case evt.NumClicks == 2:
- e.moveWord(-1, selectionClear)
- e.moveWord(1, selectionExtend)
+ e.text.MoveWord(-1, selectionClear)
+ e.text.MoveWord(1, selectionExtend)
e.dragging = false
case evt.NumClicks >= 3:
- e.moveStart(selectionClear)
- e.moveEnd(selectionExtend)
+ e.text.MoveStart(selectionClear)
+ e.text.MoveEnd(selectionExtend)
e.dragging = false
}
}
@@ 300,11 273,11 @@ func (e *Editor) processPointer(gtx layout.Context) {
case evt.Type == pointer.Drag && evt.Source == pointer.Mouse:
if e.dragging {
e.blinkStart = gtx.Now
- e.moveCoord(image.Point{
+ e.text.MoveCoord(image.Point{
X: int(math.Round(float64(evt.Position.X))),
Y: int(math.Round(float64(evt.Position.Y))),
})
- e.caret.scroll = true
+ e.scrollCaret = true
if release {
e.dragging = false
@@ 331,7 304,7 @@ func (e *Editor) clickDragEvents(gtx layout.Context) []event.Event {
}
func (e *Editor) processKey(gtx layout.Context) {
- if e.rr.Changed() {
+ if e.text.Changed() {
e.events = append(e.events, ChangeEvent{})
}
// adjust keeps track of runes dropped because of MaxLen.
@@ 350,13 323,13 @@ func (e *Editor) processKey(gtx layout.Context) {
if !e.ReadOnly && 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(),
+ Text: e.text.Text(),
})
continue
}
}
e.command(gtx, ke)
- e.caret.scroll = true
+ e.scrollCaret = true
e.scroller.Stop()
case key.SnippetEvent:
e.updateSnippet(gtx, ke.Start, ke.End)
@@ 364,7 337,7 @@ func (e *Editor) processKey(gtx layout.Context) {
if e.ReadOnly {
break
}
- e.caret.scroll = true
+ e.scrollCaret = true
e.scroller.Stop()
s := ke.Text
moves := 0
@@ 381,65 354,38 @@ func (e *Editor) processKey(gtx layout.Context) {
}
moves += e.replace(ke.Range.Start, ke.Range.End, s, true)
adjust += utf8.RuneCountInString(ke.Text) - moves
- e.caret.xoff = 0
+ // Reset caret xoff.
+ e.text.MoveCaret(0, 0)
if submit {
- if e.rr.Changed() {
+ if e.text.Changed() {
e.events = append(e.events, ChangeEvent{})
}
e.events = append(e.events, SubmitEvent{
- Text: e.Text(),
+ Text: e.text.Text(),
})
}
// Complete a paste event, initiated by Shortcut-V in Editor.command().
case clipboard.Event:
- e.caret.scroll = true
+ e.scrollCaret = true
e.scroller.Stop()
- e.append(ke.Text)
+ e.Insert(ke.Text)
case key.SelectionEvent:
- e.caret.scroll = true
+ e.scrollCaret = true
e.scroller.Stop()
ke.Start -= adjust
ke.End -= adjust
adjust = 0
- e.caret.start = e.closestToRune(ke.Start).runes
- e.caret.end = e.closestToRune(ke.End).runes
+ e.text.SetCaret(ke.Start, ke.End)
}
}
- if e.rr.Changed() {
+ if e.text.Changed() {
e.events = append(e.events, ChangeEvent{})
}
}
-func (e *Editor) closestToRune(runeIdx int) combinedPos {
- e.makeValid()
- pos, _ := e.index.closestToRune(runeIdx)
- return pos
-}
-
-func (e *Editor) closestToLineCol(line, col int) combinedPos {
- e.makeValid()
- return e.index.closestToLineCol(screenPos{line: line, col: col})
-}
-
-func (e *Editor) closestToXY(x fixed.Int26_6, y int) combinedPos {
- e.makeValid()
- return e.index.closestToXY(x, y)
-}
-
-func (e *Editor) 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)
-}
-
func (e *Editor) command(gtx layout.Context, k key.Event) {
direction := 1
- if e.locale.Direction.Progression() == system.TowardOrigin {
+ if gtx.Locale.Direction.Progression() == system.TowardOrigin {
direction = -1
}
moveByWord := k.Modifiers.Contain(key.ModShortcutAlt)
@@ 450,7 396,7 @@ func (e *Editor) command(gtx layout.Context, k key.Event) {
switch k.Name {
case key.NameReturn, key.NameEnter:
if !e.ReadOnly {
- e.append("\n")
+ e.Insert("\n")
}
case key.NameDeleteBackward:
if !e.ReadOnly {
@@ 469,35 415,35 @@ func (e *Editor) command(gtx layout.Context, k key.Event) {
}
}
case key.NameUpArrow:
- e.moveLines(-1, selAct)
+ e.text.MoveLines(-1, selAct)
case key.NameDownArrow:
- e.moveLines(+1, selAct)
+ e.text.MoveLines(+1, selAct)
case key.NameLeftArrow:
if moveByWord {
- e.moveWord(-1*direction, selAct)
+ e.text.MoveWord(-1*direction, selAct)
} else {
if selAct == selectionClear {
- e.ClearSelection()
+ e.text.ClearSelection()
}
- e.MoveCaret(-1*direction, -1*direction*int(selAct))
+ e.text.MoveCaret(-1*direction, -1*direction*int(selAct))
}
case key.NameRightArrow:
if moveByWord {
- e.moveWord(1*direction, selAct)
+ e.text.MoveWord(1*direction, selAct)
} else {
if selAct == selectionClear {
- e.ClearSelection()
+ e.text.ClearSelection()
}
- e.MoveCaret(1*direction, int(selAct)*direction)
+ e.text.MoveCaret(1*direction, int(selAct)*direction)
}
case key.NamePageUp:
- e.movePages(-1, selAct)
+ e.text.MovePages(-1, selAct)
case key.NamePageDown:
- e.movePages(+1, selAct)
+ e.text.MovePages(+1, selAct)
case key.NameHome:
- e.moveStart(selAct)
+ e.text.MoveStart(selAct)
case key.NameEnd:
- e.moveEnd(selAct)
+ e.text.MoveEnd(selAct)
// Initiate a paste operation, by requesting the clipboard contents; other
// half is in Editor.processKey() under clipboard.Event.
case "V":
@@ 506,7 452,7 @@ func (e *Editor) command(gtx layout.Context, k key.Event) {
}
// Copy or Cut selection -- ignored if nothing selected.
case "C", "X":
- if text := e.SelectedText(); text != "" {
+ if text := e.text.SelectedText(); text != "" {
clipboard.WriteOp{Text: text}.Add(gtx.Ops)
if k.Name == "X" && !e.ReadOnly {
e.Delete(1)
@@ 514,8 460,7 @@ func (e *Editor) command(gtx layout.Context, k key.Event) {
}
// Select all
case "A":
- e.caret.end = 0
- e.caret.start = e.Len()
+ e.text.SetCaret(0, e.text.Len())
case "Z":
if !e.ReadOnly {
if k.Modifiers.Contain(key.ModShift) {
@@ 537,71 482,35 @@ func (e *Editor) Focused() bool {
return e.focused
}
-// 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 *Editor) calculateViewSize(gtx layout.Context) image.Point {
- base := e.dims.Size
- if caretWidth := e.caretWidth(gtx); base.X < caretWidth {
- base.X = caretWidth
+// 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)
}
- return gtx.Constraints.Constrain(base)
+ e.text.Alignment = e.Alignment
+ e.text.SingleLine = e.SingleLine
+ e.text.Mask = e.Mask
}
// Layout lays out the editor. If content is not nil, it is laid out on top.
func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, content layout.Widget) layout.Dimensions {
- 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()
- e.processEvents(gtx)
- e.makeValid()
-
- if viewSize := e.calculateViewSize(gtx); viewSize != e.viewSize {
- e.viewSize = viewSize
- e.invalidate()
- }
- e.makeValid()
+ e.initBuffer()
+ e.text.Update(gtx, lt, font, size, e.processEvents)
dims := e.layout(gtx, content)
if e.focused {
// Notify IME of selection if it changed.
newSel := e.ime.selection
+ start, end := e.text.Selection()
newSel.rng = key.Range{
- Start: e.caret.start,
- End: e.caret.end,
+ Start: start,
+ End: end,
}
- caretPos, carAsc, carDesc := e.caretInfo()
+ caretPos, carAsc, carDesc := e.text.CaretInfo()
newSel.caret = key.Caret{
Pos: layout.FPt(caretPos),
Ascent: float32(carAsc),
@@ 628,19 537,23 @@ func (e *Editor) updateSnippet(gtx layout.Context, start, end int) {
if start > end {
start, end = end, start
}
- imeStart := e.closestToRune(start)
- imeEnd := e.closestToRune(end)
- e.ime.start = imeStart.runes
- e.ime.end = imeEnd.runes
- startOff := e.runeOffset(imeStart.runes)
- endOff := e.runeOffset(imeEnd.runes)
- e.rr.Seek(int64(startOff), io.SeekStart)
+ 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 > len(e.ime.scratch) {
+ if n > int64(len(e.ime.scratch)) {
e.ime.scratch = make([]byte, n)
}
scratch := e.ime.scratch[:n]
- read, _ := e.rr.Read(scratch)
+ read, _ := e.text.ReadAt(scratch, startOff)
if read != len(scratch) {
panic("e.rr.Read truncated data")
}
@@ 666,14 579,16 @@ func (e *Editor) updateSnippet(gtx layout.Context, start, end int) {
func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimensions {
// Adjust scrolling for new viewport and layout.
- e.scrollRel(0, 0)
+ e.text.ScrollRel(0, 0)
- if e.caret.scroll {
- e.caret.scroll = false
- e.scrollToCaret()
+ if e.scrollCaret {
+ e.scrollCaret = false
+ e.text.ScrollToCaret()
}
+ textDims := e.text.FullDimensions()
+ visibleDims := e.text.Dimensions()
- defer clip.Rect(image.Rectangle{Max: e.viewSize}).Push(gtx.Ops).Pop()
+ defer clip.Rect(image.Rectangle{Max: visibleDims.Size}).Push(gtx.Ops).Pop()
pointer.CursorText.Add(gtx.Ops)
var keys key.Set
if e.focused {
@@ 681,17 596,17 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens
const keyFilterNoRightDown = "(ShortAlt)-(Shift)-[←,↑]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z"
const keyFilterNoArrows = "(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z"
const keyFilterAllArrows = "(ShortAlt)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z"
- caret := e.closestToRune(e.caret.start)
+ caret, _ := e.text.Selection()
switch {
- case caret.runes == 0 && caret.runes == e.Len():
+ case caret == 0 && caret == e.text.Len():
keys = keyFilterNoArrows
- case caret.runes == 0:
+ case caret == 0:
if gtx.Locale.Direction.Progression() == system.FromOrigin {
keys = keyFilterNoLeftUp
} else {
keys = keyFilterNoRightDown
}
- case caret.runes == e.Len():
+ case caret == e.text.Len():
if gtx.Locale.Direction.Progression() == system.FromOrigin {
keys = keyFilterNoRightDown
} else {
@@ 710,17 625,19 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens
var scrollRange image.Rectangle
if e.SingleLine {
- scrollRange.Min.X = min(-e.scrollOff.X, 0)
- scrollRange.Max.X = max(0, e.dims.Size.X-(e.scrollOff.X+e.viewSize.X))
+ scrollOffX := e.text.ScrollOff().X
+ scrollRange.Min.X = min(-scrollOffX, 0)
+ scrollRange.Max.X = max(0, textDims.Size.X-(scrollOffX+visibleDims.Size.X))
} else {
- scrollRange.Min.Y = -e.scrollOff.Y
- scrollRange.Max.Y = max(0, e.dims.Size.Y-(e.scrollOff.Y+e.viewSize.Y))
+ scrollOffY := e.text.ScrollOff().Y
+ scrollRange.Min.Y = -scrollOffY
+ scrollRange.Max.Y = max(0, textDims.Size.Y-(scrollOffY+visibleDims.Size.Y))
}
e.scroller.Add(gtx.Ops, scrollRange)
e.clicker.Add(gtx.Ops)
e.dragger.Add(gtx.Ops)
- e.caret.on = false
+ e.showCaret = false
if e.focused {
now := gtx.Now
dt := now.Sub(e.blinkStart)
@@ 731,255 648,70 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens
redraw := op.InvalidateOp{At: nextBlink}
redraw.Add(gtx.Ops)
}
- e.caret.on = e.focused && (!blinking || dt%timePerBlink < timePerBlink/2)
+ e.showCaret = e.focused && (!blinking || dt%timePerBlink < timePerBlink/2)
}
if content != nil {
content(gtx)
}
- return layout.Dimensions{Size: e.viewSize, Baseline: e.dims.Baseline}
+ return visibleDims
}
// PaintSelection paints the contrasting background for selected text.
func (e *Editor) PaintSelection(gtx layout.Context) {
+ e.initBuffer()
if !e.focused {
return
}
- 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()
- }
+ e.text.PaintSelection(gtx)
}
func (e *Editor) 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 *Editor) caretWidth(gtx layout.Context) int {
- carWidth2 := gtx.Dp(1) / 2
- if carWidth2 < 1 {
- carWidth2 = 1
- }
- return carWidth2
+ e.initBuffer()
+ e.text.PaintText(gtx)
}
func (e *Editor) PaintCaret(gtx layout.Context) {
- if !e.caret.on || e.ReadOnly {
+ e.initBuffer()
+ if !e.showCaret || e.ReadOnly {
return
}
- 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 *Editor) 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
+ e.text.PaintCaret(gtx)
}
// Len is the length of the editor contents, in runes.
func (e *Editor) Len() int {
- e.makeValid()
- return e.closestToRune(math.MaxInt).runes
+ e.initBuffer()
+ return e.text.Len()
}
// Text returns the contents of the editor.
func (e *Editor) Text() string {
- return e.rr.String()
+ e.initBuffer()
+ return e.text.Text()
}
-// SetText replaces the contents of the editor, clearing any selection first.
func (e *Editor) SetText(s string) {
- e.rr = editBuffer{}
- e.caret.start = 0
- e.caret.end = 0
+ e.initBuffer()
if e.SingleLine {
s = strings.ReplaceAll(s, "\n", " ")
}
- e.replace(e.caret.start, e.caret.end, s, true)
- e.caret.xoff = 0
-}
-
-func (e *Editor) 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 *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) {
- 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 *Editor) layoutText(lt *text.Shaper) {
- e.rr.Reset()
- var r io.RuneReader = &e.rr
- if e.Mask != 0 {
- e.maskReader.Reset(&e.rr, 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.Layout(text.Parameters{
- Font: e.font,
- PxPerEm: e.textSize,
- Alignment: e.Alignment,
- }, 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.
- for _, _, err := r.ReadRune(); err != io.EOF; _, _, err = r.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
+ 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) {
- pos := e.closestToRune(e.caret.start)
- return pos.lineCol.line, pos.lineCol.col
+ e.initBuffer()
+ return e.text.CaretPos()
}
// CaretCoords returns the coordinates of the caret, relative to the
// editor itself.
func (e *Editor) 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 *Editor) 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 *Editor) 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.rr.runeAt(entry.bytes)
- entry.bytes += s
- entry.runes++
- }
- return entry.bytes
-}
-
-func (e *Editor) invalidate() {
- e.offIndex = e.offIndex[:0]
- e.valid = false
+ e.initBuffer()
+ return e.text.CaretCoords()
}
// Delete runes from the caret position. The sign of runes specifies the
@@ 987,44 719,37 @@ func (e *Editor) invalidate() {
//
// If there is a selection, it is deleted and counts as a single rune.
func (e *Editor) Delete(runes int) {
+ e.initBuffer()
if runes == 0 {
return
}
- start := e.caret.start
- end := e.caret.end
+ start, end := e.text.Selection()
if start != end {
runes -= sign(runes)
}
end += runes
e.replace(start, end, "", true)
- e.caret.xoff = 0
+ // Reset xoff.
+ e.text.MoveCaret(0, 0)
e.ClearSelection()
}
-// Insert inserts text at the caret, moving the caret forward. If there is a
-// selection, Insert overwrites it.
func (e *Editor) Insert(s string) {
- e.append(s)
- e.caret.scroll = true
-}
-
-// append inserts s at the cursor, leaving the caret is at the end of s. If
-// there is a selection, append overwrites it.
-// xxx|yyy + append zzz => xxxzzz|yyy
-func (e *Editor) append(s string) {
+ e.initBuffer()
if e.SingleLine {
s = strings.ReplaceAll(s, "\n", " ")
}
- moves := e.replace(e.caret.start, e.caret.end, s, true)
- e.caret.xoff = 0
- start := e.caret.start
- if end := e.caret.end; end < start {
+ start, end := e.text.Selection()
+ moves := e.replace(start, end, s, true)
+ if end < start {
start = end
}
- e.caret.start = start + moves
- e.caret.end = e.caret.start
+ // Reset xoff.
+ e.text.MoveCaret(0, 0)
+ e.SetCaret(start+moves, start+moves)
+ e.scrollCaret = true
}
// modification represents a change to the contents of the editor buffer.
@@ 1045,6 770,7 @@ type modification struct {
// undo applies the modification at e.history[e.historyIdx] and decrements
// e.historyIdx.
func (e *Editor) undo() {
+ e.initBuffer()
if len(e.history) < 1 || e.nextHistoryIdx == 0 {
return
}
@@ 1059,6 785,7 @@ func (e *Editor) undo() {
// redo applies the modification at e.history[e.historyIdx] and increments
// e.historyIdx.
func (e *Editor) redo() {
+ e.initBuffer()
if len(e.history) < 1 || e.nextHistoryIdx == len(e.history) {
return
}
@@ 1073,15 800,16 @@ func (e *Editor) redo() {
// 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.
+// 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
}
- startPos := e.closestToRune(start)
- endPos := e.closestToRune(end)
- startOff := e.runeOffset(startPos.runes)
- replaceSize := endPos.runes - startPos.runes
+ start = min(start, length)
+ end = min(end, length)
+ replaceSize := end - start
el := e.Len()
var sc int
idx := 0
@@ 1098,119 826,49 @@ func (e *Editor) replace(start, end int, s string, addHistory bool) int {
idx += n
sc++
}
- newEnd := startPos.runes + sc
if addHistory {
- e.rr.Seek(int64(startOff), 0)
deleted := make([]rune, 0, replaceSize)
+ readPos := e.text.ByteOffset(start)
for i := 0; i < replaceSize; i++ {
- ru, _, _ := e.rr.ReadRune()
+ 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: startPos.runes,
+ StartRune: start,
ApplyContent: s,
ReverseContent: string(deleted),
})
e.nextHistoryIdx++
}
- e.rr.deleteRunes(startOff, replaceSize)
- e.rr.prepend(startOff, s)
+ sc = e.text.Replace(start, end, s)
+ newEnd := start + sc
adjust := func(pos int) int {
switch {
- case newEnd < pos && pos <= endPos.runes:
+ case newEnd < pos && pos <= end:
pos = newEnd
- case endPos.runes < pos:
- diff := newEnd - endPos.runes
+ case end < pos:
+ diff := newEnd - end
pos = pos + diff
}
return pos
}
- e.caret.start = adjust(e.caret.start)
- e.caret.end = adjust(e.caret.end)
e.ime.start = adjust(e.ime.start)
e.ime.end = adjust(e.ime.end)
- e.invalidate()
return sc
}
-func (e *Editor) 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 *Editor) 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 *Editor) 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 *Editor) 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 *Editor) 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.rr.runeBefore(off)
- } else {
- r, _ = e.rr.runeAt(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)
+ e.initBuffer()
+ e.text.MoveCaret(startDelta, endDelta)
}
// deleteWord deletes the next word(s) in the specified direction.
@@ 1223,7 881,8 @@ func (e *Editor) deleteWord(distance int) {
return
}
- if e.caret.start != e.caret.end {
+ start, end := e.text.Selection()
+ if start != end {
e.Delete(1)
distance -= sign(distance)
}
@@ 1237,26 896,26 @@ func (e *Editor) deleteWord(distance int) {
if distance < 0 {
words, direction = distance*-1, -1
}
+ caret, _ := e.text.Selection()
// atEnd if offset is at or beyond either side of the buffer.
- caret := e.closestToRune(e.caret.start)
atEnd := func(runes int) bool {
- idx := caret.runes + runes*direction
+ 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 + runes*direction
+ idx := caret + runes*direction
if idx < 0 {
idx = 0
} else if idx > e.Len() {
idx = e.Len()
}
- off := e.runeOffset(idx)
+ off := e.text.ByteOffset(idx)
var r rune
if direction < 0 {
- r, _ = e.rr.runeBefore(off)
+ r, _, _ = e.text.ReadRuneBefore(int64(off))
} else {
- r, _ = e.rr.runeAt(off)
+ r, _, _ = e.text.ReadRuneAt(int64(off))
}
return r
}
@@ 1271,92 930,58 @@ func (e *Editor) deleteWord(distance int) {
e.Delete(runes * direction)
}
-func (e *Editor) 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 *Editor) SelectionLen() int {
- return abs(e.caret.start - e.caret.end)
+ 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) {
- return e.caret.start, e.caret.end
+ 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.caret.start = e.closestToRune(start).runes
- e.caret.end = e.closestToRune(end).runes
- e.caret.scroll = true
+ 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 {
- 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)
- e.rr.Seek(int64(start), io.SeekStart)
- _, err := e.rr.Read(buf)
- if err != nil {
- // The only error that rr.Read can return is EOF, which just means no
- // selection, but we've already made sure that shouldn't happen.
- panic("impossible error because end is before e.rr.Len()")
- }
- return string(buf)
-}
-
-func (e *Editor) updateSelection(selAct selectionAction) {
- if selAct == selectionClear {
- e.ClearSelection()
- }
+ e.initBuffer()
+ return e.text.SelectedText()
}
// ClearSelection clears the selection, by setting the selection end equal to
// the selection start.
func (e *Editor) ClearSelection() {
- e.caret.end = e.caret.start
+ e.initBuffer()
+ e.text.ClearSelection()
}
// WriteTo implements io.WriterTo.
func (e *Editor) WriteTo(w io.Writer) (int64, error) {
- return e.rr.WriteTo(w)
+ e.initBuffer()
+ return e.text.WriteTo(w)
}
// Seek implements io.Seeker.
func (e *Editor) Seek(offset int64, whence int) (int64, error) {
- return e.rr.Seek(offset, io.SeekStart)
+ e.initBuffer()
+ return e.text.Seek(offset, whence)
}
// Read implements io.Reader.
func (e *Editor) Read(p []byte) (int, error) {
- return e.rr.Read(p)
+ e.initBuffer()
+ return e.text.Read(p)
}
func max(a, b int) int {
M widget/editor_test.go => widget/editor_test.go +46 -46
@@ 121,7 121,7 @@ func TestEditorReadOnly(t *testing.T) {
// Select everything.
gtx.Ops.Reset()
- gtx.Queue.(*testQueue).events = append(gtx.Queue.(*testQueue).events, key.Event{Name: "A", Modifiers: key.ModShortcut})
+ gtx.Queue = &testQueue{events: []event.Event{key.Event{Name: "A", Modifiers: key.ModShortcut}}}
dims = e.Layout(gtx, cache, font, fontSize, nil)
textContent := e.Text()
cStart2, cEnd2 := e.Selection()
@@ 137,7 137,7 @@ func TestEditorReadOnly(t *testing.T) {
// Type some new characters.
gtx.Ops.Reset()
- gtx.Queue.(*testQueue).events = append(gtx.Queue.(*testQueue).events, key.EditEvent{Range: key.Range{Start: cStart2, End: cEnd2}, Text: "something else"})
+ gtx.Queue = &testQueue{events: []event.Event{key.EditEvent{Range: key.Range{Start: cStart2, End: cEnd2}, Text: "something else"}}}
dims = e.Layout(gtx, cache, font, fontSize, nil)
textContent2 := e.Text()
if textContent2 != textContent {
@@ 146,7 146,7 @@ func TestEditorReadOnly(t *testing.T) {
// Try to delete selection.
gtx.Ops.Reset()
- gtx.Queue.(*testQueue).events = append(gtx.Queue.(*testQueue).events, key.Event{Name: key.NameDeleteBackward})
+ gtx.Queue = &testQueue{events: []event.Event{key.Event{Name: key.NameDeleteBackward}}}
dims = e.Layout(gtx, cache, font, fontSize, nil)
textContent2 = e.Text()
if textContent2 != textContent {
@@ 156,7 156,7 @@ func TestEditorReadOnly(t *testing.T) {
// Click and drag from the middle of the first line
// to the center.
gtx.Ops.Reset()
- gtx.Queue.(*testQueue).events = append(gtx.Queue.(*testQueue).events,
+ gtx.Queue = &testQueue{events: []event.Event{
pointer.Event{
Type: pointer.Press,
Buttons: pointer.ButtonPrimary,
@@ 172,7 172,8 @@ func TestEditorReadOnly(t *testing.T) {
Buttons: pointer.ButtonPrimary,
Position: layout.FPt(dims.Size).Mul(.5),
},
- )
+ }}
+ e.Layout(gtx, cache, font, fontSize, nil)
cStart3, cEnd3 := e.Selection()
if cStart3 == cStart2 || cEnd3 == cEnd2 {
t.Errorf("expected mouse interaction to change selection.")
@@ 246,7 247,7 @@ func TestEditor(t *testing.T) {
// Regression test for bad in-cluster rune offset math.
e.SetText("æbc")
e.Layout(gtx, cache, font, fontSize, nil)
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
textSample := "æbc\naøå••"
@@ 258,19 259,19 @@ func TestEditor(t *testing.T) {
}
e.Layout(gtx, cache, font, fontSize, nil)
assertCaret(t, e, 0, 0, 0)
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 0, len("æbc\n"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 3, len("æbc"))
- e.moveLines(+1, selectionClear)
+ e.text.MoveLines(+1, selectionClear)
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
assertCaret(t, e, 1, 5, len("æbc\naøå••"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 5, len("æbc\naøå••"))
- e.moveLines(3, selectionClear)
+ e.text.MoveLines(3, selectionClear)
e.SetCaret(0, 0)
assertCaret(t, e, 0, 0, 0)
@@ 282,7 283,7 @@ func TestEditor(t *testing.T) {
// Ensure that password masking does not affect caret behavior
e.MoveCaret(-3, -3)
assertCaret(t, e, 1, 1, len("æbc\na"))
- e.Mask = '*'
+ e.text.Mask = '*'
e.Layout(gtx, cache, font, fontSize, nil)
assertCaret(t, e, 1, 1, len("æbc\na"))
e.MoveCaret(-3, -3)
@@ 324,9 325,9 @@ func TestEditor(t *testing.T) {
// Test that moveLine applies x offsets from previous moves.
e.SetText("long line\nshort")
e.SetCaret(0, 0)
- e.moveEnd(selectionClear)
- e.moveLines(+1, selectionClear)
- e.moveLines(-1, selectionClear)
+ e.text.MoveEnd(selectionClear)
+ e.text.MoveLines(+1, selectionClear)
+ e.text.MoveLines(-1, selectionClear)
assertCaret(t, e, 0, utf8.RuneCountInString("long line"), len("long line"))
}
@@ 366,14 367,14 @@ func TestEditorRTL(t *testing.T) {
e.MoveCaret(+1, +1)
assertCaret(t, e, 0, 3, len("الح"))
// Move to the "end" of the line. This moves to the left edge of the line.
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 4, len("الحب"))
sentence := "الحب سماء لا\nتمط غير الأحلام"
e.SetText(sentence)
e.Layout(gtx, cache, font, fontSize, nil)
assertCaret(t, e, 0, 0, 0)
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 12, len("الحب سماء لا"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 0, len("الحب سماء لا\n"))
@@ 383,13 384,13 @@ func TestEditorRTL(t *testing.T) {
assertCaret(t, e, 1, 0, len("الحب سماء لا\n"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 12, len("الحب سماء لا"))
- e.moveLines(+1, selectionClear)
+ e.text.MoveLines(+1, selectionClear)
assertCaret(t, e, 1, 14, len("الحب سماء لا\nتمط غير الأحلا"))
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام"))
- e.moveLines(3, selectionClear)
+ e.text.MoveLines(3, selectionClear)
assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام"))
e.SetCaret(utf8.RuneCountInString(sentence), 0)
assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام"))
@@ 440,7 441,7 @@ func TestEditorLigature(t *testing.T) {
assertCaret(t, e, 0, 0, 0)
e.SetText("fl") // just a ligature
e.Layout(gtx, cache, font, fontSize, nil)
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 2, len("fl"))
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 1, len("f"))
@@ 451,7 452,7 @@ func TestEditorLigature(t *testing.T) {
e.SetText("flaffl•ffi\n•fflfi") // 3 ligatures on line 0, 2 on line 1
e.Layout(gtx, cache, font, fontSize, nil)
assertCaret(t, e, 0, 0, 0)
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
assertCaret(t, e, 0, 10, len("ffaffl•ffi"))
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 0, len("ffaffl•ffi\n"))
@@ 504,13 505,13 @@ func TestEditorLigature(t *testing.T) {
e.Layout(gtx, cache, font, fontSize, nil)
// Ensure that all runes in the final cluster of a line are properly
// decoded when moving to the end of the line. This is a regression test.
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
// The first line was broken by line wrapping, not a newline character. As such,
// the cursor can reach the position after the final glyph (a space).
assertCaret(t, e, 0, 14, len("fflffl fflffl "))
- e.moveLines(1, selectionClear)
+ e.text.MoveLines(1, selectionClear)
assertCaret(t, e, 1, 13, len("fflffl fflffl fflffl fflffl"))
- e.moveLines(-1, selectionClear)
+ e.text.MoveLines(-1, selectionClear)
assertCaret(t, e, 0, 14, len("fflffl fflffl "))
// Absurdly narrow constraints to force each ligature onto its own line.
@@ 554,7 555,7 @@ func assertCaret(t *testing.T, e *Editor, line, col, bytes int) {
if gotLine != line || gotCol != col {
t.Errorf("caret at (%d, %d), expected (%d, %d)", gotLine, gotCol, line, col)
}
- caretBytes := e.runeOffset(e.caret.start)
+ caretBytes := e.text.runeOffset(e.text.caret.start)
if bytes != caretBytes {
t.Errorf("caret at buffer position %d, expected %d", caretBytes, bytes)
}
@@ 588,9 589,8 @@ func TestEditorCaretConsistency(t *testing.T) {
fontSize := unit.Sp(10)
font := text.Font{}
for _, a := range []text.Alignment{text.Start, text.Middle, text.End} {
- e := &Editor{
- Alignment: a,
- }
+ e := &Editor{}
+ e.Alignment = a
e.Layout(gtx, cache, font, fontSize, nil)
consistent := func() error {
@@ 598,8 598,8 @@ func TestEditorCaretConsistency(t *testing.T) {
gotLine, gotCol := e.CaretPos()
gotCoords := e.CaretCoords()
// Blow away index to re-compute position from scratch.
- e.invalidate()
- want := e.closestToRune(e.caret.start)
+ e.text.invalidate()
+ want := e.text.closestToRune(e.text.caret.start)
wantCoords := f32.Pt(float32(want.x)/64, float32(want.y))
if want.lineCol.line != gotLine || int(want.lineCol.col) != gotCol || gotCoords != wantCoords {
return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s",
@@ 619,17 619,17 @@ func TestEditorCaretConsistency(t *testing.T) {
case moveRune:
e.MoveCaret(int(distance), int(distance))
case moveLine:
- e.moveLines(int(distance), selectionClear)
+ e.text.MoveLines(int(distance), selectionClear)
case movePage:
- e.movePages(int(distance), selectionClear)
+ e.text.MovePages(int(distance), selectionClear)
case moveStart:
- e.moveStart(selectionClear)
+ e.text.MoveStart(selectionClear)
case moveEnd:
- e.moveEnd(selectionClear)
+ e.text.MoveEnd(selectionClear)
case moveCoord:
- e.moveCoord(image.Pt(int(x), int(y)))
+ e.text.MoveCoord(image.Pt(int(x), int(y)))
case moveWord:
- e.moveWord(int(distance), selectionClear)
+ e.text.MoveWord(int(distance), selectionClear)
case deleteWord:
e.deleteWord(int(distance))
default:
@@ 687,8 687,8 @@ func TestEditorMoveWord(t *testing.T) {
for ii, tt := range tests {
e := setup(tt.Text)
e.MoveCaret(tt.Start, tt.Start)
- e.moveWord(tt.Skip, selectionClear)
- caretBytes := e.runeOffset(e.caret.start)
+ e.text.MoveWord(tt.Skip, selectionClear)
+ caretBytes := e.text.runeOffset(e.text.caret.start)
if caretBytes != tt.Want {
t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, caretBytes, tt.Want)
}
@@ 884,7 884,7 @@ func TestEditorDeleteWord(t *testing.T) {
e.MoveCaret(tt.Start, tt.Start)
e.MoveCaret(0, tt.Selection)
e.deleteWord(tt.Delete)
- caretBytes := e.runeOffset(e.caret.start)
+ caretBytes := e.text.runeOffset(e.text.caret.start)
if caretBytes != tt.Want {
t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, caretBytes, tt.Want)
}
@@ 938,8 938,8 @@ g 2 4 6 8 g
_ = e.Events() // throw away any events from this layout
// Build the selection events
- startPos := e.closestToRune(start)
- endPos := e.closestToRune(end)
+ startPos := e.text.closestToRune(start)
+ endPos := e.text.closestToRune(end)
tq := &testQueue{
events: []event.Event{
pointer.Event{
@@ 1008,8 1008,8 @@ g 2 4 6 8 g
gtx.Queue = nil
e.Layout(gtx, cache, font, fontSize, nil)
- caretStart := e.closestToRune(e.caret.start)
- caretEnd := e.closestToRune(e.caret.end)
+ caretStart := e.text.closestToRune(e.text.caret.start)
+ caretEnd := e.text.closestToRune(e.text.caret.end)
logicalPosMatch(t, n, "start", tst.startPos, caretEnd)
logicalPosMatch(t, n, "end", tst.endPos, caretStart)
}
@@ 1189,8 1189,8 @@ func TestEditor_Submit(t *testing.T) {
// It assumes single-run lines, which isn't safe with non-test text
// data.
func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 {
- start := e.closestToLineCol(lineNum, colStart)
- end := e.closestToLineCol(lineNum, colEnd)
+ start := e.text.closestToLineCol(lineNum, colStart)
+ end := e.text.closestToLineCol(lineNum, colEnd)
delta := start.x - end.x
if delta < 0 {
delta = -delta
@@ 1201,7 1201,7 @@ func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 {
// testBaseline returns the y coordinate of the baseline for the
// given line number.
func textBaseline(e *Editor, lineNum int) float32 {
- start := e.closestToLineCol(lineNum, 0)
+ start := e.text.closestToLineCol(lineNum, 0)
return float32(start.y)
}
M widget/label.go => widget/label.go +19 -1
@@ 18,12 18,30 @@ import (
// Label is a widget for laying out and drawing text.
type Label struct {
- // Alignment specify the text alignment.
+ // Alignment specifies the text alignment.
Alignment text.Alignment
// MaxLines limits the number of lines. Zero means no limit.
MaxLines int
+ // Selectable optionally provides text selection state. If nil,
+ // text will not be selectable.
+ Selectable *Selectable
}
+// Layout the label with the given shaper, font, size, and text. Content is a function that will be invoked
+// with the label's clip area applied, and should be used to set colors and paint the text/selection.
+// content will only be invoked for labels with a non-nil Selectable. For stateless labels, the paint color
+// should be set prior to calling Layout.
+func (l Label) LayoutSelectable(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, txt string, content layout.Widget) layout.Dimensions {
+ if l.Selectable == nil {
+ return l.Layout(gtx, lt, font, size, txt)
+ }
+ l.Selectable.text.Alignment = l.Alignment
+ l.Selectable.text.MaxLines = l.MaxLines
+ l.Selectable.SetText(txt)
+ return l.Selectable.Layout(gtx, lt, font, size, content)
+}
+
+// Layout the text as non-interactive.
func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, txt string) layout.Dimensions {
cs := gtx.Constraints
textSize := fixed.I(gtx.Sp(size))
M widget/material/label.go => widget/material/label.go +20 -6
@@ 5,6 5,7 @@ package material
import (
"image/color"
+ "gioui.org/internal/f32color"
"gioui.org/layout"
"gioui.org/op/paint"
"gioui.org/text"
@@ 17,6 18,8 @@ type LabelStyle struct {
Font text.Font
// Color is the text color.
Color color.NRGBA
+ // SelectionColor is the color of the background for selected text.
+ SelectionColor color.NRGBA
// Alignment specify the text alignment.
Alignment text.Alignment
// MaxLines limits the number of lines. Zero means no limit.
@@ 25,6 28,7 @@ type LabelStyle struct {
TextSize unit.Sp
shaper *text.Shaper
+ State *widget.Selectable
}
func H1(th *Theme, txt string) LabelStyle {
@@ 85,15 89,25 @@ func Overline(th *Theme, txt string) LabelStyle {
func Label(th *Theme, size unit.Sp, txt string) LabelStyle {
return LabelStyle{
- Text: txt,
- Color: th.Palette.Fg,
- TextSize: size,
- shaper: th.Shaper,
+ Text: txt,
+ Color: th.Palette.Fg,
+ SelectionColor: f32color.MulAlpha(th.Palette.ContrastBg, 0x60),
+ TextSize: size,
+ shaper: th.Shaper,
}
}
func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
- tl := widget.Label{Alignment: l.Alignment, MaxLines: l.MaxLines}
- return tl.Layout(gtx, l.shaper, l.Font, l.TextSize, l.Text)
+ tl := widget.Label{Alignment: l.Alignment, MaxLines: l.MaxLines, Selectable: l.State}
+ if l.State == nil {
+ return tl.Layout(gtx, l.shaper, l.Font, l.TextSize, l.Text)
+ }
+ return tl.LayoutSelectable(gtx, l.shaper, l.Font, l.TextSize, l.Text, func(gtx layout.Context) layout.Dimensions {
+ paint.ColorOp{Color: l.SelectionColor}.Add(gtx.Ops)
+ l.State.PaintSelection(gtx)
+ paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
+ l.State.PaintText(gtx)
+ return layout.Dimensions{}
+ })
}
A widget/selectable.go => widget/selectable.go +343 -0
@@ 0,0 1,343 @@
+package widget
+
+import (
+ "image"
+ "math"
+ "strings"
+
+ "gioui.org/gesture"
+ "gioui.org/io/clipboard"
+ "gioui.org/io/event"
+ "gioui.org/io/key"
+ "gioui.org/io/pointer"
+ "gioui.org/io/system"
+ "gioui.org/layout"
+ "gioui.org/op/clip"
+ "gioui.org/text"
+ "gioui.org/unit"
+)
+
+// stringSource is an immutable textSource with a fixed string
+// value.
+type stringSource struct {
+ reader *strings.Reader
+}
+
+var _ textSource = stringSource{}
+
+func newStringSource(str string) stringSource {
+ return stringSource{
+ reader: strings.NewReader(str),
+ }
+}
+
+func (s stringSource) Changed() bool {
+ return false
+}
+
+func (s stringSource) Size() int64 {
+ return s.reader.Size()
+}
+
+func (s stringSource) ReadAt(b []byte, offset int64) (int, error) {
+ return s.reader.ReadAt(b, offset)
+}
+
+// ReplaceRunes is unimplemented, as a stringSource is immutable.
+func (s stringSource) ReplaceRunes(byteOffset, runeCount int64, str string) {
+ return
+}
+
+// Selectable holds text selection state.
+type Selectable struct {
+ initialized bool
+ source stringSource
+ lastValue string
+ text textView
+ focused bool
+ requestFocus bool
+ dragging bool
+ dragger gesture.Drag
+ 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
+}
+
+// initialize must be called at the beginning of any exported method that
+// manipulates text state. It ensures that the underlying text is safe to
+// access.
+func (l *Selectable) initialize() {
+ if !l.initialized {
+ l.source = newStringSource("")
+ l.text.SetSource(l.source)
+ l.initialized = true
+ }
+}
+
+// Focus requests the input focus for the label.
+func (l *Selectable) Focus() {
+ l.requestFocus = true
+}
+
+// Focused returns whether the label is focused or not.
+func (l *Selectable) Focused() bool {
+ return l.focused
+}
+
+// PaintSelection paints the contrasting background for selected text.
+func (l *Selectable) PaintSelection(gtx layout.Context) {
+ l.initialize()
+ if !l.focused {
+ return
+ }
+ l.text.PaintSelection(gtx)
+}
+
+func (l *Selectable) PaintText(gtx layout.Context) {
+ l.initialize()
+ l.text.PaintText(gtx)
+}
+
+// SelectionLen returns the length of the selection, in runes; it is
+// equivalent to utf8.RuneCountInString(e.SelectedText()).
+func (l *Selectable) SelectionLen() int {
+ l.initialize()
+ return l.text.SelectionLen()
+}
+
+// Selection returns the start and end of the selection, as rune offsets.
+// start can be > end.
+func (l *Selectable) Selection() (start, end int) {
+ l.initialize()
+ return l.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 (l *Selectable) SetCaret(start, end int) {
+ l.initialize()
+ l.text.SetCaret(start, end)
+}
+
+// SelectedText returns the currently selected text (if any) from the editor.
+func (l *Selectable) SelectedText() string {
+ l.initialize()
+ return l.text.SelectedText()
+}
+
+// ClearSelection clears the selection, by setting the selection end equal to
+// the selection start.
+func (l *Selectable) ClearSelection() {
+ l.initialize()
+ l.text.ClearSelection()
+}
+
+// Text returns the contents of the label.
+func (l *Selectable) Text() string {
+ l.initialize()
+ return l.text.Text()
+}
+
+// SetText updates the text to s if it does not already contain s. Updating the
+// text will clear the selection unless the selectable already contains s.
+func (l *Selectable) SetText(s string) {
+ l.initialize()
+ if l.lastValue != s {
+ l.source = newStringSource(s)
+ l.lastValue = s
+ l.text.SetSource(l.source)
+ }
+}
+
+// Layout clips to the dimensions of the selectable, updates the shaped text, configures input handling, and invokes
+// content. content is expected to set colors and invoke the Paint methods. content may be nil, in which case nothing
+// will be displayed.
+func (l *Selectable) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, content layout.Widget) layout.Dimensions {
+ l.initialize()
+ l.text.Update(gtx, lt, font, size, l.handleEvents)
+ dims := l.text.Dimensions()
+ defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop()
+ pointer.CursorText.Add(gtx.Ops)
+ var keys key.Set
+ if l.focused {
+ const keyFilterAllArrows = "(ShortAlt)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z"
+ keys = keyFilterAllArrows
+ }
+ key.InputOp{Tag: l, Keys: keys}.Add(gtx.Ops)
+ if l.requestFocus {
+ key.FocusOp{Tag: l}.Add(gtx.Ops)
+ key.SoftKeyboardOp{Show: true}.Add(gtx.Ops)
+ }
+ l.requestFocus = false
+
+ l.clicker.Add(gtx.Ops)
+ l.dragger.Add(gtx.Ops)
+
+ if content != nil {
+ content(gtx)
+ }
+ return dims
+}
+
+func (l *Selectable) handleEvents(gtx layout.Context) {
+ // Flush events from before the previous Layout.
+ n := copy(l.events, l.events[l.prevEvents:])
+ l.events = l.events[:n]
+ l.prevEvents = n
+ oldStart, oldLen := min(l.text.Selection()), l.text.SelectionLen()
+ l.processPointer(gtx)
+ l.processKey(gtx)
+ // Queue a SelectEvent if the selection changed, including if it went away.
+ if newStart, newLen := min(l.text.Selection()), l.text.SelectionLen(); oldStart != newStart || oldLen != newLen {
+ l.events = append(l.events, SelectEvent{})
+ }
+}
+
+func (e *Selectable) processPointer(gtx layout.Context) {
+ for _, evt := range e.clickDragEvents(gtx) {
+ switch evt := evt.(type) {
+ case gesture.ClickEvent:
+ switch {
+ case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse,
+ evt.Type == gesture.TypeClick && evt.Source != pointer.Mouse:
+ prevCaretPos, _ := e.text.Selection()
+ e.text.MoveCoord(image.Point{
+ X: int(math.Round(float64(evt.Position.X))),
+ Y: int(math.Round(float64(evt.Position.Y))),
+ })
+ e.requestFocus = 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.MoveStart(selectionClear)
+ e.text.MoveEnd(selectionExtend)
+ e.dragging = false
+ }
+ }
+ case pointer.Event:
+ release := false
+ switch {
+ case evt.Type == pointer.Release && evt.Source == pointer.Mouse:
+ release = true
+ fallthrough
+ case evt.Type == pointer.Drag && evt.Source == pointer.Mouse:
+ if e.dragging {
+ e.text.MoveCoord(image.Point{
+ X: int(math.Round(float64(evt.Position.X))),
+ Y: int(math.Round(float64(evt.Position.Y))),
+ })
+
+ if release {
+ e.dragging = false
+ }
+ }
+ }
+ }
+ }
+}
+
+func (e *Selectable) clickDragEvents(gtx layout.Context) []event.Event {
+ var combinedEvents []event.Event
+ for _, evt := range e.clicker.Events(gtx) {
+ combinedEvents = append(combinedEvents, evt)
+ }
+ for _, evt := range e.dragger.Events(gtx.Metric, gtx, gesture.Both) {
+ combinedEvents = append(combinedEvents, evt)
+ }
+ return combinedEvents
+}
+
+func (e *Selectable) processKey(gtx layout.Context) {
+ for _, ke := range gtx.Events(e) {
+ switch ke := ke.(type) {
+ case key.FocusEvent:
+ e.focused = ke.Focus
+ case key.Event:
+ if !e.focused || ke.State != key.Press {
+ break
+ }
+ e.command(gtx, ke)
+ }
+ }
+}
+
+func (e *Selectable) command(gtx layout.Context, k key.Event) {
+ 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
+ }
+ switch k.Name {
+ 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.MoveStart(selAct)
+ case key.NameEnd:
+ e.text.MoveEnd(selAct)
+ // Copy or Cut selection -- ignored if nothing selected.
+ case "C", "X":
+ if text := e.text.SelectedText(); text != "" {
+ clipboard.WriteOp{Text: text}.Add(gtx.Ops)
+ }
+ // Select all
+ case "A":
+ e.text.SetCaret(0, e.text.Len())
+ }
+}
+
+// Events returns available text events.
+func (l *Selectable) Events() []EditorEvent {
+ events := l.events
+ l.events = nil
+ l.prevEvents = 0
+ return events
+}
A widget/selectable_test.go => widget/selectable_test.go +120 -0
@@ 0,0 1,120 @@
+package widget
+
+import (
+ "fmt"
+ "image"
+ "testing"
+
+ "gioui.org/font/gofont"
+ "gioui.org/io/key"
+ "gioui.org/layout"
+ "gioui.org/op"
+ "gioui.org/text"
+ "gioui.org/unit"
+)
+
+func TestSelectableZeroValue(t *testing.T) {
+ var s Selectable
+ if s.Text() != "" {
+ t.Errorf("expected zero value to have no text, got %q", s.Text())
+ }
+ if start, end := s.Selection(); start != 0 || end != 0 {
+ t.Errorf("expected start=0, end=0, got start=%d, end=%d", start, end)
+ }
+ if selected := s.SelectedText(); selected != "" {
+ t.Errorf("expected selected text to be \"\", got %q", selected)
+ }
+ s.SetCaret(5, 5)
+ if start, end := s.Selection(); start != 0 || end != 0 {
+ t.Errorf("expected start=0, end=0, got start=%d, end=%d", start, end)
+ }
+}
+
+// Verify that an existing selection is dismissed when you press arrow keys.
+func TestSelectableMove(t *testing.T) {
+ gtx := layout.Context{
+ Ops: new(op.Ops),
+ Locale: english,
+ }
+ cache := text.NewShaper(gofont.Collection())
+ font := text.Font{}
+ fontSize := unit.Sp(10)
+
+ str := `0123456789`
+
+ // Layout once to populate e.lines and get focus.
+ gtx.Queue = newQueue(key.FocusEvent{Focus: true})
+ s := new(Selectable)
+
+ w := func(layout.Context) layout.Dimensions { return layout.Dimensions{} }
+ Label{
+ Selectable: s,
+ }.LayoutSelectable(gtx, cache, text.Font{}, fontSize, str, w)
+
+ testKey := func(keyName string) {
+ // Select 345
+ s.SetCaret(3, 6)
+ if start, end := s.Selection(); start != 3 || end != 6 {
+ t.Errorf("expected start=%d, end=%d, got start=%d, end=%d", 3, 6, start, end)
+ }
+ if expected, got := "345", s.SelectedText(); expected != got {
+ t.Errorf("KeyName %s, expected %q, got %q", keyName, expected, got)
+ }
+
+ // Press the key
+ gtx.Queue = newQueue(key.Event{State: key.Press, Name: keyName})
+ Label{
+ Selectable: s,
+ }.LayoutSelectable(gtx, cache, font, fontSize, str, w)
+
+ if expected, got := "", s.SelectedText(); expected != got {
+ t.Errorf("KeyName %s, expected %q, got %q", keyName, expected, got)
+ }
+ }
+
+ testKey(key.NameLeftArrow)
+ testKey(key.NameRightArrow)
+ testKey(key.NameUpArrow)
+ testKey(key.NameDownArrow)
+}
+
+func TestSelectableConfigurations(t *testing.T) {
+ gtx := layout.Context{
+ Ops: new(op.Ops),
+ Constraints: layout.Exact(image.Pt(300, 300)),
+ Locale: english,
+ }
+ cache := text.NewShaper(gofont.Collection())
+ fontSize := unit.Sp(10)
+ font := text.Font{}
+ sentence := "\n\n\n\n\n\n\n\n\n\n\n\nthe quick brown fox jumps over the lazy dog"
+ w := func(layout.Context) layout.Dimensions { return layout.Dimensions{} }
+
+ for _, alignment := range []text.Alignment{text.Start, text.Middle, text.End} {
+ for _, zeroMin := range []bool{true, false} {
+ t.Run(fmt.Sprintf("Alignment: %v ZeroMinConstraint: %v", alignment, zeroMin), func(t *testing.T) {
+ defer func() {
+ if err := recover(); err != nil {
+ t.Error(err)
+ }
+ }()
+ if zeroMin {
+ gtx.Constraints.Min = image.Point{}
+ } else {
+ gtx.Constraints.Min = gtx.Constraints.Max
+ }
+ s := new(Selectable)
+ label := Label{
+ Alignment: alignment,
+ Selectable: s,
+ }
+ interactiveDims := label.LayoutSelectable(gtx, cache, font, fontSize, sentence, w)
+ staticDims := label.Layout(gtx, cache, font, fontSize, sentence)
+
+ if interactiveDims != staticDims {
+ t.Errorf("expected consistent dimensions, static returned %#+v, interactive returned %#+v", staticDims, interactiveDims)
+ }
+ })
+ }
+ }
+}
M widget/text.go => widget/text.go +1 -44
@@ 37,49 37,6 @@ type textSource interface {
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
@@ 102,7 59,7 @@ type textView struct {
textSize fixed.Int26_6
seekCursor int64
rr textSource
- maskReader maskReader2
+ maskReader maskReader
lastMask rune
maxWidth, minWidth int
viewSize image.Point