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