github.com/Seikaijyu/gio@v0.0.1/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/Seikaijyu/gio/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 // region is identical to Region except that its coordinates are in document 351 // space instead of a widget coordinate space. 352 type region = Region 353 354 // locate returns highlight regions covering the glyphs that represent the runes in 355 // [startRune,endRune). If the rects parameter is non-nil, locate will use it to 356 // return results instead of allocating, provided that there is enough capacity. 357 // The returned regions have their Bounds specified relative to the provided 358 // viewport. 359 func (g *glyphIndex) locate(viewport image.Rectangle, startRune, endRune int, rects []Region) []Region { 360 if startRune > endRune { 361 startRune, endRune = endRune, startRune 362 } 363 rects = rects[:0] 364 caretStart, _ := g.closestToRune(startRune) 365 caretEnd, _ := g.closestToRune(endRune) 366 367 for lineIdx := caretStart.lineCol.line; lineIdx < len(g.lines); lineIdx++ { 368 if lineIdx > caretEnd.lineCol.line { 369 break 370 } 371 pos := g.closestToLineCol(screenPos{line: lineIdx}) 372 if int(pos.y)+pos.descent.Ceil() < viewport.Min.Y { 373 continue 374 } 375 if int(pos.y)-pos.ascent.Ceil() > viewport.Max.Y { 376 break 377 } 378 line := g.lines[lineIdx] 379 if lineIdx > caretStart.lineCol.line && lineIdx < caretEnd.lineCol.line { 380 startX := line.xOff 381 endX := startX + line.width 382 // The entire line is selected. 383 rects = append(rects, makeRegion(line, pos.y, startX, endX)) 384 continue 385 } 386 selectionStart := caretStart 387 selectionEnd := caretEnd 388 if lineIdx != caretStart.lineCol.line { 389 // This line does not contain the beginning of the selection. 390 selectionStart = g.closestToLineCol(screenPos{line: lineIdx}) 391 } 392 if lineIdx != caretEnd.lineCol.line { 393 // This line does not contain the end of the selection. 394 selectionEnd = g.closestToLineCol(screenPos{line: lineIdx, col: math.MaxInt}) 395 } 396 397 var ( 398 startX, endX fixed.Int26_6 399 eof bool 400 ) 401 lineLoop: 402 for !eof { 403 startX = selectionStart.x 404 if selectionStart.runIndex == selectionEnd.runIndex { 405 // Commit selection. 406 endX = selectionEnd.x 407 rects = append(rects, makeRegion(line, pos.y, startX, endX)) 408 break 409 } else { 410 currentDirection := selectionStart.towardOrigin 411 previous := selectionStart 412 runLoop: 413 for !eof { 414 // Increment the start position until the next logical run. 415 for startRun := selectionStart.runIndex; selectionStart.runIndex == startRun; { 416 previous = selectionStart 417 selectionStart, eof = g.incrementPosition(selectionStart) 418 if eof { 419 endX = selectionStart.x 420 rects = append(rects, makeRegion(line, pos.y, startX, endX)) 421 break runLoop 422 } 423 } 424 if selectionStart.towardOrigin != currentDirection { 425 endX = previous.x 426 rects = append(rects, makeRegion(line, pos.y, startX, endX)) 427 break 428 } 429 if selectionStart.runIndex == selectionEnd.runIndex { 430 // Commit selection. 431 endX = selectionEnd.x 432 rects = append(rects, makeRegion(line, pos.y, startX, endX)) 433 break lineLoop 434 } 435 } 436 } 437 } 438 } 439 for i := range rects { 440 rects[i].Bounds = rects[i].Bounds.Sub(viewport.Min) 441 } 442 return rects 443 } 444 445 // graphemeReader segments paragraphs of text into grapheme clusters. 446 type graphemeReader struct { 447 segmenter.Segmenter 448 graphemes []int 449 paragraph []rune 450 source io.ReaderAt 451 cursor int64 452 reader *bufio.Reader 453 runeOffset int 454 } 455 456 // SetSource configures the reader to pull from source. 457 func (p *graphemeReader) SetSource(source io.ReaderAt) { 458 p.source = source 459 p.cursor = 0 460 p.reader = bufio.NewReader(p) 461 p.runeOffset = 0 462 } 463 464 // Read exists to satisfy io.Reader. It should not be directly invoked. 465 func (p *graphemeReader) Read(b []byte) (int, error) { 466 n, err := p.source.ReadAt(b, p.cursor) 467 p.cursor += int64(n) 468 return n, err 469 } 470 471 // next decodes one paragraph of rune data. 472 func (p *graphemeReader) next() ([]rune, bool) { 473 p.paragraph = p.paragraph[:0] 474 var err error 475 var r rune 476 for err == nil { 477 r, _, err = p.reader.ReadRune() 478 if err != nil { 479 break 480 } 481 p.paragraph = append(p.paragraph, r) 482 if r == '\n' { 483 break 484 } 485 } 486 return p.paragraph, err == nil 487 } 488 489 // Graphemes will return the next paragraph's grapheme cluster boundaries, 490 // if any. If it returns an empty slice, there is no more data (all paragraphs 491 // have been segmented). 492 func (p *graphemeReader) Graphemes() []int { 493 var more bool 494 p.graphemes = p.graphemes[:0] 495 p.paragraph, more = p.next() 496 if len(p.paragraph) == 0 && !more { 497 return nil 498 } 499 p.Segmenter.Init(p.paragraph) 500 iter := p.Segmenter.GraphemeIterator() 501 if iter.Next() { 502 graph := iter.Grapheme() 503 p.graphemes = append(p.graphemes, 504 p.runeOffset+graph.Offset, 505 p.runeOffset+graph.Offset+len(graph.Text), 506 ) 507 } 508 for iter.Next() { 509 graph := iter.Grapheme() 510 p.graphemes = append(p.graphemes, p.runeOffset+graph.Offset+len(graph.Text)) 511 } 512 p.runeOffset += len(p.paragraph) 513 return p.graphemes 514 }