text,widget: [API] implement consistent, controllable line height

This commit ensures that any given paragraph of text shaped by Gio will use a single
internal line height. This line height is determined (by default) by the text size,
rather than the fonts involved. This is a breaking change, as previously we would
blindly use the largest line height of any font in a line for that line, leading to
lines within the same paragraph with extremely uneven spacing. This commit also
updates some test expectations in package widget.

I thought pretty hard about how to implement line spacing, and consulted a few sources:

[0] https://www.figma.com/blog/line-height-changes/
[1] https://practicaltypography.com/line-spacing.html
[2] https://developer.mozilla.org/en-US/docs/Web/CSS/line-height

There is no single, universal way to think about line spacing. Fonts internally specify
a line height as the sum of their ascent, descent, and gap, but the line height of two
fonts at the same pixel size (say 20 Sp) can vary wildy (especially across writing systems).
There are two strategies we could pursue to establish the line height of a paragraph of text:

- derive the line height from the fonts involved (our old behavior, and the behavior of
  many word processors)
- derive the line height from the requested text size provided by the user (the behavior of the
  web).

The challenge with the first option is that for a given piece of text in the UI, there can
be a silly number of fonts involved. If a label dispays user-generated content, the user can
put an emoji in it, and emoji fonts have different line heights from latin ones. This can cause
unexpected and nasty layout shift. Gio would previously do exactly this, on a line-by-line basis,
resulting in unevenly spaced lines within a paragraph depending on which fonts were used on
which lines. Choosing one of the fonts and enforcing its line height would make things consistent,
but it isn't clear how to choose that canonical font. There is no 1:1 mapping between the input
text.Font provided in the shaping parameters and a single font.Face. Instead, that mapping depends
upon the runes being shaped.

I think the only sane way to implement the first option would be to synthesize some text in the
provided system.Locale (mapping the language to a script and then generating a rune from that
script), shape that single rune, and then enforce the line height of the resulting face on the
entire paragraph. This would require doing a fair bit more work per paragraph than Gio does today,
so I've opted not to do it.

Instead, the second option allows us to choose a line height based on the size of the text that
the user wants to display. While this can potentially interact poorly with unusually tall fonts,
it means that text will always have a consistent line height.

I've provided two knobs to control line height:

- text.Parameters.LineHeight lets you set a specific height in pixels with a default value of
  text.Parameters.PxPerEm.
- text.Parameters.LineHeightScale applies a scaling factor to the LineHeight, allowing you to
  easily space out text without hard-coding a specific pixel size. The default value here
  (drawn from the recommendations of [1]) is 1.2, which looks pretty good across many fonts.

I've chosen this two-value API because many users will want to set one or the other value. I
considered instead a single value field and a "mode" that would specify how it was used, but
that felt uglier. Also, you *can* set both of these two fields and get predictable results.

I'd like to revisit using the line height of the chosen fonts in the future, but it seems a
little too complex to be worthwhile right now. An interesting option would be making the
select-a-face-using-locale strategy described above an opt-in feature, though some users
might instead want to just use the tallest line height among fonts in use. Something like
this Android API might be appropriate:

[3] https://learn.microsoft.com/en-us/dotnet/api/android.widget.textview.fallbacklinespacing?view=xamarin-android-sdk-13

