mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-04 08:55:35 +00:00
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:
+258
-46
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user