widget{,/material}: rebuild label and editor with textView

This commit rebuilds the editor and label types on the common
foundation provided by textView. This enables labels to have
optional state that makes them selectable, and allows the
two widgets to share the code for managing cursor positions,
displaying selections, and soforth. Labels now have an additional
Layout function which can be invoked if they have a Selectable.
It accepts a layout.Widget used to paint their contents. Stateless
labels should still use the old Layout method.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
Chris Waldon
2022-12-23 07:54:27 -05:00
committed by Elias Naur
parent f99aff96ee
commit e98c8955bb
8 changed files with 780 additions and 761 deletions
+16 -74
View File
@@ -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:])
}
+215 -590
View File
File diff suppressed because it is too large Load Diff
+46 -46
View File
@@ -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)
}
+19 -1
View File
@@ -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))
+20 -6
View File
@@ -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{}
})
}
+343
View File
@@ -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
}
+120
View File
@@ -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)
}
})
}
}
}
+1 -44
View File
@@ -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