shiny/text: add Height, LineCount and ParagraphCount methods.
Change-Id: I8770ac0ba12a478119b5a1020142c50ce948dd48
Reviewed-on: https://go-review.googlesource.com/23882
Reviewed-by: David Crawshaw <crawshaw@golang.org>
diff --git a/shiny/text/caret.go b/shiny/text/caret.go
index fc90ecc..87da5f6 100644
--- a/shiny/text/caret.go
+++ b/shiny/text/caret.go
@@ -412,6 +412,10 @@
c.leanForwards()
}
+ c.f.invalidateCaches()
+ c.f.paragraphs[c.p].invalidateCaches()
+ c.f.lines[c.l].invalidateCaches()
+
length, nl := len(s0), false
if length > 0 {
nl = s0[length-1] == '\n'
@@ -671,6 +675,7 @@
// The mergeIntoOneLine will shake out any empty Boxes.
l := c.f.mergeIntoOneLine(c.p)
layout(c.f, l)
+ c.f.invalidateCaches()
// Compact c.f.text if it's large enough and the fraction of deleted text
// is above some threshold. The actual threshold value (25%) is arbitrary.
diff --git a/shiny/text/text.go b/shiny/text/text.go
index 85a2e5b..f8bb5ca 100644
--- a/shiny/text/text.go
+++ b/shiny/text/text.go
@@ -96,6 +96,13 @@
lines []Line
boxes []Box
+ // These values cache the total height-in-pixels of or the number of
+ // elements in the paragraphs or lines linked lists. The plus one is so
+ // that the zero value means the cache is invalid.
+ cachedHeightPlus1 int32
+ cachedLineCountPlus1 int32
+ cachedParagraphCountPlus1 int32
+
// freeX is the index of the first X (Paragraph, Line or Box) in the
// respective free list. Zero means that there is no such free element.
freeP, freeL, freeB int32
@@ -103,7 +110,9 @@
firstP int32
maxWidth fixed.Int26_6
- face font.Face
+
+ faceHeight int32
+ face font.Face
// len is the total length of the Frame's current textual content, in
// bytes. It can be smaller then len(text), since that []byte can contain
@@ -136,16 +145,39 @@
f.initialize()
}
f.face = face
+ if face == nil {
+ f.faceHeight = 0
+ } else {
+ // We round up the ascent and descent separately, instead of asking for
+ // the metrics' height, since we quantize the baseline to the integer
+ // pixel grid. For example, if ascent and descent were both 3.2 pixels,
+ // then the naive height would be 6.4, which rounds up to 7, but we
+ // should really provide 8 pixels (= ceil(3.2) + ceil(3.2)) between
+ // each line to avoid overlap.
+ //
+ // TODO: is a font.Metrics.Height actually useful in practice??
+ //
+ // TODO: is it the font face's responsibility to track line spacing, as
+ // in "double line spacing", or does that belong somewhere else, since
+ // it doesn't affect the face's glyph masks?
+ m := face.Metrics()
+ f.faceHeight = int32(m.Ascent.Ceil() + m.Descent.Ceil())
+ }
if f.len != 0 {
f.relayout()
}
}
-// SetMaxWidth sets the target maximum width of a Line of text. Text will be
-// broken so that a Line's width is less than or equal to this maximum width.
-// This line breaking is not strict. A Line containing asingleverylongword
-// combined with a narrow maximum width will not be broken and will remain
-// longer than the target maximum width; soft hyphens are not inserted.
+// TODO: should SetMaxWidth take an int number of pixels instead of a
+// fixed.Int26_6 number of sub-pixels? Height returns an int, since it assumes
+// that the text baselines are quantized to the integer pixel grid.
+
+// SetMaxWidth sets the target maximum width of a Line of text, as a
+// fixed-point fractional number of pixels. Text will be broken so that a
+// Line's width is less than or equal to this maximum width. This line breaking
+// is not strict. A Line containing asingleverylongword combined with a narrow
+// maximum width will not be broken and will remain longer than the target
+// maximum width; soft hyphens are not inserted.
//
// A non-positive argument is treated as an infinite maximum width.
func (f *Frame) SetMaxWidth(m fixed.Int26_6) {
@@ -166,6 +198,7 @@
l := f.mergeIntoOneLine(p)
layout(f, l)
}
+ f.invalidateCaches()
f.seqNum++
}
@@ -187,6 +220,8 @@
}
if ll.next == 0 {
+ f.paragraphs[p].invalidateCaches()
+ f.lines[firstL].invalidateCaches()
return firstL
}
@@ -330,6 +365,61 @@
return &f.paragraphs[f.firstP]
}
+func (f *Frame) invalidateCaches() {
+ f.cachedHeightPlus1 = 0
+ f.cachedLineCountPlus1 = 0
+ f.cachedParagraphCountPlus1 = 0
+}
+
+// Height returns the height in pixels of this Frame.
+func (f *Frame) Height() int {
+ if !f.initialized() {
+ f.initialize()
+ }
+ if f.cachedHeightPlus1 <= 0 {
+ h := 1
+ for p := f.firstP; p != 0; p = f.paragraphs[p].next {
+ h += f.paragraphs[p].Height(f)
+ }
+ f.cachedHeightPlus1 = int32(h)
+ }
+ return int(f.cachedHeightPlus1 - 1)
+}
+
+// LineCount returns the number of Lines in this Frame.
+//
+// This count includes any soft returns inserted to wrap text to the maxWidth.
+func (f *Frame) LineCount() int {
+ if !f.initialized() {
+ f.initialize()
+ }
+ if f.cachedLineCountPlus1 <= 0 {
+ n := 1
+ for p := f.firstP; p != 0; p = f.paragraphs[p].next {
+ n += f.paragraphs[p].LineCount(f)
+ }
+ f.cachedLineCountPlus1 = int32(n)
+ }
+ return int(f.cachedLineCountPlus1 - 1)
+}
+
+// ParagraphCount returns the number of Paragraphs in this Frame.
+//
+// This count excludes any soft returns inserted to wrap text to the maxWidth.
+func (f *Frame) ParagraphCount() int {
+ if !f.initialized() {
+ f.initialize()
+ }
+ if f.cachedParagraphCountPlus1 <= 0 {
+ n := 1
+ for p := f.firstP; p != 0; p = f.paragraphs[p].next {
+ n++
+ }
+ f.cachedParagraphCountPlus1 = int32(n)
+ }
+ return int(f.cachedParagraphCountPlus1 - 1)
+}
+
// Len returns the number of bytes in the Frame's text.
func (f *Frame) Len() int {
// We would normally check f.initialized() at the start of each exported
@@ -546,7 +636,9 @@
// Paragraph holds Lines of text.
type Paragraph struct {
- firstL, next, prev int32
+ firstL, next, prev int32
+ cachedHeightPlus1 int32
+ cachedLineCountPlus1 int32
}
func (p *Paragraph) lastLine(f *Frame) int32 {
@@ -576,9 +668,41 @@
return &f.paragraphs[p.next]
}
+func (p *Paragraph) invalidateCaches() {
+ p.cachedHeightPlus1 = 0
+ p.cachedLineCountPlus1 = 0
+}
+
+// Height returns the height in pixels of this Paragraph.
+func (p *Paragraph) Height(f *Frame) int {
+ if p.cachedHeightPlus1 <= 0 {
+ h := 1
+ for l := p.firstL; l != 0; l = f.lines[l].next {
+ h += f.lines[l].Height(f)
+ }
+ p.cachedHeightPlus1 = int32(h)
+ }
+ return int(p.cachedHeightPlus1 - 1)
+}
+
+// LineCount returns the number of Lines in this Paragraph.
+//
+// This count includes any soft returns inserted to wrap text to the maxWidth.
+func (p *Paragraph) LineCount(f *Frame) int {
+ if p.cachedLineCountPlus1 <= 0 {
+ n := 1
+ for l := p.firstL; l != 0; l = f.lines[l].next {
+ n++
+ }
+ p.cachedLineCountPlus1 = int32(n)
+ }
+ return int(p.cachedLineCountPlus1 - 1)
+}
+
// Line holds Boxes of text.
type Line struct {
firstB, next, prev int32
+ cachedHeightPlus1 int32
}
func (l *Line) lastBox(f *Frame) int32 {
@@ -608,6 +732,23 @@
return &f.lines[l.next]
}
+func (l *Line) invalidateCaches() {
+ l.cachedHeightPlus1 = 0
+}
+
+// Height returns the height in pixels of this Line.
+func (l *Line) Height(f *Frame) int {
+ // TODO: measure the height of each box, if we allow rich text (i.e. more
+ // than one Frame-wide font face).
+ if f.face == nil {
+ return 0
+ }
+ if l.cachedHeightPlus1 <= 0 {
+ l.cachedHeightPlus1 = f.faceHeight + 1
+ }
+ return int(l.cachedHeightPlus1 - 1)
+}
+
// Box holds a contiguous run of text.
type Box struct {
next, prev int32
diff --git a/shiny/text/text_test.go b/shiny/text/text_test.go
index 3e1788b..a2eedb9 100644
--- a/shiny/text/text_test.go
+++ b/shiny/text/text_test.go
@@ -135,9 +135,14 @@
}
func (toyFace) Metrics() font.Metrics {
- return font.Metrics{}
+ return font.Metrics{
+ Ascent: fixed.I(2),
+ Descent: fixed.I(1),
+ }
}
+const toyFaceLineHeight = 3
+
// iRobot is some text that contains both ASCII and non-ASCII runes.
const iRobot = "\"I, Robot\" in Russian is \"Я, робот\".\nIt's about robots.\n"
@@ -386,6 +391,74 @@
}
}
+func TestLineCount(t *testing.T) {
+ f := iRobotFrame(0)
+
+ testCases := []struct {
+ desc string
+ maxWidth int
+ wantLines []string
+ wantLineCounts []int
+ }{{
+ desc: "infinite maxWidth",
+ maxWidth: 0,
+ wantLines: []string{
+ `"I, Robot" in Russian is "Я, робот".`,
+ `It's about robots.`,
+ ``,
+ },
+ wantLineCounts: []int{1, 1, 1},
+ }, {
+ desc: "finite maxWidth",
+ maxWidth: 10,
+ wantLines: []string{
+ `"I, Robot"`,
+ `in Russian`,
+ `is "Я,`,
+ `робот".`,
+ `It's about`,
+ `robots.`,
+ ``,
+ },
+ wantLineCounts: []int{4, 2, 1},
+ }}
+ for _, tc := range testCases {
+ f.SetMaxWidth(fixed.I(tc.maxWidth))
+ lines, lineCounts := []string{}, []int{}
+ for p := f.FirstParagraph(); p != nil; p = p.Next(f) {
+ for l := p.FirstLine(f); l != nil; l = l.Next(f) {
+ var line []byte
+ for b := l.FirstBox(f); b != nil; b = b.Next(f) {
+ line = append(line, b.TrimmedText(f)...)
+ }
+ lines = append(lines, string(line))
+ }
+ lineCounts = append(lineCounts, p.LineCount(f))
+ }
+
+ if got, want := f.LineCount(), len(tc.wantLines); got != want {
+ t.Errorf("%s: LineCount: got %d, want %d", tc.desc, got, want)
+ continue
+ }
+ if !reflect.DeepEqual(lines, tc.wantLines) {
+ t.Errorf("%s: lines: got %q, want %q", tc.desc, lines, tc.wantLines)
+ continue
+ }
+ if got, want := f.ParagraphCount(), len(tc.wantLineCounts); got != want {
+ t.Errorf("%s: ParagraphCount: got %d, want %d", tc.desc, got, want)
+ continue
+ }
+ if !reflect.DeepEqual(lineCounts, tc.wantLineCounts) {
+ t.Errorf("%s: lineCounts: got %d, want %d", tc.desc, lineCounts, tc.wantLineCounts)
+ continue
+ }
+ if got, want := f.Height(), toyFaceLineHeight*len(tc.wantLines); got != want {
+ t.Errorf("%s: Height: got %d, want %d", tc.desc, got, want)
+ continue
+ }
+ }
+}
+
func TestSetMaxWidth(t *testing.T) {
f := new(Frame)
f.SetFace(toyFace{})
@@ -655,17 +728,33 @@
}
written, wantLen := 0, 0
- for i := 0; written < 32768; i++ {
- x, y := rngIntPair(rng, len(buf)+1)
- c.Seek(int64(rng.Intn(f.Len()+1)), SeekSet)
- c.Write(buf[x:y])
- written += y - x
- wantLen += y - x
+ for i := 0; written < 131072; i++ {
+ if rng.Intn(10) != 0 {
+ x, y := rngIntPair(rng, len(buf)+1)
+ c.Seek(int64(rng.Intn(f.Len()+1)), SeekSet)
+ c.Write(buf[x:y])
+ written += y - x
+ wantLen += y - x
+ }
- x, y = rngIntPair(rng, wantLen+1)
- c.Seek(int64(x), SeekSet)
- c.Delete(Forwards, y-x)
- wantLen -= y - x
+ if rng.Intn(10) != 0 {
+ x, y := rngIntPair(rng, wantLen+1)
+ c.Seek(int64(x), SeekSet)
+ c.Delete(Forwards, y-x)
+ wantLen -= y - x
+ }
+
+ // Calculate cached counts, some of the time.
+ if rng.Intn(3) == 0 {
+ f.ParagraphCount()
+ }
+ if rng.Intn(4) == 0 {
+ f.LineCount()
+ }
+ if rng.Intn(5) == 0 {
+ c.Seek(int64(rng.Intn(f.Len()+1)), SeekSet)
+ f.paragraphs[c.p].LineCount(f)
+ }
if err := checkInvariants(f); err != nil {
t.Fatalf("i=%d: %v", i, err)
@@ -935,7 +1024,7 @@
// Iterate through the Paragraphs, Lines and Boxes. Check that every first
// child has no previous sibling, and no child is the first child of
- // multiple parents.
+ // multiple parents. Also check the cached Paragraph and Line counts.
nUsedParagraphs, nUsedLines, nUsedBoxes := 0, 0, 0
{
firstLines := map[int32]bool{}
@@ -961,6 +1050,7 @@
}
firstLines[l] = true
+ nUsedLinesThisParagraph := 0
for ; l != 0; l = f.lines[l].next {
b := f.lines[l].firstB
if b == 0 {
@@ -981,8 +1071,19 @@
}
}
nUsedLines++
+ nUsedLinesThisParagraph++
}
nUsedParagraphs++
+
+ if n := int(f.paragraphs[p].cachedLineCountPlus1 - 1); n >= 0 && n != nUsedLinesThisParagraph {
+ return fmt.Errorf("Paragraph cached Line count: got %d, want %d", n, nUsedLinesThisParagraph)
+ }
+ }
+ if n := int(f.cachedLineCountPlus1 - 1); n >= 0 && n != nUsedLines {
+ return fmt.Errorf("Frame cached Line count: got %d, want %d", n, nUsedLines)
+ }
+ if n := int(f.cachedParagraphCountPlus1 - 1); n >= 0 && n != nUsedParagraphs {
+ return fmt.Errorf("Frame cached Paragraph count: got %d, want %d", n, nUsedParagraphs)
}
}