From 42c99a5cb2d7bd0579a046ee8e2505c27037ed94 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Thu, 17 Mar 2022 09:27:07 -0400 Subject: [PATCH] widget{,/material}: [API] update editor to support complex scripts This commit updates material.Editor and material.Label to support the new text shaper. This requires breaking their assumption that glyphs of font data map 1:1 to runes of text data. References: https://todo.sr.ht/~eliasnaur/gio/146 Signed-off-by: Chris Waldon --- widget/editor.go | 104 +++++++++++--- widget/editor_test.go | 324 ++++++++++++++++++++++++++++++++++++------ widget/label.go | 49 ++++--- 3 files changed, 395 insertions(+), 82 deletions(-) diff --git a/widget/editor.go b/widget/editor.go index 6bd7033c..79d94a88 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -136,13 +136,17 @@ type combinedPos struct { // runes is the offset in runes. runes int - // lineCol.Y = line (offset into Editor.lines), and X = col (offset into + // lineCol.Y = line (offset into Editor.lines), and X = col (rune offset into // Editor.lines[Y]) lineCol screenPos // Pixel coordinates x fixed.Int26_6 y int + + // clusterIndex is the glyph cluster index that contains the rune referred + // to by the y coordinate. + clusterIndex int } type selectionAction int @@ -400,6 +404,10 @@ func (e *Editor) moveLines(distance int, selAct selectionAction) { } func (e *Editor) command(gtx layout.Context, k key.Event) bool { + direction := 1 + if e.locale.Direction.Progression() == system.TowardOrigin { + direction = -1 + } modSkip := key.ModCtrl if runtime.GOOS == "darwin" { modSkip = key.ModAlt @@ -430,21 +438,21 @@ func (e *Editor) command(gtx layout.Context, k key.Event) bool { e.moveLines(+1, selAct) case key.NameLeftArrow: if moveByWord { - e.moveWord(-1, selAct) + e.moveWord(-1*direction, selAct) } else { if selAct == selectionClear { e.ClearSelection() } - e.MoveCaret(-1, -1*int(selAct)) + e.MoveCaret(-1*direction, -1*direction*int(selAct)) } case key.NameRightArrow: if moveByWord { - e.moveWord(1, selAct) + e.moveWord(1*direction, selAct) } else { if selAct == selectionClear { e.ClearSelection() } - e.MoveCaret(1, int(selAct)) + e.MoveCaret(1*direction, int(selAct)*direction) } case key.NamePageUp: e.movePages(-1, selAct) @@ -674,8 +682,8 @@ func (e *Editor) PaintSelection(gtx layout.Context) { cl = cl.Add(scroll) pos := e.seekFirstVisibleLine(cl.Min.Y) for !posIsBelow(e.lines, pos, cl.Max.Y) { - start, end := clipLine(e.lines, e.Alignment, e.viewSize.X, cl, pos) - lineIdx := start.lineCol.Y + leftmost, rightmost := clipLine(e.lines, e.Alignment, e.viewSize.X, cl, pos) + lineIdx := leftmost.lineCol.Y if lineIdx < caretStart.lineCol.Y { // Line is before selection start; skip. pos = e.closestPosition(combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}}) @@ -685,17 +693,27 @@ func (e *Editor) PaintSelection(gtx layout.Context) { // Line is after selection end; we're done. return } + line := e.lines[leftmost.lineCol.Y] + flip := line.Layout.Direction.Progression() == system.TowardOrigin // Clamp start, end to selection. - if start.runes < selStart { - start = caretStart - } - if end.runes > selEnd { - end = caretEnd + if !flip { + if leftmost.runes < selStart { + leftmost = caretStart + } + if rightmost.runes > selEnd { + rightmost = caretEnd + } + } else { + if leftmost.runes > selEnd { + leftmost = caretEnd + } + if rightmost.runes < selStart { + rightmost = caretStart + } } - line := e.lines[start.lineCol.Y] - dotStart := image.Pt(start.x.Round(), start.y) - dotEnd := image.Pt(end.x.Round(), end.y) + dotStart := image.Pt(leftmost.x.Round(), leftmost.y) + dotEnd := image.Pt(rightmost.x.Round(), rightmost.y) t := op.Offset(layout.FPt(scroll.Mul(-1))).Push(gtx.Ops) size := image.Rectangle{ Min: dotStart.Sub(image.Point{Y: line.Ascent.Ceil()}), @@ -726,9 +744,12 @@ func (e *Editor) PaintText(gtx layout.Context) { for !posIsBelow(e.lines, pos, cl.Max.Y) { start, end := clipLine(e.lines, e.Alignment, e.viewSize.X, cl, pos) line := e.lines[start.lineCol.Y] - l := subLayout(line, start.lineCol.X, end.lineCol.X) - off := image.Point{X: start.x.Floor(), Y: start.y}.Sub(scroll) + if start.lineCol.X > end.lineCol.X { + start, end = end, start + } + l := subLayout(line, start, end) + t := op.Offset(layout.FPt(off)).Push(gtx.Ops) op := clip.Outline{Path: e.shaper.Shape(e.font, e.textSize, l)}.Op().Push(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) @@ -953,12 +974,42 @@ func positionGreaterOrEqual(lines []text.Line, p1, p2 combinedPos) bool { if eol { return true } - adv := l.Layout.Advances[p1.lineCol.X] - return p1.x+adv-p2.x >= p2.x-p1.x + + // Find the cluster containing the rune position described by p1 + // in order to determine the width of a rune within it. + clusterIdx := clusterIndexFor(l, p1.lineCol.X, p1.clusterIndex) + flip := l.Layout.Direction.Progression() == system.TowardOrigin + adv := l.Layout.Clusters[clusterIdx].RuneWidth() + + left := p1.x + adv - p2.x + right := p2.x - p1.x + + return (!flip && left >= right) || (flip && left <= right) } return true } +// clusterIndexFor returns the index of the glyph cluster containing the rune +// at the given position within the line. As a special case, if the rune is one +// beyond the final rune of the line, it returns the length of the line's clusters +// slice. Otherwise, it panics if given a rune beyond the +// dimensions of the line. +func clusterIndexFor(line text.Line, runeIdx, startIdx int) int { + if runeIdx == line.Layout.Runes.Count { + return len(line.Layout.Clusters) + } + lineStart := line.Layout.Runes.Offset + for i := startIdx; i < len(line.Layout.Clusters); i++ { + cluster := line.Layout.Clusters[i] + clusterStart := cluster.Runes.Offset - lineStart + clusterEnd := clusterStart + cluster.Runes.Count + if runeIdx >= clusterStart && runeIdx < clusterEnd { + return i + } + } + panic(fmt.Errorf("requested cluster index for rune %d outside of line with %d runes", runeIdx, line.Layout.Runes.Count)) +} + // closestPosition takes a position and returns its closest valid position. // Zero fields of pos are ignored. func (e *Editor) closestPosition(pos combinedPos) combinedPos { @@ -981,7 +1032,13 @@ func seekPosition(lines []text.Line, alignment text.Alignment, width int, start, count := 0 // Advance next and prev until next is greater than or equal to pos. for { - for ; start.lineCol.X < len(l.Layout.Advances); start.lineCol.X++ { + start.clusterIndex = clusterIndexFor(l, start.lineCol.X, start.clusterIndex) + for ; start.lineCol.X < l.Layout.Runes.Count; start.lineCol.X++ { + cluster := l.Layout.Clusters[start.clusterIndex] + if start.runes >= cluster.Runes.Offset+cluster.Runes.Count { + start.clusterIndex++ + cluster = l.Layout.Clusters[start.clusterIndex] + } if limit != 0 && count == limit { return start, false } @@ -990,8 +1047,7 @@ func seekPosition(lines []text.Line, alignment text.Alignment, width int, start, return start, true } - adv := l.Layout.Advances[start.lineCol.X] - start.x += adv + start.x += cluster.RuneWidth() start.runes++ } if start.lineCol.Y == len(lines)-1 { @@ -1002,8 +1058,12 @@ func seekPosition(lines []text.Line, alignment text.Alignment, width int, start, prevDesc := l.Descent start.lineCol.Y++ start.lineCol.X = 0 + start.clusterIndex = 0 l = lines[start.lineCol.Y] start.x = align(alignment, l.Width, width) + if l.Layout.Direction.Progression() == system.TowardOrigin { + start.x += l.Width + } start.y += (prevDesc + l.Ascent).Ceil() } } diff --git a/widget/editor_test.go b/widget/editor_test.go index b35f1eac..d33c1340 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -15,11 +15,15 @@ import ( "unicode" "unicode/utf8" + nsareg "eliasnaur.com/font/noto/sans/arabic/regular" + "eliasnaur.com/font/roboto/robotoregular" "gioui.org/f32" "gioui.org/font/gofont" + "gioui.org/font/opentype" "gioui.org/io/event" "gioui.org/io/key" "gioui.org/io/pointer" + "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" "gioui.org/text" @@ -27,12 +31,18 @@ import ( "golang.org/x/image/math/fixed" ) +var english = system.Locale{ + Language: "EN", + Direction: system.LTR, +} + func TestEditorConfigurations(t *testing.T) { gtx := layout.Context{ Ops: new(op.Ops), Constraints: layout.Exact(image.Pt(100, 100)), + Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewCache(gofont.CollectionHB()) fontSize := unit.Px(10) font := text.Font{} sentence := "\n\n\n\n\n\n\n\n\n\n\n\nthe quick brown fox jumps over the lazy dog" @@ -70,14 +80,22 @@ func TestEditor(t *testing.T) { gtx := layout.Context{ Ops: new(op.Ops), Constraints: layout.Exact(image.Pt(100, 100)), + Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewCache(gofont.CollectionHB()) fontSize := unit.Px(10) font := text.Font{} + // Regression test for bad in-cluster rune offset math. + e.SetText("æbc") + e.Layout(gtx, cache, font, fontSize, nil) + e.moveEnd(selectionClear) + assertCaret(t, e, 0, 3, len("æbc")) + + textSample := "æbc\naøå••" e.SetCaret(0, 0) // shouldn't panic assertCaret(t, e, 0, 0, 0) - e.SetText("æbc\naøå•") + e.SetText(textSample) if got, exp := e.Len(), utf8.RuneCountInString(e.Text()); got != exp { t.Errorf("got length %d, expected %d", got, exp) } @@ -89,12 +107,13 @@ func TestEditor(t *testing.T) { assertCaret(t, e, 1, 0, len("æbc\n")) e.MoveCaret(-1, -1) assertCaret(t, e, 0, 3, len("æbc")) - e.moveLines(+1, +1) - assertCaret(t, e, 1, 3, len("æbc\naøå")) + e.moveLines(+1, selectionClear) + assertCaret(t, e, 1, 4, len("æbc\naøå•")) e.moveEnd(selectionClear) - assertCaret(t, e, 1, 4, len("æbc\naøå•")) + assertCaret(t, e, 1, 5, len("æbc\naøå••")) e.MoveCaret(+1, +1) - assertCaret(t, e, 1, 4, len("æbc\naøå•")) + assertCaret(t, e, 1, 5, len("æbc\naøå••")) + e.moveLines(3, selectionClear) e.SetCaret(0, 0) assertCaret(t, e, 0, 0, 0) @@ -117,11 +136,26 @@ func TestEditor(t *testing.T) { assertCaret(t, e, 0, 3, len("æbc")) // When a password mask is applied, it should replace all visible glyphs - for i, line := range e.lines { - for j, r := range line.Layout.Text { - if r != e.Mask && !unicode.IsSpace(r) { - t.Errorf("glyph at (%d, %d) is unmasked rune %d", i, j, r) - } + spaces := 0 + for _, r := range textSample { + if unicode.IsSpace(r) { + spaces++ + } + } + nonSpaces := len([]rune(textSample)) - spaces + glyphCounts := make(map[int]int) + for _, line := range e.lines { + for _, glyph := range line.Layout.Glyphs { + glyphCounts[int(glyph.ID)]++ + } + } + if len(glyphCounts) > 2 { + t.Errorf("masked text contained glyphs other than mask and whitespace") + } + + for gid, count := range glyphCounts { + if count != spaces && count != nonSpaces { + t.Errorf("glyph with id %d occurred %d times, expected either %d or %d", gid, count, spaces, nonSpaces) } } @@ -134,6 +168,203 @@ func TestEditor(t *testing.T) { assertCaret(t, e, 0, utf8.RuneCountInString("long line"), len("long line")) } +var arabic = system.Locale{ + Language: "AR", + Direction: system.RTL, +} + +var arabicCollection = func() []text.FontFace { + parsed, _ := opentype.ParseHarfbuzz(nsareg.TTF) + return []text.FontFace{{Font: text.Font{}, Face: parsed}} +}() + +func TestEditorRTL(t *testing.T) { + e := new(Editor) + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(100, 100)), + Locale: arabic, + } + cache := text.NewCache(arabicCollection) + fontSize := unit.Px(10) + font := text.Font{} + + e.SetCaret(0, 0) // shouldn't panic + assertCaret(t, e, 0, 0, 0) + + // Set the text to a single RTL word. The caret should start at 0 column + // zero, but this is the first column on the right. + e.SetText("الحب") + e.Layout(gtx, cache, font, fontSize, nil) + assertCaret(t, e, 0, 0, 0) + e.MoveCaret(+1, +1) + assertCaret(t, e, 0, 1, len("ا")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 0, 2, len("ال")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 0, 3, len("الح")) + // Move to the "end" of the line. This moves to the left edge of the line. + e.moveEnd(selectionClear) + assertCaret(t, e, 0, 4, len("الحب")) + + sentence := "الحب سماء لا\nتمط غير الأحلام" + e.SetText(sentence) + e.Layout(gtx, cache, font, fontSize, nil) + assertCaret(t, e, 0, 0, 0) + e.moveEnd(selectionClear) + assertCaret(t, e, 0, 12, len("الحب سماء لا")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 0, len("الحب سماء لا\n")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 1, len("الحب سماء لا\nت")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 1, 0, len("الحب سماء لا\n")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 12, len("الحب سماء لا")) + e.moveLines(+1, selectionClear) + assertCaret(t, e, 1, 14, len("الحب سماء لا\nتمط غير الأحلا")) + e.moveEnd(selectionClear) + assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) + e.moveLines(3, selectionClear) + assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) + e.SetCaret(utf8.RuneCountInString(sentence), 0) + assertCaret(t, e, 1, 15, len("الحب سماء لا\nتمط غير الأحلام")) + if selection := e.SelectedText(); selection != sentence { + t.Errorf("expected selection %s, got %s", sentence, selection) + } + + e.SetCaret(0, 0) + assertCaret(t, e, 0, 0, 0) + e.SetCaret(utf8.RuneCountInString("ا"), utf8.RuneCountInString("ا")) + assertCaret(t, e, 0, 1, len("ا")) + e.SetCaret(utf8.RuneCountInString("الحب سماء لا\nتمط غ"), utf8.RuneCountInString("الحب سماء لا\nتمط غ")) + assertCaret(t, e, 1, 5, len("الحب سماء لا\nتمط غ")) +} + +func TestEditorLigature(t *testing.T) { + e := new(Editor) + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(100, 100)), + Locale: english, + } + face, err := opentype.ParseHarfbuzz(robotoregular.TTF) + if err != nil { + t.Skipf("failed parsing test font: %v", err) + } + cache := text.NewCache([]text.FontFace{ + { + Font: text.Font{ + Typeface: "Roboto", + }, + Face: face, + }, + }) + fontSize := unit.Px(10) + font := text.Font{} + + /* + In this font, the following rune sequences form ligatures: + + - ffi + - ffl + - fi + - fl + */ + + e.SetCaret(0, 0) // shouldn't panic + assertCaret(t, e, 0, 0, 0) + e.SetText("fl") // just a ligature + e.Layout(gtx, cache, font, fontSize, nil) + e.moveEnd(selectionClear) + assertCaret(t, e, 0, 2, len("fl")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 1, len("f")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 0, 0) + e.MoveCaret(+2, +2) + assertCaret(t, e, 0, 2, len("fl")) + e.SetText("flaffl•ffi\n•fflfi") // 3 ligatures on line 0, 2 on line 1 + e.Layout(gtx, cache, font, fontSize, nil) + assertCaret(t, e, 0, 0, 0) + e.moveEnd(selectionClear) + assertCaret(t, e, 0, 10, len("ffaffl•ffi")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 0, len("ffaffl•ffi\n")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 1, len("ffaffl•ffi\n•")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 2, len("ffaffl•ffi\n•f")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 3, len("ffaffl•ffi\n•ff")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 4, len("ffaffl•ffi\n•ffl")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 5, len("ffaffl•ffi\n•fflf")) + e.MoveCaret(+1, +1) + assertCaret(t, e, 1, 6, len("ffaffl•ffi\n•fflfi")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 1, 5, len("ffaffl•ffi\n•fflf")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 1, 4, len("ffaffl•ffi\n•ffl")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 1, 3, len("ffaffl•ffi\n•ff")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 1, 2, len("ffaffl•ffi\n•f")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 1, 1, len("ffaffl•ffi\n•")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 1, 0, len("ffaffl•ffi\n")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 10, len("ffaffl•ffi")) + e.MoveCaret(-2, -2) + assertCaret(t, e, 0, 8, len("ffaffl•f")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 7, len("ffaffl•")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 6, len("ffaffl")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 5, len("ffaff")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 4, len("ffaf")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 3, len("ffa")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 2, len("ff")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 1, len("f")) + e.MoveCaret(-1, -1) + assertCaret(t, e, 0, 0, 0) + gtx.Constraints = layout.Exact(image.Pt(50, 50)) + e.SetText("fflffl fflffl fflffl fflffl") // Many ligatures broken across lines. + e.Layout(gtx, cache, font, fontSize, nil) + // Ensure that all runes in the final cluster of a line are properly + // decoded when moving to the end of the line. This is a regression test. + e.moveEnd(selectionClear) + // Because the first line is broken due to line wrapping, the last + // rune of the line will not be before the cursor. + assertCaret(t, e, 0, 13, len("fflffl fflffl")) + e.moveLines(1, selectionClear) + // Because the space is at the beginning of the second line and + // the second line is the final line, there are two more runes + // before the cursor than on the first line. + assertCaret(t, e, 1, 13, len("fflffl fflffl fflffl fflffl")) + e.moveLines(-1, selectionClear) + assertCaret(t, e, 0, 13, len("fflffl fflffl")) + + // Absurdly narrow constraints to force each ligature onto its own line. + gtx.Constraints = layout.Exact(image.Pt(10, 10)) + e.SetText("ffl ffl") // Two ligatures on separate lines. + e.Layout(gtx, cache, font, fontSize, nil) + assertCaret(t, e, 0, 0, 0) + e.MoveCaret(1, 1) // Move the caret into the first ligature. + assertCaret(t, e, 0, 1, len("f")) + e.MoveCaret(4, 4) // Move the caret several positions. + assertCaret(t, e, 1, 1, len("ffl f")) +} + func TestEditorDimensions(t *testing.T) { e := new(Editor) tq := &testQueue{ @@ -145,8 +376,9 @@ func TestEditorDimensions(t *testing.T) { Ops: new(op.Ops), Constraints: layout.Constraints{Max: image.Pt(100, 100)}, Queue: tq, + Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewCache(gofont.CollectionHB()) fontSize := unit.Px(10) font := text.Font{} dims := e.Layout(gtx, cache, font, fontSize, nil) @@ -191,8 +423,9 @@ func TestEditorCaretConsistency(t *testing.T) { gtx := layout.Context{ Ops: new(op.Ops), Constraints: layout.Exact(image.Pt(100, 100)), + Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewCache(gofont.CollectionHB()) fontSize := unit.Px(10) font := text.Font{} for _, a := range []text.Alignment{text.Start, text.Middle, text.End} { @@ -283,8 +516,9 @@ func TestEditorMoveWord(t *testing.T) { gtx := layout.Context{ Ops: new(op.Ops), Constraints: layout.Exact(image.Pt(100, 100)), + Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewCache(gofont.CollectionHB()) fontSize := unit.Px(10) font := text.Font{} e.SetText(t) @@ -387,8 +621,9 @@ func TestEditorInsert(t *testing.T) { gtx := layout.Context{ Ops: new(op.Ops), Constraints: layout.Exact(image.Pt(100, 100)), + Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewCache(gofont.CollectionHB()) fontSize := unit.Px(10) font := text.Font{} e.SetText(t) @@ -476,8 +711,9 @@ func TestEditorDeleteWord(t *testing.T) { gtx := layout.Context{ Ops: new(op.Ops), Constraints: layout.Exact(image.Pt(100, 100)), + Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewCache(gofont.CollectionHB()) fontSize := unit.Px(10) font := text.Font{} e.SetText(t) @@ -518,17 +754,20 @@ func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value { // are where we expect. func TestSelect(t *testing.T) { e := new(Editor) - e.SetText(`a123456789a -b123456789b -c123456789c -d123456789d -e123456789e -f123456789f -g123456789g + e.SetText(`a 2 4 6 8 a +b 2 4 6 8 b +c 2 4 6 8 c +d 2 4 6 8 d +e 2 4 6 8 e +f 2 4 6 8 f +g 2 4 6 8 g `) - gtx := layout.Context{Ops: new(op.Ops)} - cache := text.NewCache(gofont.Collection()) + gtx := layout.Context{ + Ops: new(op.Ops), + Locale: english, + } + cache := text.NewCache(gofont.CollectionHB()) font := text.Font{} fontSize := unit.Px(10) @@ -580,10 +819,10 @@ g123456789g for n, tst := range []testCase{ {0, 1, "a", screenPos{}, screenPos{Y: 0, X: 1}}, - {0, 4, "a123", screenPos{}, screenPos{Y: 0, X: 4}}, - {0, 11, "a123456789a", screenPos{}, screenPos{Y: 1, X: 5}}, - {2, 6, "2345", screenPos{Y: 0, X: 2}, screenPos{Y: 1, X: 0}}, - {41, 66, "56789d\ne123456789e\nf12345", screenPos{Y: 6, X: 5}, screenPos{Y: 11, X: 0}}, + {0, 4, "a 2 ", screenPos{}, screenPos{Y: 0, X: 4}}, + {0, 11, "a 2 4 6 8 a", screenPos{}, screenPos{Y: 1, X: 3}}, + {6, 10, "6 8 ", screenPos{Y: 0, X: 6}, screenPos{Y: 1, X: 2}}, + {41, 66, " 6 8 d\ne 2 4 6 8 e\nf 2 4 ", screenPos{Y: 6, X: 5}, screenPos{Y: 10, X: 6}}, } { // printLines(e) @@ -618,8 +857,11 @@ func TestSelectMove(t *testing.T) { e := new(Editor) e.SetText(`0123456789`) - gtx := layout.Context{Ops: new(op.Ops)} - cache := text.NewCache(gofont.Collection()) + gtx := layout.Context{ + Ops: new(op.Ops), + Locale: english, + } + cache := text.NewCache(gofont.CollectionHB()) font := text.Font{} fontSize := unit.Px(10) @@ -691,22 +933,24 @@ func TestEditor_WriteTo(t *testing.T) { func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 { var w fixed.Int26_6 - advances := e.lines[lineNum].Layout.Advances - if colEnd > len(advances) { - colEnd = len(advances) + glyphs := e.lines[lineNum].Layout.Glyphs + if colEnd > len(glyphs) { + colEnd = len(glyphs) } - for _, adv := range advances[colStart:colEnd] { - w += adv + for _, glyph := range glyphs[colStart:colEnd] { + w += glyph.XAdvance } return float32(w.Floor()) } func textHeight(e *Editor, lineNum int) float32 { - var h fixed.Int26_6 + var h int + var prevDesc fixed.Int26_6 for _, line := range e.lines[0:lineNum] { - h += line.Ascent + line.Descent + h += (line.Ascent + prevDesc).Ceil() + prevDesc = line.Descent } - return float32(h.Floor() + 1) + return float32(h + prevDesc.Ceil() + 1) } type testQueue struct { diff --git a/widget/label.go b/widget/label.go index 4131d0e7..71e192e8 100644 --- a/widget/label.go +++ b/widget/label.go @@ -5,9 +5,9 @@ package widget import ( "fmt" "image" - "unicode/utf8" "gioui.org/io/semantic" + "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" @@ -48,39 +48,48 @@ func clipLine(lines []text.Line, alignment text.Alignment, width int, clip image line := lines[lineIdx] // runeWidth is the width of the widest rune in line. runeWidth := (line.Bounds.Max.X - line.Width).Ceil() - q := combinedPos{y: start.y, x: fixed.I(clip.Min.X - runeWidth)} + lineStart := fixed.I(clip.Min.X - runeWidth) + lineEnd := fixed.I(clip.Max.X + runeWidth) + + flip := line.Layout.Direction.Progression() == system.TowardOrigin + if flip { + lineStart, lineEnd = lineEnd, lineStart + } + q := combinedPos{y: start.y, x: lineStart} start, _ = seekPosition(lines, alignment, width, linePos, q, 0) // Seek to first invisible column after start. - q = combinedPos{y: start.y, x: fixed.I(clip.Max.X + runeWidth)} + q = combinedPos{y: start.y, x: lineEnd} end, _ = seekPosition(lines, alignment, width, start, q, 0) + if flip { + start, end = end, start + } + return start, end } -func subLayout(line text.Line, startCol, endCol int) text.Layout { - adv := line.Layout.Advances - if startCol == line.Layout.Runes.Count { +func subLayout(line text.Line, start, end combinedPos) text.Layout { + if start.lineCol.X == line.Layout.Runes.Count { return text.Layout{} } - adv = adv[startCol:endCol] - txt := line.Layout.Text - for i := 0; i < startCol; i++ { - _, s := utf8.DecodeRuneInString(txt) - txt = txt[s:] + + startCluster := clusterIndexFor(line, start.lineCol.X, start.clusterIndex) + endCluster := clusterIndexFor(line, end.lineCol.X, end.clusterIndex) + if startCluster > endCluster { + startCluster, endCluster = endCluster, startCluster } - n := 0 - for i := startCol; i < endCol; i++ { - _, s := utf8.DecodeRuneInString(txt[n:]) - n += s - } - txt = txt[:n] - return text.Layout{Text: txt, Advances: adv} + return line.Layout.Slice(startCluster, endCluster) } func firstPos(line text.Line, alignment text.Alignment, width int) combinedPos { - return combinedPos{ + p := combinedPos{ x: align(alignment, line.Width, width), y: line.Ascent.Ceil(), } + + if line.Layout.Direction.Progression() == system.TowardOrigin { + p.x += line.Width + } + return p } func (p1 screenPos) Less(p2 screenPos) bool { @@ -107,7 +116,7 @@ func (l Label) Layout(gtx layout.Context, s text.Shaper, font text.Font, size un for !posIsBelow(lines, pos, cl.Max.Y) { start, end := clipLine(lines, l.Alignment, dims.Size.X, cl, pos) line := lines[start.lineCol.Y] - lt := subLayout(line, start.lineCol.X, end.lineCol.X) + lt := subLayout(line, start, end) off := image.Point{X: start.x.Floor(), Y: start.y} t := op.Offset(layout.FPt(off)).Push(gtx.Ops)