github.com/utopiagio/gio@v0.0.8/widget/index.go (about)

     1  // SPDX-License-Identifier: Unlicense OR MIT
     2  
     3  package widget
     4  
     5  import (
     6  	"bufio"
     7  	"image"
     8  	"io"
     9  	"math"
    10  	"sort"
    11  
    12  	"github.com/utopiagio/gio/text"
    13  	"github.com/go-text/typesetting/segmenter"
    14  
    15  	"golang.org/x/image/math/fixed"
    16  )
    17  
    18  type lineInfo struct {
    19  	xOff            fixed.Int26_6
    20  	yOff            int
    21  	width           fixed.Int26_6
    22  	ascent, descent fixed.Int26_6
    23  	glyphs          int
    24  }
    25  
    26  type glyphIndex struct {
    27  	// glyphs holds the glyphs processed.
    28  	glyphs []text.Glyph
    29  	// positions contain all possible caret positions, sorted by rune index.
    30  	positions []combinedPos
    31  	// lines contains metadata about the size and position of each line of
    32  	// text.
    33  	lines []lineInfo
    34  
    35  	// currentLineMin and currentLineMax track the dimensions of the line
    36  	// that is being indexed.
    37  	currentLineMin, currentLineMax fixed.Int26_6
    38  	// currentLineGlyphs tracks how many glyphs are contained within the
    39  	// line that is being indexed.
    40  	currentLineGlyphs int
    41  	// pos tracks attributes of the next valid cursor position within the indexed
    42  	// text.
    43  	pos combinedPos
    44  	// prog tracks the current glyph text progression to detect bidi changes.
    45  	prog text.Flags
    46  	// clusterAdvance accumulates the advances of glyphs in a glyph cluster.
    47  	clusterAdvance fixed.Int26_6
    48  	// truncated indicates that the text was truncated by the shaper.
    49  	truncated bool
    50  	// midCluster tracks whether the next glyph processed is not the first glyph in a
    51  	// cluster.
    52  	midCluster bool
    53  }
    54  
    55  // reset prepares the index for reuse.
    56  func (g *glyphIndex) reset() {
    57  	g.glyphs = g.glyphs[:0]
    58  	g.positions = g.positions[:0]
    59  	g.lines = g.lines[:0]
    60  	g.currentLineMin = 0
    61  	g.currentLineMax = 0
    62  	g.currentLineGlyphs = 0
    63  	g.pos = combinedPos{}
    64  	g.prog = 0
    65  	g.clusterAdvance = 0
    66  	g.truncated = false
    67  	g.midCluster = false
    68  }
    69  
    70  // screenPos represents a character position in text line and column numbers,
    71  // not pixels.
    72  type screenPos struct {
    73  	// col is the column, measured in runes.
    74  	// FIXME: we only ever use col for start or end of lines.
    75  	// We don't need accurate accounting, so can we get rid of it?
    76  	col  int
    77  	line int
    78  }
    79  
    80  // combinedPos is a point in the editor.
    81  type combinedPos struct {
    82  	// runes is the offset in runes.
    83  	runes int
    84  
    85  	lineCol screenPos
    86  
    87  	// Pixel coordinates
    88  	x fixed.Int26_6
    89  	y int
    90  
    91  	ascent, descent fixed.Int26_6
    92  
    93  	// runIndex tracks which run this position is within, counted each time
    94  	// the index processes an end of run marker.
    95  	runIndex int
    96  	// towardOrigin tracks whether this glyph's run is progressing toward the
    97  	// origin or away from it.
    98  	towardOrigin bool
    99  }
   100  
   101  // incrementPosition returns the next position after pos (if any). Pos _must_ be
   102  // an unmodified position acquired from one of the closest* methods. If eof is
   103  // true, there was no next position.
   104  func (g *glyphIndex) incrementPosition(pos combinedPos) (next combinedPos, eof bool) {
   105  	candidate, index := g.closestToRune(pos.runes)
   106  	for candidate != pos && index+1 < len(g.positions) {
   107  		index++
   108  		candidate = g.positions[index]
   109  	}
   110  	if index+1 < len(g.positions) {
   111  		return g.positions[index+1], false
   112  	}
   113  	return candidate, true
   114  }
   115  
   116  func (g *glyphIndex) insertPosition(pos combinedPos) {
   117  	lastIdx := len(g.positions) - 1
   118  	if lastIdx >= 0 {
   119  		lastPos := g.positions[lastIdx]
   120  		if lastPos.runes == pos.runes && (lastPos.y != pos.y || (lastPos.x == pos.x)) {
   121  			// If we insert a consecutive position with the same logical position,
   122  			// overwrite the previous position with the new one.
   123  			g.positions[lastIdx] = pos
   124  			return
   125  		}
   126  	}
   127  	g.positions = append(g.positions, pos)
   128  }
   129  
   130  // Glyph indexes the provided glyph, generating text cursor positions for it.
   131  func (g *glyphIndex) Glyph(gl text.Glyph) {
   132  	g.glyphs = append(g.glyphs, gl)
   133  	g.currentLineGlyphs++
   134  	if len(g.positions) == 0 {
   135  		// First-iteration setup.
   136  		g.currentLineMin = math.MaxInt32
   137  		g.currentLineMax = 0
   138  	}
   139  	if gl.X < g.currentLineMin {
   140  		g.currentLineMin = gl.X
   141  	}
   142  	if end := gl.X + gl.Advance; end > g.currentLineMax {
   143  		g.currentLineMax = end
   144  	}
   145  
   146  	needsNewLine := gl.Flags&text.FlagLineBreak != 0
   147  	needsNewRun := gl.Flags&text.FlagRunBreak != 0
   148  	breaksParagraph := gl.Flags&text.FlagParagraphBreak != 0
   149  	breaksCluster := gl.Flags&text.FlagClusterBreak != 0
   150  	// We should insert new positions if the glyph we're processing terminates
   151  	// a glyph cluster, has nonzero runes, and is not a hard newline.
   152  	insertPositionsWithin := breaksCluster && !breaksParagraph && gl.Runes > 0
   153  
   154  	// Get the text progression/direction right.
   155  	g.prog = gl.Flags & text.FlagTowardOrigin
   156  	g.pos.towardOrigin = g.prog == text.FlagTowardOrigin
   157  	if !g.midCluster {
   158  		// Create the text position prior to the glyph.
   159  		g.pos.x = gl.X
   160  		g.pos.y = int(gl.Y)
   161  		g.pos.ascent = gl.Ascent
   162  		g.pos.descent = gl.Descent
   163  		if g.pos.towardOrigin {
   164  			g.pos.x += gl.Advance
   165  		}
   166  		g.insertPosition(g.pos)
   167  	}
   168  
   169  	g.midCluster = !breaksCluster
   170  
   171  	if breaksParagraph {
   172  		// Paragraph breaking clusters shouldn't have positions generated for both
   173  		// sides of them. They're always zero-width, so doing so would
   174  		// create two visually identical cursor positions. Just reset
   175  		// cluster state, increment by their runes, and move on to the
   176  		// next glyph.
   177  		g.clusterAdvance = 0
   178  		g.pos.runes += int(gl.Runes)
   179  	}
   180  	// Always track the cumulative advance added by the glyph, even if it
   181  	// doesn't terminate a cluster itself.
   182  	g.clusterAdvance += gl.Advance
   183  	if insertPositionsWithin {
   184  		// Construct the text positions _within_ gl.
   185  		g.pos.y = int(gl.Y)
   186  		g.pos.ascent = gl.Ascent
   187  		g.pos.descent = gl.Descent
   188  		width := g.clusterAdvance
   189  		positionCount := int(gl.Runes)
   190  		runesPerPosition := 1
   191  		if gl.Flags&text.FlagTruncator != 0 {
   192  			// Treat the truncator as a single unit that is either selected or not.
   193  			positionCount = 1
   194  			runesPerPosition = int(gl.Runes)
   195  			g.truncated = true
   196  		}
   197  		perRune := width / fixed.Int26_6(positionCount)
   198  		adjust := fixed.Int26_6(0)
   199  		if g.pos.towardOrigin {
   200  			// If RTL, subtract increments from the width of the cluster
   201  			// instead of adding.
   202  			adjust = width
   203  			perRune = -perRune
   204  		}
   205  		for i := 1; i <= positionCount; i++ {
   206  			g.pos.x = gl.X + adjust + perRune*fixed.Int26_6(i)
   207  			g.pos.runes += runesPerPosition
   208  			g.pos.lineCol.col += runesPerPosition
   209  			g.insertPosition(g.pos)
   210  		}
   211  		g.clusterAdvance = 0
   212  	}
   213  	if needsNewRun {
   214  		g.pos.runIndex++
   215  	}
   216  	if needsNewLine {
   217  		g.lines = append(g.lines, lineInfo{
   218  			xOff:    g.currentLineMin,
   219  			yOff:    int(gl.Y),
   220  			width:   g.currentLineMax - g.currentLineMin,
   221  			ascent:  g.positions[len(g.positions)-1].ascent,
   222  			descent: g.positions[len(g.positions)-1].descent,
   223  			glyphs:  g.currentLineGlyphs,
   224  		})
   225  		g.pos.lineCol.line++
   226  		g.pos.lineCol.col = 0
   227  		g.pos.runIndex = 0
   228  		g.currentLineMin = math.MaxInt32
   229  		g.currentLineMax = 0
   230  		g.currentLineGlyphs = 0
   231  	}
   232  }
   233  
   234  func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) {
   235  	if len(g.positions) == 0 {
   236  		return combinedPos{}, 0
   237  	}
   238  	i := sort.Search(len(g.positions), func(i int) bool {
   239  		pos := g.positions[i]
   240  		return pos.runes >= runeIdx
   241  	})
   242  	if i > 0 {
   243  		i--
   244  	}
   245  	closest := g.positions[i]
   246  	closestI := i
   247  	for ; i < len(g.positions); i++ {
   248  		if g.positions[i].runes == runeIdx {
   249  			return g.positions[i], i
   250  		}
   251  	}
   252  	return closest, closestI
   253  }
   254  
   255  func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos {
   256  	if len(g.positions) == 0 {
   257  		return combinedPos{}
   258  	}
   259  	i := sort.Search(len(g.positions), func(i int) bool {
   260  		pos := g.positions[i]
   261  		return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col)
   262  	})
   263  	if i > 0 {
   264  		i--
   265  	}
   266  	prior := g.positions[i]
   267  	if i+1 >= len(g.positions) {
   268  		return prior
   269  	}
   270  	next := g.positions[i+1]
   271  	if next.lineCol != lineCol {
   272  		return prior
   273  	}
   274  	return next
   275  }
   276  
   277  func dist(a, b fixed.Int26_6) fixed.Int26_6 {
   278  	if a > b {
   279  		return a - b
   280  	}
   281  	return b - a
   282  }
   283  
   284  func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos {
   285  	if len(g.positions) == 0 {
   286  		return combinedPos{}
   287  	}
   288  	i := sort.Search(len(g.positions), func(i int) bool {
   289  		pos := g.positions[i]
   290  		return pos.y+pos.descent.Round() >= y
   291  	})
   292  	// If no position was greater than the provided Y, the text is too
   293  	// short. Return either the last position or (if there are no
   294  	// positions) the zero position.
   295  	if i == len(g.positions) {
   296  		return g.positions[i-1]
   297  	}
   298  	first := g.positions[i]
   299  	// Find the best X coordinate.
   300  	closest := i
   301  	closestDist := dist(first.x, x)
   302  	line := first.lineCol.line
   303  	// NOTE(whereswaldon): there isn't a simple way to accelerate this. Bidi text means that the x coordinates
   304  	// for positions have no fixed relationship. In the future, we can consider sorting the positions
   305  	// on a line by their x coordinate and caching that. It'll be a one-time O(nlogn) per line, but
   306  	// subsequent uses of this function for that line become O(logn). Right now it's always O(n).
   307  	for i := i + 1; i < len(g.positions) && g.positions[i].lineCol.line == line; i++ {
   308  		candidate := g.positions[i]
   309  		distance := dist(candidate.x, x)
   310  		// If we are *really* close to the current position candidate, just choose it.
   311  		if distance.Round() == 0 {
   312  			return g.positions[i]
   313  		}
   314  		if distance < closestDist {
   315  			closestDist = distance
   316  			closest = i
   317  		}
   318  	}
   319  	return g.positions[closest]
   320  }
   321  
   322  // makeRegion creates a text-aligned rectangle from start to end. The vertical
   323  // dimensions of the rectangle are derived from the provided line's ascent and
   324  // descent, and the y offset of the line's baseline is provided as y.
   325  func makeRegion(line lineInfo, y int, start, end fixed.Int26_6) Region {
   326  	if start > end {
   327  		start, end = end, start
   328  	}
   329  	dotStart := image.Pt(start.Round(), y)
   330  	dotEnd := image.Pt(end.Round(), y)
   331  	return Region{
   332  		Bounds: image.Rectangle{
   333  			Min: dotStart.Sub(image.Point{Y: line.ascent.Ceil()}),
   334  			Max: dotEnd.Add(image.Point{Y: line.descent.Floor()}),
   335  		},
   336  		Baseline: line.descent.Floor(),
   337  	}
   338  }
   339  
   340  // Region describes the position and baseline of an area of interest within
   341  // shaped text.
   342  type Region struct {
   343  	// Bounds is the coordinates of the bounding box relative to the containing
   344  	// widget.
   345  	Bounds image.Rectangle
   346  	// Baseline is the quantity of vertical pixels between the baseline and
   347  	// the bottom of bounds.
   348  	Baseline int
   349  }
   350  
   351  // locate returns highlight regions covering the glyphs that represent the runes in
   352  // [startRune,endRune). If the rects parameter is non-nil, locate will use it to
   353  // return results instead of allocating, provided that there is enough capacity.
   354  // The returned regions have their Bounds specified relative to the provided
   355  // viewport.
   356  func (g *glyphIndex) locate(viewport image.Rectangle, startRune, endRune int, rects []Region) []Region {
   357  	if startRune > endRune {
   358  		startRune, endRune = endRune, startRune
   359  	}
   360  	rects = rects[:0]
   361  	caretStart, _ := g.closestToRune(startRune)
   362  	caretEnd, _ := g.closestToRune(endRune)
   363  
   364  	for lineIdx := caretStart.lineCol.line; lineIdx < len(g.lines); lineIdx++ {
   365  		if lineIdx > caretEnd.lineCol.line {
   366  			break
   367  		}
   368  		pos := g.closestToLineCol(screenPos{line: lineIdx})
   369  		if int(pos.y)+pos.descent.Ceil() < viewport.Min.Y {
   370  			continue
   371  		}
   372  		if int(pos.y)-pos.ascent.Ceil() > viewport.Max.Y {
   373  			break
   374  		}
   375  		line := g.lines[lineIdx]
   376  		if lineIdx > caretStart.lineCol.line && lineIdx < caretEnd.lineCol.line {
   377  			startX := line.xOff
   378  			endX := startX + line.width
   379  			// The entire line is selected.
   380  			rects = append(rects, makeRegion(line, pos.y, startX, endX))
   381  			continue
   382  		}
   383  		selectionStart := caretStart
   384  		selectionEnd := caretEnd
   385  		if lineIdx != caretStart.lineCol.line {
   386  			// This line does not contain the beginning of the selection.
   387  			selectionStart = g.closestToLineCol(screenPos{line: lineIdx})
   388  		}
   389  		if lineIdx != caretEnd.lineCol.line {
   390  			// This line does not contain the end of the selection.
   391  			selectionEnd = g.closestToLineCol(screenPos{line: lineIdx, col: math.MaxInt})
   392  		}
   393  
   394  		var (
   395  			startX, endX fixed.Int26_6
   396  			eof          bool
   397  		)
   398  	lineLoop:
   399  		for !eof {
   400  			startX = selectionStart.x
   401  			if selectionStart.runIndex == selectionEnd.runIndex {
   402  				// Commit selection.
   403  				endX = selectionEnd.x
   404  				rects = append(rects, makeRegion(line, pos.y, startX, endX))
   405  				break
   406  			} else {
   407  				currentDirection := selectionStart.towardOrigin
   408  				previous := selectionStart
   409  			runLoop:
   410  				for !eof {
   411  					// Increment the start position until the next logical run.
   412  					for startRun := selectionStart.runIndex; selectionStart.runIndex == startRun; {
   413  						previous = selectionStart
   414  						selectionStart, eof = g.incrementPosition(selectionStart)
   415  						if eof {
   416  							endX = selectionStart.x
   417  							rects = append(rects, makeRegion(line, pos.y, startX, endX))
   418  							break runLoop
   419  						}
   420  					}
   421  					if selectionStart.towardOrigin != currentDirection {
   422  						endX = previous.x
   423  						rects = append(rects, makeRegion(line, pos.y, startX, endX))
   424  						break
   425  					}
   426  					if selectionStart.runIndex == selectionEnd.runIndex {
   427  						// Commit selection.
   428  						endX = selectionEnd.x
   429  						rects = append(rects, makeRegion(line, pos.y, startX, endX))
   430  						break lineLoop
   431  					}
   432  				}
   433  			}
   434  		}
   435  	}
   436  	for i := range rects {
   437  		rects[i].Bounds = rects[i].Bounds.Sub(viewport.Min)
   438  	}
   439  	return rects
   440  }
   441  
   442  // graphemeReader segments paragraphs of text into grapheme clusters.
   443  type graphemeReader struct {
   444  	segmenter.Segmenter
   445  	graphemes  []int
   446  	paragraph  []rune
   447  	source     io.ReaderAt
   448  	cursor     int64
   449  	reader     *bufio.Reader
   450  	runeOffset int
   451  }
   452  
   453  // SetSource configures the reader to pull from source.
   454  func (p *graphemeReader) SetSource(source io.ReaderAt) {
   455  	p.source = source
   456  	p.cursor = 0
   457  	p.reader = bufio.NewReader(p)
   458  	p.runeOffset = 0
   459  }
   460  
   461  // Read exists to satisfy io.Reader. It should not be directly invoked.
   462  func (p *graphemeReader) Read(b []byte) (int, error) {
   463  	n, err := p.source.ReadAt(b, p.cursor)
   464  	p.cursor += int64(n)
   465  	return n, err
   466  }
   467  
   468  // next decodes one paragraph of rune data.
   469  func (p *graphemeReader) next() ([]rune, bool) {
   470  	p.paragraph = p.paragraph[:0]
   471  	var err error
   472  	var r rune
   473  	for err == nil {
   474  		r, _, err = p.reader.ReadRune()
   475  		if err != nil {
   476  			break
   477  		}
   478  		p.paragraph = append(p.paragraph, r)
   479  		if r == '\n' {
   480  			break
   481  		}
   482  	}
   483  	return p.paragraph, err == nil
   484  }
   485  
   486  // Graphemes will return the next paragraph's grapheme cluster boundaries,
   487  // if any. If it returns an empty slice, there is no more data (all paragraphs
   488  // have been segmented).
   489  func (p *graphemeReader) Graphemes() []int {
   490  	var more bool
   491  	p.graphemes = p.graphemes[:0]
   492  	p.paragraph, more = p.next()
   493  	if len(p.paragraph) == 0 && !more {
   494  		return nil
   495  	}
   496  	p.Segmenter.Init(p.paragraph)
   497  	iter := p.Segmenter.GraphemeIterator()
   498  	if iter.Next() {
   499  		graph := iter.Grapheme()
   500  		p.graphemes = append(p.graphemes,
   501  			p.runeOffset+graph.Offset,
   502  			p.runeOffset+graph.Offset+len(graph.Text),
   503  		)
   504  	}
   505  	for iter.Next() {
   506  		graph := iter.Grapheme()
   507  		p.graphemes = append(p.graphemes, p.runeOffset+graph.Offset+len(graph.Text))
   508  	}
   509  	p.runeOffset += len(p.paragraph)
   510  	return p.graphemes
   511  }