label.go raw

   1  // SPDX-License-Identifier: Unlicense OR MIT
   2  
   3  package widget
   4  
   5  import (
   6  	"fmt"
   7  	"image"
   8  	"unicode/utf8"
   9  
  10  	"github.com/p9c/p9/pkg/gel/gio/layout"
  11  	"github.com/p9c/p9/pkg/gel/gio/op"
  12  	"github.com/p9c/p9/pkg/gel/gio/op/clip"
  13  	"github.com/p9c/p9/pkg/gel/gio/op/paint"
  14  	"github.com/p9c/p9/pkg/gel/gio/text"
  15  	"github.com/p9c/p9/pkg/gel/gio/unit"
  16  
  17  	"golang.org/x/image/math/fixed"
  18  )
  19  
  20  // Label is a widget for laying out and drawing text.
  21  type Label struct {
  22  	// Alignment specify the text alignment.
  23  	Alignment text.Alignment
  24  	// MaxLines limits the number of lines. Zero means no limit.
  25  	MaxLines int
  26  }
  27  
  28  // screenPos describes a character position (in text line and column numbers,
  29  // not pixels): Y = line number, X = rune column.
  30  type screenPos image.Point
  31  
  32  type segmentIterator struct {
  33  	Lines     []text.Line
  34  	Clip      image.Rectangle
  35  	Alignment text.Alignment
  36  	Width     int
  37  	Offset    image.Point
  38  	startSel  screenPos
  39  	endSel    screenPos
  40  
  41  	pos    screenPos   // current position
  42  	line   text.Line   // current line
  43  	layout text.Layout // current line's Layout
  44  
  45  	// pixel positions
  46  	off         fixed.Point26_6
  47  	y, prevDesc fixed.Int26_6
  48  }
  49  
  50  const inf = 1e6
  51  
  52  func (l *segmentIterator) Next() (text.Layout, image.Point, bool, int, image.Point, bool) {
  53  	for l.pos.Y < len(l.Lines) {
  54  		if l.pos.X == 0 {
  55  			l.line = l.Lines[l.pos.Y]
  56  
  57  			// Calculate X & Y pixel coordinates of left edge of line. We need y
  58  			// for the next line, so it's in l, but we only need x here, so it's
  59  			// not.
  60  			x := align(l.Alignment, l.line.Width, l.Width) + fixed.I(l.Offset.X)
  61  			l.y += l.prevDesc + l.line.Ascent
  62  			l.prevDesc = l.line.Descent
  63  			// Align baseline and line start to the pixel grid.
  64  			l.off = fixed.Point26_6{X: fixed.I(x.Floor()), Y: fixed.I(l.y.Ceil())}
  65  			l.y = l.off.Y
  66  			l.off.Y += fixed.I(l.Offset.Y)
  67  			if (l.off.Y + l.line.Bounds.Min.Y).Floor() > l.Clip.Max.Y {
  68  				break
  69  			}
  70  
  71  			if (l.off.Y + l.line.Bounds.Max.Y).Ceil() < l.Clip.Min.Y {
  72  				// This line is outside/before the clip area; go on to the next line.
  73  				l.pos.Y++
  74  				continue
  75  			}
  76  
  77  			// Copy the line's Layout, since we slice it up later.
  78  			l.layout = l.line.Layout
  79  
  80  			// Find the left edge of the text visible in the l.Clip clipping
  81  			// area.
  82  			for len(l.layout.Advances) > 0 {
  83  				_, n := utf8.DecodeRuneInString(l.layout.Text)
  84  				adv := l.layout.Advances[0]
  85  				if (l.off.X + adv + l.line.Bounds.Max.X - l.line.Width).Ceil() >= l.Clip.Min.X {
  86  					break
  87  				}
  88  				l.off.X += adv
  89  				l.layout.Text = l.layout.Text[n:]
  90  				l.layout.Advances = l.layout.Advances[1:]
  91  				l.pos.X++
  92  			}
  93  		}
  94  
  95  		selected := l.inSelection()
  96  		endx := l.off.X
  97  		rune := 0
  98  		nextLine := true
  99  		retLayout := l.layout
 100  		for n := range l.layout.Text {
 101  			selChanged := selected != l.inSelection()
 102  			beyondClipEdge := (endx + l.line.Bounds.Min.X).Floor() > l.Clip.Max.X
 103  			if selChanged || beyondClipEdge {
 104  				retLayout.Advances = l.layout.Advances[:rune]
 105  				retLayout.Text = l.layout.Text[:n]
 106  				if selChanged {
 107  					// Save the rest of the line
 108  					l.layout.Advances = l.layout.Advances[rune:]
 109  					l.layout.Text = l.layout.Text[n:]
 110  					nextLine = false
 111  				}
 112  				break
 113  			}
 114  			endx += l.layout.Advances[rune]
 115  			rune++
 116  			l.pos.X++
 117  		}
 118  		offFloor := image.Point{X: l.off.X.Floor(), Y: l.off.Y.Floor()}
 119  
 120  		// Calculate the width & height if the returned text.
 121  		//
 122  		// If there's a better way to do this, I'm all ears.
 123  		var d fixed.Int26_6
 124  		for _, adv := range retLayout.Advances {
 125  			d += adv
 126  		}
 127  		size := image.Point{
 128  			X: d.Ceil(),
 129  			Y: (l.line.Ascent + l.line.Descent).Ceil(),
 130  		}
 131  
 132  		if nextLine {
 133  			l.pos.Y++
 134  			l.pos.X = 0
 135  		} else {
 136  			l.off.X = endx
 137  		}
 138  
 139  		return retLayout, offFloor, selected, l.prevDesc.Ceil() - size.Y, size, true
 140  	}
 141  	return text.Layout{}, image.Point{}, false, 0, image.Point{}, false
 142  }
 143  
 144  func (l *segmentIterator) inSelection() bool {
 145  	return l.startSel.LessOrEqual(l.pos) &&
 146  		l.pos.Less(l.endSel)
 147  }
 148  
 149  func (p1 screenPos) LessOrEqual(p2 screenPos) bool {
 150  	return p1.Y < p2.Y || (p1.Y == p2.Y && p1.X <= p2.X)
 151  }
 152  
 153  func (p1 screenPos) Less(p2 screenPos) bool {
 154  	return p1.Y < p2.Y || (p1.Y == p2.Y && p1.X < p2.X)
 155  }
 156  
 157  func (l Label) Layout(gtx layout.Context, s text.Shaper, font text.Font, size unit.Value, txt string) layout.Dimensions {
 158  	cs := gtx.Constraints
 159  	textSize := fixed.I(gtx.Px(size))
 160  	lines := s.LayoutString(font, textSize, cs.Max.X, txt)
 161  	if max := l.MaxLines; max > 0 && len(lines) > max {
 162  		lines = lines[:max]
 163  	}
 164  	dims := linesDimens(lines)
 165  	dims.Size = cs.Constrain(dims.Size)
 166  	cl := textPadding(lines)
 167  	cl.Max = cl.Max.Add(dims.Size)
 168  	it := segmentIterator{
 169  		Lines:     lines,
 170  		Clip:      cl,
 171  		Alignment: l.Alignment,
 172  		Width:     dims.Size.X,
 173  	}
 174  	for {
 175  		l, off, _, _, _, ok := it.Next()
 176  		if !ok {
 177  			break
 178  		}
 179  		stack := op.Save(gtx.Ops)
 180  		op.Offset(layout.FPt(off)).Add(gtx.Ops)
 181  		s.Shape(font, textSize, l).Add(gtx.Ops)
 182  		clip.Rect(cl.Sub(off)).Add(gtx.Ops)
 183  		paint.PaintOp{}.Add(gtx.Ops)
 184  		stack.Load()
 185  	}
 186  	return dims
 187  }
 188  
 189  func textPadding(lines []text.Line) (padding image.Rectangle) {
 190  	if len(lines) == 0 {
 191  		return
 192  	}
 193  	first := lines[0]
 194  	if d := first.Ascent + first.Bounds.Min.Y; d < 0 {
 195  		padding.Min.Y = d.Ceil()
 196  	}
 197  	last := lines[len(lines)-1]
 198  	if d := last.Bounds.Max.Y - last.Descent; d > 0 {
 199  		padding.Max.Y = d.Ceil()
 200  	}
 201  	if d := first.Bounds.Min.X; d < 0 {
 202  		padding.Min.X = d.Ceil()
 203  	}
 204  	if d := first.Bounds.Max.X - first.Width; d > 0 {
 205  		padding.Max.X = d.Ceil()
 206  	}
 207  	return
 208  }
 209  
 210  func linesDimens(lines []text.Line) layout.Dimensions {
 211  	var width fixed.Int26_6
 212  	var h int
 213  	var baseline int
 214  	if len(lines) > 0 {
 215  		baseline = lines[0].Ascent.Ceil()
 216  		var prevDesc fixed.Int26_6
 217  		for _, l := range lines {
 218  			h += (prevDesc + l.Ascent).Ceil()
 219  			prevDesc = l.Descent
 220  			if l.Width > width {
 221  				width = l.Width
 222  			}
 223  		}
 224  		h += lines[len(lines)-1].Descent.Ceil()
 225  	}
 226  	w := width.Ceil()
 227  	return layout.Dimensions{
 228  		Size: image.Point{
 229  			X: w,
 230  			Y: h,
 231  		},
 232  		Baseline: h - baseline,
 233  	}
 234  }
 235  
 236  func align(align text.Alignment, width fixed.Int26_6, maxWidth int) fixed.Int26_6 {
 237  	mw := fixed.I(maxWidth)
 238  	switch align {
 239  	case text.Middle:
 240  		return fixed.I(((mw - width) / 2).Floor())
 241  	case text.End:
 242  		return fixed.I((mw - width).Floor())
 243  	case text.Start:
 244  		return 0
 245  	default:
 246  		panic(fmt.Errorf("unknown alignment %v", align))
 247  	}
 248  }
 249