gioui.org@v0.6.1-0.20240506124620-7a9ce51988ce/widget/editor_test.go (about) 1 // SPDX-License-Identifier: Unlicense OR MIT 2 3 package widget 4 5 import ( 6 "bytes" 7 "fmt" 8 "image" 9 "io" 10 "math/rand" 11 "reflect" 12 "testing" 13 "testing/quick" 14 "time" 15 "unicode/utf8" 16 17 nsareg "eliasnaur.com/font/noto/sans/arabic/regular" 18 "eliasnaur.com/font/roboto/robotoregular" 19 "gioui.org/f32" 20 "gioui.org/font" 21 "gioui.org/font/gofont" 22 "gioui.org/font/opentype" 23 "gioui.org/io/input" 24 "gioui.org/io/key" 25 "gioui.org/io/pointer" 26 "gioui.org/io/system" 27 "gioui.org/layout" 28 "gioui.org/op" 29 "gioui.org/text" 30 "gioui.org/unit" 31 ) 32 33 var english = system.Locale{ 34 Language: "EN", 35 Direction: system.LTR, 36 } 37 38 // TestEditorHistory ensures that undo and redo behave correctly. 39 func TestEditorHistory(t *testing.T) { 40 e := new(Editor) 41 // Insert some multi-byte unicode text. 42 e.SetText("안П你 hello 안П你") 43 assertContents(t, e, "안П你 hello 안П你", 0, 0) 44 // Overwrite all of the text with the empty string. 45 e.SetCaret(0, len([]rune("안П你 hello 안П你"))) 46 e.Insert("") 47 assertContents(t, e, "", 0, 0) 48 // Ensure that undoing the overwrite succeeds. 49 e.undo() 50 assertContents(t, e, "안П你 hello 안П你", 13, 0) 51 // Ensure that redoing the overwrite succeeds. 52 e.redo() 53 assertContents(t, e, "", 0, 0) 54 // Insert some smaller text. 55 e.Insert("안П你 hello") 56 assertContents(t, e, "안П你 hello", 9, 9) 57 // Replace a region in the middle of the text. 58 e.SetCaret(1, 5) 59 e.Insert("П") 60 assertContents(t, e, "안Пello", 2, 2) 61 // Replace a second region in the middle. 62 e.SetCaret(3, 4) 63 e.Insert("П") 64 assertContents(t, e, "안ПeПlo", 4, 4) 65 // Ensure both operations undo successfully. 66 e.undo() 67 assertContents(t, e, "안Пello", 4, 3) 68 e.undo() 69 assertContents(t, e, "안П你 hello", 5, 1) 70 // Make a new modification. 71 e.Insert("Something New") 72 // Ensure that redo history is discarded now that 73 // we've diverged from the linear editing history. 74 // This redo() call should do nothing. 75 text := e.Text() 76 start, end := e.Selection() 77 e.redo() 78 assertContents(t, e, text, start, end) 79 } 80 81 func assertContents(t *testing.T, e *Editor, contents string, selectionStart, selectionEnd int) { 82 t.Helper() 83 actualContents := e.Text() 84 if actualContents != contents { 85 t.Errorf("expected editor to contain %s, got %s", contents, actualContents) 86 } 87 actualStart, actualEnd := e.Selection() 88 if actualStart != selectionStart { 89 t.Errorf("expected selection start to be %d, got %d", selectionStart, actualStart) 90 } 91 if actualEnd != selectionEnd { 92 t.Errorf("expected selection end to be %d, got %d", selectionEnd, actualEnd) 93 } 94 } 95 96 // TestEditorReadOnly ensures that mouse and keyboard interactions with readonly 97 // editors do nothing but manipulate the text selection. 98 func TestEditorReadOnly(t *testing.T) { 99 r := new(input.Router) 100 gtx := layout.Context{ 101 Ops: new(op.Ops), 102 Constraints: layout.Constraints{ 103 Max: image.Pt(100, 100), 104 }, 105 Locale: english, 106 Source: r.Source(), 107 } 108 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 109 fontSize := unit.Sp(10) 110 font := font.Font{} 111 e := new(Editor) 112 e.ReadOnly = true 113 e.SetText("The quick brown fox jumps over the lazy dog. We just need a few lines of text in the editor so that it can adequately test a few different modes of selection. The quick brown fox jumps over the lazy dog. We just need a few lines of text in the editor so that it can adequately test a few different modes of selection.") 114 cStart, cEnd := e.Selection() 115 if cStart != cEnd { 116 t.Errorf("unexpected initial caret positions") 117 } 118 gtx.Execute(key.FocusCmd{Tag: e}) 119 layoutEditor := func() layout.Dimensions { 120 return e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 121 } 122 layoutEditor() 123 r.Frame(gtx.Ops) 124 gtx.Ops.Reset() 125 layoutEditor() 126 r.Frame(gtx.Ops) 127 gtx.Ops.Reset() 128 layoutEditor() 129 r.Frame(gtx.Ops) 130 131 // Select everything. 132 gtx.Ops.Reset() 133 r.Queue(key.Event{Name: "A", Modifiers: key.ModShortcut}) 134 layoutEditor() 135 textContent := e.Text() 136 cStart2, cEnd2 := e.Selection() 137 if cStart2 > cEnd2 { 138 cStart2, cEnd2 = cEnd2, cStart2 139 } 140 if cEnd2 != e.Len() { 141 t.Errorf("expected selection to contain %d runes, got %d", e.Len(), cEnd2) 142 } 143 if cStart2 != 0 { 144 t.Errorf("expected selection to start at rune 0, got %d", cStart2) 145 } 146 147 // Type some new characters. 148 gtx.Ops.Reset() 149 r.Queue(key.EditEvent{Range: key.Range{Start: cStart2, End: cEnd2}, Text: "something else"}) 150 e.Update(gtx) 151 textContent2 := e.Text() 152 if textContent2 != textContent { 153 t.Errorf("readonly editor modified by key.EditEvent") 154 } 155 156 // Try to delete selection. 157 gtx.Ops.Reset() 158 r.Queue(key.Event{Name: key.NameDeleteBackward}) 159 dims := layoutEditor() 160 textContent2 = e.Text() 161 if textContent2 != textContent { 162 t.Errorf("readonly editor modified by delete key.Event") 163 } 164 165 // Click and drag from the middle of the first line 166 // to the center. 167 gtx.Ops.Reset() 168 r.Queue( 169 pointer.Event{ 170 Kind: pointer.Press, 171 Buttons: pointer.ButtonPrimary, 172 Position: f32.Pt(float32(dims.Size.X)*.5, 5), 173 }, 174 pointer.Event{ 175 Kind: pointer.Move, 176 Buttons: pointer.ButtonPrimary, 177 Position: layout.FPt(dims.Size).Mul(.5), 178 }, 179 pointer.Event{ 180 Kind: pointer.Release, 181 Buttons: pointer.ButtonPrimary, 182 Position: layout.FPt(dims.Size).Mul(.5), 183 }, 184 ) 185 e.Update(gtx) 186 cStart3, cEnd3 := e.Selection() 187 if cStart3 == cStart2 || cEnd3 == cEnd2 { 188 t.Errorf("expected mouse interaction to change selection.") 189 } 190 } 191 192 func TestEditorConfigurations(t *testing.T) { 193 gtx := layout.Context{ 194 Ops: new(op.Ops), 195 Constraints: layout.Exact(image.Pt(300, 300)), 196 Locale: english, 197 } 198 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 199 fontSize := unit.Sp(10) 200 font := font.Font{} 201 sentence := "\n\n\n\n\n\n\n\n\n\n\n\nthe quick brown fox jumps over the lazy dog" 202 runes := len([]rune(sentence)) 203 204 // Ensure that both ends of the text are reachable in all permutations 205 // of settings that influence layout. 206 for _, singleLine := range []bool{true, false} { 207 for _, alignment := range []text.Alignment{text.Start, text.Middle, text.End} { 208 for _, zeroMin := range []bool{true, false} { 209 t.Run(fmt.Sprintf("SingleLine: %v Alignment: %v ZeroMinConstraint: %v", singleLine, alignment, zeroMin), func(t *testing.T) { 210 defer func() { 211 if err := recover(); err != nil { 212 t.Error(err) 213 } 214 }() 215 if zeroMin { 216 gtx.Constraints.Min = image.Point{} 217 } else { 218 gtx.Constraints.Min = gtx.Constraints.Max 219 } 220 e := new(Editor) 221 e.SingleLine = singleLine 222 e.Alignment = alignment 223 e.SetText(sentence) 224 e.SetCaret(0, 0) 225 dims := e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 226 if dims.Size.X < gtx.Constraints.Min.X || dims.Size.Y < gtx.Constraints.Min.Y { 227 t.Errorf("expected min size %#+v, got %#+v", gtx.Constraints.Min, dims.Size) 228 } 229 coords := e.CaretCoords() 230 if halfway := float32(gtx.Constraints.Min.X) * .5; !singleLine && alignment == text.Middle && !zeroMin && coords.X != halfway { 231 t.Errorf("expected caret X to be %f, got %f", halfway, coords.X) 232 } 233 e.SetCaret(runes, runes) 234 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 235 coords = e.CaretCoords() 236 if int(coords.X) > gtx.Constraints.Max.X || int(coords.Y) > gtx.Constraints.Max.Y { 237 t.Errorf("caret coordinates %v exceed constraints %v", coords, gtx.Constraints.Max) 238 } 239 }) 240 } 241 } 242 } 243 } 244 245 func TestEditor(t *testing.T) { 246 e := new(Editor) 247 gtx := layout.Context{ 248 Ops: new(op.Ops), 249 Constraints: layout.Exact(image.Pt(100, 100)), 250 Locale: english, 251 } 252 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 253 fontSize := unit.Sp(10) 254 font := font.Font{} 255 256 // Regression test for bad in-cluster rune offset math. 257 e.SetText("æbc") 258 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 259 e.text.MoveLineEnd(selectionClear) 260 assertCaret(t, e, 0, 3, len("æbc")) 261 262 textSample := "æbc\naøå••" 263 e.SetCaret(0, 0) // shouldn't panic 264 assertCaret(t, e, 0, 0, 0) 265 e.SetText(textSample) 266 if got, exp := e.Len(), utf8.RuneCountInString(e.Text()); got != exp { 267 t.Errorf("got length %d, expected %d", got, exp) 268 } 269 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 270 assertCaret(t, e, 0, 0, 0) 271 e.text.MoveLineEnd(selectionClear) 272 assertCaret(t, e, 0, 3, len("æbc")) 273 e.MoveCaret(+1, +1) 274 assertCaret(t, e, 1, 0, len("æbc\n")) 275 e.MoveCaret(-1, -1) 276 assertCaret(t, e, 0, 3, len("æbc")) 277 e.text.MoveLines(+1, selectionClear) 278 assertCaret(t, e, 1, 4, len("æbc\naøå•")) 279 e.text.MoveLineEnd(selectionClear) 280 assertCaret(t, e, 1, 5, len("æbc\naøå••")) 281 e.MoveCaret(+1, +1) 282 assertCaret(t, e, 1, 5, len("æbc\naøå••")) 283 e.text.MoveLines(3, selectionClear) 284 285 e.SetCaret(0, 0) 286 assertCaret(t, e, 0, 0, 0) 287 e.SetCaret(utf8.RuneCountInString("æ"), utf8.RuneCountInString("æ")) 288 assertCaret(t, e, 0, 1, 2) 289 e.SetCaret(utf8.RuneCountInString("æbc\naøå•"), utf8.RuneCountInString("æbc\naøå•")) 290 assertCaret(t, e, 1, 4, len("æbc\naøå•")) 291 292 // Ensure that password masking does not affect caret behavior 293 e.MoveCaret(-3, -3) 294 assertCaret(t, e, 1, 1, len("æbc\na")) 295 e.text.Mask = '*' 296 e.Update(gtx) 297 assertCaret(t, e, 1, 1, len("æbc\na")) 298 e.MoveCaret(-3, -3) 299 assertCaret(t, e, 0, 2, len("æb")) 300 // Test that moveLine applies x offsets from previous moves. 301 e.SetText("long line\nshort") 302 e.SetCaret(0, 0) 303 e.text.MoveLineEnd(selectionClear) 304 e.text.MoveLines(+1, selectionClear) 305 e.text.MoveLines(-1, selectionClear) 306 assertCaret(t, e, 0, utf8.RuneCountInString("long line"), len("long line")) 307 } 308 309 var arabic = system.Locale{ 310 Language: "AR", 311 Direction: system.RTL, 312 } 313 314 var arabicCollection = func() []font.FontFace { 315 parsed, _ := opentype.Parse(nsareg.TTF) 316 return []font.FontFace{{Font: font.Font{}, Face: parsed}} 317 }() 318 319 func TestEditorRTL(t *testing.T) { 320 e := new(Editor) 321 gtx := layout.Context{ 322 Ops: new(op.Ops), 323 Constraints: layout.Exact(image.Pt(100, 100)), 324 Locale: arabic, 325 } 326 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(arabicCollection)) 327 fontSize := unit.Sp(10) 328 font := font.Font{} 329 330 e.SetCaret(0, 0) // shouldn't panic 331 assertCaret(t, e, 0, 0, 0) 332 333 // Set the text to a single RTL word. The caret should start at 0 column 334 // zero, but this is the first column on the right. 335 e.SetText("الحب") 336 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 337 assertCaret(t, e, 0, 0, 0) 338 e.MoveCaret(+1, +1) 339 assertCaret(t, e, 0, 1, len("ا")) 340 e.MoveCaret(+1, +1) 341 assertCaret(t, e, 0, 2, len("ال")) 342 e.MoveCaret(+1, +1) 343 assertCaret(t, e, 0, 3, len("الح")) 344 // Move to the "end" of the line. This moves to the left edge of the line. 345 e.text.MoveLineEnd(selectionClear) 346 assertCaret(t, e, 0, 4, len("الحب")) 347 348 sentence := "الحب سماء لا\nتمط غير الأحلام" 349 e.SetText(sentence) 350 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 351 assertCaret(t, e, 0, 0, 0) 352 e.text.MoveLineEnd(selectionClear) 353 assertCaret(t, e, 0, 12, len("الحب سماء لا")) 354 e.MoveCaret(+1, +1) 355 assertCaret(t, e, 1, 0, len("الحب سماء لا\n")) 356 e.MoveCaret(+1, +1) 357 assertCaret(t, e, 1, 1, len("الحب سماء لا\nت")) 358 e.MoveCaret(-1, -1) 359 assertCaret(t, e, 1, 0, len("الحب سماء لا\n")) 360 e.MoveCaret(-1, -1) 361 assertCaret(t, e, 0, 12, len("الحب سماء لا")) 362 e.text.MoveLines(+1, selectionClear) 363 assertCaret(t, e, 1, 14, len("الحب سماء لا\nتمط غير الأحلا")) 364 e.text.MoveLineEnd(selectionClear) 365 assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) 366 e.MoveCaret(+1, +1) 367 assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) 368 e.text.MoveLines(3, selectionClear) 369 assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) 370 e.SetCaret(utf8.RuneCountInString(sentence), 0) 371 assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) 372 if selection := e.SelectedText(); selection != sentence { 373 t.Errorf("expected selection %s, got %s", sentence, selection) 374 } 375 376 e.SetCaret(0, 0) 377 assertCaret(t, e, 0, 0, 0) 378 e.SetCaret(utf8.RuneCountInString("ا"), utf8.RuneCountInString("ا")) 379 assertCaret(t, e, 0, 1, len("ا")) 380 e.SetCaret(utf8.RuneCountInString("الحب سماء لا\nتمط غ"), utf8.RuneCountInString("الحب سماء لا\nتمط غ")) 381 assertCaret(t, e, 1, 5, len("الحب سماء لا\nتمط غ")) 382 } 383 384 func TestEditorLigature(t *testing.T) { 385 e := new(Editor) 386 e.WrapPolicy = text.WrapWords 387 gtx := layout.Context{ 388 Ops: new(op.Ops), 389 Constraints: layout.Exact(image.Pt(100, 100)), 390 Locale: english, 391 } 392 face, err := opentype.Parse(robotoregular.TTF) 393 if err != nil { 394 t.Skipf("failed parsing test font: %v", err) 395 } 396 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{ 397 { 398 Font: font.Font{ 399 Typeface: "Roboto", 400 }, 401 Face: face, 402 }, 403 })) 404 fontSize := unit.Sp(10) 405 font := font.Font{} 406 407 /* 408 In this font, the following rune sequences form ligatures: 409 410 - ffi 411 - ffl 412 - fi 413 - fl 414 */ 415 416 e.SetCaret(0, 0) // shouldn't panic 417 assertCaret(t, e, 0, 0, 0) 418 e.SetText("fl") // just a ligature 419 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 420 e.text.MoveLineEnd(selectionClear) 421 assertCaret(t, e, 0, 2, len("fl")) 422 e.MoveCaret(-1, -1) 423 assertCaret(t, e, 0, 1, len("f")) 424 e.MoveCaret(-1, -1) 425 assertCaret(t, e, 0, 0, 0) 426 e.MoveCaret(+2, +2) 427 assertCaret(t, e, 0, 2, len("fl")) 428 e.SetText("flaffl•ffi\n•fflfi") // 3 ligatures on line 0, 2 on line 1 429 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 430 assertCaret(t, e, 0, 0, 0) 431 e.text.MoveLineEnd(selectionClear) 432 assertCaret(t, e, 0, 10, len("ffaffl•ffi")) 433 e.MoveCaret(+1, +1) 434 assertCaret(t, e, 1, 0, len("ffaffl•ffi\n")) 435 e.MoveCaret(+1, +1) 436 assertCaret(t, e, 1, 1, len("ffaffl•ffi\n•")) 437 e.MoveCaret(+1, +1) 438 assertCaret(t, e, 1, 2, len("ffaffl•ffi\n•f")) 439 e.MoveCaret(+1, +1) 440 assertCaret(t, e, 1, 3, len("ffaffl•ffi\n•ff")) 441 e.MoveCaret(+1, +1) 442 assertCaret(t, e, 1, 4, len("ffaffl•ffi\n•ffl")) 443 e.MoveCaret(+1, +1) 444 assertCaret(t, e, 1, 5, len("ffaffl•ffi\n•fflf")) 445 e.MoveCaret(+1, +1) 446 assertCaret(t, e, 1, 6, len("ffaffl•ffi\n•fflfi")) 447 e.MoveCaret(-1, -1) 448 assertCaret(t, e, 1, 5, len("ffaffl•ffi\n•fflf")) 449 e.MoveCaret(-1, -1) 450 assertCaret(t, e, 1, 4, len("ffaffl•ffi\n•ffl")) 451 e.MoveCaret(-1, -1) 452 assertCaret(t, e, 1, 3, len("ffaffl•ffi\n•ff")) 453 e.MoveCaret(-1, -1) 454 assertCaret(t, e, 1, 2, len("ffaffl•ffi\n•f")) 455 e.MoveCaret(-1, -1) 456 assertCaret(t, e, 1, 1, len("ffaffl•ffi\n•")) 457 e.MoveCaret(-1, -1) 458 assertCaret(t, e, 1, 0, len("ffaffl•ffi\n")) 459 e.MoveCaret(-1, -1) 460 assertCaret(t, e, 0, 10, len("ffaffl•ffi")) 461 e.MoveCaret(-2, -2) 462 assertCaret(t, e, 0, 8, len("ffaffl•f")) 463 e.MoveCaret(-1, -1) 464 assertCaret(t, e, 0, 7, len("ffaffl•")) 465 e.MoveCaret(-1, -1) 466 assertCaret(t, e, 0, 6, len("ffaffl")) 467 e.MoveCaret(-1, -1) 468 assertCaret(t, e, 0, 5, len("ffaff")) 469 e.MoveCaret(-1, -1) 470 assertCaret(t, e, 0, 4, len("ffaf")) 471 e.MoveCaret(-1, -1) 472 assertCaret(t, e, 0, 3, len("ffa")) 473 e.MoveCaret(-1, -1) 474 assertCaret(t, e, 0, 2, len("ff")) 475 e.MoveCaret(-1, -1) 476 assertCaret(t, e, 0, 1, len("f")) 477 e.MoveCaret(-1, -1) 478 assertCaret(t, e, 0, 0, 0) 479 gtx.Constraints = layout.Exact(image.Pt(50, 50)) 480 e.SetText("fflffl fflffl fflffl fflffl") // Many ligatures broken across lines. 481 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 482 // Ensure that all runes in the final cluster of a line are properly 483 // decoded when moving to the end of the line. This is a regression test. 484 e.text.MoveLineEnd(selectionClear) 485 // The first line was broken by line wrapping, not a newline character, and has a trailing 486 // whitespace. However, we should never be able to reach the "other side" of such a trailing 487 // whitespace glyph. 488 assertCaret(t, e, 0, 13, len("fflffl fflffl")) 489 e.text.MoveLines(1, selectionClear) 490 assertCaret(t, e, 1, 13, len("fflffl fflffl fflffl fflffl")) 491 e.text.MoveLines(-1, selectionClear) 492 assertCaret(t, e, 0, 13, len("fflffl fflffl")) 493 494 // Absurdly narrow constraints to force each ligature onto its own line. 495 gtx.Constraints = layout.Exact(image.Pt(10, 10)) 496 e.SetText("ffl ffl") // Two ligatures on separate lines. 497 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 498 assertCaret(t, e, 0, 0, 0) 499 e.MoveCaret(1, 1) // Move the caret into the first ligature. 500 assertCaret(t, e, 0, 1, len("f")) 501 e.MoveCaret(4, 4) // Move the caret several positions. 502 assertCaret(t, e, 1, 1, len("ffl f")) 503 } 504 505 func TestEditorDimensions(t *testing.T) { 506 e := new(Editor) 507 r := new(input.Router) 508 gtx := layout.Context{ 509 Ops: new(op.Ops), 510 Constraints: layout.Constraints{Max: image.Pt(100, 100)}, 511 Source: r.Source(), 512 Locale: english, 513 } 514 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 515 fontSize := unit.Sp(10) 516 font := font.Font{} 517 gtx.Execute(key.FocusCmd{Tag: e}) 518 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 519 r.Frame(gtx.Ops) 520 r.Queue(key.EditEvent{Text: "A"}) 521 dims := e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 522 if dims.Size.X < 5 { 523 t.Errorf("EditEvent was not reflected in Editor width") 524 } 525 } 526 527 // assertCaret asserts that the editor caret is at a particular line 528 // and column, and that the byte position matches as well. 529 func assertCaret(t *testing.T, e *Editor, line, col, bytes int) { 530 t.Helper() 531 gotLine, gotCol := e.CaretPos() 532 if gotLine != line || gotCol != col { 533 t.Errorf("caret at (%d, %d), expected (%d, %d)", gotLine, gotCol, line, col) 534 } 535 caretBytes := e.text.runeOffset(e.text.caret.start) 536 if bytes != caretBytes { 537 t.Errorf("caret at buffer position %d, expected %d", caretBytes, bytes) 538 } 539 // Ensure that SelectedText() does not panic no matter what the 540 // editor's state is. 541 _ = e.SelectedText() 542 } 543 544 type editMutation int 545 546 const ( 547 setText editMutation = iota 548 moveRune 549 moveLine 550 movePage 551 moveTextStart 552 moveTextEnd 553 moveLineStart 554 moveLineEnd 555 moveCoord 556 moveWord 557 deleteWord 558 moveLast // Mark end; never generated. 559 ) 560 561 func TestEditorCaretConsistency(t *testing.T) { 562 gtx := layout.Context{ 563 Ops: new(op.Ops), 564 Constraints: layout.Exact(image.Pt(100, 100)), 565 Locale: english, 566 } 567 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 568 fontSize := unit.Sp(10) 569 font := font.Font{} 570 for _, a := range []text.Alignment{text.Start, text.Middle, text.End} { 571 e := &Editor{} 572 e.Alignment = a 573 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 574 575 consistent := func() error { 576 t.Helper() 577 gotLine, gotCol := e.CaretPos() 578 gotCoords := e.CaretCoords() 579 // Blow away index to re-compute position from scratch. 580 e.text.invalidate() 581 want := e.text.closestToRune(e.text.caret.start) 582 wantCoords := f32.Pt(float32(want.x)/64, float32(want.y)) 583 if want.lineCol.line != gotLine || int(want.lineCol.col) != gotCol || gotCoords != wantCoords { 584 return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s", 585 gotLine, gotCol, gotCoords, want.lineCol.line, want.lineCol.col, wantCoords) 586 } 587 return nil 588 } 589 if err := consistent(); err != nil { 590 t.Errorf("initial editor inconsistency (alignment %s): %v", a, err) 591 } 592 593 move := func(mutation editMutation, str string, distance int8, x, y uint16) bool { 594 switch mutation { 595 case setText: 596 e.SetText(str) 597 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 598 case moveRune: 599 e.MoveCaret(int(distance), int(distance)) 600 case moveLine: 601 e.text.MoveLines(int(distance), selectionClear) 602 case movePage: 603 e.text.MovePages(int(distance), selectionClear) 604 case moveLineStart: 605 e.text.MoveLineStart(selectionClear) 606 case moveLineEnd: 607 e.text.MoveLineEnd(selectionClear) 608 case moveTextStart: 609 e.text.MoveTextStart(selectionClear) 610 case moveTextEnd: 611 e.text.MoveTextEnd(selectionClear) 612 case moveCoord: 613 e.text.MoveCoord(image.Pt(int(x), int(y))) 614 case moveWord: 615 e.text.MoveWord(int(distance), selectionClear) 616 case deleteWord: 617 e.deleteWord(int(distance)) 618 default: 619 return false 620 } 621 if err := consistent(); err != nil { 622 t.Error(err) 623 return false 624 } 625 return true 626 } 627 if err := quick.Check(move, nil); err != nil { 628 t.Errorf("editor inconsistency (alignment %s): %v", a, err) 629 } 630 } 631 } 632 633 func TestEditorMoveWord(t *testing.T) { 634 type Test struct { 635 Text string 636 Start int 637 Skip int 638 Want int 639 } 640 tests := []Test{ 641 {"", 0, 0, 0}, 642 {"", 0, -1, 0}, 643 {"", 0, 1, 0}, 644 {"hello", 0, -1, 0}, 645 {"hello", 0, 1, 5}, 646 {"hello world", 3, 1, 5}, 647 {"hello world", 3, -1, 0}, 648 {"hello world", 8, -1, 6}, 649 {"hello world", 8, 1, 11}, 650 {"hello world", 3, 1, 5}, 651 {"hello world", 3, 2, 14}, 652 {"hello world", 8, 1, 14}, 653 {"hello world", 8, -1, 0}, 654 {"hello brave new world", 0, 3, 15}, 655 } 656 setup := func(t string) *Editor { 657 e := new(Editor) 658 gtx := layout.Context{ 659 Ops: new(op.Ops), 660 Constraints: layout.Exact(image.Pt(100, 100)), 661 Locale: english, 662 } 663 e.SetText(t) 664 e.Update(gtx) 665 return e 666 } 667 for ii, tt := range tests { 668 e := setup(tt.Text) 669 e.MoveCaret(tt.Start, tt.Start) 670 e.text.MoveWord(tt.Skip, selectionClear) 671 caretBytes := e.text.runeOffset(e.text.caret.start) 672 if caretBytes != tt.Want { 673 t.Fatalf("[%d] moveWord: bad caret position: got %d, want %d", ii, caretBytes, tt.Want) 674 } 675 } 676 } 677 678 func TestEditorInsert(t *testing.T) { 679 type Test struct { 680 Text string 681 Start int 682 Selection int 683 Insertion string 684 685 Result string 686 } 687 tests := []Test{ 688 // Nothing inserted 689 {"", 0, 0, "", ""}, 690 {"", 0, -1, "", ""}, 691 {"", 0, 1, "", ""}, 692 {"", 0, -2, "", ""}, 693 {"", 0, 2, "", ""}, 694 {"world", 0, 0, "", "world"}, 695 {"world", 0, -1, "", "world"}, 696 {"world", 0, 1, "", "orld"}, 697 {"world", 2, 0, "", "world"}, 698 {"world", 2, -1, "", "wrld"}, 699 {"world", 2, 1, "", "wold"}, 700 {"world", 5, 0, "", "world"}, 701 {"world", 5, -1, "", "worl"}, 702 {"world", 5, 1, "", "world"}, 703 // One rune inserted 704 {"", 0, 0, "_", "_"}, 705 {"", 0, -1, "_", "_"}, 706 {"", 0, 1, "_", "_"}, 707 {"", 0, -2, "_", "_"}, 708 {"", 0, 2, "_", "_"}, 709 {"world", 0, 0, "_", "_world"}, 710 {"world", 0, -1, "_", "_world"}, 711 {"world", 0, 1, "_", "_orld"}, 712 {"world", 2, 0, "_", "wo_rld"}, 713 {"world", 2, -1, "_", "w_rld"}, 714 {"world", 2, 1, "_", "wo_ld"}, 715 {"world", 5, 0, "_", "world_"}, 716 {"world", 5, -1, "_", "worl_"}, 717 {"world", 5, 1, "_", "world_"}, 718 // More runes inserted 719 {"", 0, 0, "-3-", "-3-"}, 720 {"", 0, -1, "-3-", "-3-"}, 721 {"", 0, 1, "-3-", "-3-"}, 722 {"", 0, -2, "-3-", "-3-"}, 723 {"", 0, 2, "-3-", "-3-"}, 724 {"world", 0, 0, "-3-", "-3-world"}, 725 {"world", 0, -1, "-3-", "-3-world"}, 726 {"world", 0, 1, "-3-", "-3-orld"}, 727 {"world", 2, 0, "-3-", "wo-3-rld"}, 728 {"world", 2, -1, "-3-", "w-3-rld"}, 729 {"world", 2, 1, "-3-", "wo-3-ld"}, 730 {"world", 5, 0, "-3-", "world-3-"}, 731 {"world", 5, -1, "-3-", "worl-3-"}, 732 {"world", 5, 1, "-3-", "world-3-"}, 733 // Runes with length > 1 inserted 734 {"", 0, 0, "éêè", "éêè"}, 735 {"", 0, -1, "éêè", "éêè"}, 736 {"", 0, 1, "éêè", "éêè"}, 737 {"", 0, -2, "éêè", "éêè"}, 738 {"", 0, 2, "éêè", "éêè"}, 739 {"world", 0, 0, "éêè", "éêèworld"}, 740 {"world", 0, -1, "éêè", "éêèworld"}, 741 {"world", 0, 1, "éêè", "éêèorld"}, 742 {"world", 2, 0, "éêè", "woéêèrld"}, 743 {"world", 2, -1, "éêè", "wéêèrld"}, 744 {"world", 2, 1, "éêè", "woéêèld"}, 745 {"world", 5, 0, "éêè", "worldéêè"}, 746 {"world", 5, -1, "éêè", "worléêè"}, 747 {"world", 5, 1, "éêè", "worldéêè"}, 748 // Runes with length > 1 deleted from selection 749 {"élançé", 0, 1, "", "lançé"}, 750 {"élançé", 0, 1, "-3-", "-3-lançé"}, 751 {"élançé", 3, 2, "-3-", "éla-3-é"}, 752 {"élançé", 3, 3, "-3-", "éla-3-"}, 753 {"élançé", 3, 10, "-3-", "éla-3-"}, 754 {"élançé", 5, -1, "-3-", "élan-3-é"}, 755 {"élançé", 6, -1, "-3-", "élanç-3-"}, 756 {"élançé", 6, -3, "-3-", "éla-3-"}, 757 } 758 setup := func(t string) *Editor { 759 e := new(Editor) 760 gtx := layout.Context{ 761 Ops: new(op.Ops), 762 Constraints: layout.Exact(image.Pt(100, 100)), 763 Locale: english, 764 } 765 e.SetText(t) 766 e.Update(gtx) 767 return e 768 } 769 for ii, tt := range tests { 770 e := setup(tt.Text) 771 e.MoveCaret(tt.Start, tt.Start) 772 e.MoveCaret(0, tt.Selection) 773 e.Insert(tt.Insertion) 774 if e.Text() != tt.Result { 775 t.Fatalf("[%d] Insert: invalid result: got %q, want %q", ii, e.Text(), tt.Result) 776 } 777 } 778 } 779 780 func TestEditorDeleteWord(t *testing.T) { 781 type Test struct { 782 Text string 783 Start int 784 Selection int 785 Delete int 786 787 Want int 788 Result string 789 } 790 tests := []Test{ 791 // No text selected 792 {"", 0, 0, 0, 0, ""}, 793 {"", 0, 0, -1, 0, ""}, 794 {"", 0, 0, 1, 0, ""}, 795 {"", 0, 0, -2, 0, ""}, 796 {"", 0, 0, 2, 0, ""}, 797 {"hello", 0, 0, -1, 0, "hello"}, 798 {"hello", 0, 0, 1, 0, ""}, 799 800 // Document (imho) incorrect behavior w.r.t. deleting spaces following 801 // words. 802 {"hello world", 0, 0, 1, 0, " world"}, // Should be "world", if you ask me. 803 {"hello world", 0, 0, 2, 0, "world"}, // Should be "". 804 {"hello ", 0, 0, 1, 0, " "}, // Should be "". 805 {"hello world", 11, 0, -1, 6, "hello "}, // Should be "hello". 806 {"hello world", 11, 0, -2, 5, "hello"}, // Should be "". 807 {"hello ", 6, 0, -1, 0, ""}, // Correct result. 808 809 {"hello world", 3, 0, 1, 3, "hel world"}, 810 {"hello world", 3, 0, -1, 0, "lo world"}, 811 {"hello world", 8, 0, -1, 6, "hello rld"}, 812 {"hello world", 8, 0, 1, 8, "hello wo"}, 813 {"hello world", 3, 0, 1, 3, "hel world"}, 814 {"hello world", 3, 0, 2, 3, "helworld"}, 815 {"hello world", 8, 0, 1, 8, "hello "}, 816 {"hello world", 8, 0, -1, 5, "hello world"}, 817 {"hello brave new world", 0, 0, 3, 0, " new world"}, 818 {"helléèçàô world", 3, 0, 1, 3, "hel world"}, // unicode char with length > 1 in deleted part 819 // Add selected text. 820 // 821 // Several permutations must be tested: 822 // - select from the left or right 823 // - Delete + or - 824 // - abs(Delete) == 1 or > 1 825 // 826 // "brave |" selected; caret at | 827 {"hello there brave new world", 12, 6, 1, 12, "hello there new world"}, // #16 828 {"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. 829 {"hello there brave new world", 12, 6, -1, 12, "hello there new world"}, 830 {"hello there brave new world", 12, 6, -2, 6, "hello new world"}, 831 {"hello there b®âve new world", 12, 6, 1, 12, "hello there new world"}, // unicode chars with length > 1 in selection 832 {"hello there b®âve new world", 12, 6, 2, 12, "hello there world"}, // ditto 833 {"hello there b®âve new world", 12, 6, -1, 12, "hello there new world"}, // ditto 834 {"hello there b®âve new world", 12, 6, -2, 6, "hello new world"}, // ditto 835 // "|brave " selected 836 {"hello there brave new world", 18, -6, 1, 12, "hello there new world"}, // #20 837 {"hello there brave new world", 18, -6, 2, 12, "hello there world"}, // ditto 838 {"hello there brave new world", 18, -6, -1, 12, "hello there new world"}, 839 {"hello there brave new world", 18, -6, -2, 6, "hello new world"}, 840 {"hello there b®âve new world", 18, -6, 1, 12, "hello there new world"}, // unicode chars with length > 1 in selection 841 // Random edge cases 842 {"hello there brave new world", 12, 6, 99, 12, "hello there "}, 843 {"hello there brave new world", 18, -6, -99, 0, "new world"}, 844 } 845 setup := func(t string) *Editor { 846 e := new(Editor) 847 gtx := layout.Context{ 848 Ops: new(op.Ops), 849 Constraints: layout.Exact(image.Pt(100, 100)), 850 Locale: english, 851 } 852 e.SetText(t) 853 e.Update(gtx) 854 return e 855 } 856 for ii, tt := range tests { 857 e := setup(tt.Text) 858 e.MoveCaret(tt.Start, tt.Start) 859 e.MoveCaret(0, tt.Selection) 860 e.deleteWord(tt.Delete) 861 caretBytes := e.text.runeOffset(e.text.caret.start) 862 if caretBytes != tt.Want { 863 t.Fatalf("[%d] deleteWord: bad caret position: got %d, want %d", ii, caretBytes, tt.Want) 864 } 865 if e.Text() != tt.Result { 866 t.Fatalf("[%d] deleteWord: invalid result: got %q, want %q", ii, e.Text(), tt.Result) 867 } 868 } 869 } 870 871 func TestEditorNoLayout(t *testing.T) { 872 var e Editor 873 e.SetText("hi!\n") 874 e.MoveCaret(1, 1) 875 } 876 877 // Generate generates a value of itself, for testing/quick. 878 func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value { 879 t := editMutation(rand.Intn(int(moveLast))) 880 return reflect.ValueOf(t) 881 } 882 883 // TestEditorSelect tests the selection code. It lays out an editor with several 884 // lines in it, selects some text, verifies the selection, resizes the editor 885 // to make it much narrower (which makes the lines in the editor reflow), and 886 // then verifies that the updated (col, line) positions of the selected text 887 // are where we expect. 888 func TestEditorSelectReflow(t *testing.T) { 889 e := new(Editor) 890 e.SetText(`a 2 4 6 8 a 891 b 2 4 6 8 b 892 c 2 4 6 8 c 893 d 2 4 6 8 d 894 e 2 4 6 8 e 895 f 2 4 6 8 f 896 g 2 4 6 8 g 897 `) 898 899 r := new(input.Router) 900 gtx := layout.Context{ 901 Ops: new(op.Ops), 902 Locale: english, 903 Source: r.Source(), 904 } 905 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 906 font := font.Font{} 907 fontSize := unit.Sp(10) 908 909 var tim time.Duration 910 selected := func(start, end int) string { 911 gtx.Execute(key.FocusCmd{Tag: e}) 912 // Layout once with no events; populate e.lines. 913 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 914 915 r.Frame(gtx.Ops) 916 gtx.Source = r.Source() 917 // Build the selection events 918 startPos := e.text.closestToRune(start) 919 endPos := e.text.closestToRune(end) 920 r.Queue( 921 pointer.Event{ 922 Buttons: pointer.ButtonPrimary, 923 Kind: pointer.Press, 924 Source: pointer.Mouse, 925 Time: tim, 926 Position: f32.Pt(textWidth(e, startPos.lineCol.line, 0, startPos.lineCol.col), textBaseline(e, startPos.lineCol.line)), 927 }, 928 pointer.Event{ 929 Kind: pointer.Release, 930 Source: pointer.Mouse, 931 Time: tim, 932 Position: f32.Pt(textWidth(e, endPos.lineCol.line, 0, endPos.lineCol.col), textBaseline(e, endPos.lineCol.line)), 933 }, 934 ) 935 tim += time.Second // Avoid multi-clicks. 936 937 for { 938 _, ok := e.Update(gtx) // throw away any events from this layout 939 if !ok { 940 break 941 } 942 } 943 return e.SelectedText() 944 } 945 type screenPos image.Point 946 logicalPosMatch := func(t *testing.T, n int, label string, expected screenPos, actual combinedPos) { 947 t.Helper() 948 if actual.lineCol.line != expected.Y || actual.lineCol.col != expected.X { 949 t.Errorf("Test %d: Expected %s %#v; got %#v", 950 n, label, 951 expected, actual) 952 } 953 } 954 955 type testCase struct { 956 // input text offsets 957 start, end int 958 959 // expected selected text 960 selection string 961 // expected line/col positions of selection after resize 962 startPos, endPos screenPos 963 } 964 965 for n, tst := range []testCase{ 966 {0, 1, "a", screenPos{}, screenPos{Y: 0, X: 1}}, 967 {0, 4, "a 2 ", screenPos{}, screenPos{Y: 0, X: 4}}, 968 {0, 11, "a 2 4 6 8 a", screenPos{}, screenPos{Y: 1, X: 3}}, 969 {6, 10, "6 8 ", screenPos{Y: 0, X: 6}, screenPos{Y: 1, X: 2}}, 970 {41, 66, " 6 8 d\ne 2 4 6 8 e\nf 2 4 ", screenPos{Y: 6, X: 5}, screenPos{Y: 10, X: 6}}, 971 } { 972 gtx.Constraints = layout.Exact(image.Pt(100, 100)) 973 if got := selected(tst.start, tst.end); got != tst.selection { 974 t.Errorf("Test %d pt1: Expected %q, got %q", n, tst.selection, got) 975 continue 976 } 977 978 // Constrain the editor to roughly 6 columns wide and redraw 979 gtx.Constraints = layout.Exact(image.Pt(36, 36)) 980 // Keep existing selection 981 gtx = gtx.Disabled() 982 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 983 984 caretStart := e.text.closestToRune(e.text.caret.start) 985 caretEnd := e.text.closestToRune(e.text.caret.end) 986 logicalPosMatch(t, n, "start", tst.startPos, caretEnd) 987 logicalPosMatch(t, n, "end", tst.endPos, caretStart) 988 } 989 } 990 991 func TestEditorSelectShortcuts(t *testing.T) { 992 tFont := font.Font{} 993 tFontSize := unit.Sp(10) 994 tShaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 995 var tEditor = &Editor{ 996 SingleLine: false, 997 ReadOnly: true, 998 } 999 lines := "abc abc abc\ndef def def\nghi ghi ghi" 1000 tEditor.SetText(lines) 1001 type testCase struct { 1002 // Initial text selection. 1003 startPos, endPos int 1004 // Keyboard shortcut to execute. 1005 keyEvent key.Event 1006 // Expected text selection. 1007 selection string 1008 } 1009 1010 pos1, pos2 := 14, 21 1011 for n, tst := range []testCase{ 1012 {pos1, pos2, key.Event{Name: "A", Modifiers: key.ModShortcut}, lines}, 1013 {pos2, pos1, key.Event{Name: "A", Modifiers: key.ModShortcut}, lines}, 1014 {pos1, pos2, key.Event{Name: key.NameHome, Modifiers: key.ModShift}, "def def d"}, 1015 {pos1, pos2, key.Event{Name: key.NameEnd, Modifiers: key.ModShift}, "ef"}, 1016 {pos2, pos1, key.Event{Name: key.NameHome, Modifiers: key.ModShift}, "de"}, 1017 {pos2, pos1, key.Event{Name: key.NameEnd, Modifiers: key.ModShift}, "f def def"}, 1018 {pos1, pos2, key.Event{Name: key.NameHome, Modifiers: key.ModShortcut | key.ModShift}, "abc abc abc\ndef def d"}, 1019 {pos1, pos2, key.Event{Name: key.NameEnd, Modifiers: key.ModShortcut | key.ModShift}, "ef\nghi ghi ghi"}, 1020 {pos2, pos1, key.Event{Name: key.NameHome, Modifiers: key.ModShortcut | key.ModShift}, "abc abc abc\nde"}, 1021 {pos2, pos1, key.Event{Name: key.NameEnd, Modifiers: key.ModShortcut | key.ModShift}, "f def def\nghi ghi ghi"}, 1022 } { 1023 tRouter := new(input.Router) 1024 gtx := layout.Context{ 1025 Ops: new(op.Ops), 1026 Locale: english, 1027 Constraints: layout.Exact(image.Pt(100, 100)), 1028 Source: tRouter.Source(), 1029 } 1030 gtx.Execute(key.FocusCmd{Tag: tEditor}) 1031 tEditor.Layout(gtx, tShaper, tFont, tFontSize, op.CallOp{}, op.CallOp{}) 1032 1033 tEditor.SetCaret(tst.startPos, tst.endPos) 1034 if cStart, cEnd := tEditor.Selection(); cStart != tst.startPos || cEnd != tst.endPos { 1035 t.Errorf("TestEditorSelect %d: initial selection", n) 1036 } 1037 tRouter.Queue(tst.keyEvent) 1038 tEditor.Update(gtx) 1039 if got := tEditor.SelectedText(); got != tst.selection { 1040 t.Errorf("TestEditorSelect %d: Expected %q, got %q", n, tst.selection, got) 1041 } 1042 } 1043 } 1044 1045 // Verify that an existing selection is dismissed when you press arrow keys. 1046 func TestSelectMove(t *testing.T) { 1047 e := new(Editor) 1048 e.SetText(`0123456789`) 1049 1050 r := new(input.Router) 1051 gtx := layout.Context{ 1052 Ops: new(op.Ops), 1053 Locale: english, 1054 Source: r.Source(), 1055 } 1056 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 1057 font := font.Font{} 1058 fontSize := unit.Sp(10) 1059 1060 // Layout once to populate e.lines and get focus. 1061 gtx.Execute(key.FocusCmd{Tag: e}) 1062 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1063 r.Frame(gtx.Ops) 1064 // Set up selecton so the Editor key handler filters for all 4 directional keys. 1065 e.SetCaret(3, 6) 1066 gtx.Ops.Reset() 1067 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1068 r.Frame(gtx.Ops) 1069 gtx.Ops.Reset() 1070 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1071 r.Frame(gtx.Ops) 1072 1073 for _, keyName := range []key.Name{key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow} { 1074 // Select 345 1075 e.SetCaret(3, 6) 1076 if expected, got := "345", e.SelectedText(); expected != got { 1077 t.Errorf("KeyName %s, expected %q, got %q", keyName, expected, got) 1078 } 1079 1080 // Press the key 1081 r.Queue(key.Event{State: key.Press, Name: keyName}) 1082 gtx.Ops.Reset() 1083 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1084 r.Frame(gtx.Ops) 1085 1086 if expected, got := "", e.SelectedText(); expected != got { 1087 t.Errorf("KeyName %s, expected %q, got %q", keyName, expected, got) 1088 } 1089 } 1090 } 1091 1092 func TestEditor_Read(t *testing.T) { 1093 s := "hello world" 1094 buf := make([]byte, len(s)) 1095 e := new(Editor) 1096 e.SetText(s) 1097 1098 _, err := e.Seek(0, io.SeekStart) 1099 if err != nil { 1100 t.Error(err) 1101 } 1102 n, err := io.ReadFull(e, buf) 1103 if err != nil { 1104 t.Error(err) 1105 } 1106 if got, want := n, len(s); got != want { 1107 t.Errorf("got %d; want %d", got, want) 1108 } 1109 if got, want := string(buf), s; got != want { 1110 t.Errorf("got %q; want %q", got, want) 1111 } 1112 } 1113 1114 func TestEditor_WriteTo(t *testing.T) { 1115 s := "hello world" 1116 var buf bytes.Buffer 1117 e := new(Editor) 1118 e.SetText(s) 1119 1120 n, err := io.Copy(&buf, e) 1121 if err != nil { 1122 t.Error(err) 1123 } 1124 if got, want := int(n), len(s); got != want { 1125 t.Errorf("got %d; want %d", got, want) 1126 } 1127 if got, want := buf.String(), s; got != want { 1128 t.Errorf("got %q; want %q", got, want) 1129 } 1130 } 1131 1132 func TestEditor_MaxLen(t *testing.T) { 1133 e := new(Editor) 1134 1135 e.MaxLen = 8 1136 e.SetText("123456789") 1137 if got, want := e.Text(), "12345678"; got != want { 1138 t.Errorf("editor failed to cap SetText") 1139 } 1140 1141 e.SetText("2345678") 1142 r := new(input.Router) 1143 gtx := layout.Context{ 1144 Ops: new(op.Ops), 1145 Constraints: layout.Exact(image.Pt(100, 100)), 1146 Source: r.Source(), 1147 } 1148 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 1149 fontSize := unit.Sp(10) 1150 font := font.Font{} 1151 gtx.Execute(key.FocusCmd{Tag: e}) 1152 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1153 r.Frame(gtx.Ops) 1154 r.Queue( 1155 key.EditEvent{Range: key.Range{Start: 0, End: 2}, Text: "1234"}, 1156 key.SelectionEvent{Start: 4, End: 4}, 1157 ) 1158 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1159 1160 if got, want := e.Text(), "12345678"; got != want { 1161 t.Errorf("editor failed to cap EditEvent") 1162 } 1163 if start, end := e.Selection(); start != 3 || end != 3 { 1164 t.Errorf("editor failed to adjust SelectionEvent") 1165 } 1166 } 1167 1168 func TestEditor_Filter(t *testing.T) { 1169 e := new(Editor) 1170 1171 e.Filter = "123456789" 1172 e.SetText("abcde1234") 1173 if got, want := e.Text(), "1234"; got != want { 1174 t.Errorf("editor failed to filter SetText") 1175 } 1176 1177 e.SetText("2345678") 1178 r := new(input.Router) 1179 gtx := layout.Context{ 1180 Ops: new(op.Ops), 1181 Constraints: layout.Exact(image.Pt(100, 100)), 1182 Source: r.Source(), 1183 } 1184 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 1185 fontSize := unit.Sp(10) 1186 font := font.Font{} 1187 gtx.Execute(key.FocusCmd{Tag: e}) 1188 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1189 r.Frame(gtx.Ops) 1190 r.Queue( 1191 key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1"}, 1192 key.SelectionEvent{Start: 4, End: 4}, 1193 ) 1194 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1195 1196 if got, want := e.Text(), "12345678"; got != want { 1197 t.Errorf("editor failed to filter EditEvent") 1198 } 1199 if start, end := e.Selection(); start != 2 || end != 2 { 1200 t.Errorf("editor failed to adjust SelectionEvent") 1201 } 1202 } 1203 1204 func TestEditor_Submit(t *testing.T) { 1205 e := new(Editor) 1206 e.Submit = true 1207 1208 r := new(input.Router) 1209 gtx := layout.Context{ 1210 Ops: new(op.Ops), 1211 Constraints: layout.Exact(image.Pt(100, 100)), 1212 Source: r.Source(), 1213 } 1214 cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) 1215 fontSize := unit.Sp(10) 1216 font := font.Font{} 1217 gtx.Execute(key.FocusCmd{Tag: e}) 1218 e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) 1219 r.Frame(gtx.Ops) 1220 r.Queue( 1221 key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1\n"}, 1222 ) 1223 1224 got := []EditorEvent{} 1225 for { 1226 ev, ok := e.Update(gtx) 1227 if !ok { 1228 break 1229 } 1230 got = append(got, ev) 1231 } 1232 if got, want := e.Text(), "ab1"; got != want { 1233 t.Errorf("editor failed to filter newline") 1234 } 1235 want := []EditorEvent{ 1236 ChangeEvent{}, 1237 SubmitEvent{Text: e.Text()}, 1238 } 1239 if !reflect.DeepEqual(want, got) { 1240 t.Errorf("editor failed to register submit") 1241 } 1242 } 1243 1244 func TestNoFilterAllocs(t *testing.T) { 1245 b := testing.Benchmark(func(b *testing.B) { 1246 r := new(input.Router) 1247 e := new(Editor) 1248 gtx := layout.Context{ 1249 Ops: new(op.Ops), 1250 Constraints: layout.Constraints{ 1251 Max: image.Pt(100, 100), 1252 }, 1253 Locale: english, 1254 Source: r.Source(), 1255 } 1256 b.ReportAllocs() 1257 b.ResetTimer() 1258 for i := 0; i < b.N; i++ { 1259 e.Update(gtx) 1260 } 1261 }) 1262 if allocs := b.AllocsPerOp(); allocs != 0 { 1263 t.Fatalf("expected 0 AllocsPerOp, got %d", allocs) 1264 } 1265 } 1266 1267 // textWidth is a text helper for building simple selection events. 1268 // It assumes single-run lines, which isn't safe with non-test text 1269 // data. 1270 func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 { 1271 start := e.text.closestToLineCol(lineNum, colStart) 1272 end := e.text.closestToLineCol(lineNum, colEnd) 1273 delta := start.x - end.x 1274 if delta < 0 { 1275 delta = -delta 1276 } 1277 return float32(delta.Round()) 1278 } 1279 1280 // testBaseline returns the y coordinate of the baseline for the 1281 // given line number. 1282 func textBaseline(e *Editor, lineNum int) float32 { 1283 start := e.text.closestToLineCol(lineNum, 0) 1284 return float32(start.y) 1285 }