mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
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 <christopher.waldon.dev@gmail.com>
This commit is contained in:
+82
-22
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+284
-40
@@ -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 {
|
||||
|
||||
+29
-20
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user