widget,widget/material: add selection to the editor

- Allow dragging to be on both horizontal and vertical axes at once.
- Split Editor.caret.pos into caret.start and caret.stop. caret.start is
  the old caret.pos, and is both the position of the caret, and also the
  start of selected text. caret.end is the end of the selected text.
  Start can be after end, e.g. after after Shift-DownArrow.
- Update caret.end after a mouse drag, and various shifted keys
  (Shift-UpArrow, Shift-DownArrow, etc).
- Change Shortcut-C to copy only the selected text, not the whole editor
  text.
- Add Shortcut-X to copy and delete selected text, and Shortcut-A to
  select all text.
- The various Insert/Delete/etc functions now overwrite or delete the
  selection, as appropriate.
- Change MoveCaret to accept a distance for selection end, as well.
  Change SetCaret to accept a selection end offset.
- Add SelectionLen to get the selection length, Selection to get
  selection offsets, SelectedText to get the selected text, and
  ClearSelection to clear the selection.
- Add a rudimentary selection unit test, and extend the deleteWord unit
  test with some text selection cases.
- Add SelectionColor to material.EditorStyle, which defaults to
  Theme.Palette.ContrastBg.

Signed-off-by: Larry Clapp <larry@theclapp.org>
This commit is contained in:
Larry Clapp
2021-01-20 20:57:49 -05:00
committed by Elias Naur
parent e78bd15564
commit 34273940a0
5 changed files with 735 additions and 193 deletions
+258 -46
View File
@@ -7,6 +7,7 @@ import (
"image"
"math/rand"
"reflect"
"strings"
"testing"
"testing/quick"
"unicode"
@@ -15,10 +16,12 @@ import (
"gioui.org/font/gofont"
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
"golang.org/x/image/math/fixed"
)
func TestEditor(t *testing.T) {
@@ -34,37 +37,37 @@ func TestEditor(t *testing.T) {
e.SetText("æbc\naøå•")
e.Layout(gtx, cache, font, fontSize)
assertCaret(t, e, 0, 0, 0)
e.moveEnd()
e.moveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
e.MoveCaret(+1)
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 0, len("æbc\n"))
e.MoveCaret(-1)
e.MoveCaret(-1, -1)
assertCaret(t, e, 0, 3, len("æbc"))
e.moveLines(+1)
e.moveLines(+1, +1)
assertCaret(t, e, 1, 3, len("æbc\naøå"))
e.moveEnd()
e.moveEnd(selectionClear)
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
e.MoveCaret(+1)
e.MoveCaret(+1, +1)
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
e.SetCaret(0)
e.SetCaret(0, 0)
assertCaret(t, e, 0, 0, 0)
e.SetCaret(len("æ"))
e.SetCaret(len("æ"), len("æ"))
assertCaret(t, e, 0, 1, 2)
e.SetCaret(len("æbc\naøå•"))
e.SetCaret(len("æbc\naøå•"), len("æbc\naøå•"))
assertCaret(t, e, 1, 4, len("æbc\naøå•"))
// Ensure that password masking does not affect caret behavior
e.MoveCaret(-3)
e.MoveCaret(-3, -3)
assertCaret(t, e, 1, 1, len("æbc\na"))
e.Mask = '*'
e.Layout(gtx, cache, font, fontSize)
assertCaret(t, e, 1, 1, len("æbc\na"))
e.MoveCaret(-3)
e.MoveCaret(-3, -3)
assertCaret(t, e, 0, 2, len("æb"))
e.Mask = '\U0001F92B'
e.Layout(gtx, cache, font, fontSize)
e.moveEnd()
e.moveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
// When a password mask is applied, it should replace all visible glyphs
@@ -106,8 +109,8 @@ 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)
}
if bytes != e.caret.pos.ofs {
t.Errorf("caret at buffer position %d, expected %d", e.caret.pos.ofs, bytes)
if bytes != e.caret.start.ofs {
t.Errorf("caret at buffer position %d, expected %d", e.caret.start.ofs, bytes)
}
}
@@ -144,7 +147,7 @@ func TestEditorCaretConsistency(t *testing.T) {
t.Helper()
gotLine, gotCol := e.CaretPos()
gotCoords := e.CaretCoords()
want, _ := e.offsetToScreenPos(e.caret.pos.ofs)
want, _ := e.offsetToScreenPos(e.caret.start.ofs)
wantCoords := f32.Pt(float32(want.x)/64, float32(want.y))
if want.lineCol.Y == gotLine && want.lineCol.X == gotCol && gotCoords == wantCoords {
return nil
@@ -162,19 +165,19 @@ func TestEditorCaretConsistency(t *testing.T) {
e.SetText(str)
e.Layout(gtx, cache, font, fontSize)
case moveRune:
e.MoveCaret(int(distance))
e.MoveCaret(int(distance), int(distance))
case moveLine:
e.moveLines(int(distance))
e.moveLines(int(distance), selectionClear)
case movePage:
e.movePages(int(distance))
e.movePages(int(distance), selectionClear)
case moveStart:
e.moveStart()
e.moveStart(selectionClear)
case moveEnd:
e.moveEnd()
e.moveEnd(selectionClear)
case moveCoord:
e.moveCoord(image.Pt(int(x), int(y)))
case moveWord:
e.moveWord(int(distance))
e.moveWord(int(distance), selectionClear)
case deleteWord:
e.deleteWord(int(distance))
default:
@@ -230,38 +233,72 @@ func TestEditorMoveWord(t *testing.T) {
}
for ii, tt := range tests {
e := setup(tt.Text)
e.MoveCaret(tt.Start)
e.moveWord(tt.Skip)
if e.caret.pos.ofs != tt.Want {
t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, e.caret.pos.ofs, tt.Want)
e.MoveCaret(tt.Start, tt.Start)
e.moveWord(tt.Skip, selectionClear)
if e.caret.start.ofs != tt.Want {
t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, e.caret.start.ofs, tt.Want)
}
}
}
func TestEditorDeleteWord(t *testing.T) {
type Test struct {
Text string
Start int
Delete int
Text string
Start int
Selection int
Delete int
Want int
Result string
}
tests := []Test{
{"", 0, 0, 0, ""},
{"", 0, -1, 0, ""},
{"", 0, 1, 0, ""},
{"hello", 0, -1, 0, "hello"},
{"hello", 0, 1, 0, ""},
{"hello world", 3, 1, 3, "hel world"},
{"hello world", 3, -1, 0, "lo world"},
{"hello world", 8, -1, 6, "hello rld"},
{"hello world", 8, 1, 8, "hello wo"},
{"hello world", 3, 1, 3, "hel world"},
{"hello world", 3, 2, 3, "helworld"},
{"hello world", 8, 1, 8, "hello "},
{"hello world", 8, -1, 5, "hello world"},
{"hello brave new world", 0, 3, 0, " new world"},
// No text selected
{"", 0, 0, 0, 0, ""},
{"", 0, 0, -1, 0, ""},
{"", 0, 0, 1, 0, ""},
{"", 0, 0, -2, 0, ""},
{"", 0, 0, 2, 0, ""},
{"hello", 0, 0, -1, 0, "hello"},
{"hello", 0, 0, 1, 0, ""},
// Document (imho) incorrect behavior w.r.t. deleting spaces following
// words.
{"hello world", 0, 0, 1, 0, " world"}, // Should be "world", if you ask me.
{"hello world", 0, 0, 2, 0, "world"}, // Should be "".
{"hello ", 0, 0, 1, 0, " "}, // Should be "".
{"hello world", 11, 0, -1, 6, "hello "}, // Should be "hello".
{"hello world", 11, 0, -2, 5, "hello"}, // Should be "".
{"hello ", 6, 0, -1, 0, ""}, // Correct result.
{"hello world", 3, 0, 1, 3, "hel world"},
{"hello world", 3, 0, -1, 0, "lo world"},
{"hello world", 8, 0, -1, 6, "hello rld"},
{"hello world", 8, 0, 1, 8, "hello wo"},
{"hello world", 3, 0, 1, 3, "hel world"},
{"hello world", 3, 0, 2, 3, "helworld"},
{"hello world", 8, 0, 1, 8, "hello "},
{"hello world", 8, 0, -1, 5, "hello world"},
{"hello brave new world", 0, 0, 3, 0, " new world"},
// Add selected text.
//
// Several permutations must be tested:
// - select from the left or right
// - Delete + or -
// - abs(Delete) == 1 or > 1
//
// "brave |" selected; caret at |
{"hello there brave new world", 12, 6, 1, 12, "hello there new world"}, // #16
{"hello there brave new world", 12, 6, 2, 12, "hello there world"}, // The two spaces after "there" are actually suboptimal, if you ask me. See also above cases.
{"hello there brave new world", 12, 6, -1, 12, "hello there new world"},
{"hello there brave new world", 12, 6, -2, 6, "hello new world"},
// "|brave " selected
{"hello there brave new world", 18, -6, 1, 12, "hello there new world"}, // #20
{"hello there brave new world", 18, -6, 2, 12, "hello there world"}, // ditto
{"hello there brave new world", 18, -6, -1, 12, "hello there new world"},
{"hello there brave new world", 18, -6, -2, 6, "hello new world"},
// Random edge cases
{"hello there brave new world", 12, 6, 99, 12, "hello there "},
{"hello there brave new world", 18, -6, -99, 0, "new world"},
}
setup := func(t string) *Editor {
e := new(Editor)
@@ -278,10 +315,11 @@ func TestEditorDeleteWord(t *testing.T) {
}
for ii, tt := range tests {
e := setup(tt.Text)
e.MoveCaret(tt.Start)
e.MoveCaret(tt.Start, tt.Start)
e.MoveCaret(0, tt.Selection)
e.deleteWord(tt.Delete)
if e.caret.pos.ofs != tt.Want {
t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, e.caret.pos.ofs, tt.Want)
if e.caret.start.ofs != tt.Want {
t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, e.caret.start.ofs, tt.Want)
}
if e.Text() != tt.Result {
t.Fatalf("[%d] deleteWord: invalid result: got %q, want %q", ii, e.Text(), tt.Result)
@@ -292,7 +330,7 @@ func TestEditorDeleteWord(t *testing.T) {
func TestEditorNoLayout(t *testing.T) {
var e Editor
e.SetText("hi!\n")
e.MoveCaret(1)
e.MoveCaret(1, 1)
}
// Generate generates a value of itself, for testing/quick.
@@ -301,10 +339,184 @@ func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value {
return reflect.ValueOf(t)
}
// TestSelect tests the selection code. It lays out an editor with several
// lines in it, selects some text, verifies the selection, resizes the editor
// to make it much narrower (which makes the lines in the editor reflow), and
// then verifies that the updated (col, line) positions of the selected text
// are where we expect.
func TestSelect(t *testing.T) {
e := new(Editor)
e.SetText(`a123456789a
b123456789b
c123456789c
d123456789d
e123456789e
f123456789f
g123456789g
`)
gtx := layout.Context{Ops: new(op.Ops)}
cache := text.NewCache(gofont.Collection())
font := text.Font{}
fontSize := unit.Px(10)
selected := func(start, end int) string {
// Layout once with no events; populate e.lines.
gtx.Queue = nil
e.Layout(gtx, cache, font, fontSize)
_ = e.Events() // throw away any events from this layout
// Build the selection events
startPos, endPos := e.offsetToScreenPos2(sortInts(start, end))
tq := &testQueue{
events: []event.Event{
pointer.Event{
Buttons: pointer.ButtonLeft,
Type: pointer.Press,
Source: pointer.Mouse,
Position: f32.Pt(textWidth(e, startPos.lineCol.Y, 0, startPos.lineCol.X), textHeight(e, startPos.lineCol.Y)),
},
pointer.Event{
Type: pointer.Release,
Source: pointer.Mouse,
Position: f32.Pt(textWidth(e, endPos.lineCol.Y, 0, endPos.lineCol.X), textHeight(e, endPos.lineCol.Y)),
},
},
}
gtx.Queue = tq
e.Layout(gtx, cache, font, fontSize)
for _, evt := range e.Events() {
switch evt.(type) {
case SelectEvent:
return e.SelectedText()
}
}
return ""
}
type testCase struct {
// input text offsets
start, end int
// expected selected text
selection string
// expected line/col positions of selection after resize
startPos, endPos screenPos
}
for n, tst := range []testCase{
{0, 1, "a", screenPos{}, screenPos{Y: 0, X: 1}},
{0, 4, "a123", screenPos{}, screenPos{Y: 0, X: 4}},
{0, 11, "a123456789a", screenPos{}, screenPos{Y: 1, X: 5}},
{2, 6, "2345", screenPos{Y: 0, X: 2}, screenPos{Y: 1, X: 0}},
{41, 66, "56789d\ne123456789e\nf12345", screenPos{Y: 6, X: 5}, screenPos{Y: 11, X: 0}},
} {
// printLines(e)
gtx.Constraints = layout.Exact(image.Pt(100, 100))
if got := selected(tst.start, tst.end); got != tst.selection {
t.Errorf("Test %d pt1: Expected %q, got %q", n, tst.selection, got)
continue
}
// Constrain the editor to roughly 6 columns wide and redraw
gtx.Constraints = layout.Exact(image.Pt(36, 36))
// Keep existing selection
gtx.Queue = nil
e.Layout(gtx, cache, font, fontSize)
if e.caret.end.lineCol != tst.startPos || e.caret.start.lineCol != tst.endPos {
t.Errorf("Test %d pt2: Expected %#v, %#v; got %#v, %#v",
n,
e.caret.end.lineCol, e.caret.start.lineCol,
tst.startPos, tst.endPos)
continue
}
// printLines(e)
}
}
// Verify that an existing selection is dismissed when you press arrow keys.
func TestSelectMove(t *testing.T) {
e := new(Editor)
e.SetText(`0123456789`)
gtx := layout.Context{Ops: new(op.Ops)}
cache := text.NewCache(gofont.Collection())
font := text.Font{}
fontSize := unit.Px(10)
// Layout once to populate e.lines and get focus.
gtx.Queue = newQueue(key.FocusEvent{Focus: true})
e.Layout(gtx, cache, font, fontSize)
testKey := func(keyName string) {
// Select 345
e.SetCaret(3, 6)
if expected, got := "345", e.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})
e.Layout(gtx, cache, font, fontSize)
if expected, got := "", e.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 textWidth(e *Editor, lineNum, colStart, colEnd int) float32 {
var w fixed.Int26_6
advances := e.lines[lineNum].Layout.Advances
if colEnd > len(advances) {
colEnd = len(advances)
}
for _, adv := range advances[colStart:colEnd] {
w += adv
}
return float32(w.Floor())
}
func textHeight(e *Editor, lineNum int) float32 {
var h fixed.Int26_6
for _, line := range e.lines[0:lineNum] {
h += line.Ascent + line.Descent
}
return float32(h.Floor() + 1)
}
type testQueue struct {
events []event.Event
}
func newQueue(e ...event.Event) *testQueue {
return &testQueue{events: e}
}
func (q *testQueue) Events(_ event.Tag) []event.Event {
return q.events
}
func printLines(e *Editor) {
for n, line := range e.lines {
text := strings.TrimSuffix(line.Layout.Text, "\n")
fmt.Printf("%d: %s\n", n, text)
}
}
// sortInts returns a and b sorted such that a2 <= b2.
func sortInts(a, b int) (a2, b2 int) {
if b < a {
return b, a
}
return a, b
}