forked from joejulian/gio
text: provide start of paragraph glyph marker
This commit adds a new flag to glyphs indicating that they are the beginning of a new paragraph, as well as adding a guarantee that a glyph with this flag will always follow a glyph with FlagParagraphBreak, even if a paragraph break is the last rune in the text. This helps widgets to find the boundaries and positions of text ending with newlines reliably. Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
+45
-11
@@ -96,19 +96,28 @@ const (
|
||||
// sequence of glyphs which are logically a single unit, but require multiple
|
||||
// symbols from a font to display.
|
||||
FlagClusterBreak
|
||||
// FlagSynthetic indicates that the glyph cluster does not represent actual
|
||||
// FlagParagraphBreak indicates that the glyph cluster does not represent actual
|
||||
// font glyphs, but was inserted by the shaper to represent line-breaking
|
||||
// whitespace characters.
|
||||
FlagSynthetic
|
||||
// whitespace characters. After a glyph with FlagParagraphBreak set, the shaper
|
||||
// will always return a glyph with FlagParagraphStart providing the X and Y
|
||||
// coordinates of the start of the next line, even if that line has no contents.
|
||||
FlagParagraphBreak
|
||||
// FlagParagraphStart indicates that the glyph starts a new paragraph.
|
||||
FlagParagraphStart
|
||||
)
|
||||
|
||||
func (f Flags) String() string {
|
||||
var b strings.Builder
|
||||
if f&FlagSynthetic > 0 {
|
||||
if f&FlagParagraphStart > 0 {
|
||||
b.WriteString("S")
|
||||
} else {
|
||||
b.WriteString("_")
|
||||
}
|
||||
if f&FlagParagraphBreak > 0 {
|
||||
b.WriteString("P")
|
||||
} else {
|
||||
b.WriteString("_")
|
||||
}
|
||||
if f&FlagTowardOrigin > 0 {
|
||||
b.WriteString("T")
|
||||
} else {
|
||||
@@ -144,10 +153,12 @@ type Shaper struct {
|
||||
reader strings.Reader
|
||||
|
||||
// Iterator state.
|
||||
txt document
|
||||
line int
|
||||
run int
|
||||
glyph int
|
||||
brokeParagraph bool
|
||||
pararagraphStart Glyph
|
||||
txt document
|
||||
line int
|
||||
run int
|
||||
glyph int
|
||||
// advance is the width of glyphs from the current run that have already been displayed.
|
||||
advance fixed.Int26_6
|
||||
// done tracks whether iteration is over.
|
||||
@@ -223,7 +234,7 @@ func (l *Shaper) layoutText(params Parameters, minWidth, maxWidth int, lc system
|
||||
}
|
||||
done = endByte == len(str)
|
||||
}
|
||||
if startByte != endByte || len(l.paragraph) > 0 {
|
||||
if startByte != endByte || (len(l.paragraph) > 0 || len(l.txt.lines) == 0) {
|
||||
l.txt.append(l.layoutParagraph(params, minWidth, maxWidth, lc, str[startByte:endByte], l.paragraph))
|
||||
if truncating {
|
||||
params.MaxLines = maxLines - len(l.txt.lines)
|
||||
@@ -275,6 +286,10 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
|
||||
}
|
||||
for {
|
||||
if l.line == len(l.txt.lines) {
|
||||
if l.brokeParagraph {
|
||||
l.brokeParagraph = false
|
||||
return l.pararagraphStart, true
|
||||
}
|
||||
if l.err == nil {
|
||||
l.err = io.EOF
|
||||
}
|
||||
@@ -297,7 +312,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
|
||||
X: align,
|
||||
Y: int32(line.yOffset),
|
||||
Runes: 0,
|
||||
Flags: FlagLineBreak | FlagClusterBreak | FlagRunBreak | FlagSynthetic,
|
||||
Flags: FlagLineBreak | FlagClusterBreak | FlagRunBreak,
|
||||
Ascent: line.ascent,
|
||||
Descent: line.descent,
|
||||
}, true
|
||||
@@ -352,6 +367,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
|
||||
if endOfLine {
|
||||
glyph.Flags |= FlagLineBreak
|
||||
}
|
||||
endOfText := endOfLine && l.line == len(l.txt.lines)-1
|
||||
nextGlyph := l.glyph
|
||||
if rtl {
|
||||
nextGlyph = len(run.Glyphs) - 1 - nextGlyph
|
||||
@@ -365,8 +381,26 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
|
||||
if run.Direction.Progression() == system.TowardOrigin {
|
||||
glyph.Flags |= FlagTowardOrigin
|
||||
}
|
||||
if l.brokeParagraph {
|
||||
glyph.Flags |= FlagParagraphStart
|
||||
l.brokeParagraph = false
|
||||
}
|
||||
if g.glyphCount == 0 {
|
||||
glyph.Flags |= FlagSynthetic
|
||||
glyph.Flags |= FlagParagraphBreak
|
||||
l.brokeParagraph = true
|
||||
if endOfText {
|
||||
l.pararagraphStart = Glyph{
|
||||
Ascent: glyph.Ascent,
|
||||
Descent: glyph.Descent,
|
||||
Flags: FlagParagraphStart | FlagLineBreak | FlagRunBreak | FlagClusterBreak,
|
||||
}
|
||||
// If a glyph is both a paragraph break and the final glyph, it's a newline
|
||||
// at the end of the text. We must inform widgets like the text editor
|
||||
// of a valid cursor position they can use for "after" such a newline,
|
||||
// taking text alignment into account.
|
||||
l.pararagraphStart.X = l.txt.alignment.Align(line.direction, 0, l.txt.alignWidth)
|
||||
l.pararagraphStart.Y = glyph.Y + int32((glyph.Ascent + glyph.Descent).Ceil())
|
||||
}
|
||||
}
|
||||
|
||||
return glyph, true
|
||||
|
||||
+83
-25
@@ -1,12 +1,14 @@
|
||||
package text
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
|
||||
"gioui.org/font/opentype"
|
||||
"gioui.org/io/system"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
@@ -16,7 +18,7 @@ import (
|
||||
func TestWrappingTruncation(t *testing.T) {
|
||||
// Use a test string containing multiple newlines to ensure that they are shaped
|
||||
// as separate paragraphs.
|
||||
textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua."
|
||||
textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua.\n"
|
||||
ltrFace, _ := opentype.Parse(goregular.TTF)
|
||||
collection := []FontFace{{Face: ltrFace}}
|
||||
cache := NewShaper(collection)
|
||||
@@ -33,10 +35,18 @@ func TestWrappingTruncation(t *testing.T) {
|
||||
MaxLines: i,
|
||||
}, 200, 200, english, textInput)
|
||||
lineCount := len(cache.txt.lines)
|
||||
if i <= untruncatedCount && lineCount != i {
|
||||
t.Errorf("expected %d lines, got %d", i, lineCount)
|
||||
} else if i > untruncatedCount && lineCount != untruncatedCount {
|
||||
t.Errorf("expected %d lines, got %d", untruncatedCount, lineCount)
|
||||
glyphs := []Glyph{}
|
||||
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
|
||||
glyphs = append(glyphs, g)
|
||||
}
|
||||
if i <= untruncatedCount {
|
||||
if lineCount != i {
|
||||
t.Errorf("expected %d lines, got %d", i, lineCount)
|
||||
}
|
||||
} else if i > untruncatedCount {
|
||||
if lineCount != untruncatedCount {
|
||||
t.Errorf("expected %d lines, got %d", untruncatedCount, lineCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,26 +54,74 @@ func TestWrappingTruncation(t *testing.T) {
|
||||
// TestShapingNewlineHandling checks that the shaper's newline splitting behaves
|
||||
// consistently and does not create spurious lines of text.
|
||||
func TestShapingNewlineHandling(t *testing.T) {
|
||||
// Use a test string containing multiple newlines to ensure that they are shaped
|
||||
// as separate paragraphs.
|
||||
textInput := "\n"
|
||||
ltrFace, _ := opentype.Parse(goregular.TTF)
|
||||
collection := []FontFace{{Face: ltrFace}}
|
||||
cache := NewShaper(collection)
|
||||
cache.LayoutString(Parameters{
|
||||
Alignment: Middle,
|
||||
PxPerEm: fixed.I(10),
|
||||
}, 200, 200, english, textInput)
|
||||
if lineCount := len(cache.txt.lines); lineCount > 1 {
|
||||
t.Errorf("shaping string %q created %d lines", textInput, lineCount)
|
||||
type testcase struct {
|
||||
textInput string
|
||||
expectedLines int
|
||||
expectedGlyphs int
|
||||
}
|
||||
for _, tc := range []testcase{
|
||||
{textInput: "a\n", expectedLines: 1, expectedGlyphs: 3},
|
||||
{textInput: "a\nb", expectedLines: 2, expectedGlyphs: 3},
|
||||
{textInput: "", expectedLines: 1, expectedGlyphs: 1},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", tc.textInput), func(t *testing.T) {
|
||||
ltrFace, _ := opentype.Parse(goregular.TTF)
|
||||
collection := []FontFace{{Face: ltrFace}}
|
||||
cache := NewShaper(collection)
|
||||
checkGlyphs := func() {
|
||||
glyphs := []Glyph{}
|
||||
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
|
||||
glyphs = append(glyphs, g)
|
||||
}
|
||||
if len(glyphs) != tc.expectedGlyphs {
|
||||
t.Errorf("expected %d glyphs, got %d", tc.expectedGlyphs, len(glyphs))
|
||||
}
|
||||
findBreak := func(g Glyph) bool {
|
||||
return g.Flags&FlagParagraphBreak != 0
|
||||
}
|
||||
found := 0
|
||||
for idx := slices.IndexFunc(glyphs, findBreak); idx != -1; idx = slices.IndexFunc(glyphs, findBreak) {
|
||||
found++
|
||||
breakGlyph := glyphs[idx]
|
||||
startGlyph := glyphs[idx+1]
|
||||
glyphs = glyphs[idx+1:]
|
||||
if flags := breakGlyph.Flags; flags&FlagParagraphBreak == 0 {
|
||||
t.Errorf("expected newline glyph to have P flag, got %s", flags)
|
||||
}
|
||||
if flags := startGlyph.Flags; flags&FlagParagraphStart == 0 {
|
||||
t.Errorf("expected newline glyph to have S flag, got %s", flags)
|
||||
}
|
||||
breakX, breakY := breakGlyph.X, breakGlyph.Y
|
||||
startX, startY := startGlyph.X, startGlyph.Y
|
||||
if breakX == startX {
|
||||
t.Errorf("expected paragraph start glyph to have cursor x")
|
||||
}
|
||||
if breakY == startY {
|
||||
t.Errorf("expected paragraph start glyph to have cursor y")
|
||||
}
|
||||
}
|
||||
if count := strings.Count(tc.textInput, "\n"); found != count {
|
||||
t.Errorf("expected %d paragraph breaks, found %d", count, found)
|
||||
}
|
||||
}
|
||||
cache.LayoutString(Parameters{
|
||||
Alignment: Middle,
|
||||
PxPerEm: fixed.I(10),
|
||||
}, 200, 200, english, tc.textInput)
|
||||
if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
|
||||
t.Errorf("shaping string %q created %d lines", tc.textInput, lineCount)
|
||||
}
|
||||
checkGlyphs()
|
||||
|
||||
cache.Layout(Parameters{
|
||||
Alignment: Middle,
|
||||
PxPerEm: fixed.I(10),
|
||||
}, 200, 200, english, strings.NewReader(textInput))
|
||||
if lineCount := len(cache.txt.lines); lineCount > 1 {
|
||||
t.Errorf("shaping reader %q created %d lines", textInput, lineCount)
|
||||
cache.Layout(Parameters{
|
||||
Alignment: Middle,
|
||||
PxPerEm: fixed.I(10),
|
||||
}, 200, 200, english, strings.NewReader(tc.textInput))
|
||||
if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
|
||||
t.Errorf("shaping reader %q created %d lines", tc.textInput, lineCount)
|
||||
}
|
||||
checkGlyphs()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +146,7 @@ func TestCacheEmptyString(t *testing.T) {
|
||||
checkFlag(t, true, FlagClusterBreak, glyph, 0)
|
||||
checkFlag(t, true, FlagRunBreak, glyph, 0)
|
||||
checkFlag(t, true, FlagLineBreak, glyph, 0)
|
||||
checkFlag(t, true, FlagSynthetic, glyph, 0)
|
||||
checkFlag(t, false, FlagParagraphBreak, glyph, 0)
|
||||
if glyph.Ascent == 0 {
|
||||
t.Errorf("expected non-zero ascent")
|
||||
}
|
||||
@@ -205,7 +263,7 @@ func TestCacheGlyphConverstion(t *testing.T) {
|
||||
checkFlag(t, endOfLine, FlagLineBreak, actual, glyphCursor)
|
||||
checkFlag(t, endOfRun, FlagRunBreak, actual, glyphCursor)
|
||||
checkFlag(t, towardOrigin, FlagTowardOrigin, actual, glyphCursor)
|
||||
checkFlag(t, synthetic, FlagSynthetic, actual, glyphCursor)
|
||||
checkFlag(t, synthetic, FlagParagraphBreak, actual, glyphCursor)
|
||||
checkFlag(t, endOfCluster, FlagClusterBreak, actual, glyphCursor)
|
||||
glyphCursor++
|
||||
if glyphIdx == end {
|
||||
|
||||
Reference in New Issue
Block a user