gioui.org@v0.6.1-0.20240506124620-7a9ce51988ce/widget/index_test.go (about) 1 package widget 2 3 import ( 4 "bytes" 5 "io" 6 "testing" 7 8 nsareg "eliasnaur.com/font/noto/sans/arabic/regular" 9 "gioui.org/font" 10 "gioui.org/font/opentype" 11 "gioui.org/text" 12 "golang.org/x/image/font/gofont/goregular" 13 "golang.org/x/image/math/fixed" 14 ) 15 16 // makePosTestText returns two bidi samples of shaped text at the given 17 // font size and wrapped to the given line width. The runeLimit, if nonzero, 18 // truncates the sample text to ensure shorter output for expensive tests. 19 func makePosTestText(fontSize, lineWidth int, alignOpposite bool) (source string, bidiLTR, bidiRTL []text.Glyph) { 20 ltrFace, _ := opentype.Parse(goregular.TTF) 21 rtlFace, _ := opentype.Parse(nsareg.TTF) 22 23 shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{ 24 { 25 Font: font.Font{Typeface: "LTR"}, 26 Face: ltrFace, 27 }, 28 { 29 Font: font.Font{Typeface: "RTL"}, 30 Face: rtlFace, 31 }, 32 })) 33 // bidiSource is crafted to contain multiple consecutive RTL runs (by 34 // changing scripts within the RTL). 35 bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog." 36 ltrParams := text.Parameters{ 37 PxPerEm: fixed.I(fontSize), 38 MaxWidth: lineWidth, 39 MinWidth: lineWidth, 40 Locale: english, 41 } 42 rtlParams := text.Parameters{ 43 Alignment: text.End, 44 PxPerEm: fixed.I(fontSize), 45 MaxWidth: lineWidth, 46 MinWidth: lineWidth, 47 Locale: arabic, 48 } 49 if alignOpposite { 50 ltrParams.Alignment = text.End 51 rtlParams.Alignment = text.Start 52 } 53 shaper.LayoutString(ltrParams, bidiSource) 54 for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { 55 bidiLTR = append(bidiLTR, g) 56 } 57 shaper.LayoutString(rtlParams, bidiSource) 58 for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { 59 bidiRTL = append(bidiRTL, g) 60 } 61 return bidiSource, bidiLTR, bidiRTL 62 } 63 64 // makeAccountingTestText shapes text designed to stress rune accounting 65 // logic within the index. 66 func makeAccountingTestText(str string, fontSize, lineWidth int) (txt []text.Glyph) { 67 ltrFace, _ := opentype.Parse(goregular.TTF) 68 rtlFace, _ := opentype.Parse(nsareg.TTF) 69 70 shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{{ 71 Font: font.Font{Typeface: "LTR"}, 72 Face: ltrFace, 73 }, 74 { 75 Font: font.Font{Typeface: "RTL"}, 76 Face: rtlFace, 77 }, 78 })) 79 params := text.Parameters{ 80 PxPerEm: fixed.I(fontSize), 81 MaxWidth: lineWidth, 82 Locale: english, 83 } 84 shaper.LayoutString(params, str) 85 for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { 86 txt = append(txt, g) 87 } 88 return txt 89 } 90 91 // getGlyphs shapes text as english. 92 func getGlyphs(fontSize, minWidth, lineWidth int, align text.Alignment, str string) (txt []text.Glyph) { 93 ltrFace, _ := opentype.Parse(goregular.TTF) 94 rtlFace, _ := opentype.Parse(nsareg.TTF) 95 96 shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{{ 97 Font: font.Font{Typeface: "LTR"}, 98 Face: ltrFace, 99 }, 100 { 101 Font: font.Font{Typeface: "RTL"}, 102 Face: rtlFace, 103 }, 104 })) 105 params := text.Parameters{ 106 PxPerEm: fixed.I(fontSize), 107 Alignment: align, 108 MinWidth: minWidth, 109 MaxWidth: lineWidth, 110 Locale: english, 111 WrapPolicy: text.WrapWords, 112 } 113 shaper.LayoutString(params, str) 114 for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { 115 txt = append(txt, g) 116 } 117 return txt 118 } 119 120 // TestIndexPositionWhitespace checks that the index correctly generates cursor positions 121 // for empty lines and the empty string. 122 func TestIndexPositionWhitespace(t *testing.T) { 123 type testcase struct { 124 name string 125 str string 126 lineWidth int 127 align text.Alignment 128 expected []combinedPos 129 } 130 for _, tc := range []testcase{ 131 { 132 name: "empty string", 133 str: "", 134 lineWidth: 200, 135 expected: []combinedPos{ 136 {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, 137 }, 138 }, 139 { 140 name: "just hard newline", 141 str: "\n", 142 lineWidth: 200, 143 expected: []combinedPos{ 144 {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, 145 {x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}}, 146 }, 147 }, 148 { 149 name: "trailing newline", 150 str: "a\n", 151 lineWidth: 200, 152 expected: []combinedPos{ 153 {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, 154 {x: fixed.Int26_6(570), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{col: 1}}, 155 {x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 1}}, 156 }, 157 }, 158 { 159 name: "just blank line", 160 str: "\n\n", 161 lineWidth: 200, 162 expected: []combinedPos{ 163 {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, 164 {x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}}, 165 {x: fixed.Int26_6(0), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 2}}, 166 }, 167 }, 168 { 169 name: "middle aligned blank lines", 170 str: "\n\n\nabc", 171 align: text.Middle, 172 lineWidth: 200, 173 expected: []combinedPos{ 174 {x: fixed.Int26_6(832), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, 175 {x: fixed.Int26_6(832), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}}, 176 {x: fixed.Int26_6(832), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 2}}, 177 {x: fixed.Int26_6(6), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 3, lineCol: screenPos{line: 3}}, 178 {x: fixed.Int26_6(576), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 4, lineCol: screenPos{line: 3, col: 1}}, 179 {x: fixed.Int26_6(1146), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 5, lineCol: screenPos{line: 3, col: 2}}, 180 {x: fixed.Int26_6(1658), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 6, lineCol: screenPos{line: 3, col: 3}}, 181 }, 182 }, 183 { 184 name: "blank line", 185 str: "a\n\nb", 186 lineWidth: 200, 187 expected: []combinedPos{ 188 {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, 189 {x: fixed.Int26_6(570), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{col: 1}}, 190 {x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 1}}, 191 {x: fixed.Int26_6(0), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 3, lineCol: screenPos{line: 2}}, 192 {x: fixed.Int26_6(570), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 4, lineCol: screenPos{line: 2, col: 1}}, 193 }, 194 }, 195 { 196 name: "soft wrap", 197 str: "abc def", 198 lineWidth: 30, 199 expected: []combinedPos{ 200 {runes: 0, lineCol: screenPos{line: 0, col: 0}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 0, y: 16}, 201 {runes: 1, lineCol: screenPos{line: 0, col: 1}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 570, y: 16}, 202 {runes: 2, lineCol: screenPos{line: 0, col: 2}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1140, y: 16}, 203 {runes: 3, lineCol: screenPos{line: 0, col: 3}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1652, y: 16}, 204 {runes: 4, lineCol: screenPos{line: 1, col: 0}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 0, y: 35}, 205 {runes: 5, lineCol: screenPos{line: 1, col: 1}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 570, y: 35}, 206 {runes: 6, lineCol: screenPos{line: 1, col: 2}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1140, y: 35}, 207 {runes: 7, lineCol: screenPos{line: 1, col: 3}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1425, y: 35}, 208 }, 209 }, 210 { 211 name: "soft wrap arabic", 212 str: "ثنائي الاتجاه", 213 lineWidth: 30, 214 expected: []combinedPos{ 215 {runes: 0, lineCol: screenPos{line: 0, col: 0}, ascent: 1407, descent: 756, x: 2250, y: 22, towardOrigin: true}, 216 {runes: 1, lineCol: screenPos{line: 0, col: 1}, ascent: 1407, descent: 756, x: 1944, y: 22, towardOrigin: true}, 217 {runes: 2, lineCol: screenPos{line: 0, col: 2}, ascent: 1407, descent: 756, x: 1593, y: 22, towardOrigin: true}, 218 {runes: 3, lineCol: screenPos{line: 0, col: 3}, ascent: 1407, descent: 756, x: 1295, y: 22, towardOrigin: true}, 219 {runes: 4, lineCol: screenPos{line: 0, col: 4}, ascent: 1407, descent: 756, x: 1020, y: 22, towardOrigin: true}, 220 {runes: 5, lineCol: screenPos{line: 0, col: 5}, ascent: 1407, descent: 756, x: 266, y: 22, towardOrigin: true}, 221 {runes: 6, lineCol: screenPos{line: 1, col: 0}, ascent: 1407, descent: 756, x: 2511, y: 41, towardOrigin: true}, 222 {runes: 7, lineCol: screenPos{line: 1, col: 1}, ascent: 1407, descent: 756, x: 2267, y: 41, towardOrigin: true}, 223 {runes: 8, lineCol: screenPos{line: 1, col: 2}, ascent: 1407, descent: 756, x: 1969, y: 41, towardOrigin: true}, 224 {runes: 9, lineCol: screenPos{line: 1, col: 3}, ascent: 1407, descent: 756, x: 1671, y: 41, towardOrigin: true}, 225 {runes: 10, lineCol: screenPos{line: 1, col: 4}, ascent: 1407, descent: 756, x: 1365, y: 41, towardOrigin: true}, 226 {runes: 11, lineCol: screenPos{line: 1, col: 5}, ascent: 1407, descent: 756, x: 713, y: 41, towardOrigin: true}, 227 {runes: 12, lineCol: screenPos{line: 1, col: 6}, ascent: 1407, descent: 756, x: 415, y: 41, towardOrigin: true}, 228 {runes: 13, lineCol: screenPos{line: 1, col: 7}, ascent: 1407, descent: 756, x: 0, y: 41, towardOrigin: true}, 229 }, 230 }, 231 } { 232 t.Run(tc.name, func(t *testing.T) { 233 glyphs := getGlyphs(16, 0, tc.lineWidth, tc.align, tc.str) 234 var gi glyphIndex 235 gi.reset() 236 for _, g := range glyphs { 237 gi.Glyph(g) 238 } 239 if len(gi.positions) != len(tc.expected) { 240 t.Errorf("expected %d positions, got %d", len(tc.expected), len(gi.positions)) 241 } 242 for i := 0; i < min(len(gi.positions), len(tc.expected)); i++ { 243 actual := gi.positions[i] 244 expected := tc.expected[i] 245 if actual != expected { 246 t.Errorf("position %d: expected:\n%#+v, got:\n%#+v", i, expected, actual) 247 } 248 } 249 if t.Failed() { 250 printPositions(t, gi.positions) 251 printGlyphs(t, glyphs) 252 } 253 }) 254 } 255 256 } 257 258 // TestIndexPositionBidi tests whether the index correct generates cursor positions for 259 // complex bidirectional text. 260 func TestIndexPositionBidi(t *testing.T) { 261 fontSize := 16 262 lineWidth := fontSize * 10 263 _, bidiLTRText, bidiRTLText := makePosTestText(fontSize, lineWidth, false) 264 type testcase struct { 265 name string 266 glyphs []text.Glyph 267 expectedXs []fixed.Int26_6 268 } 269 for _, tc := range []testcase{ 270 { 271 name: "bidi ltr", 272 glyphs: bidiLTRText, 273 expectedXs: []fixed.Int26_6{ 274 0, 626, 1196, 1766, 2051, 2621, 3191, 3444, 3956, 4468, 4753, 7133, 6330, 5738, 5440, 5019, // Positions on line 0. 275 276 3953, 3185, 2417, 1649, 881, 596, 298, 0, 3953, 4238, 4523, 5093, 5605, 5890, 7905, 7599, 7007, 6156, // Positions on line 1. 277 278 4660, 3892, 3124, 2356, 1588, 1303, 788, 406, 0, 4660, 4945, 5235, 5805, 6375, 6660, 6934, 7504, 8016, 8528, // Positions on line 2. 279 280 0, 570, 1140, 1710, 2034, // Positions on line 3. 281 }, 282 }, 283 { 284 name: "bidi rtl", 285 glyphs: bidiRTLText, 286 expectedXs: []fixed.Int26_6{ 287 2646, 3272, 3842, 4412, 4697, 5267, 5837, 6090, 6602, 7114, 2646, 2380, 1577, 985, 687, 266, // Positions on line 0. 288 289 7867, 7099, 6331, 5563, 4795, 4510, 4212, 3914, 3648, 2281, 2566, 3136, 3648, 2281, 2015, 1709, 1117, 266, // Positions on line 1. 290 291 8794, 8026, 7258, 6490, 5722, 5437, 4922, 4540, 4134, 3868, 0, 290, 860, 1430, 1715, 1989, 2559, 3071, 3583, // Positions on line 2. 292 293 324, 894, 1464, 2034, 324, 0, // Positions on line 3. 294 }, 295 }, 296 } { 297 t.Run(tc.name, func(t *testing.T) { 298 var gi glyphIndex 299 gi.reset() 300 for _, g := range tc.glyphs { 301 gi.Glyph(g) 302 } 303 if len(gi.positions) != len(tc.expectedXs) { 304 t.Errorf("expected %d positions, got %d", len(tc.expectedXs), len(gi.positions)) 305 } 306 lastRunes := 0 307 lastLine := 0 308 lastCol := -1 309 lastY := 0 310 for i := 0; i < min(len(gi.positions), len(tc.expectedXs)); i++ { 311 actualX := gi.positions[i].x 312 expectedX := tc.expectedXs[i] 313 if actualX != expectedX { 314 t.Errorf("position %d: expected x=%v(%d), got x=%v(%d)", i, expectedX, expectedX, actualX, actualX) 315 } 316 if r := gi.positions[i].runes; r < lastRunes { 317 t.Errorf("position %d: expected runes >= %d, got %d", i, lastRunes, r) 318 } 319 lastRunes = gi.positions[i].runes 320 if y := gi.positions[i].y; y < lastY { 321 t.Errorf("position %d: expected y>= %d, got %d", i, lastY, y) 322 } 323 lastY = gi.positions[i].y 324 if y := gi.positions[i].y; y < lastY { 325 t.Errorf("position %d: expected y>= %d, got %d", i, lastY, y) 326 } 327 lastY = gi.positions[i].y 328 if lineCol := gi.positions[i].lineCol; lineCol.line == lastLine && lineCol.col < lastCol { 329 t.Errorf("position %d: expected col >= %d, got %d", i, lastCol, lineCol.col) 330 } 331 lastCol = gi.positions[i].lineCol.col 332 if line := gi.positions[i].lineCol.line; line < lastLine { 333 t.Errorf("position %d: expected line >= %d, got %d", i, lastLine, line) 334 } 335 lastLine = gi.positions[i].lineCol.line 336 } 337 printPositions(t, gi.positions) 338 if t.Failed() { 339 printGlyphs(t, tc.glyphs) 340 } 341 }) 342 } 343 } 344 345 func TestIndexPositionLines(t *testing.T) { 346 fontSize := 16 347 lineWidth := fontSize * 10 348 source1, bidiLTRText, bidiRTLText := makePosTestText(fontSize, lineWidth, false) 349 source2, bidiLTRTextOpp, bidiRTLTextOpp := makePosTestText(fontSize, lineWidth, true) 350 type testcase struct { 351 name string 352 source string 353 glyphs []text.Glyph 354 expectedLines []lineInfo 355 } 356 for _, tc := range []testcase{ 357 { 358 name: "bidi ltr", 359 source: source1, 360 glyphs: bidiLTRText, 361 expectedLines: []lineInfo{ 362 { 363 xOff: fixed.Int26_6(0), 364 yOff: 22, 365 glyphs: 15, 366 width: fixed.Int26_6(7133), 367 ascent: fixed.Int26_6(1407), 368 descent: fixed.Int26_6(756), 369 }, 370 { 371 xOff: fixed.Int26_6(0), 372 yOff: 41, 373 glyphs: 15, 374 width: fixed.Int26_6(7905), 375 ascent: fixed.Int26_6(1407), 376 descent: fixed.Int26_6(756), 377 }, 378 { 379 xOff: fixed.Int26_6(0), 380 yOff: 60, 381 glyphs: 18, 382 width: fixed.Int26_6(8813), 383 ascent: fixed.Int26_6(1407), 384 descent: fixed.Int26_6(756), 385 }, 386 { 387 xOff: fixed.Int26_6(0), 388 yOff: 79, 389 glyphs: 4, 390 width: fixed.Int26_6(2034), 391 ascent: fixed.Int26_6(968), 392 descent: fixed.Int26_6(216), 393 }, 394 }, 395 }, 396 { 397 name: "bidi rtl", 398 source: source1, 399 glyphs: bidiRTLText, 400 expectedLines: []lineInfo{ 401 { 402 xOff: fixed.Int26_6(0), 403 yOff: 22, 404 glyphs: 15, 405 width: fixed.Int26_6(7114), 406 ascent: fixed.Int26_6(1407), 407 descent: fixed.Int26_6(756), 408 }, 409 { 410 xOff: fixed.Int26_6(0), 411 yOff: 41, 412 glyphs: 15, 413 width: fixed.Int26_6(7867), 414 ascent: fixed.Int26_6(1407), 415 descent: fixed.Int26_6(756), 416 }, 417 { 418 xOff: fixed.Int26_6(0), 419 yOff: 60, 420 glyphs: 18, 421 width: fixed.Int26_6(8794), 422 ascent: fixed.Int26_6(1407), 423 descent: fixed.Int26_6(756), 424 }, 425 { 426 xOff: fixed.Int26_6(0), 427 yOff: 79, 428 glyphs: 4, 429 width: fixed.Int26_6(2034), 430 ascent: fixed.Int26_6(968), 431 descent: fixed.Int26_6(216), 432 }, 433 }, 434 }, 435 { 436 name: "bidi ltr opposite alignment", 437 source: source2, 438 glyphs: bidiLTRTextOpp, 439 expectedLines: []lineInfo{ 440 { 441 xOff: fixed.Int26_6(3107), 442 yOff: 22, 443 glyphs: 15, 444 width: fixed.Int26_6(7133), 445 ascent: fixed.Int26_6(1407), 446 descent: fixed.Int26_6(756), 447 }, 448 { 449 xOff: fixed.Int26_6(2335), 450 yOff: 41, 451 glyphs: 15, 452 width: fixed.Int26_6(7905), 453 ascent: fixed.Int26_6(1407), 454 descent: fixed.Int26_6(756), 455 }, 456 { 457 xOff: fixed.Int26_6(1427), 458 yOff: 60, 459 glyphs: 18, 460 width: fixed.Int26_6(8813), 461 ascent: fixed.Int26_6(1407), 462 descent: fixed.Int26_6(756), 463 }, 464 { 465 xOff: fixed.Int26_6(8206), 466 yOff: 79, 467 glyphs: 4, 468 width: fixed.Int26_6(2034), 469 ascent: fixed.Int26_6(968), 470 descent: fixed.Int26_6(216), 471 }, 472 }, 473 }, 474 { 475 name: "bidi rtl opposite alignment", 476 source: source2, 477 glyphs: bidiRTLTextOpp, 478 expectedLines: []lineInfo{ 479 { 480 xOff: fixed.Int26_6(3126), 481 yOff: 22, 482 glyphs: 15, 483 width: fixed.Int26_6(7114), 484 ascent: fixed.Int26_6(1407), 485 descent: fixed.Int26_6(756), 486 }, 487 { 488 xOff: fixed.Int26_6(2373), 489 yOff: 41, 490 glyphs: 15, 491 width: fixed.Int26_6(7867), 492 ascent: fixed.Int26_6(1407), 493 descent: fixed.Int26_6(756), 494 }, 495 { 496 xOff: fixed.Int26_6(1446), 497 yOff: 60, 498 glyphs: 18, 499 width: fixed.Int26_6(8794), 500 ascent: fixed.Int26_6(1407), 501 descent: fixed.Int26_6(756), 502 }, 503 { 504 xOff: fixed.Int26_6(8206), 505 yOff: 79, 506 glyphs: 4, 507 width: fixed.Int26_6(2034), 508 ascent: fixed.Int26_6(968), 509 descent: fixed.Int26_6(216), 510 }, 511 }, 512 }, 513 } { 514 t.Run(tc.name, func(t *testing.T) { 515 var gi glyphIndex 516 gi.reset() 517 for _, g := range tc.glyphs { 518 gi.Glyph(g) 519 } 520 if len(gi.lines) != len(tc.expectedLines) { 521 t.Errorf("expected %d lines, got %d", len(tc.expectedLines), len(gi.lines)) 522 } 523 for i := 0; i < min(len(gi.lines), len(tc.expectedLines)); i++ { 524 actual := gi.lines[i] 525 expected := tc.expectedLines[i] 526 if actual != expected { 527 t.Errorf("line %d: expected:\n%#+v, got:\n%#+v", i, expected, actual) 528 } 529 } 530 }) 531 } 532 } 533 534 // TestIndexPositionRunes checks for rune accounting errors in positions 535 // generated by the index. 536 func TestIndexPositionRunes(t *testing.T) { 537 fontSize := 16 538 lineWidth := fontSize * 10 539 // source is crafted to contain multiple consecutive RTL runs (by 540 // changing scripts within the RTL). 541 source := "The\nquick سماء של\nום لا fox\nتمط של\nום." 542 testText := makeAccountingTestText(source, fontSize, lineWidth) 543 type testcase struct { 544 name string 545 source string 546 glyphs []text.Glyph 547 expected []combinedPos 548 } 549 for _, tc := range []testcase{ 550 { 551 name: "many newlines", 552 source: source, 553 glyphs: testText, 554 expected: []combinedPos{ 555 {runes: 0, lineCol: screenPos{line: 0, col: 0}, runIndex: 0, towardOrigin: false}, 556 {runes: 1, lineCol: screenPos{line: 0, col: 1}, runIndex: 0, towardOrigin: false}, 557 {runes: 2, lineCol: screenPos{line: 0, col: 2}, runIndex: 0, towardOrigin: false}, 558 {runes: 3, lineCol: screenPos{line: 0, col: 3}, runIndex: 0, towardOrigin: false}, 559 {runes: 4, lineCol: screenPos{line: 1, col: 0}, runIndex: 0, towardOrigin: false}, 560 {runes: 5, lineCol: screenPos{line: 1, col: 1}, runIndex: 0, towardOrigin: false}, 561 {runes: 6, lineCol: screenPos{line: 1, col: 2}, runIndex: 0, towardOrigin: false}, 562 {runes: 7, lineCol: screenPos{line: 1, col: 3}, runIndex: 0, towardOrigin: false}, 563 {runes: 8, lineCol: screenPos{line: 1, col: 4}, runIndex: 0, towardOrigin: false}, 564 {runes: 9, lineCol: screenPos{line: 1, col: 5}, runIndex: 0, towardOrigin: false}, 565 {runes: 10, lineCol: screenPos{line: 1, col: 6}, runIndex: 0, towardOrigin: false}, 566 {runes: 10, lineCol: screenPos{line: 1, col: 6}, runIndex: 1, towardOrigin: true}, 567 {runes: 11, lineCol: screenPos{line: 1, col: 7}, runIndex: 1, towardOrigin: true}, 568 {runes: 12, lineCol: screenPos{line: 1, col: 8}, runIndex: 1, towardOrigin: true}, 569 {runes: 13, lineCol: screenPos{line: 1, col: 9}, runIndex: 1, towardOrigin: true}, 570 {runes: 14, lineCol: screenPos{line: 1, col: 10}, runIndex: 1, towardOrigin: true}, 571 {runes: 15, lineCol: screenPos{line: 1, col: 11}, runIndex: 2, towardOrigin: true}, 572 {runes: 16, lineCol: screenPos{line: 1, col: 12}, runIndex: 2, towardOrigin: true}, 573 {runes: 17, lineCol: screenPos{line: 1, col: 13}, runIndex: 2, towardOrigin: true}, 574 {runes: 18, lineCol: screenPos{line: 2, col: 0}, runIndex: 0, towardOrigin: true}, 575 {runes: 19, lineCol: screenPos{line: 2, col: 1}, runIndex: 0, towardOrigin: true}, 576 {runes: 20, lineCol: screenPos{line: 2, col: 2}, runIndex: 0, towardOrigin: true}, 577 {runes: 21, lineCol: screenPos{line: 2, col: 3}, runIndex: 1, towardOrigin: true}, 578 {runes: 22, lineCol: screenPos{line: 2, col: 4}, runIndex: 1, towardOrigin: true}, 579 {runes: 23, lineCol: screenPos{line: 2, col: 5}, runIndex: 1, towardOrigin: true}, 580 {runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 1, towardOrigin: true}, 581 {runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 2, towardOrigin: false}, 582 {runes: 25, lineCol: screenPos{line: 2, col: 7}, runIndex: 2, towardOrigin: false}, 583 {runes: 26, lineCol: screenPos{line: 2, col: 8}, runIndex: 2, towardOrigin: false}, 584 {runes: 27, lineCol: screenPos{line: 2, col: 9}, runIndex: 2, towardOrigin: false}, 585 {runes: 28, lineCol: screenPos{line: 3, col: 0}, runIndex: 0, towardOrigin: true}, 586 {runes: 29, lineCol: screenPos{line: 3, col: 1}, runIndex: 0, towardOrigin: true}, 587 {runes: 30, lineCol: screenPos{line: 3, col: 2}, runIndex: 0, towardOrigin: true}, 588 {runes: 31, lineCol: screenPos{line: 3, col: 3}, runIndex: 0, towardOrigin: true}, 589 {runes: 32, lineCol: screenPos{line: 3, col: 4}, runIndex: 1, towardOrigin: true}, 590 {runes: 33, lineCol: screenPos{line: 3, col: 5}, runIndex: 1, towardOrigin: true}, 591 {runes: 34, lineCol: screenPos{line: 3, col: 6}, runIndex: 1, towardOrigin: true}, 592 {runes: 35, lineCol: screenPos{line: 4, col: 0}, runIndex: 0, towardOrigin: true}, 593 {runes: 36, lineCol: screenPos{line: 4, col: 1}, runIndex: 0, towardOrigin: true}, 594 {runes: 37, lineCol: screenPos{line: 4, col: 2}, runIndex: 0, towardOrigin: true}, 595 {runes: 38, lineCol: screenPos{line: 4, col: 3}, runIndex: 0, towardOrigin: true}, 596 }, 597 }, 598 } { 599 t.Run(tc.name, func(t *testing.T) { 600 var gi glyphIndex 601 gi.reset() 602 for _, g := range tc.glyphs { 603 gi.Glyph(g) 604 } 605 if len(gi.positions) != len(tc.expected) { 606 t.Errorf("expected %d positions, got %d", len(tc.expected), len(gi.positions)) 607 } 608 for i := 0; i < min(len(gi.positions), len(tc.expected)); i++ { 609 actual := gi.positions[i] 610 expected := tc.expected[i] 611 if expected.runes != actual.runes { 612 t.Errorf("position %d: expected runes=%d, got %d", i, expected.runes, actual.runes) 613 } 614 if expected.lineCol != actual.lineCol { 615 t.Errorf("position %d: expected lineCol=%v, got %v", i, expected.lineCol, actual.lineCol) 616 } 617 if expected.runIndex != actual.runIndex { 618 t.Errorf("position %d: expected runIndex=%d, got %d", i, expected.runIndex, actual.runIndex) 619 } 620 if expected.towardOrigin != actual.towardOrigin { 621 t.Errorf("position %d: expected towardOrigin=%v, got %v", i, expected.towardOrigin, actual.towardOrigin) 622 } 623 } 624 printPositions(t, gi.positions) 625 if t.Failed() { 626 printGlyphs(t, tc.glyphs) 627 } 628 }) 629 } 630 } 631 func printPositions(t *testing.T, positions []combinedPos) { 632 t.Helper() 633 for i, p := range positions { 634 t.Logf("positions[%2d] = {runes: %2d, line: %2d, col: %2d, x: %5d, y: %3d}", i, p.runes, p.lineCol.line, p.lineCol.col, p.x, p.y) 635 } 636 } 637 638 func printGlyphs(t *testing.T, glyphs []text.Glyph) { 639 t.Helper() 640 for i, g := range glyphs { 641 t.Logf("glyphs[%2d] = {ID: 0x%013x, Flags: %4s, Advance: %4d(%6v), Runes: %d, Y: %3d, X: %4d(%6v)} ", i, g.ID, g.Flags, g.Advance, g.Advance, g.Runes, g.Y, g.X, g.X) 642 } 643 } 644 645 func TestGraphemeReaderNext(t *testing.T) { 646 latinDoc := bytes.NewReader([]byte(latinDocument)) 647 arabicDoc := bytes.NewReader([]byte(arabicDocument)) 648 emojiDoc := bytes.NewReader([]byte(emojiDocument)) 649 complexDoc := bytes.NewReader([]byte(complexDocument)) 650 type testcase struct { 651 name string 652 input *bytes.Reader 653 read func() ([]rune, bool) 654 } 655 var pr graphemeReader 656 for _, tc := range []testcase{ 657 { 658 name: "latin", 659 input: latinDoc, 660 read: pr.next, 661 }, 662 { 663 name: "arabic", 664 input: arabicDoc, 665 read: pr.next, 666 }, 667 { 668 name: "emoji", 669 input: emojiDoc, 670 read: pr.next, 671 }, 672 { 673 name: "complex", 674 input: complexDoc, 675 read: pr.next, 676 }, 677 } { 678 t.Run(tc.name, func(t *testing.T) { 679 pr.SetSource(tc.input) 680 681 runes := []rune{} 682 var paragraph []rune 683 ok := true 684 for ok { 685 paragraph, ok = tc.read() 686 if ok && len(paragraph) > 0 && paragraph[len(paragraph)-1] != '\n' { 687 } 688 for i, r := range paragraph { 689 if i == len(paragraph)-1 { 690 if r != '\n' && ok { 691 t.Error("non-final paragraph does not end with newline") 692 } 693 } else if r == '\n' { 694 t.Errorf("paragraph[%d] contains newline", i) 695 } 696 } 697 runes = append(runes, paragraph...) 698 } 699 tc.input.Seek(0, 0) 700 b, _ := io.ReadAll(tc.input) 701 asRunes := []rune(string(b)) 702 if len(asRunes) != len(runes) { 703 t.Errorf("expected %d runes, got %d", len(asRunes), len(runes)) 704 } 705 for i := 0; i < max(len(asRunes), len(runes)); i++ { 706 if i < min(len(asRunes), len(runes)) { 707 if runes[i] != asRunes[i] { 708 t.Errorf("expected runes[%d]=%d, got %d", i, asRunes[i], runes[i]) 709 } 710 } else if i < len(asRunes) { 711 t.Errorf("expected runes[%d]=%d, got nothing", i, asRunes[i]) 712 } else if i < len(runes) { 713 t.Errorf("expected runes[%d]=nothing, got %d", i, runes[i]) 714 } 715 } 716 }) 717 } 718 } 719 func TestGraphemeReaderGraphemes(t *testing.T) { 720 latinDoc := bytes.NewReader([]byte(latinDocument)) 721 arabicDoc := bytes.NewReader([]byte(arabicDocument)) 722 emojiDoc := bytes.NewReader([]byte(emojiDocument)) 723 complexDoc := bytes.NewReader([]byte(complexDocument)) 724 type testcase struct { 725 name string 726 input *bytes.Reader 727 read func() []int 728 } 729 var pr graphemeReader 730 for _, tc := range []testcase{ 731 { 732 name: "latin", 733 input: latinDoc, 734 read: pr.Graphemes, 735 }, 736 { 737 name: "arabic", 738 input: arabicDoc, 739 read: pr.Graphemes, 740 }, 741 { 742 name: "emoji", 743 input: emojiDoc, 744 read: pr.Graphemes, 745 }, 746 { 747 name: "complex", 748 input: complexDoc, 749 read: pr.Graphemes, 750 }, 751 } { 752 t.Run(tc.name, func(t *testing.T) { 753 pr.SetSource(tc.input) 754 755 graphemes := []int{} 756 for g := tc.read(); len(g) > 0; g = tc.read() { 757 if len(graphemes) > 0 && g[0] != graphemes[len(graphemes)-1] { 758 t.Errorf("expected first boundary in new paragraph %d to match final boundary in previous %d", g[0], graphemes[len(graphemes)-1]) 759 } 760 if len(graphemes) > 0 { 761 // Drop duplicated boundary. 762 g = g[1:] 763 } 764 graphemes = append(graphemes, g...) 765 } 766 tc.input.Seek(0, 0) 767 b, _ := io.ReadAll(tc.input) 768 asRunes := []rune(string(b)) 769 if len(asRunes)+1 < len(graphemes) { 770 t.Errorf("expected <= %d graphemes, got %d", len(asRunes)+1, len(graphemes)) 771 } 772 for i := 0; i < len(graphemes)-1; i++ { 773 if graphemes[i] >= graphemes[i+1] { 774 t.Errorf("graphemes[%d](%d) >= graphemes[%d](%d)", i, graphemes[i], i+1, graphemes[i+1]) 775 } 776 } 777 }) 778 } 779 } 780 func BenchmarkGraphemeReaderNext(b *testing.B) { 781 latinDoc := bytes.NewReader([]byte(latinDocument)) 782 arabicDoc := bytes.NewReader([]byte(arabicDocument)) 783 emojiDoc := bytes.NewReader([]byte(emojiDocument)) 784 complexDoc := bytes.NewReader([]byte(complexDocument)) 785 type testcase struct { 786 name string 787 input *bytes.Reader 788 read func() ([]rune, bool) 789 } 790 pr := &graphemeReader{} 791 for _, tc := range []testcase{ 792 { 793 name: "latin", 794 input: latinDoc, 795 read: pr.next, 796 }, 797 { 798 name: "arabic", 799 input: arabicDoc, 800 read: pr.next, 801 }, 802 { 803 name: "emoji", 804 input: emojiDoc, 805 read: pr.next, 806 }, 807 { 808 name: "complex", 809 input: complexDoc, 810 read: pr.next, 811 }, 812 } { 813 var paragraph []rune = make([]rune, 4096) 814 b.Run(tc.name, func(b *testing.B) { 815 b.ResetTimer() 816 for i := 0; i < b.N; i++ { 817 pr.SetSource(tc.input) 818 819 ok := true 820 for ok { 821 paragraph, ok = tc.read() 822 _ = paragraph 823 } 824 _ = paragraph 825 } 826 }) 827 } 828 } 829 func BenchmarkGraphemeReaderGraphemes(b *testing.B) { 830 latinDoc := bytes.NewReader([]byte(latinDocument)) 831 arabicDoc := bytes.NewReader([]byte(arabicDocument)) 832 emojiDoc := bytes.NewReader([]byte(emojiDocument)) 833 complexDoc := bytes.NewReader([]byte(complexDocument)) 834 type testcase struct { 835 name string 836 input *bytes.Reader 837 read func() []int 838 } 839 pr := &graphemeReader{} 840 for _, tc := range []testcase{ 841 { 842 name: "latin", 843 input: latinDoc, 844 read: pr.Graphemes, 845 }, 846 { 847 name: "arabic", 848 input: arabicDoc, 849 read: pr.Graphemes, 850 }, 851 { 852 name: "emoji", 853 input: emojiDoc, 854 read: pr.Graphemes, 855 }, 856 { 857 name: "complex", 858 input: complexDoc, 859 read: pr.Graphemes, 860 }, 861 } { 862 b.Run(tc.name, func(b *testing.B) { 863 b.ResetTimer() 864 for i := 0; i < b.N; i++ { 865 pr.SetSource(tc.input) 866 for g := tc.read(); len(g) > 0; g = tc.read() { 867 _ = g 868 } 869 } 870 }) 871 } 872 }