From 6ea4119a3ceb36f009af1486e41b47f08c2239bd Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Fri, 14 Jul 2023 16:59:29 -0400 Subject: [PATCH] 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 --- text/gotext.go | 43 +++++++++++++++++++++++++++++++++++++------ text/lru.go | 2 ++ text/shaper.go | 32 ++++++++++++++++++++++---------- widget/index_test.go | 28 ++++++++++++++-------------- 4 files changed, 75 insertions(+), 30 deletions(-) diff --git a/text/gotext.go b/text/gotext.go index a358ea8d..6d729da8 100644 --- a/text/gotext.go +++ b/text/gotext.go @@ -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 { diff --git a/text/lru.go b/text/lru.go index 35dd4b4f..4eff262f 100644 --- a/text/lru.go +++ b/text/lru.go @@ -160,6 +160,8 @@ type layoutKey struct { font giofont.Font forceTruncate bool wrapPolicy WrapPolicy + lineHeight fixed.Int26_6 + lineHeightScale float32 } type pathKey struct { diff --git a/text/shaper.go b/text/shaper.go index b2297501..bcab39e3 100644 --- a/text/shaper.go +++ b/text/shaper.go @@ -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 diff --git a/widget/index_test.go b/widget/index_test.go index 3d767652..ee86410a 100644 --- a/widget/index_test.go +++ b/widget/index_test.go @@ -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),