mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 15:45:38 +00:00
edbf872b44
We previously were not handling glyphs that extended vertically beyond the ascent/descent declared by their font. This is done rarely with text fonts, but is apparently common among symbol and emoji fonts. Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
231 lines
8.2 KiB
Go
231 lines
8.2 KiB
Go
// SPDX-License-Identifier: Unlicense OR MIT
|
|
|
|
package widget
|
|
|
|
import (
|
|
"image"
|
|
|
|
"gioui.org/f32"
|
|
"gioui.org/font"
|
|
"gioui.org/io/semantic"
|
|
"gioui.org/layout"
|
|
"gioui.org/op"
|
|
"gioui.org/op/clip"
|
|
"gioui.org/op/paint"
|
|
"gioui.org/text"
|
|
"gioui.org/unit"
|
|
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
// Label is a widget for laying out and drawing text. Labels are always
|
|
// non-interactive text. They cannot be selected or copied.
|
|
type Label struct {
|
|
// Alignment specifies the text alignment.
|
|
Alignment text.Alignment
|
|
// MaxLines limits the number of lines. Zero means no limit.
|
|
MaxLines int
|
|
// Truncator is the text that will be shown at the end of the final
|
|
// line if MaxLines is exceeded. Defaults to "…" if empty.
|
|
Truncator string
|
|
// WrapPolicy configures how displayed text will be broken into lines.
|
|
WrapPolicy text.WrapPolicy
|
|
// LineHeight controls the distance between the baselines of lines of text.
|
|
// If zero, a sensible default will be used.
|
|
LineHeight unit.Sp
|
|
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
|
|
// sensible default will be used.
|
|
LineHeightScale float32
|
|
}
|
|
|
|
// Layout the label with the given shaper, font, size, text, and material.
|
|
func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, txt string, textMaterial op.CallOp) layout.Dimensions {
|
|
dims, _ := l.LayoutDetailed(gtx, lt, font, size, txt, textMaterial)
|
|
return dims
|
|
}
|
|
|
|
// TextInfo provides metadata about shaped text.
|
|
type TextInfo struct {
|
|
// Truncated contains the number of runes of text that are represented by a truncator
|
|
// symbol in the text. If zero, there is no truncator symbol.
|
|
Truncated int
|
|
}
|
|
|
|
// Layout the label with the given shaper, font, size, text, and material, returning metadata about the shaped text.
|
|
func (l Label) LayoutDetailed(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, txt string, textMaterial op.CallOp) (layout.Dimensions, TextInfo) {
|
|
cs := gtx.Constraints
|
|
textSize := fixed.I(gtx.Sp(size))
|
|
lineHeight := fixed.I(gtx.Sp(l.LineHeight))
|
|
lt.LayoutString(text.Parameters{
|
|
Font: font,
|
|
PxPerEm: textSize,
|
|
MaxLines: l.MaxLines,
|
|
Truncator: l.Truncator,
|
|
Alignment: l.Alignment,
|
|
WrapPolicy: l.WrapPolicy,
|
|
MaxWidth: cs.Max.X,
|
|
MinWidth: cs.Min.X,
|
|
Locale: gtx.Locale,
|
|
LineHeight: lineHeight,
|
|
LineHeightScale: l.LineHeightScale,
|
|
}, txt)
|
|
m := op.Record(gtx.Ops)
|
|
viewport := image.Rectangle{Max: cs.Max}
|
|
it := textIterator{
|
|
viewport: viewport,
|
|
maxLines: l.MaxLines,
|
|
material: textMaterial,
|
|
}
|
|
semantic.LabelOp(txt).Add(gtx.Ops)
|
|
var glyphs [32]text.Glyph
|
|
line := glyphs[:0]
|
|
for g, ok := lt.NextGlyph(); ok; g, ok = lt.NextGlyph() {
|
|
var ok bool
|
|
if line, ok = it.paintGlyph(gtx, lt, g, line); !ok {
|
|
break
|
|
}
|
|
}
|
|
call := m.Stop()
|
|
viewport.Min = viewport.Min.Add(it.padding.Min)
|
|
viewport.Max = viewport.Max.Add(it.padding.Max)
|
|
clipStack := clip.Rect(viewport).Push(gtx.Ops)
|
|
call.Add(gtx.Ops)
|
|
dims := layout.Dimensions{Size: it.bounds.Size()}
|
|
dims.Size = cs.Constrain(dims.Size)
|
|
dims.Baseline = dims.Size.Y - it.baseline
|
|
clipStack.Pop()
|
|
return dims, TextInfo{Truncated: it.truncated}
|
|
}
|
|
|
|
func r2p(r clip.Rect) clip.Op {
|
|
return clip.Stroke{Path: r.Path(), Width: 1}.Op()
|
|
}
|
|
|
|
// textIterator computes the bounding box of and paints text.
|
|
type textIterator struct {
|
|
// viewport is the rectangle of document coordinates that the iterator is
|
|
// trying to fill with text.
|
|
viewport image.Rectangle
|
|
// maxLines is the maximum number of text lines that should be displayed.
|
|
maxLines int
|
|
// material sets the paint material for the text glyphs. If none is provided
|
|
// the color of the glyphs is undefined and may change unpredictably if the
|
|
// text contains color glyphs.
|
|
material op.CallOp
|
|
// truncated tracks the count of truncated runes in the text.
|
|
truncated int
|
|
// linesSeen tracks the quantity of line endings this iterator has seen.
|
|
linesSeen int
|
|
// lineOff tracks the origin for the glyphs in the current line.
|
|
lineOff f32.Point
|
|
// padding is the space needed outside of the bounds of the text to ensure no
|
|
// part of a glyph is clipped.
|
|
padding image.Rectangle
|
|
// bounds is the logical bounding box of the text.
|
|
bounds image.Rectangle
|
|
// visible tracks whether the most recently iterated glyph is visible within
|
|
// the viewport.
|
|
visible bool
|
|
// first tracks whether the iterator has processed a glyph yet.
|
|
first bool
|
|
// baseline tracks the location of the first line of text's baseline.
|
|
baseline int
|
|
}
|
|
|
|
// processGlyph checks whether the glyph is visible within the iterator's configured
|
|
// viewport and (if so) updates the iterator's text dimensions to include the glyph.
|
|
func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visibleOrBefore bool) {
|
|
if it.maxLines > 0 {
|
|
if g.Flags&text.FlagTruncator != 0 && g.Flags&text.FlagClusterBreak != 0 {
|
|
// A glyph carrying both of these flags provides the count of truncated runes.
|
|
it.truncated = g.Runes
|
|
}
|
|
if g.Flags&text.FlagLineBreak != 0 {
|
|
it.linesSeen++
|
|
}
|
|
if it.linesSeen == it.maxLines && g.Flags&text.FlagParagraphBreak != 0 {
|
|
return g, false
|
|
}
|
|
}
|
|
// Compute the maximum extent to which glyphs overhang on the horizontal
|
|
// axis.
|
|
if d := g.Bounds.Min.X.Floor(); d < it.padding.Min.X {
|
|
// If the distance between the dot and the left edge of this glyph is
|
|
// less than the current padding, increase the left padding.
|
|
it.padding.Min.X = d
|
|
}
|
|
if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > it.padding.Max.X {
|
|
// If the distance between the dot and the right edge of this glyph
|
|
// minus the logical advance of this glyph is greater than the current
|
|
// padding, increase the right padding.
|
|
it.padding.Max.X = d
|
|
}
|
|
if d := (g.Bounds.Min.Y + g.Ascent).Floor(); d < it.padding.Min.Y {
|
|
// If the distance between the dot and the top of this glyph is greater
|
|
// than the ascent of the glyph, increase the top padding.
|
|
it.padding.Min.Y = d
|
|
}
|
|
if d := (g.Bounds.Max.Y - g.Descent).Ceil(); d > it.padding.Max.Y {
|
|
// If the distance between the dot and the bottom of this glyph is greater
|
|
// than the descent of the glyph, increase the bottom padding.
|
|
it.padding.Max.Y = d
|
|
}
|
|
logicalBounds := image.Rectangle{
|
|
Min: image.Pt(g.X.Floor(), int(g.Y)-g.Ascent.Ceil()),
|
|
Max: image.Pt((g.X + g.Advance).Ceil(), int(g.Y)+g.Descent.Ceil()),
|
|
}
|
|
if !it.first {
|
|
it.first = true
|
|
it.baseline = int(g.Y)
|
|
it.bounds = logicalBounds
|
|
}
|
|
|
|
above := logicalBounds.Max.Y < it.viewport.Min.Y
|
|
below := logicalBounds.Min.Y > it.viewport.Max.Y
|
|
left := logicalBounds.Max.X < it.viewport.Min.X
|
|
right := logicalBounds.Min.X > it.viewport.Max.X
|
|
it.visible = !above && !below && !left && !right
|
|
if it.visible {
|
|
it.bounds.Min.X = min(it.bounds.Min.X, logicalBounds.Min.X)
|
|
it.bounds.Min.Y = min(it.bounds.Min.Y, logicalBounds.Min.Y)
|
|
it.bounds.Max.X = max(it.bounds.Max.X, logicalBounds.Max.X)
|
|
it.bounds.Max.Y = max(it.bounds.Max.Y, logicalBounds.Max.Y)
|
|
}
|
|
return g, ok && !below
|
|
}
|
|
|
|
func fixedToFloat(i fixed.Int26_6) float32 {
|
|
return float32(i) / 64.0
|
|
}
|
|
|
|
// paintGlyph buffers up and paints text glyphs. It should be invoked iteratively upon each glyph
|
|
// until it returns false. The line parameter should be a slice with
|
|
// a backing array of sufficient size to buffer multiple glyphs.
|
|
// A modified slice will be returned with each invocation, and is
|
|
// expected to be passed back in on the following invocation.
|
|
// This design is awkward, but prevents the line slice from escaping
|
|
// to the heap.
|
|
func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []text.Glyph) ([]text.Glyph, bool) {
|
|
_, visibleOrBefore := it.processGlyph(glyph, true)
|
|
if it.visible {
|
|
if len(line) == 0 {
|
|
it.lineOff = f32.Point{X: fixedToFloat(glyph.X), Y: float32(glyph.Y)}.Sub(layout.FPt(it.viewport.Min))
|
|
}
|
|
line = append(line, glyph)
|
|
}
|
|
if glyph.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 || !visibleOrBefore {
|
|
t := op.Affine(f32.Affine2D{}.Offset(it.lineOff)).Push(gtx.Ops)
|
|
path := shaper.Shape(line)
|
|
outline := clip.Outline{Path: path}.Op().Push(gtx.Ops)
|
|
it.material.Add(gtx.Ops)
|
|
paint.PaintOp{}.Add(gtx.Ops)
|
|
outline.Pop()
|
|
if call := shaper.Bitmaps(line); call != (op.CallOp{}) {
|
|
call.Add(gtx.Ops)
|
|
}
|
|
t.Pop()
|
|
line = line[:0]
|
|
}
|
|
return line, visibleOrBefore
|
|
}
|