package widget import ( "bytes" "image" "image/png" "io" "os" "testing" nsareg "eliasnaur.com/font/noto/sans/arabic/regular" "gioui.org/font" "gioui.org/font/opentype" "gioui.org/gpu/headless" "gioui.org/layout" "gioui.org/op" "gioui.org/text" "golang.org/x/image/font/gofont/goregular" "golang.org/x/image/math/fixed" ) // makePosTestText returns two bidi samples of shaped text at the given // font size and wrapped to the given line width. The runeLimit, if nonzero, // truncates the sample text to ensure shorter output for expensive tests. func makePosTestText(fontSize, lineWidth int, alignOpposite bool) (shaper *text.Shaper, source string, bidiLTR, bidiRTL []text.Glyph) { ltrFace, _ := opentype.Parse(goregular.TTF) rtlFace, _ := opentype.Parse(nsareg.TTF) shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{ { Font: font.Font{Typeface: "LTR"}, Face: ltrFace, }, { Font: font.Font{Typeface: "RTL"}, Face: rtlFace, }, })) // bidiSource is crafted to contain multiple consecutive RTL runs (by // changing scripts within the RTL). bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog." ltrParams := text.Parameters{ PxPerEm: fixed.I(fontSize), MaxWidth: lineWidth, MinWidth: lineWidth, Locale: english, } rtlParams := text.Parameters{ Alignment: text.End, PxPerEm: fixed.I(fontSize), MaxWidth: lineWidth, MinWidth: lineWidth, Locale: arabic, } if alignOpposite { ltrParams.Alignment = text.End rtlParams.Alignment = text.Start } shaper.LayoutString(ltrParams, bidiSource) for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { bidiLTR = append(bidiLTR, g) } shaper.LayoutString(rtlParams, bidiSource) for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { bidiRTL = append(bidiRTL, g) } return shaper, bidiSource, bidiLTR, bidiRTL } // makeAccountingTestText shapes text designed to stress rune accounting // logic within the index. func makeAccountingTestText(str string, fontSize, lineWidth int) (txt []text.Glyph) { ltrFace, _ := opentype.Parse(goregular.TTF) rtlFace, _ := opentype.Parse(nsareg.TTF) shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{ { Font: font.Font{Typeface: "LTR"}, Face: ltrFace, }, { Font: font.Font{Typeface: "RTL"}, Face: rtlFace, }, })) params := text.Parameters{ PxPerEm: fixed.I(fontSize), MaxWidth: lineWidth, Locale: english, } shaper.LayoutString(params, str) for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { txt = append(txt, g) } return txt } // getGlyphs shapes text as english. func getGlyphs(fontSize, minWidth, lineWidth int, align text.Alignment, str string) (txt []text.Glyph) { ltrFace, _ := opentype.Parse(goregular.TTF) rtlFace, _ := opentype.Parse(nsareg.TTF) shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{ { Font: font.Font{Typeface: "LTR"}, Face: ltrFace, }, { Font: font.Font{Typeface: "RTL"}, Face: rtlFace, }, })) params := text.Parameters{ PxPerEm: fixed.I(fontSize), Alignment: align, MinWidth: minWidth, MaxWidth: lineWidth, Locale: english, WrapPolicy: text.WrapWords, } shaper.LayoutString(params, str) for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { txt = append(txt, g) } return txt } // TestIndexPositionWhitespace checks that the index correctly generates cursor positions // for empty lines and the empty string. func TestIndexPositionWhitespace(t *testing.T) { type testcase struct { name string str string lineWidth int align text.Alignment expected []combinedPos } for _, tc := range []testcase{ { name: "empty string", str: "", lineWidth: 200, expected: []combinedPos{ {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, }, }, { name: "just hard newline", str: "\n", lineWidth: 200, expected: []combinedPos{ {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, {x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}}, }, }, { name: "trailing newline", str: "a\n", lineWidth: 200, expected: []combinedPos{ {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, {x: fixed.Int26_6(570), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{col: 1}}, {x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 1}}, }, }, { name: "just blank line", str: "\n\n", lineWidth: 200, expected: []combinedPos{ {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, {x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}}, {x: fixed.Int26_6(0), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 2}}, }, }, { name: "middle aligned blank lines", str: "\n\n\nabc", align: text.Middle, lineWidth: 200, expected: []combinedPos{ {x: fixed.Int26_6(832), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, {x: fixed.Int26_6(832), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}}, {x: fixed.Int26_6(832), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 2}}, {x: fixed.Int26_6(6), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 3, lineCol: screenPos{line: 3}}, {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}}, {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}}, {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}}, }, }, { name: "blank line", str: "a\n\nb", lineWidth: 200, expected: []combinedPos{ {x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)}, {x: fixed.Int26_6(570), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{col: 1}}, {x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 1}}, {x: fixed.Int26_6(0), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 3, lineCol: screenPos{line: 2}}, {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}}, }, }, { name: "soft wrap", str: "abc def", lineWidth: 30, expected: []combinedPos{ {runes: 0, lineCol: screenPos{line: 0, col: 0}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 0, y: 16}, {runes: 1, lineCol: screenPos{line: 0, col: 1}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 570, y: 16}, {runes: 2, lineCol: screenPos{line: 0, col: 2}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1140, y: 16}, {runes: 3, lineCol: screenPos{line: 0, col: 3}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1652, y: 16}, {runes: 4, lineCol: screenPos{line: 1, col: 0}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 0, y: 35}, {runes: 5, lineCol: screenPos{line: 1, col: 1}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 570, y: 35}, {runes: 6, lineCol: screenPos{line: 1, col: 2}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1140, y: 35}, {runes: 7, lineCol: screenPos{line: 1, col: 3}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1425, y: 35}, }, }, { name: "soft wrap arabic", str: "ثنائي الاتجاه", lineWidth: 30, expected: []combinedPos{ {runes: 0, lineCol: screenPos{line: 0, col: 0}, ascent: 1407, descent: 756, x: 2250, y: 22, towardOrigin: true}, {runes: 1, lineCol: screenPos{line: 0, col: 1}, ascent: 1407, descent: 756, x: 1944, y: 22, towardOrigin: true}, {runes: 2, lineCol: screenPos{line: 0, col: 2}, ascent: 1407, descent: 756, x: 1593, y: 22, towardOrigin: true}, {runes: 3, lineCol: screenPos{line: 0, col: 3}, ascent: 1407, descent: 756, x: 1295, y: 22, towardOrigin: true}, {runes: 4, lineCol: screenPos{line: 0, col: 4}, ascent: 1407, descent: 756, x: 1020, y: 22, towardOrigin: true}, {runes: 5, lineCol: screenPos{line: 0, col: 5}, ascent: 1407, descent: 756, x: 266, y: 22, towardOrigin: true}, {runes: 6, lineCol: screenPos{line: 1, col: 0}, ascent: 1407, descent: 756, x: 2511, y: 41, towardOrigin: true}, {runes: 7, lineCol: screenPos{line: 1, col: 1}, ascent: 1407, descent: 756, x: 2267, y: 41, towardOrigin: true}, {runes: 8, lineCol: screenPos{line: 1, col: 2}, ascent: 1407, descent: 756, x: 1969, y: 41, towardOrigin: true}, {runes: 9, lineCol: screenPos{line: 1, col: 3}, ascent: 1407, descent: 756, x: 1671, y: 41, towardOrigin: true}, {runes: 10, lineCol: screenPos{line: 1, col: 4}, ascent: 1407, descent: 756, x: 1365, y: 41, towardOrigin: true}, {runes: 11, lineCol: screenPos{line: 1, col: 5}, ascent: 1407, descent: 756, x: 713, y: 41, towardOrigin: true}, {runes: 12, lineCol: screenPos{line: 1, col: 6}, ascent: 1407, descent: 756, x: 415, y: 41, towardOrigin: true}, {runes: 13, lineCol: screenPos{line: 1, col: 7}, ascent: 1407, descent: 756, x: 0, y: 41, towardOrigin: true}, }, }, } { t.Run(tc.name, func(t *testing.T) { glyphs := getGlyphs(16, 0, tc.lineWidth, tc.align, tc.str) var gi glyphIndex gi.reset() for _, g := range glyphs { gi.Glyph(g) } if len(gi.positions) != len(tc.expected) { t.Errorf("expected %d positions, got %d", len(tc.expected), len(gi.positions)) } for i := range min(len(gi.positions), len(tc.expected)) { actual := gi.positions[i] expected := tc.expected[i] if actual != expected { t.Errorf("position %d: expected:\n%#+v, got:\n%#+v", i, expected, actual) } } if t.Failed() { printPositions(t, gi.positions) printGlyphs(t, glyphs) } }) } } // TestIndexPositionBidi tests whether the index correct generates cursor positions for // complex bidirectional text. func TestIndexPositionBidi(t *testing.T) { fontSize := 16 lineWidth := fontSize * 10 shaper, _, bidiLTRText, bidiRTLText := makePosTestText(fontSize, lineWidth, false) type testcase struct { name string glyphs []text.Glyph expectedXs []fixed.Int26_6 } for _, tc := range []testcase{ { name: "bidi ltr", glyphs: bidiLTRText, expectedXs: []fixed.Int26_6{ 0, 626, 1196, 1766, 2051, 2621, 3191, 3444, 3956, 4468, 4753, 7133, 6330, 5738, 5440, 5019, // Positions on line 0. 3953, 3185, 2417, 1649, 881, 596, 298, 0, 3953, 4238, 4523, 5093, 5605, 5890, 7905, 7599, 7007, 6156, // Positions on line 1. 4660, 3892, 3124, 2356, 1588, 1303, 788, 406, 0, 4660, 4945, 5235, 5805, 6375, 6660, 6934, 7504, 8016, 8528, // Positions on line 2. 0, 570, 1140, 1710, 2034, // Positions on line 3. }, }, { name: "bidi rtl", glyphs: bidiRTLText, expectedXs: []fixed.Int26_6{ // Line 0 5718, 6344, 6914, 7484, 7769, 8339, 8909, 9162, 9674, 10186, 5718, 5452, 4649, 4057, 3759, 3338, 3072, 2304, 1536, 768, 0, // Line 1 9170, 8872, 8574, 8308, 6941, 7226, 7796, 8308, 6941, 6675, 6369, 5777, 4926, 4660, 3892, 3124, 2356, 1588, 1303, 788, 406, 0, // Line 2 324, 614, 1184, 1754, 2039, 2313, 2883, 3395, 3907, 4192, 4762, 5332, 5902, 324, 0, }, }, } { t.Run(tc.name, func(t *testing.T) { var gi glyphIndex gi.reset() for _, g := range tc.glyphs { gi.Glyph(g) } if len(gi.positions) != len(tc.expectedXs) { t.Errorf("expected %d positions, got %d", len(tc.expectedXs), len(gi.positions)) } lastRunes := 0 lastLine := 0 lastCol := -1 lastY := 0 for i := range min(len(gi.positions), len(tc.expectedXs)) { actualX := gi.positions[i].x expectedX := tc.expectedXs[i] if actualX != expectedX { t.Errorf("position %d: expected x=%v(%d), got x=%v(%d)", i, expectedX, expectedX, actualX, actualX) } if r := gi.positions[i].runes; r < lastRunes { t.Errorf("position %d: expected runes >= %d, got %d", i, lastRunes, r) } lastRunes = gi.positions[i].runes if y := gi.positions[i].y; y < lastY { t.Errorf("position %d: expected y>= %d, got %d", i, lastY, y) } lastY = gi.positions[i].y if y := gi.positions[i].y; y < lastY { t.Errorf("position %d: expected y>= %d, got %d", i, lastY, y) } lastY = gi.positions[i].y if lineCol := gi.positions[i].lineCol; lineCol.line == lastLine && lineCol.col < lastCol { t.Errorf("position %d: expected col >= %d, got %d", i, lastCol, lineCol.col) } lastCol = gi.positions[i].lineCol.col if line := gi.positions[i].lineCol.line; line < lastLine { t.Errorf("position %d: expected line >= %d, got %d", i, lastLine, line) } lastLine = gi.positions[i].lineCol.line } printPositions(t, gi.positions) if t.Failed() { printGlyphs(t, tc.glyphs) width := lineWidth height := 100 cap := image.NewRGBA(image.Rect(0, 0, width, height)) w, _ := headless.NewWindow(width, height) defer w.Release() ops := new(op.Ops) gtx := layout.Context{ Constraints: layout.Constraints{Max: image.Pt(width, height)}, Ops: ops, } it := textIterator{viewport: image.Rectangle{Max: image.Point{X: width, Y: height}}} for _, g := range tc.glyphs { it.processGlyph(g, true) } var glyphs [32]text.Glyph line := glyphs[:0] for _, g := range gi.glyphs { var ok bool if line, ok = it.paintGlyph(gtx, shaper, g, line); !ok { break } } w.Frame(ops) w.Screenshot(cap) b := new(bytes.Buffer) _ = png.Encode(b, cap) screenshotName := tc.name + ".png" _ = os.WriteFile(screenshotName, b.Bytes(), 0o644) t.Logf("wrote %q", screenshotName) } }) } } func TestIndexPositionLines(t *testing.T) { fontSize := 16 lineWidth := fontSize * 10 _, source1, bidiLTRText, bidiRTLText := makePosTestText(fontSize, lineWidth, false) _, source2, bidiLTRTextOpp, bidiRTLTextOpp := makePosTestText(fontSize, lineWidth, true) type testcase struct { name string source string glyphs []text.Glyph expectedLines []lineInfo } for _, tc := range []testcase{ { name: "bidi ltr", source: source1, glyphs: bidiLTRText, expectedLines: []lineInfo{ { xOff: fixed.Int26_6(0), yOff: 22, glyphs: 15, width: fixed.Int26_6(7133), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(0), yOff: 41, glyphs: 15, width: fixed.Int26_6(7905), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(0), yOff: 60, glyphs: 18, width: fixed.Int26_6(8528), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(0), yOff: 79, glyphs: 4, width: fixed.Int26_6(2034), ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), }, }, }, { name: "bidi rtl", source: source1, glyphs: bidiRTLText, expectedLines: []lineInfo{ { xOff: fixed.Int26_6(0), yOff: 22, glyphs: 20, width: fixed.Int26_6(10186), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(0), yOff: 41, glyphs: 19, width: fixed.Int26_6(9170), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(0), yOff: 60, glyphs: 13, width: fixed.Int26_6(5902), ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), }, }, }, { name: "bidi ltr opposite alignment", source: source2, glyphs: bidiLTRTextOpp, expectedLines: []lineInfo{ { xOff: fixed.Int26_6(3107), yOff: 22, glyphs: 15, width: fixed.Int26_6(7133), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(2335), yOff: 41, glyphs: 15, width: fixed.Int26_6(7905), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(1712), yOff: 60, glyphs: 18, width: fixed.Int26_6(8528), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(8206), yOff: 79, glyphs: 4, width: fixed.Int26_6(2034), ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), }, }, }, { name: "bidi rtl opposite alignment", source: source2, glyphs: bidiRTLTextOpp, expectedLines: []lineInfo{ { xOff: fixed.Int26_6(54), yOff: 22, glyphs: 20, width: fixed.Int26_6(10186), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(1070), yOff: 41, glyphs: 19, width: fixed.Int26_6(9170), ascent: fixed.Int26_6(1407), descent: fixed.Int26_6(756), }, { xOff: fixed.Int26_6(4338), yOff: 60, glyphs: 13, width: fixed.Int26_6(5902), ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), }, }, }, } { t.Run(tc.name, func(t *testing.T) { var gi glyphIndex gi.reset() for _, g := range tc.glyphs { gi.Glyph(g) } if len(gi.lines) != len(tc.expectedLines) { t.Errorf("expected %d lines, got %d", len(tc.expectedLines), len(gi.lines)) } for i := range min(len(gi.lines), len(tc.expectedLines)) { actual := gi.lines[i] expected := tc.expectedLines[i] if actual != expected { t.Errorf("line %d: expected:\n%#+v, got:\n%#+v", i, expected, actual) } } }) } } // TestIndexPositionRunes checks for rune accounting errors in positions // generated by the index. func TestIndexPositionRunes(t *testing.T) { fontSize := 16 lineWidth := fontSize * 10 // source is crafted to contain multiple consecutive RTL runs (by // changing scripts within the RTL). source := "The\nquick سماء של\nום لا fox\nتمط של\nום." testText := makeAccountingTestText(source, fontSize, lineWidth) type testcase struct { name string source string glyphs []text.Glyph expected []combinedPos } for _, tc := range []testcase{ { name: "many newlines", source: source, glyphs: testText, expected: []combinedPos{ {runes: 0, lineCol: screenPos{line: 0, col: 0}, runIndex: 0, towardOrigin: false}, {runes: 1, lineCol: screenPos{line: 0, col: 1}, runIndex: 0, towardOrigin: false}, {runes: 2, lineCol: screenPos{line: 0, col: 2}, runIndex: 0, towardOrigin: false}, {runes: 3, lineCol: screenPos{line: 0, col: 3}, runIndex: 0, towardOrigin: false}, {runes: 4, lineCol: screenPos{line: 1, col: 0}, runIndex: 0, towardOrigin: false}, {runes: 5, lineCol: screenPos{line: 1, col: 1}, runIndex: 0, towardOrigin: false}, {runes: 6, lineCol: screenPos{line: 1, col: 2}, runIndex: 0, towardOrigin: false}, {runes: 7, lineCol: screenPos{line: 1, col: 3}, runIndex: 0, towardOrigin: false}, {runes: 8, lineCol: screenPos{line: 1, col: 4}, runIndex: 0, towardOrigin: false}, {runes: 9, lineCol: screenPos{line: 1, col: 5}, runIndex: 0, towardOrigin: false}, {runes: 10, lineCol: screenPos{line: 1, col: 6}, runIndex: 0, towardOrigin: false}, {runes: 10, lineCol: screenPos{line: 1, col: 6}, runIndex: 1, towardOrigin: true}, {runes: 11, lineCol: screenPos{line: 1, col: 7}, runIndex: 1, towardOrigin: true}, {runes: 12, lineCol: screenPos{line: 1, col: 8}, runIndex: 1, towardOrigin: true}, {runes: 13, lineCol: screenPos{line: 1, col: 9}, runIndex: 1, towardOrigin: true}, {runes: 14, lineCol: screenPos{line: 1, col: 10}, runIndex: 1, towardOrigin: true}, {runes: 15, lineCol: screenPos{line: 1, col: 11}, runIndex: 2, towardOrigin: true}, {runes: 16, lineCol: screenPos{line: 1, col: 12}, runIndex: 2, towardOrigin: true}, {runes: 17, lineCol: screenPos{line: 1, col: 13}, runIndex: 2, towardOrigin: true}, {runes: 18, lineCol: screenPos{line: 2, col: 0}, runIndex: 0, towardOrigin: true}, {runes: 19, lineCol: screenPos{line: 2, col: 1}, runIndex: 0, towardOrigin: true}, {runes: 20, lineCol: screenPos{line: 2, col: 2}, runIndex: 0, towardOrigin: true}, {runes: 21, lineCol: screenPos{line: 2, col: 3}, runIndex: 1, towardOrigin: true}, {runes: 22, lineCol: screenPos{line: 2, col: 4}, runIndex: 1, towardOrigin: true}, {runes: 23, lineCol: screenPos{line: 2, col: 5}, runIndex: 1, towardOrigin: true}, {runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 1, towardOrigin: true}, {runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 2, towardOrigin: false}, {runes: 25, lineCol: screenPos{line: 2, col: 7}, runIndex: 2, towardOrigin: false}, {runes: 26, lineCol: screenPos{line: 2, col: 8}, runIndex: 2, towardOrigin: false}, {runes: 27, lineCol: screenPos{line: 2, col: 9}, runIndex: 2, towardOrigin: false}, {runes: 28, lineCol: screenPos{line: 3, col: 0}, runIndex: 0, towardOrigin: true}, {runes: 29, lineCol: screenPos{line: 3, col: 1}, runIndex: 0, towardOrigin: true}, {runes: 30, lineCol: screenPos{line: 3, col: 2}, runIndex: 0, towardOrigin: true}, {runes: 31, lineCol: screenPos{line: 3, col: 3}, runIndex: 0, towardOrigin: true}, {runes: 32, lineCol: screenPos{line: 3, col: 4}, runIndex: 1, towardOrigin: true}, {runes: 33, lineCol: screenPos{line: 3, col: 5}, runIndex: 1, towardOrigin: true}, {runes: 34, lineCol: screenPos{line: 3, col: 6}, runIndex: 1, towardOrigin: true}, {runes: 35, lineCol: screenPos{line: 4, col: 0}, runIndex: 0, towardOrigin: true}, {runes: 36, lineCol: screenPos{line: 4, col: 1}, runIndex: 0, towardOrigin: true}, {runes: 37, lineCol: screenPos{line: 4, col: 2}, runIndex: 0, towardOrigin: true}, {runes: 38, lineCol: screenPos{line: 4, col: 3}, runIndex: 0, towardOrigin: true}, }, }, } { t.Run(tc.name, func(t *testing.T) { var gi glyphIndex gi.reset() for _, g := range tc.glyphs { gi.Glyph(g) } if len(gi.positions) != len(tc.expected) { t.Errorf("expected %d positions, got %d", len(tc.expected), len(gi.positions)) } for i := range min(len(gi.positions), len(tc.expected)) { actual := gi.positions[i] expected := tc.expected[i] if expected.runes != actual.runes { t.Errorf("position %d: expected runes=%d, got %d", i, expected.runes, actual.runes) } if expected.lineCol != actual.lineCol { t.Errorf("position %d: expected lineCol=%v, got %v", i, expected.lineCol, actual.lineCol) } if expected.runIndex != actual.runIndex { t.Errorf("position %d: expected runIndex=%d, got %d", i, expected.runIndex, actual.runIndex) } if expected.towardOrigin != actual.towardOrigin { t.Errorf("position %d: expected towardOrigin=%v, got %v", i, expected.towardOrigin, actual.towardOrigin) } } printPositions(t, gi.positions) if t.Failed() { printGlyphs(t, tc.glyphs) } }) } } func printPositions(t *testing.T, positions []combinedPos) { t.Helper() for i, p := range positions { 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) } } func printGlyphs(t *testing.T, glyphs []text.Glyph) { t.Helper() for i, g := range glyphs { 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) } } func TestGraphemeReaderNext(t *testing.T) { latinDoc := bytes.NewReader([]byte(latinDocument)) arabicDoc := bytes.NewReader([]byte(arabicDocument)) emojiDoc := bytes.NewReader([]byte(emojiDocument)) complexDoc := bytes.NewReader([]byte(complexDocument)) type testcase struct { name string input *bytes.Reader read func() ([]rune, bool) } var pr graphemeReader for _, tc := range []testcase{ { name: "latin", input: latinDoc, read: pr.next, }, { name: "arabic", input: arabicDoc, read: pr.next, }, { name: "emoji", input: emojiDoc, read: pr.next, }, { name: "complex", input: complexDoc, read: pr.next, }, } { t.Run(tc.name, func(t *testing.T) { pr.SetSource(tc.input) runes := []rune{} var paragraph []rune ok := true for ok { paragraph, ok = tc.read() if ok && len(paragraph) > 0 && paragraph[len(paragraph)-1] != '\n' { } for i, r := range paragraph { if i == len(paragraph)-1 { if r != '\n' && ok { t.Error("non-final paragraph does not end with newline") } } else if r == '\n' { t.Errorf("paragraph[%d] contains newline", i) } } runes = append(runes, paragraph...) } tc.input.Seek(0, 0) b, _ := io.ReadAll(tc.input) asRunes := []rune(string(b)) if len(asRunes) != len(runes) { t.Errorf("expected %d runes, got %d", len(asRunes), len(runes)) } for i := range max(len(asRunes), len(runes)) { if i < min(len(asRunes), len(runes)) { if runes[i] != asRunes[i] { t.Errorf("expected runes[%d]=%d, got %d", i, asRunes[i], runes[i]) } } else if i < len(asRunes) { t.Errorf("expected runes[%d]=%d, got nothing", i, asRunes[i]) } else if i < len(runes) { t.Errorf("expected runes[%d]=nothing, got %d", i, runes[i]) } } }) } } func TestGraphemeReaderGraphemes(t *testing.T) { latinDoc := bytes.NewReader([]byte(latinDocument)) arabicDoc := bytes.NewReader([]byte(arabicDocument)) emojiDoc := bytes.NewReader([]byte(emojiDocument)) complexDoc := bytes.NewReader([]byte(complexDocument)) type testcase struct { name string input *bytes.Reader read func() []int } var pr graphemeReader for _, tc := range []testcase{ { name: "latin", input: latinDoc, read: pr.Graphemes, }, { name: "arabic", input: arabicDoc, read: pr.Graphemes, }, { name: "emoji", input: emojiDoc, read: pr.Graphemes, }, { name: "complex", input: complexDoc, read: pr.Graphemes, }, } { t.Run(tc.name, func(t *testing.T) { pr.SetSource(tc.input) graphemes := []int{} for g := tc.read(); len(g) > 0; g = tc.read() { if len(graphemes) > 0 && g[0] != graphemes[len(graphemes)-1] { t.Errorf("expected first boundary in new paragraph %d to match final boundary in previous %d", g[0], graphemes[len(graphemes)-1]) } if len(graphemes) > 0 { // Drop duplicated boundary. g = g[1:] } graphemes = append(graphemes, g...) } tc.input.Seek(0, 0) b, _ := io.ReadAll(tc.input) asRunes := []rune(string(b)) if len(asRunes)+1 < len(graphemes) { t.Errorf("expected <= %d graphemes, got %d", len(asRunes)+1, len(graphemes)) } for i := range len(graphemes) - 1 { if graphemes[i] >= graphemes[i+1] { t.Errorf("graphemes[%d](%d) >= graphemes[%d](%d)", i, graphemes[i], i+1, graphemes[i+1]) } } }) } } func BenchmarkGraphemeReaderNext(b *testing.B) { latinDoc := bytes.NewReader([]byte(latinDocument)) arabicDoc := bytes.NewReader([]byte(arabicDocument)) emojiDoc := bytes.NewReader([]byte(emojiDocument)) complexDoc := bytes.NewReader([]byte(complexDocument)) type testcase struct { name string input *bytes.Reader read func() ([]rune, bool) } pr := &graphemeReader{} for _, tc := range []testcase{ { name: "latin", input: latinDoc, read: pr.next, }, { name: "arabic", input: arabicDoc, read: pr.next, }, { name: "emoji", input: emojiDoc, read: pr.next, }, { name: "complex", input: complexDoc, read: pr.next, }, } { var paragraph []rune = make([]rune, 4096) b.Run(tc.name, func(b *testing.B) { b.ResetTimer() for b.Loop() { pr.SetSource(tc.input) ok := true for ok { paragraph, ok = tc.read() _ = paragraph } _ = paragraph } }) } } func BenchmarkGraphemeReaderGraphemes(b *testing.B) { latinDoc := bytes.NewReader([]byte(latinDocument)) arabicDoc := bytes.NewReader([]byte(arabicDocument)) emojiDoc := bytes.NewReader([]byte(emojiDocument)) complexDoc := bytes.NewReader([]byte(complexDocument)) type testcase struct { name string input *bytes.Reader read func() []int } pr := &graphemeReader{} for _, tc := range []testcase{ { name: "latin", input: latinDoc, read: pr.Graphemes, }, { name: "arabic", input: arabicDoc, read: pr.Graphemes, }, { name: "emoji", input: emojiDoc, read: pr.Graphemes, }, { name: "complex", input: complexDoc, read: pr.Graphemes, }, } { b.Run(tc.name, func(b *testing.B) { b.ResetTimer() for b.Loop() { pr.SetSource(tc.input) for g := tc.read(); len(g) > 0; g = tc.read() { _ = g } } }) } }