I'd like to thank Dominik Honnef for some good discussion around this feature, and for pointing
me to some good sources on the subject.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
Chris Waldon
2023-07-14 16:59:29 -04:00
committed by Elias Naur
parent 15031d0b52
commit 6ea4119a3c
4 changed files with 75 additions and 30 deletions
+37 -6
View File
@@ -74,6 +74,9 @@ type line struct {
// descent is the height below the baseline, including
// the line gap.
descent fixed.Int26_6
// lineHeight captures the gap that should exist between the baseline of this
// line and the previous (if any).
lineHeight fixed.Int26_6
// bounds is the visible bounds of the line.
bounds fixed.Rectangle26_6
// direction is the dominant direction of the line. This direction will be
@@ -471,13 +474,17 @@ func (s *shaperImpl) Layout(params Parameters, txt io.RuneReader) document {
}
func calculateYOffsets(lines []line) {
currentY := 0
prevDesc := fixed.I(0)
if len(lines) < 1 {
return
}
// Ceil the first value to ensure that we don't baseline it too close to the top of the
// viewport and cut off the top pixel.
currentY := fixed.I(lines[0].ascent.Ceil())
for i := range lines {
ascent, descent := lines[i].ascent, lines[i].descent
currentY += (prevDesc + ascent).Ceil()
lines[i].yOffset = currentY
prevDesc = descent
if i > 0 {
currentY += lines[i].lineHeight
}
lines[i].yOffset = currentY.Round()
}
}
@@ -499,8 +506,12 @@ func (s *shaperImpl) LayoutRunes(params Parameters, txt []rune) document {
}
// Convert to Lines.
textLines := make([]line, len(ls))
maxHeight := fixed.Int26_6(0)
for i := range ls {
otLine := toLine(&s.orderer, ls[i], params.Locale.Direction)
if otLine.lineHeight > maxHeight {
maxHeight = otLine.lineHeight
}
isFinalLine := i == len(ls)-1
if isFinalLine && hasNewline {
// If there was a trailing newline update the rune counts to include
@@ -548,6 +559,17 @@ func (s *shaperImpl) LayoutRunes(params Parameters, txt []rune) document {
}
textLines[i] = otLine
}
if params.LineHeight != 0 {
maxHeight = params.LineHeight
}
if params.LineHeightScale == 0 {
params.LineHeightScale = 1.2
}
maxHeight = floatToFixed(fixedToFloat(maxHeight) * params.LineHeightScale)
for i := range textLines {
textLines[i].lineHeight = maxHeight
}
calculateYOffsets(textLines)
return document{
lines: textLines,
@@ -633,6 +655,10 @@ func fixedToFloat(i fixed.Int26_6) float32 {
return float32(i) / 64.0
}
func floatToFixed(f float32) fixed.Int26_6 {
return fixed.Int26_6(f * 64)
}
// Bitmaps returns an op.CallOp that will display all bitmap glyphs within gs.
// The positioning of the bitmaps uses the same logic as Shape(), so the returned
// CallOp can be added at the same offset as the path data returned by Shape()
@@ -781,8 +807,12 @@ func toLine(orderer *faceOrderer, o shaping.Line, dir system.TextDirection) line
runs: make([]runLayout, len(o)),
direction: dir,
}
maxSize := fixed.Int26_6(0)
for i := range o {
run := o[i]
if run.Size > maxSize {
maxSize = run.Size
}
line.runs[i] = runLayout{
Glyphs: toGioGlyphs(run.Glyphs, run.Size, orderer.indexFor(run.Face)),
Runes: Range{
@@ -810,6 +840,7 @@ func toLine(orderer *faceOrderer, o shaping.Line, dir system.TextDirection) line
line.descent = -run.LineBounds.Descent + run.LineBounds.Gap
}
}
line.lineHeight = maxSize
computeVisualOrder(&line)
// Account for glyphs hanging off of either side in the bounds.
if len(line.visualOrder) > 0 {
+2
View File
@@ -160,6 +160,8 @@ type layoutKey struct {
font giofont.Font
forceTruncate bool
wrapPolicy WrapPolicy
lineHeight fixed.Int26_6
lineHeightScale float32
}
type pathKey struct {
+22 -10
View File
@@ -62,6 +62,16 @@ type Parameters struct {
// Locale provides primary direction and language information for the shaped text.
Locale system.Locale
// LineHeightScale is a scaling factor applied to the LineHeight of a paragraph. If zero, a default
// value of 1.2 will be used.
LineHeightScale float32
// LineHeight is the distance between the baselines of two lines of text. If zero, the PxPerEm
// of the any given paragraph will set the LineHeight of that paragraph. This value will be
// scaled by LineHeightScale, so applications desiring a specific fixed value
// should set LineHeightScale to 1.
LineHeight fixed.Int26_6
// forceTruncate controls whether the truncator string is inserted on the final line of
// text with a MaxLines. It is unexported because this behavior only makes sense for the
// shaper to control when it iterates paragraphs of text.
@@ -334,16 +344,18 @@ func (l *Shaper) layoutParagraph(params Parameters, asStr string, asBytes []byte
}
// Alignment is not part of the cache key because changing it does not impact shaping.
lk := layoutKey{
ppem: params.PxPerEm,
maxWidth: params.MaxWidth,
minWidth: params.MinWidth,
maxLines: params.MaxLines,
truncator: params.Truncator,
locale: params.Locale,
font: params.Font,
forceTruncate: params.forceTruncate,
wrapPolicy: params.WrapPolicy,
str: asStr,
ppem: params.PxPerEm,
maxWidth: params.MaxWidth,
minWidth: params.MinWidth,
maxLines: params.MaxLines,
truncator: params.Truncator,
locale: params.Locale,
font: params.Font,
forceTruncate: params.forceTruncate,
wrapPolicy: params.WrapPolicy,
str: asStr,
lineHeight: params.LineHeight,
lineHeightScale: params.LineHeightScale,
}
if l, ok := l.layoutCache.Get(lk); ok {
return l
+14 -14
View File
@@ -169,10 +169,10 @@ func TestIndexPositionWhitespace(t *testing.T) {
{x: fixed.Int26_6(832), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
{x: fixed.Int26_6(832), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}},
{x: fixed.Int26_6(832), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 2, lineCol: screenPos{line: 2}},
{x: fixed.Int26_6(6), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 3, lineCol: screenPos{line: 3}},
{x: fixed.Int26_6(576), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 4, lineCol: screenPos{line: 3, col: 1}},
{x: fixed.Int26_6(1146), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 5, lineCol: screenPos{line: 3, col: 2}},
{x: fixed.Int26_6(1658), y: 73, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 6, lineCol: screenPos{line: 3, col: 3}},
{x: fixed.Int26_6(6), y: 74, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 3, lineCol: screenPos{line: 3}},
{x: fixed.Int26_6(576), y: 74, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 4, lineCol: screenPos{line: 3, col: 1}},
{x: fixed.Int26_6(1146), y: 74, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 5, lineCol: screenPos{line: 3, col: 2}},
{x: fixed.Int26_6(1658), y: 74, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 6, lineCol: screenPos{line: 3, col: 3}},
},
},
{
@@ -320,7 +320,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(0),
yOff: 56,
yOff: 41,
glyphs: 15,
width: fixed.Int26_6(7905),
ascent: fixed.Int26_6(1407),
@@ -328,7 +328,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(0),
yOff: 90,
yOff: 60,
glyphs: 18,
width: fixed.Int26_6(8813),
ascent: fixed.Int26_6(1407),
@@ -336,7 +336,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(0),
yOff: 117,
yOff: 80,
glyphs: 4,
width: fixed.Int26_6(2034),
ascent: fixed.Int26_6(968),
@@ -359,7 +359,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(0),
yOff: 56,
yOff: 41,
glyphs: 19,
width: fixed.Int26_6(8801),
ascent: fixed.Int26_6(1407),
@@ -367,7 +367,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(0),
yOff: 90,
yOff: 60,
glyphs: 13,
width: fixed.Int26_6(5852),
ascent: fixed.Int26_6(1407),
@@ -390,7 +390,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(2335),
yOff: 56,
yOff: 41,
glyphs: 15,
width: fixed.Int26_6(7905),
ascent: fixed.Int26_6(1407),
@@ -398,7 +398,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(1427),
yOff: 90,
yOff: 60,
glyphs: 18,
width: fixed.Int26_6(8813),
ascent: fixed.Int26_6(1407),
@@ -406,7 +406,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(8206),
yOff: 117,
yOff: 80,
glyphs: 4,
width: fixed.Int26_6(2034),
ascent: fixed.Int26_6(968),
@@ -429,7 +429,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(1439),
yOff: 56,
yOff: 41,
glyphs: 19,
width: fixed.Int26_6(8801),
ascent: fixed.Int26_6(1407),
@@ -437,7 +437,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(4388),
yOff: 90,
yOff: 60,
glyphs: 13,
width: fixed.Int26_6(5852),
ascent: fixed.Int26_6(1407),