github.com/utopiagio/gio@v0.0.8/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  	"github.com/utopiagio/gio/font"
    10  	"github.com/utopiagio/gio/font/opentype"
    11  	"github.com/utopiagio/gio/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  }