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)
 		}
 	}