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 }