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