editor_test.go raw
1 // SPDX-License-Identifier: Unlicense OR MIT
2
3 package widget
4
5 import (
6 "fmt"
7 "image"
8 "math/rand"
9 "reflect"
10 "strings"
11 "testing"
12 "testing/quick"
13 "unicode"
14
15 "github.com/p9c/p9/pkg/gel/gio/f32"
16 "github.com/p9c/p9/pkg/gel/gio/font/gofont"
17 "github.com/p9c/p9/pkg/gel/gio/io/event"
18 "github.com/p9c/p9/pkg/gel/gio/io/key"
19 "github.com/p9c/p9/pkg/gel/gio/io/pointer"
20 "github.com/p9c/p9/pkg/gel/gio/layout"
21 "github.com/p9c/p9/pkg/gel/gio/op"
22 "github.com/p9c/p9/pkg/gel/gio/text"
23 "github.com/p9c/p9/pkg/gel/gio/unit"
24 "golang.org/x/image/math/fixed"
25 )
26
27 func TestEditor(t *testing.T) {
28 e := new(Editor)
29 gtx := layout.Context{
30 Ops: new(op.Ops),
31 Constraints: layout.Exact(image.Pt(100, 100)),
32 }
33 cache := text.NewCache(gofont.Collection())
34 fontSize := unit.Px(10)
35 font := text.Font{}
36
37 e.SetCaret(0, 0) // shouldn't panic
38 assertCaret(t, e, 0, 0, 0)
39 e.SetText("æbc\naøå•")
40 e.Layout(gtx, cache, font, fontSize)
41 assertCaret(t, e, 0, 0, 0)
42 e.moveEnd(selectionClear)
43 assertCaret(t, e, 0, 3, len("æbc"))
44 e.MoveCaret(+1, +1)
45 assertCaret(t, e, 1, 0, len("æbc\n"))
46 e.MoveCaret(-1, -1)
47 assertCaret(t, e, 0, 3, len("æbc"))
48 e.moveLines(+1, +1)
49 assertCaret(t, e, 1, 3, len("æbc\naøå"))
50 e.moveEnd(selectionClear)
51 assertCaret(t, e, 1, 4, len("æbc\naøå•"))
52 e.MoveCaret(+1, +1)
53 assertCaret(t, e, 1, 4, len("æbc\naøå•"))
54
55 e.SetCaret(0, 0)
56 assertCaret(t, e, 0, 0, 0)
57 e.SetCaret(len("æ"), len("æ"))
58 assertCaret(t, e, 0, 1, 2)
59 e.SetCaret(len("æbc\naøå•"), len("æbc\naøå•"))
60 assertCaret(t, e, 1, 4, len("æbc\naøå•"))
61
62 // Ensure that password masking does not affect caret behavior
63 e.MoveCaret(-3, -3)
64 assertCaret(t, e, 1, 1, len("æbc\na"))
65 e.Mask = '*'
66 e.Layout(gtx, cache, font, fontSize)
67 assertCaret(t, e, 1, 1, len("æbc\na"))
68 e.MoveCaret(-3, -3)
69 assertCaret(t, e, 0, 2, len("æb"))
70 e.Mask = '\U0001F92B'
71 e.Layout(gtx, cache, font, fontSize)
72 e.moveEnd(selectionClear)
73 assertCaret(t, e, 0, 3, len("æbc"))
74
75 // When a password mask is applied, it should replace all visible glyphs
76 for i, line := range e.lines {
77 for j, r := range line.Layout.Text {
78 if r != e.Mask && !unicode.IsSpace(r) {
79 t.Errorf("glyph at (%d, %d) is unmasked rune %d", i, j, r)
80 }
81 }
82 }
83 }
84
85 func TestEditorDimensions(t *testing.T) {
86 e := new(Editor)
87 tq := &testQueue{
88 events: []event.Event{
89 key.EditEvent{Text: "A"},
90 },
91 }
92 gtx := layout.Context{
93 Ops: new(op.Ops),
94 Constraints: layout.Constraints{Max: image.Pt(100, 100)},
95 Queue: tq,
96 }
97 cache := text.NewCache(gofont.Collection())
98 fontSize := unit.Px(10)
99 font := text.Font{}
100 dims := e.Layout(gtx, cache, font, fontSize)
101 if dims.Size.X == 0 {
102 t.Errorf("EditEvent was not reflected in Editor width")
103 }
104 }
105
106 // assertCaret asserts that the editor caret is at a particular line
107 // and column, and that the byte position matches as well.
108 func assertCaret(t *testing.T, e *Editor, line, col, bytes int) {
109 t.Helper()
110 gotLine, gotCol := e.CaretPos()
111 if gotLine != line || gotCol != col {
112 t.Errorf("caret at (%d, %d), expected (%d, %d)", gotLine, gotCol, line, col)
113 }
114 if bytes != e.caret.start.ofs {
115 t.Errorf("caret at buffer position %d, expected %d", e.caret.start.ofs, bytes)
116 }
117 }
118
119 type editMutation int
120
121 const (
122 setText editMutation = iota
123 moveRune
124 moveLine
125 movePage
126 moveStart
127 moveEnd
128 moveCoord
129 moveWord
130 deleteWord
131 moveLast // Mark end; never generated.
132 )
133
134 func TestEditorCaretConsistency(t *testing.T) {
135 gtx := layout.Context{
136 Ops: new(op.Ops),
137 Constraints: layout.Exact(image.Pt(100, 100)),
138 }
139 cache := text.NewCache(gofont.Collection())
140 fontSize := unit.Px(10)
141 font := text.Font{}
142 for _, a := range []text.Alignment{text.Start, text.Middle, text.End} {
143 e := &Editor{
144 Alignment: a,
145 }
146 e.Layout(gtx, cache, font, fontSize)
147
148 consistent := func() error {
149 t.Helper()
150 gotLine, gotCol := e.CaretPos()
151 gotCoords := e.CaretCoords()
152 want, _ := e.offsetToScreenPos(e.caret.start.ofs)
153 wantCoords := f32.Pt(float32(want.x)/64, float32(want.y))
154 if want.lineCol.Y == gotLine && want.lineCol.X == gotCol && gotCoords == wantCoords {
155 return nil
156 }
157 return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s",
158 gotLine, gotCol, gotCoords, want.lineCol.Y, want.lineCol.X, wantCoords)
159 }
160 if err := consistent(); err != nil {
161 t.Errorf("initial editor inconsistency (alignment %s): %v", a, err)
162 }
163
164 move := func(mutation editMutation, str string, distance int8, x, y uint16) bool {
165 switch mutation {
166 case setText:
167 e.SetText(str)
168 e.Layout(gtx, cache, font, fontSize)
169 case moveRune:
170 e.MoveCaret(int(distance), int(distance))
171 case moveLine:
172 e.moveLines(int(distance), selectionClear)
173 case movePage:
174 e.movePages(int(distance), selectionClear)
175 case moveStart:
176 e.moveStart(selectionClear)
177 case moveEnd:
178 e.moveEnd(selectionClear)
179 case moveCoord:
180 e.moveCoord(image.Pt(int(x), int(y)))
181 case moveWord:
182 e.moveWord(int(distance), selectionClear)
183 case deleteWord:
184 e.deleteWord(int(distance))
185 default:
186 return false
187 }
188 if err := consistent(); err != nil {
189 t.Error(err)
190 return false
191 }
192 return true
193 }
194 if err := quick.Check(move, nil); err != nil {
195 t.Errorf("editor inconsistency (alignment %s): %v", a, err)
196 }
197 }
198 }
199
200 func TestEditorMoveWord(t *testing.T) {
201 type Test struct {
202 Text string
203 Start int
204 Skip int
205 Want int
206 }
207 tests := []Test{
208 {"", 0, 0, 0},
209 {"", 0, -1, 0},
210 {"", 0, 1, 0},
211 {"hello", 0, -1, 0},
212 {"hello", 0, 1, 5},
213 {"hello world", 3, 1, 5},
214 {"hello world", 3, -1, 0},
215 {"hello world", 8, -1, 6},
216 {"hello world", 8, 1, 11},
217 {"hello world", 3, 1, 5},
218 {"hello world", 3, 2, 14},
219 {"hello world", 8, 1, 14},
220 {"hello world", 8, -1, 0},
221 {"hello brave new world", 0, 3, 15},
222 }
223 setup := func(t string) *Editor {
224 e := new(Editor)
225 gtx := layout.Context{
226 Ops: new(op.Ops),
227 Constraints: layout.Exact(image.Pt(100, 100)),
228 }
229 cache := text.NewCache(gofont.Collection())
230 fontSize := unit.Px(10)
231 font := text.Font{}
232 e.SetText(t)
233 e.Layout(gtx, cache, font, fontSize)
234 return e
235 }
236 for ii, tt := range tests {
237 e := setup(tt.Text)
238 e.MoveCaret(tt.Start, tt.Start)
239 e.moveWord(tt.Skip, selectionClear)
240 if e.caret.start.ofs != tt.Want {
241 t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, e.caret.start.ofs, tt.Want)
242 }
243 }
244 }
245
246 func TestEditorDeleteWord(t *testing.T) {
247 type Test struct {
248 Text string
249 Start int
250 Selection int
251 Delete int
252
253 Want int
254 Result string
255 }
256 tests := []Test{
257 // No text selected
258 {"", 0, 0, 0, 0, ""},
259 {"", 0, 0, -1, 0, ""},
260 {"", 0, 0, 1, 0, ""},
261 {"", 0, 0, -2, 0, ""},
262 {"", 0, 0, 2, 0, ""},
263 {"hello", 0, 0, -1, 0, "hello"},
264 {"hello", 0, 0, 1, 0, ""},
265
266 // Document (imho) incorrect behavior w.r.t. deleting spaces following
267 // words.
268 {"hello world", 0, 0, 1, 0, " world"}, // Should be "world", if you ask me.
269 {"hello world", 0, 0, 2, 0, "world"}, // Should be "".
270 {"hello ", 0, 0, 1, 0, " "}, // Should be "".
271 {"hello world", 11, 0, -1, 6, "hello "}, // Should be "hello".
272 {"hello world", 11, 0, -2, 5, "hello"}, // Should be "".
273 {"hello ", 6, 0, -1, 0, ""}, // Correct result.
274
275 {"hello world", 3, 0, 1, 3, "hel world"},
276 {"hello world", 3, 0, -1, 0, "lo world"},
277 {"hello world", 8, 0, -1, 6, "hello rld"},
278 {"hello world", 8, 0, 1, 8, "hello wo"},
279 {"hello world", 3, 0, 1, 3, "hel world"},
280 {"hello world", 3, 0, 2, 3, "helworld"},
281 {"hello world", 8, 0, 1, 8, "hello "},
282 {"hello world", 8, 0, -1, 5, "hello world"},
283 {"hello brave new world", 0, 0, 3, 0, " new world"},
284 // Add selected text.
285 //
286 // Several permutations must be tested:
287 // - select from the left or right
288 // - Delete + or -
289 // - abs(Delete) == 1 or > 1
290 //
291 // "brave |" selected; caret at |
292 {"hello there brave new world", 12, 6, 1, 12, "hello there new world"}, // #16
293 {"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.
294 {"hello there brave new world", 12, 6, -1, 12, "hello there new world"},
295 {"hello there brave new world", 12, 6, -2, 6, "hello new world"},
296 // "|brave " selected
297 {"hello there brave new world", 18, -6, 1, 12, "hello there new world"}, // #20
298 {"hello there brave new world", 18, -6, 2, 12, "hello there world"}, // ditto
299 {"hello there brave new world", 18, -6, -1, 12, "hello there new world"},
300 {"hello there brave new world", 18, -6, -2, 6, "hello new world"},
301 // Random edge cases
302 {"hello there brave new world", 12, 6, 99, 12, "hello there "},
303 {"hello there brave new world", 18, -6, -99, 0, "new world"},
304 }
305 setup := func(t string) *Editor {
306 e := new(Editor)
307 gtx := layout.Context{
308 Ops: new(op.Ops),
309 Constraints: layout.Exact(image.Pt(100, 100)),
310 }
311 cache := text.NewCache(gofont.Collection())
312 fontSize := unit.Px(10)
313 font := text.Font{}
314 e.SetText(t)
315 e.Layout(gtx, cache, font, fontSize)
316 return e
317 }
318 for ii, tt := range tests {
319 e := setup(tt.Text)
320 e.MoveCaret(tt.Start, tt.Start)
321 e.MoveCaret(0, tt.Selection)
322 e.deleteWord(tt.Delete)
323 if e.caret.start.ofs != tt.Want {
324 t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, e.caret.start.ofs, tt.Want)
325 }
326 if e.Text() != tt.Result {
327 t.Fatalf("[%d] deleteWord: invalid result: got %q, want %q", ii, e.Text(), tt.Result)
328 }
329 }
330 }
331
332 func TestEditorNoLayout(t *testing.T) {
333 var e Editor
334 e.SetText("hi!\n")
335 e.MoveCaret(1, 1)
336 }
337
338 // Generate generates a value of itself, for testing/quick.
339 func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value {
340 t := editMutation(rand.Intn(int(moveLast)))
341 return reflect.ValueOf(t)
342 }
343
344 // TestSelect tests the selection code. It lays out an editor with several
345 // lines in it, selects some text, verifies the selection, resizes the editor
346 // to make it much narrower (which makes the lines in the editor reflow), and
347 // then verifies that the updated (col, line) positions of the selected text
348 // are where we expect.
349 func TestSelect(t *testing.T) {
350 e := new(Editor)
351 e.SetText(`a123456789a
352 b123456789b
353 c123456789c
354 d123456789d
355 e123456789e
356 f123456789f
357 g123456789g
358 `)
359
360 gtx := layout.Context{Ops: new(op.Ops)}
361 cache := text.NewCache(gofont.Collection())
362 font := text.Font{}
363 fontSize := unit.Px(10)
364
365 selected := func(start, end int) string {
366 // Layout once with no events; populate e.lines.
367 gtx.Queue = nil
368 e.Layout(gtx, cache, font, fontSize)
369 _ = e.Events() // throw away any events from this layout
370
371 // Build the selection events
372 startPos, endPos := e.offsetToScreenPos2(sortInts(start, end))
373 tq := &testQueue{
374 events: []event.Event{
375 pointer.Event{
376 Buttons: pointer.ButtonPrimary,
377 Type: pointer.Press,
378 Source: pointer.Mouse,
379 Position: f32.Pt(textWidth(e, startPos.lineCol.Y, 0, startPos.lineCol.X), textHeight(e, startPos.lineCol.Y)),
380 },
381 pointer.Event{
382 Type: pointer.Release,
383 Source: pointer.Mouse,
384 Position: f32.Pt(textWidth(e, endPos.lineCol.Y, 0, endPos.lineCol.X), textHeight(e, endPos.lineCol.Y)),
385 },
386 },
387 }
388 gtx.Queue = tq
389
390 e.Layout(gtx, cache, font, fontSize)
391 for _, evt := range e.Events() {
392 switch evt.(type) {
393 case SelectEvent:
394 return e.SelectedText()
395 }
396 }
397 return ""
398 }
399
400 type testCase struct {
401 // input text offsets
402 start, end int
403
404 // expected selected text
405 selection string
406 // expected line/col positions of selection after resize
407 startPos, endPos screenPos
408 }
409
410 for n, tst := range []testCase{
411 {0, 1, "a", screenPos{}, screenPos{Y: 0, X: 1}},
412 {0, 4, "a123", screenPos{}, screenPos{Y: 0, X: 4}},
413 {0, 11, "a123456789a", screenPos{}, screenPos{Y: 1, X: 5}},
414 {2, 6, "2345", screenPos{Y: 0, X: 2}, screenPos{Y: 1, X: 0}},
415 {41, 66, "56789d\ne123456789e\nf12345", screenPos{Y: 6, X: 5}, screenPos{Y: 11, X: 0}},
416 } {
417 // printLines(e)
418
419 gtx.Constraints = layout.Exact(image.Pt(100, 100))
420 if got := selected(tst.start, tst.end); got != tst.selection {
421 t.Errorf("Test %d pt1: Expected %q, got %q", n, tst.selection, got)
422 continue
423 }
424
425 // Constrain the editor to roughly 6 columns wide and redraw
426 gtx.Constraints = layout.Exact(image.Pt(36, 36))
427 // Keep existing selection
428 gtx.Queue = nil
429 e.Layout(gtx, cache, font, fontSize)
430
431 if e.caret.end.lineCol != tst.startPos || e.caret.start.lineCol != tst.endPos {
432 t.Errorf("Test %d pt2: Expected %#v, %#v; got %#v, %#v",
433 n,
434 e.caret.end.lineCol, e.caret.start.lineCol,
435 tst.startPos, tst.endPos)
436 continue
437 }
438
439 // printLines(e)
440 }
441 }
442
443 // Verify that an existing selection is dismissed when you press arrow keys.
444 func TestSelectMove(t *testing.T) {
445 e := new(Editor)
446 e.SetText(`0123456789`)
447
448 gtx := layout.Context{Ops: new(op.Ops)}
449 cache := text.NewCache(gofont.Collection())
450 font := text.Font{}
451 fontSize := unit.Px(10)
452
453 // Layout once to populate e.lines and get focus.
454 gtx.Queue = newQueue(key.FocusEvent{Focus: true})
455 e.Layout(gtx, cache, font, fontSize)
456
457 testKey := func(keyName string) {
458 // Select 345
459 e.SetCaret(3, 6)
460 if expected, got := "345", e.SelectedText(); expected != got {
461 t.Errorf("KeyName %s, expected %q, got %q", keyName, expected, got)
462 }
463
464 // Press the key
465 gtx.Queue = newQueue(key.Event{State: key.Press, Name: keyName})
466 e.Layout(gtx, cache, font, fontSize)
467
468 if expected, got := "", e.SelectedText(); expected != got {
469 t.Errorf("KeyName %s, expected %q, got %q", keyName, expected, got)
470 }
471 }
472
473 testKey(key.NameLeftArrow)
474 testKey(key.NameRightArrow)
475 testKey(key.NameUpArrow)
476 testKey(key.NameDownArrow)
477 }
478
479 func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 {
480 var w fixed.Int26_6
481 advances := e.lines[lineNum].Layout.Advances
482 if colEnd > len(advances) {
483 colEnd = len(advances)
484 }
485 for _, adv := range advances[colStart:colEnd] {
486 w += adv
487 }
488 return float32(w.Floor())
489 }
490
491 func textHeight(e *Editor, lineNum int) float32 {
492 var h fixed.Int26_6
493 for _, line := range e.lines[0:lineNum] {
494 h += line.Ascent + line.Descent
495 }
496 return float32(h.Floor() + 1)
497 }
498
499 type testQueue struct {
500 events []event.Event
501 }
502
503 func newQueue(e ...event.Event) *testQueue {
504 return &testQueue{events: e}
505 }
506
507 func (q *testQueue) Events(_ event.Tag) []event.Event {
508 return q.events
509 }
510
511 func printLines(e *Editor) {
512 for n, line := range e.lines {
513 text := strings.TrimSuffix(line.Layout.Text, "\n")
514 fmt.Printf("%d: %s\n", n, text)
515 }
516 }
517
518 // sortInts returns a and b sorted such that a2 <= b2.
519 func sortInts(a, b int) (a2, b2 int) {
520 if b < a {
521 return b, a
522 }
523 return a, b
524 }
525