1 /++ 2 A homemade text layout and editing engine, designed for the needs of minigui's custom widgets to be good enough for me to use. May or may not work for you. 3 4 5 You use it by creating a [TextLayouter] and populating it with some data. Then you connect it to a user interface which calls [TextLayouter.getDrawableText] to know what and where to display the content and manipulates the content through the [Selection] object. Your text has styles applied to it through a [TextStyle] interface, which is deliberately minimal for the layouter - you are expected to cast it back to your implementation as-needed to get your other data out. 6 7 See the docs on each of those objects for more details. 8 9 Bugs: 10 BiDi and right-to-left text in general is not yet implemented. I'm pretty sure I can do it, but I need unicode tables that aren't available to arsd yet. 11 12 Doesn't do text kerning since the other implementations I've looked at on-screen don't do it either so it seems unnecessary. I might revisit this. 13 14 Also doesn't handle shaped text, which breaks click point detection on Windows for certain script families. 15 16 The edit implementation is a simple string. It performs surprisingly well, but I'll probably go back to it and change to a gap buffer later. 17 18 Relaying out and saving state is only partially incremental at this time. 19 20 The main interfaces are written with eventually fixing these in mind, but I might have to extend the [MeasurableFont] and [TextStyle] interfaces, and it might need some helper objects injected too. So possible they will be small breaking changes to support these, but I'm pretty sure it won't require any major rewrites of the code nor of user code when these are added, just adding methods to interfaces. 21 22 History: 23 Written in December 2022. Released in arsd 11.0. 24 +/ 25 module arsd.textlayouter; 26 27 // FIXME: elastic tabstops https://nick-gravgaard.com/elastic-tabstops/ 28 /+ 29 Each cell ends with a tab character. A column block is a run of uninterrupted vertically adjacent cells. A column block is as wide as the widest piece of text in the cells it contains or a minimum width (plus padding). Text outside column blocks is ignored. 30 +/ 31 // opening tabs work as indentation just like they do now, but wrt the algorithm are just considered one unit. 32 // then groups of lines with more tabs than the opening ones are processed together but only if they all right next to each other 33 34 // FIXME: soft word wrap w/ indentation preserved 35 // FIXME: line number stuff? 36 37 // want to support PS (new paragraph), LS (forced line break), FF (next page) 38 // and GS = <table> RS = <tr> US = <td> FS = </table> maybe. 39 // use \a bell for bookmarks in the text? 40 41 // note: ctrl+c == ascii 3 and ctrl+d == ascii 4 == end of text 42 43 44 // FIXME: maybe i need another overlay of block style not just text style. list, alignment, heading, paragraph spacing, etc. should it nest? 45 46 // FIXME: copy/paste preserving style. 47 48 49 // see: https://harfbuzz.github.io/a-simple-shaping-example.html 50 51 // FIXME: unicode private use area could be delegated out but it might also be used by something else. 52 // just really want an encoding scheme for replaced elements that punt it outside.. 53 54 import arsd.simpledisplay; 55 56 /+ 57 FIXME: caret style might need to be separate from anything drawn. 58 FIXME: when adding things, inform new sizes for scrollbar updates in real time 59 FIXME: scroll when selecting and dragging oob. generally capture on mouse down and release on mouse up. 60 FIXME: page up, page down. 61 62 FIXME: there is a spot right between some glyphs when changing fonts where it selected none. 63 64 65 Need to know style at insertion point (which is the one before the caret codepoint unless it is at start of line, in which case it is the one at it) 66 67 68 The style interface might actually want like toHtml and toRtf. at least on the minigui side, not strictly necessary here. 69 +/ 70 71 72 /+ 73 subclass w/ style 74 lazy layout queuing 75 76 style info could possibly be a linked list but it prolly don't have to be anything too special 77 78 track changes 79 +/ 80 81 /+ 82 Word wrap needs to maintain indentation in some cases 83 84 The left and right margins of exclusion area 85 86 Exclusion are in the center? 87 88 line-spacing 89 90 if you click on the gap above a bounding box of a segment it doesn't find that segement despite being in the same line. need to check not just by segment bounding box but by line bounding box. 91 92 FIXME: in sdpy, font is not reset upon returning from a child painter 93 FIXME: in minigui the scrollbars can steal focus from the thing the are controlling 94 FIXME: scw needs a per-button-click scroll amount since 1 may not be sufficient every time (tho 1 should be a possibility somehow) 95 +/ 96 97 /+ 98 REPLACED CONTENT 99 100 magic char followed by a dchar 101 the dchar represents the replaced content array index 102 replaced content needs to tell the layouter: ascent, descent, width. 103 all replaced content gets its own special segment. 104 replaced content must be registered and const? or at the very least not modify things the layouter cares about. but better if nothing changes for undo sake. 105 106 it has a style but it only cares about the alignment from it. 107 +/ 108 109 /+ 110 HTML 111 generally take all the text nodes and make them have unique text style instances 112 the text style can then refer back to the dom for click handling, css forwarding etc. 113 114 but html has blocks... 115 116 BLOCK ELEMENTS 117 118 margin+padding behavior 119 bounding box of nested things for background images and borders 120 121 an inline-block gets this stuff but does not go on its own line. 122 123 INLINE TABLES 124 +/ 125 126 // FIXME: add center, left, right, justify and valign top, bottom, middle, baseline 127 // valign top = ascent = 0 of line. bottom = descent = bottom of line. middle = ascent+descent/2 = middle of line. baseline = matched baselines 128 129 // draw underline and strike through line segments - the offets may be in the font and underline might not want to slice the bottom fo p etc 130 // drawble textm ight give the offsets into the slice after all, and/or give non-trabable character things 131 132 133 // You can do the caret by any time it gets drawn, you set the flag that it is on, then you can xor it to turn it off and keep track of that at top level. 134 135 136 // FIXME: might want to be able to swap out all styles at once and trigger whole relayout, as if a document theme changed wholesale, without changing the saved style handles 137 // FIXME: line and paragrpah numbering options while drawing 138 /++ 139 Represents the style of a span of text. 140 141 You should never mutate one of these, instead construct a new one. 142 143 Please note that methods may be added to this interface without being a full breaking change. 144 +/ 145 interface TextStyle { 146 /++ 147 Must never return `null`. 148 +/ 149 MeasurableFont font(); 150 151 /++ 152 History: 153 Added February 24, 2025 154 +/ 155 //ParagraphMetrics paragraphMetrics(); 156 157 // FIXME: list styles? 158 // FIXME: table styles? 159 160 /// ditto 161 static struct ParagraphMetrics { 162 /++ 163 Extra spacing between each line, given in physical pixels. 164 +/ 165 int lineSpacing; 166 /++ 167 Spacing between each paragraph, given in physical pixels. 168 +/ 169 int paragraphSpacing; 170 /++ 171 Extra indentation on the first line of each paragraph, given in physical pixels. 172 +/ 173 int paragraphIndentation; 174 175 // margin left and right? 176 177 /++ 178 Note that TextAlignment.Left might be redefined to mean "Forward", meaning left if left-to-right, right if right-to-left, 179 but right now it ignores bidi anyway. 180 +/ 181 TextAlignment alignment = TextAlignment.Left; 182 } 183 184 // FIXME: I might also want a duplicate function for saving state. 185 186 // verticalAlign? 187 188 // i should keep a refcount here, then i can do a COW if i wanted to. 189 190 // you might use different style things to represent different html elements or something too for click responses. 191 192 /++ 193 You can mix this in to your implementation class to get default implementations of new methods I add. 194 195 You will almost certainly want to override the things anyway, but this can help you keep things compiling. 196 197 Please note that there is no default for font. 198 +/ 199 static mixin template Defaults() { 200 /++ 201 The default returns a [TerminalFontRepresentation]. This is almost certainly NOT what you want, 202 so implement your own `font()` member anyway. 203 +/ 204 MeasurableFont font() { 205 return TerminalFontRepresentation.instance; 206 } 207 208 /++ 209 The default returns reasonable values, you might want to call this to get the defaults, 210 then change some values and return the rest. 211 +/ 212 ParagraphMetrics paragraphMetrics() { 213 return ParagraphMetrics.init; 214 } 215 } 216 } 217 218 /++ 219 This is a demo implementation of [MeasurableFont]. The expectation is more often that you'd use a [arsd.simpledisplay.OperatingSystemFont], which also implements this interface, but if you wanted to do your own thing this basic demo might help. 220 +/ 221 class TerminalFontRepresentation : MeasurableFont { 222 static TerminalFontRepresentation instance() { 223 static TerminalFontRepresentation i; 224 if(i is null) 225 i = new TerminalFontRepresentation(); 226 return i; 227 } 228 229 bool isMonospace() { return true; } 230 int averageWidth() { return 1; } 231 int height() { return 1; } 232 /// since it is a grid this is a bit bizarre to translate. 233 int ascent() { return 1; } 234 int descent() { return 0; } 235 236 int stringWidth(scope const(char)[] s, SimpleWindow window = null) { 237 int count; 238 foreach(dchar ch; s) 239 count++; 240 return count; 241 } 242 } 243 244 /++ 245 A selection has four pieces: 246 247 1) A position 248 2) An anchor 249 3) A focus 250 4) A user coordinate 251 252 The user coordinate should only ever be changed in direct response to actual user action and indicates 253 where they ideally want the focus to be. 254 255 If they move in the horizontal direction, the x user coordinate should change. The y should not, even if the actual focus moved around (e.g. moving to a previous line while left arrowing). 256 257 If they move in a vertical direction, the y user coordinate should change. The x should not even if the actual focus moved around (e.g. going to the end of a shorter line while up arrowing). 258 259 The position, anchor, and focus are stored in opaque units. The user coordinate is EITHER grid coordinates (line, glyph) or screen coordinates (pixels). 260 261 Most methods on the selection move the position. This is not visible to the user, it is just an internal marker. 262 263 setAnchor() sets the anchor to the current position. 264 setFocus() sets the focus to the current position. 265 266 The anchor is the part of the selection that doesn't move as you drag. The focus is the part of the selection that holds the caret and would move as you dragged around. (Open a program like Notepad and click and drag around. Your first click set the anchor, then as you drag, the focus moves around. The selection is everything between the anchor and the focus.) 267 268 The selection, while being fairly opaque, lets you do a great many things. Consider, for example, vim's 5dd command - delete five lines from the current position. You can do this by taking a selection, going to the beginning of the current line. Then dropping anchor. Then go down five lines and go to end of line. Then extend through the EOL character. Now delete the selection. Finally, restore the anchor and focus from the user coordinate, so their cursor on screen remains in the same approximate position. 269 270 The code can look something like this: 271 272 --- 273 selection 274 .moveHome 275 .setAnchor 276 .moveDown(5) 277 .moveEnd 278 .moveForward(&isEol) 279 .setFocus 280 .deleteContent 281 .moveToUserCoordinate 282 .setAnchor; 283 --- 284 285 If you can think about how you'd do it on the standard keyboard, you can do it with this api. Everything between a setAnchor and setFocus would be like holding shift while doing the other things. 286 287 void selectBetween(Selection other); 288 289 Please note that this is just a handle to another object. Treat it as a reference type. 290 +/ 291 public struct Selection { 292 /++ 293 You cannot construct these yourself. Instead, use [TextLayouter.selection] to get it. 294 +/ 295 @disable this(); 296 private this(TextLayouter layouter, int selectionId) { 297 this.layouter = layouter; 298 this.selectionId = selectionId; 299 } 300 private TextLayouter layouter; 301 private int selectionId; 302 303 private ref SelectionImpl impl() { 304 return layouter._selections[selectionId]; 305 } 306 307 /+ Inspection +/ 308 309 /++ 310 Returns `true` if the selection is currently empty. An empty selection still has a position - where the cursor is drawn - but has no text inside it. 311 312 See_Also: 313 [getContent], [getContentString] 314 +/ 315 bool isEmpty() { 316 return impl.focus == impl.anchor; 317 } 318 319 /++ 320 Function to get the content of the selection. It is fed to you as a series of zero or more chunks of text and style information. 321 322 Please note that some text blocks may be empty, indicating only style has changed. 323 324 See_Also: 325 [getContentString], [isEmpty] 326 +/ 327 void getContent(scope void delegate(scope const(char)[] text, TextStyle style) dg) { 328 dg(layouter.text[impl.start .. impl.end], null); // FIXME: style 329 } 330 331 /++ 332 Convenience function to get the content of the selection as a simple string. 333 334 See_Also: 335 [getContent], [isEmpty] 336 +/ 337 string getContentString() { 338 string s; 339 getContent((txt, style) { 340 s ~= txt; 341 }); 342 return s; 343 } 344 345 // need this so you can scroll found text into view and similar 346 Rectangle focusBoundingBox() { 347 return layouter.boundingBoxOfGlyph(layouter.findContainingSegment(impl.focus), impl.focus); 348 } 349 350 /+ Setting the explicit positions to the current internal position +/ 351 352 /++ 353 These functions set the actual selection from the current internal position. 354 355 A selection has two major pieces, the anchor and the focus, and a third bookkeeping coordinate, called the user coordinate. 356 357 It is best to think about these by thinking about the user interface. When you click and drag in a text document, the point where 358 you clicked is the anchor position. As you drag, it moves the focus position. The selection is all the text between the anchor and 359 focus. The cursor (also known as the caret) is drawn at the focus point. 360 361 Meanwhile, the user coordinate is the point where the user last explicitly moved the focus. Try clicking near the end of a long line, 362 then moving up past a short line, to another long line. Your cursor should remain near the column of the original click, even though 363 the focus moved left while passing through the short line. The user coordinate is how this is achieved - explicit user action on the 364 horizontal axis (like pressing the left or right arrows) sets the X coordinate with [setUserXCoordinate], and explicit user action on the vertical axis sets the Y coordinate (like the up or down arrows) with [setUserYCoordinate], leaving X alone even if the focus moved horizontally due to a shorter or longer line. They're only moved together if the user action worked on both axes together (like a mouse click) with the [setUserCoordinate] function. Note that `setUserCoordinate` remembers the column even if there is no glyph there, making it ideal for mouse interaction, whereas the `setUserXCoordinate` and `setUserYCoordinate` set it to the position of the glyph on the focus, making them more suitable for keyboard interaction. 365 366 Before you set one of these values, you move the internal position with the `move` family of functions ([moveTo], [moveLeft], etc.). 367 368 Setting the anchor also always sets the focus. 369 370 For example, to select the whole document: 371 372 --- 373 with(selection) { 374 moveToStartOfDocument(); // changes internal position without affecting the actual selection 375 setAnchor(); // set the anchor, actually changing the selection. 376 // Note that setting the anchor also always sets the focus, so the selection is empty at this time. 377 moveToEndOfDocument(); // move the internal position to the end 378 setFocus(); // and now set the focus, which extends the selection from the anchor, meaning the whole document is selected now 379 } 380 --- 381 382 I didn't set the user coordinate there since the user's action didn't specify a row or column. 383 +/ 384 Selection setAnchor() { 385 impl.anchor = impl.position; 386 impl.focus = impl.position; 387 // layouter.notifySelectionChanged(); 388 return this; 389 } 390 391 /// ditto 392 Selection setFocus() { 393 impl.focus = impl.position; 394 // layouter.notifySelectionChanged(); 395 return this; 396 } 397 398 /// ditto 399 Selection setUserCoordinate(Point p) { 400 impl.virtualFocusPosition = p; 401 return this; 402 } 403 404 /// ditto 405 Selection setUserXCoordinate() { 406 impl.virtualFocusPosition.x = layouter.boundingBoxOfGlyph(layouter.findContainingSegment(impl.position), impl.position).left; 407 return this; 408 } 409 410 /// ditto 411 Selection setUserYCoordinate() { 412 impl.virtualFocusPosition.y = layouter.boundingBoxOfGlyph(layouter.findContainingSegment(impl.position), impl.position).top; 413 return this; 414 } 415 416 /++ 417 Gets the current user coordinate, the point where they explicitly want the caret to be near. 418 419 History: 420 Added January 24, 2025 421 +/ 422 Point getUserCoordinate() { 423 return impl.virtualFocusPosition; 424 } 425 426 /+ Moving the internal position +/ 427 428 /++ 429 430 +/ 431 Selection moveTo(Point p, bool setUserCoordinate = true) { 432 impl.position = layouter.offsetOfClick(p); 433 if(setUserCoordinate) 434 impl.virtualFocusPosition = p; 435 return this; 436 } 437 438 /++ 439 440 +/ 441 Selection moveToStartOfDocument() { 442 impl.position = 0; 443 return this; 444 } 445 446 /// ditto 447 Selection moveToEndOfDocument() { 448 impl.position = cast(int) layouter.text.length - 1; // never include the 0 terminator 449 return this; 450 } 451 452 /++ 453 454 +/ 455 Selection moveToStartOfLine(bool byRender = true, bool includeLeadingWhitespace = true) { 456 // FIXME: chekc for word wrap by checking segment.displayLineNumber 457 // FIXME: includeLeadingWhitespace 458 while(impl.position > 0 && layouter.text[impl.position - 1] != '\n') 459 impl.position--; 460 461 return this; 462 } 463 464 /// ditto 465 Selection moveToEndOfLine(bool byRender = true) { 466 // FIXME: chekc for word wrap by checking segment.displayLineNumber 467 while(impl.position + 1 < layouter.text.length && layouter.text[impl.position] != '\n') // never include the 0 terminator 468 impl.position++; 469 return this; 470 } 471 472 /++ 473 If the position is abutting an end of line marker, it moves past it, to include it. 474 If not, it does nothing. 475 476 The intention is so you can delete a whole line by doing: 477 478 --- 479 with(selection) { 480 moveToStartOfLine(); 481 setAnchor(); 482 // this moves to the end of the visible line, but if you stopped here, you'd be left with an empty line 483 moveToEndOfLine(); 484 // this moves past the line marker, meaning you don't just delete the line's content, it deletes the entire line 485 moveToIncludeAdjacentEndOfLineMarker(); 486 setFocus(); 487 replaceContent(""); 488 } 489 --- 490 +/ 491 Selection moveToIncludeAdjacentEndOfLineMarker() { 492 // FIXME: i need to decide what i want to do about \r too. Prolly should remove it at the boundaries. 493 if(impl.position + 1 < layouter.text.length && layouter.text[impl.position] == '\n') { // never include the 0 terminator 494 impl.position++; 495 } 496 return this; 497 } 498 499 // note there's move up / down / left / right 500 // in addition to move forward / backward glyph/line 501 // the directions always match what's on screen. 502 // the others always match the logical order in the string. 503 /++ 504 505 +/ 506 Selection moveUp(int count = 1, bool byRender = true) { 507 verticalMoveImpl(-1, count, byRender); 508 return this; 509 } 510 511 /// ditto 512 Selection moveDown(int count = 1, bool byRender = true) { 513 verticalMoveImpl(1, count, byRender); 514 return this; 515 } 516 517 /// ditto 518 Selection moveLeft(int count = 1, bool byRender = true) { 519 horizontalMoveImpl(-1, count, byRender); 520 return this; 521 } 522 523 /// ditto 524 Selection moveRight(int count = 1, bool byRender = true) { 525 horizontalMoveImpl(1, count, byRender); 526 return this; 527 } 528 529 /+ 530 enum PlacementOfFind { 531 beginningOfHit, 532 endOfHit 533 } 534 535 enum IfNotFound { 536 changeNothing, 537 moveToEnd, 538 callDelegate 539 } 540 541 enum CaseSensitive { 542 yes, 543 no 544 } 545 546 void find(scope const(char)[] text, PlacementOfFind placeAt = PlacementOfFind.beginningOfHit, IfNotFound ifNotFound = IfNotFound.changeNothing) { 547 } 548 +/ 549 550 /++ 551 Does a custom search through the text. 552 553 Params: 554 predicate = a search filter. It passes you back a slice of your buffer filled with text at the current search position. You pass the slice of this buffer that matched your search, or `null` if there was no match here. You MUST return either null or a slice of the buffer that was passed to you. If you return an empty slice of of the buffer (buffer[0..0] for example), it cancels the search. 555 556 The window buffer will try to move one code unit at a time. It may straddle code point boundaries - you need to account for this in your predicate. 557 558 windowBuffer = a buffer to temporarily hold text for comparison. You should size this for the text you're trying to find 559 560 searchBackward = determines the direction of the search. If true, it searches from the start of current selection backward to the beginning of the document. If false, it searches from the end of current selection forward to the end of the document. 561 Returns: 562 an object representing the search results and letting you manipulate the selection based upon it 563 564 +/ 565 FindResult find( 566 scope const(char)[] delegate(scope return const(char)[] buffer) predicate, 567 int windowBufferSize, 568 bool searchBackward, 569 ) { 570 assert(windowBufferSize != 0, "you must pass a buffer of some size"); 571 572 char[] windowBuffer = new char[](windowBufferSize); // FIXME i don't need to actually copy in the current impl 573 574 int currentSpot = impl.position; 575 576 const finalSpot = searchBackward ? currentSpot : cast(int) layouter.text.length; 577 578 if(searchBackward) { 579 currentSpot -= windowBuffer.length; 580 if(currentSpot < 0) 581 currentSpot = 0; 582 } 583 584 auto endingSpot = currentSpot + windowBuffer.length; 585 if(endingSpot > finalSpot) 586 endingSpot = finalSpot; 587 588 keep_searching: 589 windowBuffer[0 .. endingSpot - currentSpot] = layouter.text[currentSpot .. endingSpot]; 590 auto result = predicate(windowBuffer[0 .. endingSpot - currentSpot]); 591 if(result !is null) { 592 // we're done, it was found 593 auto offsetStart = result is null ? currentSpot : cast(int) (result.ptr - windowBuffer.ptr); 594 assert(offsetStart >= 0 && offsetStart < windowBuffer.length); 595 return FindResult(this, currentSpot + offsetStart, result !is null, currentSpot + cast(int) (offsetStart + result.length)); 596 } else if((searchBackward && currentSpot > 0) || (!searchBackward && endingSpot < finalSpot)) { 597 // not found, keep searching 598 if(searchBackward) { 599 currentSpot--; 600 endingSpot--; 601 } else { 602 currentSpot++; 603 endingSpot++; 604 } 605 goto keep_searching; 606 } else { 607 // not found, at end of search 608 return FindResult(this, currentSpot, false, currentSpot /* zero length result */); 609 } 610 611 assert(0); 612 } 613 614 /// ditto 615 static struct FindResult { 616 private Selection selection; 617 private int position; 618 private bool found; 619 private int endPosition; 620 621 /// 622 bool wasFound() { 623 return found; 624 } 625 626 /// 627 Selection moveTo() { 628 selection.impl.position = position; 629 return selection; 630 } 631 632 /// 633 Selection moveToEnd() { 634 selection.impl.position = endPosition; 635 return selection; 636 } 637 638 /// 639 void selectHit() { 640 selection.impl.position = position; 641 selection.setAnchor(); 642 selection.impl.position = endPosition; 643 selection.setFocus(); 644 } 645 } 646 647 648 649 /+ 650 /+ + 651 Searches by regex. 652 653 This is a template because the regex engine can be a heavy dependency, so it is only 654 included if you need it. The RegEx object is expected to match the Phobos std.regex.RegEx 655 api, so while you could, in theory, replace it, it is probably easier to just use the Phobos one. 656 +/ 657 void find(RegEx)(RegEx re) { 658 659 } 660 +/ 661 662 /+ Manipulating the data in the selection +/ 663 664 /++ 665 Replaces the content of the selection. If you replace it with an empty `newText`, it will delete the content. 666 667 If newText == "\b", it will delete the selection if it is non-empty, and otherwise delete the thing before the cursor. 668 669 If you want to do normal editor backspace key, you might want to check `if(!selection.isEmpty()) selection.moveLeft();` 670 before calling `selection.deleteContent()`. Similarly, for the delete key, you might use `moveRight` instead, since this 671 function will do nothing for an empty selection by itself. 672 673 FIXME: what if i want to replace it with some multiply styled text? Could probably call it in sequence actually. 674 +/ 675 Selection replaceContent(scope const(char)[] newText, TextLayouter.StyleHandle style = TextLayouter.StyleHandle.init) { 676 layouter.wasMutated_ = true; 677 678 if(style == TextLayouter.StyleHandle.init) 679 style = layouter.getInsertionStyleAt(impl.focus); 680 681 int removeBegin, removeEnd; 682 if(this.isEmpty()) { 683 if(newText.length == 1 && newText[0] == '\b') { 684 auto place = impl.focus; 685 if(place > 0) { 686 int amount = 1; 687 while((layouter.text[place - amount] & 0b11000000) == 0b10000000) // all non-start bytes of a utf-8 sequence have this convenient property 688 amount++; // assumes this will never go over the edge cuz of it being valid utf 8 internally 689 690 removeBegin = place - amount; 691 removeEnd = place; 692 693 if(removeBegin < 0) 694 removeBegin = 0; 695 if(removeEnd < 0) 696 removeEnd = 0; 697 } 698 699 newText = null; 700 } else { 701 removeBegin = impl.terminus; 702 removeEnd = impl.terminus; 703 } 704 } else { 705 removeBegin = impl.start; 706 removeEnd = impl.end; 707 if(newText.length == 1 && newText[0] == '\b') { 708 newText = null; 709 } 710 } 711 712 auto place = impl.terminus; 713 714 auto changeInLength = cast(int) newText.length - (removeEnd - removeBegin); 715 716 // FIXME: the horror 717 auto trash = layouter.text[0 .. removeBegin]; 718 trash ~= newText; 719 trash ~= layouter.text[removeEnd .. $]; 720 layouter.text = trash; 721 722 impl.position = removeBegin + cast(int) newText.length; 723 this.setAnchor(); 724 725 /+ 726 For styles: 727 if one part resides in the deleted zone, it should be truncated to the edge of the deleted zone 728 if they are entirely in the deleted zone - their new length is zero - they should simply be deleted 729 if they are entirely before the deleted zone, it can stay the same 730 if they are entirely after the deleted zone, they should get += changeInLength 731 732 FIXME: if the deleted zone lies entirely inside one of the styles, that style's length should be extended to include the new text if it has no style, or otherwise split into a few style blocks 733 734 However, the algorithm for default style in the new zone is a bit different: if at index 0 or right after a \n, it uses the next style. otherwise it uses the previous one. 735 +/ 736 737 //writeln(removeBegin, " ", removeEnd); 738 //foreach(st; layouter.styles) writeln("B: ", st.offset, "..", st.offset + st.length, " ", st.styleInformationIndex); 739 740 // first I'm going to update all of them so it is in a consistent state 741 foreach(ref st; layouter.styles) { 742 auto begin = st.offset; 743 auto end = st.offset + st.length; 744 745 void adjust(ref int what) { 746 if(what < removeBegin) { 747 // no change needed 748 } else if(what >= removeBegin && what < removeEnd) { 749 what = removeBegin; 750 } else if(what) { 751 what += changeInLength; 752 } 753 } 754 755 adjust(begin); 756 adjust(end); 757 758 assert(end >= begin); // empty styles are not permitted by the implementation 759 st.offset = begin; 760 st.length = end - begin; 761 } 762 763 // then go back and inject the new style, if needed 764 if(changeInLength > 0) { 765 changeStyle(removeBegin, removeBegin + cast(int) newText.length, style); 766 } 767 768 removeEmptyStyles(); 769 770 // or do i want to use init to just keep using the same style as is already there? 771 // FIXME 772 //if(style !is StyleHandle.init) { 773 // styles ~= StyleBlock(cast(int) before.length, cast(int) changeInLength, style.index); 774 //} 775 776 777 auto endInvalidate = removeBegin + newText.length; 778 if(removeEnd > endInvalidate) 779 endInvalidate = removeEnd; 780 layouter.invalidateLayout(removeBegin, endInvalidate, changeInLength); 781 782 // there's a new style from removeBegin to removeBegin + newText.length 783 784 // FIXME other selections in the zone need to be adjusted too 785 // if they are in the deleted zone, it should be moved to the end of the new zone (removeBegin + newText.length) 786 // if they are before the deleted zone, they can stay the same 787 // if they are after the deleted zone, they should be adjusted by changeInLength 788 foreach(idx, ref selection; layouter._selections[0 .. layouter.selectionsInUse]) { 789 790 // don't adjust ourselves here, we already did it above 791 // and besides don't want mutation in here 792 if(idx == selectionId) 793 continue; 794 795 void adjust(ref int what) { 796 if(what < removeBegin) { 797 // no change needed 798 } else if(what >= removeBegin && what < removeEnd) { 799 what = removeBegin; 800 } else if(what) { 801 what += changeInLength; 802 } 803 } 804 805 adjust(selection.anchor); 806 adjust(selection.terminus); 807 } 808 // you might need to set the user coordinate after this! 809 810 return this; 811 } 812 813 private void removeEmptyStyles() { 814 /+ the code doesn't like empty style blocks, so gonna go back and remove those +/ 815 for(int i = 0; i < cast(int) layouter.styles.length; i++) { 816 if(layouter.styles[i].length == 0) { 817 for(auto i2 = i; i2 + 1 < layouter.styles.length; i2++) 818 layouter.styles[i2] = layouter.styles[i2 + 1]; 819 layouter.styles = layouter.styles[0 .. $-1]; 820 layouter.styles.assumeSafeAppend(); 821 i--; 822 } 823 } 824 } 825 826 /++ 827 Changes the style of the given selection. Gives existing styles in the selection to your delegate 828 and you return a new style to assign to that block. 829 +/ 830 public void changeStyle(TextLayouter.StyleHandle delegate(TextStyle existing) newStyle) { 831 // FIXME there might be different sub-styles so we should actually look them up and send each one 832 auto ns = newStyle(null); 833 changeStyle(impl.start, impl.end, ns); 834 removeEmptyStyles(); 835 836 layouter.invalidateLayout(impl.start, impl.end, 0); 837 } 838 839 /+ Impl helpers +/ 840 841 private void changeStyle(int newStyleBegin, int newStyleEnd, TextLayouter.StyleHandle style) { 842 // FIXME: binary search 843 for(size_t i = 0; i < layouter.styles.length; i++) { 844 auto s = &layouter.styles[i]; 845 const oldStyleBegin = s.offset; 846 const oldStyleEnd = s.offset + s.length; 847 848 if(newStyleBegin >= oldStyleBegin && newStyleBegin < oldStyleEnd) { 849 // the cases: 850 851 // it is an exact match in size, we can simply overwrite it 852 if(newStyleBegin == oldStyleBegin && newStyleEnd == oldStyleEnd) { 853 s.styleInformationIndex = style.index; 854 break; // all done 855 } 856 // we're the same as the existing style, so it is just a matter of extending it to include us 857 else if(s.styleInformationIndex == style.index) { 858 if(newStyleEnd > oldStyleEnd) { 859 s.length = newStyleEnd - oldStyleBegin; 860 861 // then need to fix up all the subsequent blocks, adding the offset, reducing the length 862 int remainingFixes = newStyleEnd - oldStyleEnd; 863 foreach(st; layouter.styles[i + 1 .. $]) { 864 auto thisFixup = remainingFixes; 865 if(st.length < thisFixup) 866 thisFixup = st.length; 867 // this can result in 0 length, the loop after this will delete that. 868 st.offset += thisFixup; 869 st.length -= thisFixup; 870 871 remainingFixes -= thisFixup; 872 873 assert(remainingFixes >= 0); 874 875 if(remainingFixes == 0) 876 break; 877 } 878 } 879 // otherwise it is all already there and nothing need be done at all 880 break; 881 } 882 // for the rest of the cases, the style does not match and is not a size match, 883 // so a new block is going to have to be inserted 884 // /////////// 885 // we're entirely contained inside, so keep the left, insert ourselves, and re-create right. 886 else if(newStyleEnd > oldStyleBegin && newStyleEnd < oldStyleEnd) { 887 // keep the old style on the left... 888 s.length = newStyleBegin - oldStyleBegin; 889 890 auto toInsert1 = TextLayouter.StyleBlock(newStyleBegin, newStyleEnd - newStyleBegin, style.index); 891 auto toInsert2 = TextLayouter.StyleBlock(newStyleEnd, oldStyleEnd - newStyleEnd, s.styleInformationIndex); 892 893 layouter.styles = layouter.styles[0 .. i + 1] ~ toInsert1 ~ toInsert2 ~ layouter.styles[i + 1 .. $]; 894 895 // writeln(*s); writeln(toInsert1); writeln(toInsert2); 896 897 break; // no need to continue processing as the other offsets are unaffected 898 } 899 // we need to keep the left end of the original thing, but then insert ourselves on afterward 900 else if(newStyleBegin >= oldStyleBegin) { 901 // want to just shorten the original thing, then adjust the values 902 // so next time through the loop can work on that existing block 903 904 s.length = newStyleBegin - oldStyleBegin; 905 906 // extend the following style to start here, so there's no gap in the next loop iteration 907 if(i + i < layouter.styles.length) { 908 auto originalOffset = layouter.styles[i+1].offset; 909 assert(originalOffset >= newStyleBegin); 910 layouter.styles[i+1].offset = newStyleBegin; 911 layouter.styles[i+1].length += originalOffset - newStyleBegin; 912 913 // i will NOT change the style info index yet, since the next iteration will do that 914 continue; 915 } else { 916 // at the end of the loop we can just append the new thing and break out of here 917 layouter.styles ~= TextLayouter.StyleBlock(newStyleBegin, newStyleEnd - newStyleBegin, style.index); 918 break; 919 } 920 } 921 else { 922 // this should be impossible as i think i covered all the cases above 923 // as we iterate through 924 // writeln(oldStyleBegin, "..", oldStyleEnd, " -- ", newStyleBegin, "..", newStyleEnd); 925 assert(0); 926 } 927 } 928 } 929 930 // foreach(st; layouter.styles) writeln("A: ", st.offset, "..", st.offset + st.length, " ", st.styleInformationIndex); 931 } 932 933 // returns the edge of the new cursor position 934 private void horizontalMoveImpl(int direction, int count, bool byRender) { 935 assert(direction != 0); 936 937 auto place = impl.focus + direction; 938 939 foreach(c; 0 .. count) { 940 while(place >= 0 && place < layouter.text.length && (layouter.text[place] & 0b11000000) == 0b10000000) // all non-start bytes of a utf-8 sequence have this convenient property 941 place += direction; 942 } 943 944 // FIXME if(byRender), if we're on a rtl line, swap the things. but if it is mixed it won't even do anything and stay in logical order 945 946 if(place < 0) 947 place = 0; 948 if(place >= layouter.text.length) 949 place = cast(int) layouter.text.length - 1; 950 951 impl.position = place; 952 953 } 954 955 // returns the baseline of the new cursor 956 private void verticalMoveImpl(int direction, int count, bool byRender) { 957 assert(direction != 0); 958 // this needs to find the closest glyph on the virtual x on the previous (rendered) line 959 960 int segmentIndex = layouter.findContainingSegment(impl.terminus); 961 962 // we know this is going to lead to a different segment since 963 // the layout breaks up that way, so we can just go straight backward 964 965 auto segment = layouter.segments[segmentIndex]; 966 967 auto idealX = impl.virtualFocusPosition.x; 968 969 auto targetLineNumber = segment.displayLineNumber + (direction * count); 970 if(targetLineNumber < 0) 971 targetLineNumber = 0; 972 973 // FIXME if(!byRender) 974 975 976 // FIXME: when you are going down, a line that begins with tab doesn't highlight the right char. 977 978 int bestHit = -1; 979 int bestHitDistance = int.max; 980 981 // writeln(targetLineNumber, " ", segmentIndex, " ", layouter.segments.length); 982 983 segmentLoop: while(segmentIndex >= 0 && segmentIndex < layouter.segments.length) { 984 segment = layouter.segments[segmentIndex]; 985 if(segment.displayLineNumber == targetLineNumber) { 986 // we're in the right line... but not necessarily the right segment 987 // writeln("line found"); 988 if(idealX >= segment.boundingBox.left && idealX < segment.boundingBox.right) { 989 // need to find the exact thing in here 990 991 auto hit = segment.textBeginOffset; 992 auto ul = segment.upperLeft; 993 994 bool found; 995 auto txt = layouter.text[segment.textBeginOffset .. segment.textEndOffset]; 996 auto codepoint = 0; 997 foreach(idx, dchar d; txt) { 998 auto width = layouter.segmentsWidths[segmentIndex][codepoint]; 999 1000 hit = segment.textBeginOffset + cast(int) idx; 1001 1002 auto distanceToLeft = ul.x - idealX; 1003 if(distanceToLeft < 0) distanceToLeft = -distanceToLeft; 1004 if(distanceToLeft < bestHitDistance) { 1005 bestHit = hit; 1006 bestHitDistance = distanceToLeft; 1007 } else { 1008 // getting further away = no help 1009 break; 1010 } 1011 1012 /* 1013 // FIXME: I probably want something slightly different 1014 if(ul.x >= idealX) { 1015 found = true; 1016 break; 1017 } 1018 */ 1019 1020 ul.x += width; 1021 codepoint++; 1022 } 1023 1024 /* 1025 if(!found) 1026 hit = segment.textEndOffset - 1; 1027 1028 impl.position = hit; 1029 bestHit = -1; 1030 */ 1031 1032 impl.position = bestHit; 1033 bestHit = -1; 1034 1035 // selections[selectionId].virtualFocusPosition = Point(selections[selectionId].virtualFocusPosition.x, segment.boundingBox.bottom); 1036 1037 break segmentLoop; 1038 } else { 1039 // FIXME: assuming ltr here 1040 auto distance = idealX - segment.boundingBox.right; 1041 if(distance < 0) 1042 distance = -distance; 1043 if(bestHit == -1 || distance < bestHitDistance) { 1044 bestHit = segment.textEndOffset - 1; 1045 bestHitDistance = distance; 1046 } 1047 } 1048 } else if(bestHit != -1) { 1049 impl.position = bestHit; 1050 bestHit = -1; 1051 break segmentLoop; 1052 } 1053 1054 segmentIndex += direction; 1055 } 1056 1057 if(bestHit != -1) 1058 impl.position = bestHit; 1059 1060 if(impl.position == layouter.text.length) 1061 impl.position -- ; // never select the eof marker 1062 } 1063 } 1064 1065 unittest { 1066 auto l = new TextLayouter(new class TextStyle { 1067 mixin Defaults; 1068 }); 1069 1070 l.appendText("this is a test string again"); 1071 auto s = l.selection(); 1072 auto result = s.find(b => (b == "a") ? b : null, 1, false); 1073 assert(result.wasFound); 1074 assert(result.position == 8); 1075 assert(result.endPosition == 9); 1076 result.selectHit(); 1077 assert(s.getContentString() == "a"); 1078 result.moveToEnd(); 1079 result = s.find(b => (b == "a") ? b : null, 1, false); // should find next 1080 assert(result.wasFound); 1081 assert(result.position == 22); 1082 assert(result.endPosition == 23); 1083 } 1084 1085 private struct SelectionImpl { 1086 // you want multiple selections at most points 1087 int id; 1088 int anchor; 1089 int terminus; 1090 1091 int position; 1092 1093 alias focus = terminus; 1094 1095 /+ 1096 As you move through lines of different lengths, your actual x will change, 1097 but the user will want to stay in the same relative spot, consider passing: 1098 1099 long thing 1100 short 1101 long thing 1102 1103 from the 'i'. When you go down, you'd be back by the t, but go down again, you should 1104 go back to the i. This variable helps achieve this. 1105 +/ 1106 Point virtualFocusPosition; 1107 1108 int start() { 1109 return anchor <= terminus ? anchor : terminus; 1110 } 1111 int end() { 1112 return anchor <= terminus ? terminus : anchor; 1113 } 1114 bool empty() { 1115 return anchor == terminus; 1116 } 1117 bool containsOffset(int textOffset) { 1118 return textOffset >= start && textOffset < end; 1119 } 1120 bool isIncludedInRange(int textStart, int textEnd) { 1121 // if either end are in there, we're obviously in the range 1122 if((start >= textStart && start < textEnd) || (end >= textStart && end < textEnd)) 1123 return true; 1124 // or if the selection is entirely inside the given range... 1125 if(start >= textStart && end < textEnd) 1126 return true; 1127 // or if the given range is at all inside the selection 1128 if((textStart >= start && textStart < end) || (textEnd >= start && textEnd < end)) 1129 return true; 1130 return false; 1131 } 1132 } 1133 1134 /++ 1135 Bugs: 1136 Only tested on Latin scripts at this time. Other things should be possible but might need work. Let me know if you need it and I'll see what I can do. 1137 +/ 1138 class TextLayouter { 1139 1140 1141 // actually running this invariant gives quadratic performance in the layouter (cuz of isWordwrapPoint lol) 1142 // so gonna only version it in for special occasions 1143 version(none) 1144 invariant() { 1145 // There is one and exactly one segment for every char in the string. 1146 // The segments are stored in sequence from index 0 to the end of the string. 1147 // styleInformationIndex is always in bounds of the styles array. 1148 // There is one and exactly one style block for every char in the string. 1149 // Style blocks are stored in sequence from index 0 to the end of the string. 1150 1151 assert(text.length > 0 && text[$-1] == 0); 1152 assert(styles.length >= 1); 1153 int last = 0; 1154 foreach(style; styles) { 1155 assert(style.offset == last); // all styles must be in order and contiguous 1156 assert(style.length > 0); // and non-empty 1157 assert(style.styleInformationIndex != -1); // and not default constructed (this should be resolved before adding) 1158 assert(style.styleInformationIndex >= 0 && style.styleInformationIndex < stylePalette.length); // all must be in-bounds 1159 last = style.offset + style.length; 1160 } 1161 assert(last == text.length); // and all chars in the array must be covered by a style block 1162 } 1163 1164 /+ 1165 private void notifySelectionChanged() { 1166 if(onSelectionChanged !is null) 1167 onSelectionChanged(this); 1168 } 1169 1170 /++ 1171 A delegate called when the current selection is changed through api or user action. 1172 1173 History: 1174 Added July 10, 2024 1175 +/ 1176 void delegate(TextLayouter l) onSelectionChanged; 1177 +/ 1178 1179 /++ 1180 Gets the object representing the given selection. 1181 1182 Normally, id = 0 is the user's selection, then id's 60, 61, 62, and 63 are private to the application. 1183 +/ 1184 Selection selection(int id = 0) { 1185 assert(id >= 0 && id < _selections.length); 1186 return Selection(this, id); 1187 } 1188 1189 /++ 1190 The rendered size of the text. 1191 +/ 1192 public int width() { 1193 relayoutIfNecessary(); 1194 return _width; 1195 } 1196 1197 /// ditto 1198 public int height() { 1199 relayoutIfNecessary(); 1200 return _height; 1201 } 1202 1203 static struct State { 1204 // for the delta compression, the text is the main field to worry about 1205 // and what it really needs to know is just based on what, then what is added and what is removed. 1206 // i think everything else i'd just copy in (or reference the same array) anyway since they're so 1207 // much smaller anyway. 1208 // 1209 // and if the text is small might as well just copy/reference it too tbh. 1210 private { 1211 char[] text; 1212 TextStyle[] stylePalette; 1213 StyleBlock[] styles; 1214 SelectionImpl[] selections; 1215 } 1216 } 1217 1218 // for manual undo stuff 1219 // and all state should be able to do do it incrementally too; each modification to those should be compared. 1220 /++ 1221 The editor's internal state can be saved and restored as an opaque blob. You might use this to make undo checkpoints and similar. 1222 1223 Its implementation may use delta compression from a previous saved state, it will try to do this transparently for you to save memory. 1224 +/ 1225 const(State)* saveState() { 1226 return new State(text.dup, stylePalette.dup, styles.dup, _selections.dup); 1227 } 1228 /// ditto 1229 void restoreState(const(State)* state) { 1230 auto changeInLength = cast(int) this.text.length - cast(int) state.text.length; 1231 this.text = state.text.dup; 1232 // FIXME: bad cast 1233 this.stylePalette = (cast(TextStyle[]) state.stylePalette).dup; 1234 this.styles = state.styles.dup; 1235 this._selections = state.selections.dup; 1236 1237 invalidateLayout(0, text.length, changeInLength); 1238 } 1239 1240 // FIXME: I might want to make the original line number exposed somewhere too like in the segment draw information 1241 1242 // FIXME: all the actual content - styles, text, and selection stuff - needs to be able to communicate its changes 1243 // incrementally for the network use case. the segments tho not that important. 1244 1245 // FIXME: for the password thing all the glyph positions need to be known to this system, so it can't just draw it 1246 // that way (unless it knows it is using a monospace font... but we can trick it by giving it a fake font that gives all those metrics) 1247 // so actually that is the magic lol 1248 1249 private static struct StyleBlock { 1250 int offset; 1251 int length; 1252 1253 int styleInformationIndex; 1254 1255 bool isSpecialStyle; 1256 } 1257 1258 /+ 1259 void resetSelection(int selectionId) { 1260 1261 } 1262 1263 // FIXME: is it moving teh anchor or the focus? 1264 void extendSelection(int selectionId, bool fromBeginning, bool direction, int delegate(scope const char[] c) handler) { 1265 // iterates through the selection, giving you the chars, until you return 0 1266 // can use this to do things like delete words in front of cursor 1267 } 1268 1269 void duplicateSelection(int receivingSelectionId, int sourceSelectionId) { 1270 1271 } 1272 +/ 1273 1274 private int findContainingSegment(int textOffset) { 1275 1276 relayoutIfNecessary(); 1277 1278 // FIXME: binary search 1279 1280 // FIXME: when the index is here, use it 1281 foreach(idx, segment; segments) { 1282 // this assumes the segments are in order of text offset 1283 if(textOffset >= segment.textBeginOffset && textOffset < segment.textEndOffset) 1284 return cast(int) idx; 1285 } 1286 assert(0); 1287 } 1288 1289 // need page up+down, home, edit, arrows, etc. 1290 1291 /++ 1292 Finds the given text, setting the given selection to it, if found. 1293 1294 Starts from the given selection and moves in the direction to find next. 1295 1296 Returns true if found. 1297 1298 NOT IMPLEMENTED use a selection instead 1299 +/ 1300 FindResult find(int selectionId, in const(char)[] text, bool direction, bool wraparound) { 1301 return FindResult.NotFound; 1302 } 1303 /// ditto 1304 enum FindResult : int { 1305 NotFound = 0, 1306 Found = 1, 1307 WrappedAround = 2 1308 } 1309 1310 private bool wasMutated_ = false; 1311 /++ 1312 The layouter maintains a flag to tell if the content has been changed. 1313 +/ 1314 public bool wasMutated() { 1315 return wasMutated_; 1316 } 1317 1318 /// ditto 1319 public void clearWasMutatedFlag() { 1320 wasMutated_ = false; 1321 } 1322 1323 /++ 1324 Represents a possible registered style for a segment of text. 1325 +/ 1326 public static struct StyleHandle { 1327 private this(int idx) { this.index = idx; } 1328 private int index = -1; 1329 } 1330 1331 /++ 1332 Registers a text style you can use in text segments. 1333 +/ 1334 // FIXME: i might have to construct it internally myself so i can return it const. 1335 public StyleHandle registerStyle(TextStyle style) { 1336 stylePalette ~= style; 1337 return StyleHandle(cast(int) stylePalette.length - 1); 1338 } 1339 1340 1341 /++ 1342 Appends text at the end, without disturbing user selection. If style is not specified, it reuses the most recent style. If it is, it switches to that style. 1343 1344 If you put `isSpecialStyle` to `true`, the style will only apply to this text specifically and user edits will not inherit it. 1345 +/ 1346 public void appendText(scope const(char)[] text, StyleHandle style = StyleHandle.init, bool isSpecialStyle = false) { 1347 wasMutated_ = true; 1348 auto before = this.text; 1349 this.text.length += text.length; 1350 this.text[before.length-1 .. before.length-1 + text.length] = text[]; 1351 this.text[$-1] = 0; // gotta maintain the zero terminator i use 1352 // or do i want to use init to just keep using the same style as is already there? 1353 if(style is StyleHandle.init) { 1354 // default is to extend the existing style 1355 styles[$-1].length += text.length; 1356 } else { 1357 // otherwise, insert a new block for it 1358 styles[$-1].length -= 1; // it no longer covers the zero terminator 1359 1360 if(isSpecialStyle) { 1361 auto oldIndex = styles[$-1].styleInformationIndex; 1362 styles ~= StyleBlock(cast(int) before.length - 1, cast(int) text.length, style.index, true); 1363 // cover the zero terminator back in the old style 1364 styles ~= StyleBlock(cast(int) this.text.length - 1, 1, oldIndex, false); 1365 } else { 1366 // but this does, hence the +1 1367 styles ~= StyleBlock(cast(int) before.length - 1, cast(int) text.length + 1, style.index, false); 1368 } 1369 } 1370 1371 invalidateLayout(cast(int) before.length - 1 /* zero terminator */, this.text.length, cast(int) text.length); 1372 } 1373 1374 /++ 1375 Calls your delegate for each segment of the text, guaranteeing you will be called exactly once for each non-nil char in the string and each slice will have exactly one style. A segment may be as small as a single char. 1376 1377 FIXME: have a getTextInSelection 1378 1379 FIXME: have some kind of index stuff so you can select some text found in here (think regex search) 1380 1381 This function might be cut in a future version in favor of [getDrawableText] 1382 +/ 1383 void getText(scope void delegate(scope const(char)[] segment, TextStyle style) handler) { 1384 handler(text[0 .. $-1], null); // cut off the null terminator 1385 } 1386 1387 /++ 1388 Gets the current text value as a plain-text string. 1389 +/ 1390 string getTextString() { 1391 string s; 1392 getText((segment, style) { 1393 s ~= segment; 1394 }); 1395 return s; 1396 } 1397 1398 alias getContentString = getTextString; 1399 1400 public static struct DrawingInformation { 1401 Rectangle boundingBox; 1402 Point initialBaseline; 1403 ulong selections; // 0 if not selected. bitmask of selection ids otherwise 1404 1405 int direction; // you start at initialBaseline then draw ltr or rtl or up or down. 1406 // might also store glyph id, which could be encoded texture # + position, stuff like that. if each segment were 1407 // a glyph at least which is sometimes needed but prolly not gonna stress abut that in my use cases, i'd rather batch. 1408 } 1409 1410 public static struct CaretInformation { 1411 int id; 1412 Rectangle boundingBox; 1413 } 1414 1415 // assumes the idx is indeed in the segment 1416 private Rectangle boundingBoxOfGlyph(size_t segmentIndex, int idx) { 1417 // I can't relayoutIfNecessary here because that might invalidate the segmentIndex!! 1418 // relayoutIfNecessary(); 1419 auto segment = segments[segmentIndex]; 1420 1421 int codepointCounter = 0; 1422 auto bb = segment.boundingBox; 1423 foreach(thing, dchar cp; text[segment.textBeginOffset .. segment.textEndOffset]) { 1424 auto w = segmentsWidths[segmentIndex][codepointCounter]; 1425 1426 if(thing + segment.textBeginOffset == idx) { 1427 bb.right = bb.left + w; 1428 return bb; 1429 } 1430 1431 bb.left += w; 1432 1433 codepointCounter++; 1434 } 1435 1436 bb.right = bb.left + 1; 1437 1438 return bb; 1439 } 1440 1441 /+ 1442 void getTextAtPosition(Point p) { 1443 relayoutIfNecessary(); 1444 // return the text in that segment, the style info attached, and if that specific point is part of a selection (can be used to tell if it should be a drag operation) 1445 // then might want dropTextAt(Point p) 1446 } 1447 +/ 1448 1449 /++ 1450 Gets the text that you need to draw, guaranteeing each call to your delegate will: 1451 1452 * Have a contiguous slice into text 1453 * Have exactly one style (which may be null, meaning use all your default values. Be sure you draw with the same font you passed as the default font to TextLayouter.) 1454 * Be a linear block of text that fits in a single rectangular segment 1455 * A segment will be as large a block of text as the implementation can do, but it may be as short as a single char. 1456 * The segment may be a special escape sequence. FIXME explain how to check this. 1457 1458 Return `false` from your delegate to stop iterating through the text. 1459 1460 Please note that the `caretPosition` can be `Rectangle.init`, indicating it is not present in this segment. If it is not that, it will be the bounding box of the glyph. 1461 1462 You can use the `startFrom` parameter to skip ahead. The intended use case for this is to start from a scrolling position in the box; the first segment given will include this point. FIXME: maybe it should just go ahead and do a bounding box. Note that the segments may extend outside the point; it is just meant that it will include that and try to trim the rest. 1463 1464 The segment may include all forms of whitespace, including newlines, tab characters, etc. Generally, a tab character will be in its own segment and \n will appear at the end of a segment. You will probably want to `stripRight` each segment depending on your drawing functions. 1465 +/ 1466 public void getDrawableText(scope bool delegate(scope const(char)[] segment, TextStyle style, DrawingInformation information, CaretInformation[] carets...) dg, Rectangle box = Rectangle.init) { 1467 relayoutIfNecessary(); 1468 getInternalSegments(delegate bool(size_t segmentIndex, scope ref Segment segment) { 1469 if(segment.textBeginOffset == -1) 1470 return true; 1471 1472 TextStyle style; 1473 assert(segment.styleInformationIndex < stylePalette.length); 1474 1475 style = stylePalette[segment.styleInformationIndex]; 1476 1477 ubyte[64] possibleSelections; 1478 int possibleSelectionsCount; 1479 1480 CaretInformation[64] caretInformation; 1481 int cic; 1482 1483 // bounding box reduction 1484 foreach(si, selection; _selections[0 .. selectionsInUse]) { 1485 if(selection.isIncludedInRange(segment.textBeginOffset, segment.textEndOffset)) { 1486 if(!selection.empty()) { 1487 possibleSelections[possibleSelectionsCount++] = cast(ubyte) si; 1488 } 1489 if(selection.focus >= segment.textBeginOffset && selection.focus < segment.textEndOffset) { 1490 1491 // make sure the caret box represents that it would be if we actually 1492 // did the insertion, so adjust the bounding box to account for a possibly 1493 // different font 1494 1495 auto insertionStyle = stylePalette[getInsertionStyleAt(selection.focus).index]; 1496 auto glyphStyle = style; 1497 1498 auto bb = boundingBoxOfGlyph(segmentIndex, selection.focus); 1499 1500 bb.top += glyphStyle.font.ascent; 1501 bb.bottom -= glyphStyle.font.descent; 1502 1503 bb.top -= insertionStyle.font.ascent; 1504 bb.bottom += insertionStyle.font.descent; 1505 1506 caretInformation[cic++] = CaretInformation(cast(int) si, bb); 1507 } 1508 } 1509 } 1510 1511 // the rest of this might need splitting based on selections 1512 1513 DrawingInformation di; 1514 di.boundingBox = Rectangle(segment.upperLeft, Size(segment.width, segment.height)); 1515 di.selections = 0; 1516 1517 // di.initialBaseline = Point(x, y); // FIXME 1518 // FIXME if the selection focus is in this box, we should set the caretPosition to the bounding box of the associated glyph 1519 // di.caretPosition = Rectangle(x, y, w, h); // FIXME 1520 1521 auto end = segment.textEndOffset; 1522 if(end == text.length) 1523 end--; // don't send the terminating 0 to the user as that's an internal detail 1524 1525 auto txt = text[segment.textBeginOffset .. end]; 1526 1527 if(possibleSelectionsCount == 0) { 1528 // no selections present, no need to iterate 1529 // FIXME: but i might have to take some gap chars and such out anyway. 1530 return dg(txt, style, di, caretInformation[0 .. cic]); 1531 } else { 1532 ulong lastSel = 0; 1533 size_t lastSelPos = 0; 1534 size_t lastSelCodepoint = 0; 1535 bool exit = false; 1536 1537 void sendSegment(size_t start, size_t end, size_t codepointStart, size_t codepointEnd) { 1538 di.selections = lastSel; 1539 1540 Rectangle bbOriginal = di.boundingBox; 1541 1542 int segmentWidth; 1543 1544 foreach(width; segmentsWidths[segmentIndex][codepointStart .. codepointEnd]) { 1545 segmentWidth += width; 1546 } 1547 1548 auto diFragment = di; 1549 diFragment.boundingBox.right = diFragment.boundingBox.left + segmentWidth; 1550 1551 // FIXME: adjust the rest of di for this 1552 // FIXME: the caretInformation arguably should be truncated for those not in this particular sub-segment 1553 exit = !dg(txt[start .. end], style, diFragment, caretInformation[0 .. cic]); 1554 1555 di.initialBaseline.x += segmentWidth; 1556 di.boundingBox.left += segmentWidth; 1557 1558 lastSelPos = end; 1559 lastSelCodepoint = codepointEnd; 1560 } 1561 1562 size_t codepoint = 0; 1563 1564 foreach(ci, dchar ch; txt) { 1565 auto sel = selectionsAt(cast(int) ci + segment.textBeginOffset); 1566 if(sel != lastSel) { 1567 // send this segment 1568 1569 sendSegment(lastSelPos, ci, lastSelCodepoint, codepoint); 1570 lastSel = sel; 1571 if(exit) return false; 1572 } 1573 1574 codepoint++; 1575 } 1576 1577 sendSegment(lastSelPos, txt.length, lastSelCodepoint, codepoint); 1578 if(exit) return false; 1579 } 1580 1581 return true; 1582 }, box); 1583 } 1584 1585 // returns any segments that may lie inside the bounding box. if the box's size is 0, it is unbounded and goes through all segments 1586 // may return more than is necessary; it uses the box as a hint to speed the search, not as the strict bounds it returns. 1587 protected void getInternalSegments(scope bool delegate(size_t idx, scope ref Segment segment) dg, Rectangle box = Rectangle.init) { 1588 relayoutIfNecessary(); 1589 1590 if(box.right == box.left) 1591 box.right = int.max; 1592 if(box.bottom == box.top) 1593 box.bottom = int.max; 1594 1595 if(segments.length < 64 || box.top < 64) { 1596 foreach(idx, ref segment; segments) { 1597 if(dg(idx, segment) == false) 1598 break; 1599 } 1600 } else { 1601 int maximum = cast(int) segments.length; 1602 int searchPoint = maximum / 2; 1603 1604 keepSearching: 1605 //writeln(searchPoint); 1606 if(segments[searchPoint].upperLeft.y > box.top) { 1607 // we're too far ahead to find the box 1608 maximum = searchPoint; 1609 auto newSearchPoint = maximum / 2; 1610 if(newSearchPoint == searchPoint) { 1611 searchPoint = newSearchPoint; 1612 goto useIt; 1613 } 1614 searchPoint = newSearchPoint; 1615 goto keepSearching; 1616 } else if(segments[searchPoint].boundingBox.bottom < box.top) { 1617 // the box is a way down from here still 1618 auto newSearchPoint = (maximum - searchPoint) / 2 + searchPoint; 1619 if(newSearchPoint == searchPoint) { 1620 searchPoint = newSearchPoint; 1621 goto useIt; 1622 } 1623 searchPoint = newSearchPoint; 1624 goto keepSearching; 1625 } 1626 1627 useIt: 1628 1629 auto line = segments[searchPoint].displayLineNumber; 1630 if(line) { 1631 // go to the line right before this to ensure we have everything in here 1632 while(searchPoint != 0 && segments[searchPoint].displayLineNumber == line) 1633 searchPoint--; 1634 } 1635 1636 foreach(idx, ref segment; segments[searchPoint .. $]) { 1637 if(dg(idx + searchPoint, segment) == false) 1638 break; 1639 } 1640 } 1641 } 1642 1643 private { 1644 // user code can add new styles to the palette 1645 TextStyle[] stylePalette; 1646 1647 // if editable by user, these will change 1648 char[] text; 1649 StyleBlock[] styles; 1650 1651 // the layout function calculates these 1652 Segment[] segments; 1653 short[][] segmentsWidths; 1654 } 1655 1656 /++ 1657 1658 +/ 1659 this(TextStyle defaultStyle) { 1660 this.stylePalette ~= defaultStyle; 1661 this.text = [0]; // i never want to let the editor go over, so this pseudochar makes that a bit easier 1662 this.styles ~= StyleBlock(0, 1, 0); // default style should never be deleted too at the end of the file 1663 this.invalidateLayout(0, 1, 0); 1664 } 1665 1666 // maybe unstable 1667 TextStyle defaultStyle() { 1668 auto ts = this.stylePalette[0]; 1669 invalidateLayout(0, text.length, 0); // assume they are going to mutate it 1670 return ts; 1671 } 1672 1673 // most of these are unimplemented... 1674 bool editable; 1675 int wordWrapLength = 0; 1676 int delegate(int x) tabStop = null; 1677 int delegate(Rectangle line) leftOffset = null; 1678 int delegate(Rectangle line) rightOffset = null; 1679 int lineSpacing = 0; 1680 1681 /+ 1682 the function it could call is drawStringSegment with a certain slice of it, an offset (guaranteed to be rectangular) and then you do the styles. it does need to know the font tho. 1683 1684 it takes a flag: UpperLeft or Baseline. this tells its coordinates for the string segment when you draw. 1685 1686 The style can just be a void* or something, not really the problem of the layouter; it only cares about font metrics 1687 1688 The layout thing needs to know: 1689 1) is it word wrapped 1690 2) a delegate for offset left for the given line height 1691 2) a delegate for offset right for the given line height 1692 1693 GetSelection() returns the segments that are currently selected 1694 Caret position, if there is one 1695 1696 Each TextLayouter can represent a block element in HTML terms. Centering and such done outside. 1697 Selections going across blocks is an exercise for the outside code (it'd select start to all of one, all of middle, all to end of last). 1698 1699 1700 EDITING: 1701 just like addText which it does replacing the selection if there is one or inserting/overstriking at the caret 1702 1703 everything has an optional void* style which it does as offset-based overlays 1704 1705 user responsibility to getSelection if they want to add something to the style 1706 +/ 1707 1708 private static struct Segment { 1709 // 32 bytes rn, i can reasonably save 6 with shorts 1710 // do i even need the segmentsWidths cache or can i reasonably recalculate it lazily? 1711 1712 int textBeginOffset; 1713 int textEndOffset; // can make a short length i think 1714 1715 int styleInformationIndex; 1716 1717 // calculated values after iterating through the segment 1718 int width; // short 1719 int height; // short 1720 1721 Point upperLeft; 1722 1723 int displayLineNumber; // I might change this to be a fractional thing, like 24 bits line number, 8 bits fractional number (from word wrap) tho realistically i suspect an index of original lines would be easier to maintain (could only have one value per like 100 real lines cuz it just narrows down the linear search 1724 1725 /* 1726 Point baseline() { 1727 1728 } 1729 */ 1730 1731 Rectangle boundingBox() { 1732 return Rectangle(upperLeft, Size(width, height)); 1733 } 1734 } 1735 1736 private int _width; 1737 private int _height; 1738 1739 private SelectionImpl[64] _selections; 1740 private int selectionsInUse = 1; 1741 1742 /++ 1743 Selections have two parts: an anchor (typically set to where the user clicked the mouse down) 1744 and a focus (typically where the user released the mouse button). As you click and drag, you 1745 want to change the focus while keeping the anchor the same. 1746 1747 The caret is drawn at the focus. If the anchor and focus are the same point, the selection 1748 is empty. 1749 1750 Please note that the selection focus is different than a keyboard focus. (I'd personally prefer 1751 to call it a terminus, but I'm trying to use the same terminology as the web standards, even if 1752 I don't like it.) 1753 1754 After calling this, you don't need to call relayout(), but you might want to redraw to show the 1755 user the result of this action. 1756 +/ 1757 1758 /+ 1759 Returns the nearest offset in the text for the given point. 1760 1761 it should return if it was inside the segment bounding box tho 1762 1763 might make this private 1764 1765 FIXME: the public one might be like segmentOfClick so you can get the style info out (which might hold hyperlink data) 1766 +/ 1767 int offsetOfClick(Point p) { 1768 int idx = cast(int) text.length - 1; 1769 1770 relayoutIfNecessary(); 1771 1772 if(p.y > _height) 1773 return idx; 1774 1775 getInternalSegments(delegate bool(size_t segmentIndex, scope ref Segment segment) { 1776 idx = segment.textBeginOffset; 1777 // FIXME: this all assumes ltr 1778 1779 auto boundingBox = Rectangle(segment.upperLeft, Size(segment.width, segment.height)); 1780 if(boundingBox.contains(p)) { 1781 int x = segment.upperLeft.x; 1782 int codePointIndex = 0; 1783 1784 int bestHit = int.max; 1785 int bestHitDistance = int.max; 1786 if(bestHitDistance < 0) bestHitDistance = -bestHitDistance; 1787 foreach(i, dchar ch; text[segment.textBeginOffset .. segment.textEndOffset]) { 1788 const width = segmentsWidths[segmentIndex][codePointIndex]; 1789 idx = segment.textBeginOffset + cast(int) i; // can't just idx++ since it needs utf-8 stride 1790 1791 auto distanceToLeft = p.x - x; 1792 if(distanceToLeft < 0) distanceToLeft = -distanceToLeft; 1793 1794 //auto distanceToRight = p.x - (x + width); 1795 //if(distanceToRight < 0) distanceToRight = -distanceToRight; 1796 1797 //bool improved = false; 1798 1799 if(distanceToLeft < bestHitDistance) { 1800 bestHit = idx; 1801 bestHitDistance = distanceToLeft; 1802 // improved = true; 1803 } 1804 /* 1805 if(distanceToRight < bestHitDistance) { 1806 bestHit = idx + 1; 1807 bestHitDistance = distanceToRight; 1808 improved = true; 1809 } 1810 */ 1811 1812 //if(!improved) { 1813 // we're moving further away, no point continuing 1814 // (please note that RTL transitions = different segment) 1815 //break; 1816 //} 1817 1818 x += width; 1819 codePointIndex++; 1820 } 1821 1822 if(bestHit != int.max) 1823 idx = bestHit; 1824 1825 return false; 1826 } else if(p.x < boundingBox.left && p.y >= boundingBox.top && p.y < boundingBox.bottom) { 1827 // to the left of a line 1828 // assumes ltr 1829 idx = segment.textBeginOffset; 1830 return false; 1831 /+ 1832 } else if(p.x >= boundingBox.right && p.y >= boundingBox.top && p.y < boundingBox.bottom) { 1833 // to the right of a line 1834 idx = segment.textEndOffset; 1835 return false; 1836 +/ 1837 } else if(p.y < segment.upperLeft.y) { 1838 // should go to the end of the previous line 1839 auto thisLine = segment.displayLineNumber; 1840 idx = 0; 1841 while(segmentIndex > 0) { 1842 segmentIndex--; 1843 1844 if(segments[segmentIndex].displayLineNumber < thisLine) { 1845 idx = segments[segmentIndex].textEndOffset - 1; 1846 break; 1847 } 1848 } 1849 return false; 1850 } else { 1851 // for single line if nothing else matched we'd best put it at the end; will be reset for the next iteration 1852 // if there is one. and if not, this is where we want it - at the end of the text 1853 idx = cast(int) text.length - 1; 1854 } 1855 1856 return true; 1857 }, Rectangle(p, Size(0, 0))); 1858 return idx; 1859 } 1860 1861 /++ 1862 1863 History: 1864 Added September 13, 2024 1865 +/ 1866 const(TextStyle) styleAtPoint(Point p) { 1867 TextStyle s; 1868 getInternalSegments(delegate bool(size_t segmentIndex, scope ref Segment segment) { 1869 if(segment.boundingBox.contains(p)) { 1870 s = stylePalette[segment.styleInformationIndex]; 1871 return false; 1872 } 1873 1874 return true; 1875 }, Rectangle(p, Size(1, 1))); 1876 1877 return s; 1878 } 1879 1880 private StyleHandle getInsertionStyleAt(int offset) { 1881 assert(offset >= 0 && offset < text.length); 1882 /+ 1883 If we are at the first part of a logical line, use the next local style (the one in bounds at the offset). 1884 1885 Otherwise, use the previous one (the one in bounds). 1886 +/ 1887 1888 if(offset == 0 || text[offset - 1] == '\n') { 1889 // no adjust needed, we use the style here 1890 } else { 1891 offset--; // use the previous one 1892 } 1893 1894 return getStyleAt(offset, false); 1895 } 1896 1897 private StyleHandle getStyleAt(int offset, bool allowSpecialStyle = true) { 1898 // FIXME: binary search 1899 foreach(idx, style; styles) { 1900 if(offset >= style.offset && offset < (style.offset + style.length)) { 1901 if(style.isSpecialStyle && !allowSpecialStyle) { 1902 // we need to find the next style that is not special... 1903 foreach(s2; styles[idx + 1 .. $]) 1904 if(!s2.isSpecialStyle) 1905 return StyleHandle(s2.styleInformationIndex); 1906 } 1907 return StyleHandle(style.styleInformationIndex); 1908 } 1909 } 1910 assert(0); 1911 } 1912 1913 /++ 1914 Returns a bitmask of the selections active at any given offset. 1915 1916 May not be stable. 1917 +/ 1918 ulong selectionsAt(int offset) { 1919 ulong result; 1920 ulong bit = 1; 1921 foreach(selection; _selections[0 .. selectionsInUse]) { 1922 if(selection.containsOffset(offset)) 1923 result |= bit; 1924 bit <<= 1; 1925 } 1926 return result; 1927 } 1928 1929 private int wordWrapWidth_; 1930 1931 /++ 1932 Set to 0 to disable word wrapping. 1933 +/ 1934 public void wordWrapWidth(int width) { 1935 if(width != wordWrapWidth_) { 1936 wordWrapWidth_ = width; 1937 invalidateLayout(0, text.length, 0); 1938 } 1939 } 1940 1941 private int justificationWidth_; 1942 1943 /++ 1944 Not implemented. 1945 +/ 1946 public void justificationWidth(int width) { 1947 if(width != justificationWidth_) { 1948 justificationWidth_ = width; 1949 invalidateLayout(0, text.length, 0); 1950 } 1951 } 1952 1953 /++ 1954 Can override this to define if a char is a word splitter for word wrapping. 1955 +/ 1956 protected bool isWordwrapPoint(dchar c) { 1957 // FIXME: assume private use characters are split points 1958 if(c == ' ') 1959 return true; 1960 return false; 1961 } 1962 1963 /+ 1964 /++ 1965 1966 +/ 1967 protected ReplacedCharacter privateUseCharacterInfo(dchar c) { 1968 return ReplacedCharacter.init; 1969 } 1970 1971 /// ditto 1972 static struct ReplacedCharacter { 1973 bool overrideFont; /// if false, it uses the font like any other character, if true, it uses info from this struct 1974 int width; /// in device pixels 1975 int height; /// in device pixels 1976 } 1977 +/ 1978 1979 private bool invalidateLayout_; 1980 private int invalidStart = int.max; 1981 private int invalidEnd = 0; 1982 private int invalidatedChangeInTextLength = 0; 1983 /++ 1984 This should be called (internally, end users don't need to see it) any time the text or style has changed. 1985 +/ 1986 protected void invalidateLayout(size_t start, size_t end, int changeInTextLength) { 1987 invalidateLayout_ = true; 1988 1989 if(start < invalidStart) 1990 invalidStart = cast(int) start; 1991 if(end > invalidEnd) 1992 invalidEnd = cast(int) end; 1993 1994 invalidatedChangeInTextLength += changeInTextLength; 1995 } 1996 1997 /++ 1998 This should be called (internally, end users don't need to see it) any time you're going to return something to the user that is dependent on the layout. 1999 +/ 2000 protected void relayoutIfNecessary() { 2001 if(invalidateLayout_) { 2002 relayoutImplementation(); 2003 invalidateLayout_ = false; 2004 invalidStart = int.max; 2005 invalidEnd = 0; 2006 invalidatedChangeInTextLength = 0; 2007 } 2008 } 2009 2010 /++ 2011 Params: 2012 wordWrapLength = the length, in display pixels, of the layout's bounding box as far as word wrap is concerned. If 0, word wrapping is disabled. 2013 2014 FIXME: wordWrapChars and if you word wrap, should it indent it too? more realistically i pass the string to the helper and it has to findWordBoundary and then it can prolly return the left offset too, based on the previous line offset perhaps. 2015 2016 substituteGlyph? actually that can prolly be a fake password font. 2017 2018 2019 int maximumHeight. if non-zero, the leftover text is returned so you can pass it to another layout instance (e.g. for columns or pagination) 2020 +/ 2021 protected void relayoutImplementation() { 2022 2023 2024 // an optimization here is to avoid redoing stuff outside the invalidated zone. 2025 // basically it would keep going until a segment after the invalidated end area was in the state before and after. 2026 2027 debug(text_layouter_bench) { 2028 // writeln("relayouting"); 2029 import core.time; 2030 auto start = MonoTime.currTime; 2031 scope(exit) { 2032 writeln(MonoTime.currTime - start); 2033 } 2034 } 2035 2036 auto originalSegments = segments; 2037 auto originalWidth = _width; 2038 auto originalHeight = _height; 2039 auto originalSegmentsWidths = segmentsWidths; 2040 2041 _width = 0; 2042 _height = 0; 2043 2044 assert(invalidStart != int.max); 2045 assert(invalidStart >= 0); 2046 assert(invalidStart < text.length); 2047 2048 if(invalidEnd > text.length) 2049 invalidEnd = cast(int) text.length; 2050 2051 int firstInvalidSegment = 0; 2052 2053 Point currentCorner = Point(0, 0); 2054 int displayLineNumber = 0; 2055 int lineSegmentIndexStart = 0; 2056 2057 if(invalidStart != 0) { 2058 // while i could binary search for the invalid thing, 2059 // i also need to rebuild _width and _height anyway so 2060 // just gonna loop through and hope for the best. 2061 bool found = false; 2062 2063 // I can't just use the segment bounding box per se since that isn't the whole line 2064 // and the finishLine adjustment for mixed fonts/sizes will throw things off. so we 2065 // want to start at the very corner of the line 2066 int lastLineY; 2067 int thisLineY; 2068 foreach(idx, segment; segments) { 2069 // FIXME: i might actually need to go back to the logical line 2070 if(displayLineNumber != segment.displayLineNumber) { 2071 lastLineY = thisLineY; 2072 displayLineNumber = segment.displayLineNumber; 2073 lineSegmentIndexStart = cast(int) idx; 2074 } 2075 auto b = segment.boundingBox.bottom; 2076 if(b > thisLineY) 2077 thisLineY = b; 2078 2079 if(invalidStart >= segment.textBeginOffset && invalidStart < segment.textEndOffset) { 2080 // we'll redo the whole line with the invalidated region since it might have other coordinate things 2081 2082 segment = segments[lineSegmentIndexStart]; 2083 2084 firstInvalidSegment = lineSegmentIndexStart;// cast(int) idx; 2085 invalidStart = segment.textBeginOffset; 2086 displayLineNumber = segment.displayLineNumber; 2087 currentCorner = segment.upperLeft; 2088 currentCorner.y = lastLineY; 2089 2090 found = true; 2091 break; 2092 } 2093 2094 // FIXME: since we rewind to the line segment start above this might not be correct anymore. 2095 auto bb = segment.boundingBox; 2096 if(bb.right > _width) 2097 _width = bb.right; 2098 if(bb.bottom > _height) 2099 _height = bb.bottom; 2100 } 2101 assert(found); 2102 } 2103 2104 // writeln(invalidStart, " starts segment ", firstInvalidSegment, " and line ", displayLineNumber, " seg ", lineSegmentIndexStart); 2105 2106 segments = segments[0 .. firstInvalidSegment]; 2107 segments.assumeSafeAppend(); 2108 2109 segmentsWidths = segmentsWidths[0 .. firstInvalidSegment]; 2110 segmentsWidths.assumeSafeAppend(); 2111 2112 version(try_kerning_hack) { 2113 size_t previousIndex = 0; 2114 int lastWidth; 2115 int lastWidthDistance; 2116 } 2117 2118 Segment segment; 2119 2120 Segment previousOldSavedSegment; 2121 short[] previousOldSavedWidths; 2122 TextStyle currentStyle = null; 2123 int currentStyleIndex = 0; 2124 MeasurableFont font; 2125 bool glyphCacheValid; 2126 ubyte[128] glyphWidths; 2127 void loadNewFont(MeasurableFont what) { 2128 font = what; 2129 2130 // caching the ascii widths locally can give a boost to ~ 20% of the speed of this function 2131 glyphCacheValid = true; 2132 foreach(char c; 32 .. 128) { 2133 auto w = font.stringWidth((&c)[0 .. 1]); 2134 if(w >= 256) { 2135 glyphCacheValid = false; 2136 break; 2137 } 2138 glyphWidths[c] = cast(ubyte) w; // FIXME: what if it doesn't fit? 2139 } 2140 } 2141 2142 auto styles = this.styles; 2143 2144 foreach(style; this.styles) { 2145 if(invalidStart >= style.offset && invalidStart < (style.offset + style.length)) { 2146 currentStyle = stylePalette[style.styleInformationIndex]; 2147 if(currentStyle !is null) 2148 loadNewFont(currentStyle.font); 2149 currentStyleIndex = style.styleInformationIndex; 2150 2151 styles = styles[1 .. $]; 2152 break; 2153 } else if(style.offset > invalidStart) { 2154 break; 2155 } 2156 styles = styles[1 .. $]; 2157 } 2158 2159 int offsetToNextStyle = int.max; 2160 if(styles.length) { 2161 offsetToNextStyle = styles[0].offset; 2162 } 2163 2164 2165 assert(offsetToNextStyle >= 0); 2166 2167 short[] widths; 2168 2169 size_t segmentBegan = invalidStart; 2170 void finishSegment(size_t idx) { 2171 if(idx == segmentBegan) 2172 return; 2173 segmentBegan = idx; 2174 segment.textEndOffset = cast(int) idx; 2175 segment.displayLineNumber = displayLineNumber; 2176 2177 if(segments.length < originalSegments.length) { 2178 previousOldSavedSegment = originalSegments[segments.length]; 2179 previousOldSavedWidths = originalSegmentsWidths[segmentsWidths.length]; 2180 } else { 2181 previousOldSavedSegment = Segment.init; 2182 previousOldSavedWidths = null; 2183 } 2184 2185 segments ~= segment; 2186 segmentsWidths ~= widths; 2187 2188 segment = Segment.init; 2189 segment.upperLeft = currentCorner; 2190 segment.styleInformationIndex = currentStyleIndex; 2191 segment.textBeginOffset = cast(int) idx; 2192 widths = null; 2193 } 2194 2195 // FIXME: when we start in an invalidated thing this is not necessarily right, it should be calculated above 2196 int biggestDescent = font.descent; 2197 int lineHeight = font.height; 2198 2199 bool finishLine(size_t idx, MeasurableFont outerFont) { 2200 if(segment.textBeginOffset == idx) 2201 return false; // no need to keep nothing. 2202 2203 if(currentCorner.x > this._width) 2204 this._width = currentCorner.x; 2205 2206 auto thisLineY = currentCorner.y; 2207 2208 auto thisLineHeight = lineHeight; 2209 currentCorner.y += lineHeight; 2210 currentCorner.x = 0; 2211 2212 finishSegment(idx); // i use currentCorner in there! so this must be after that 2213 displayLineNumber++; 2214 2215 lineHeight = outerFont.height; 2216 biggestDescent = outerFont.descent; 2217 2218 // go back and adjust all the segments on this line to have the right height and do vertical alignment with the baseline 2219 foreach(ref seg; segments[lineSegmentIndexStart .. $]) { 2220 MeasurableFont font; 2221 if(seg.styleInformationIndex < stylePalette.length) { 2222 auto si = stylePalette[seg.styleInformationIndex]; 2223 if(si) 2224 font = si.font; 2225 } 2226 2227 auto baseline = thisLineHeight - biggestDescent; 2228 2229 seg.upperLeft.y += baseline - font.ascent; 2230 seg.height = thisLineHeight - (baseline - font.ascent); 2231 } 2232 2233 // now need to check if we can finish relayout early 2234 2235 // if we're beyond the invalidated section and have original data to compare against... 2236 previousOldSavedSegment.textBeginOffset += invalidatedChangeInTextLength; 2237 previousOldSavedSegment.textEndOffset += invalidatedChangeInTextLength; 2238 2239 /+ 2240 // FIXME: would be nice to make this work somehow - when you input a new line it needs to just adjust the y stuff 2241 // part of the problem is that it needs to inject a new segment for the newline and then the whole old array is 2242 // broken. 2243 int deltaY; 2244 int deltaLineNumber; 2245 2246 if(idx >= invalidEnd && segments[$-1] != previousOldSavedSegment) { 2247 deltaY = thisLineHeight; 2248 deltaLineNumber = 1; 2249 previousOldSavedSegment.upperLeft.y += deltaY; 2250 previousOldSavedSegment.displayLineNumber += deltaLineNumber; 2251 writeln("trying deltaY = ", deltaY); 2252 writeln(previousOldSavedSegment); 2253 writeln(segments[$-1]); 2254 } 2255 +/ 2256 2257 // FIXME: if the only thing that's changed is a y coordinate, adjust that too 2258 // finishEarly(); 2259 if(idx >= invalidEnd && segments[$-1] == previousOldSavedSegment) { 2260 if(segmentsWidths[$-1] == previousOldSavedWidths) { 2261 // we've hit a point where nothing has changed, it is time to stop processing 2262 2263 foreach(ref seg; originalSegments[segments.length .. $]) { 2264 seg.textBeginOffset += invalidatedChangeInTextLength; 2265 seg.textEndOffset += invalidatedChangeInTextLength; 2266 2267 /+ 2268 seg.upperLeft.y += deltaY; 2269 seg.displayLineNumber += deltaLineNumber; 2270 +/ 2271 2272 auto bb = seg.boundingBox; 2273 if(bb.right > _width) 2274 _width = bb.right; 2275 if(bb.bottom > _height) 2276 _height = bb.bottom; 2277 } 2278 2279 // these refer to the same array or should anyway so hopefully this doesn't do anything. 2280 // FIXME: confirm this isn't sucky 2281 segments ~= originalSegments[segments.length .. $]; 2282 segmentsWidths ~= originalSegmentsWidths[segmentsWidths.length .. $]; 2283 2284 return true; 2285 } else { 2286 // writeln("not matched"); 2287 // writeln(previousOldSavedWidths != segmentsWidths[$-1]); 2288 } 2289 } 2290 2291 lineSegmentIndexStart = cast(int) segments.length; 2292 2293 return false; 2294 } 2295 2296 void finishEarly() { 2297 // lol i did all the work before triggering this 2298 } 2299 2300 segment.upperLeft = currentCorner; 2301 segment.styleInformationIndex = currentStyleIndex; 2302 segment.textBeginOffset = invalidStart; 2303 2304 bool endSegment; 2305 bool endLine; 2306 2307 bool tryWordWrapOnNext; 2308 2309 // writeln("Prior to loop: ", MonoTime.currTime - start, " ", invalidStart); 2310 2311 // FIXME: i should prolly go by grapheme 2312 foreach(idxRaw, dchar ch; text[invalidStart .. $]) { 2313 auto idx = idxRaw + invalidStart; 2314 2315 version(try_kerning_hack) 2316 lastWidthDistance++; 2317 auto oldFont = font; 2318 if(offsetToNextStyle == idx) { 2319 auto oldStyle = currentStyle; 2320 if(styles.length) { 2321 StyleBlock currentStyleBlock = styles[0]; 2322 offsetToNextStyle += currentStyleBlock.length; 2323 styles = styles[1 .. $]; 2324 2325 currentStyle = stylePalette[currentStyleBlock.styleInformationIndex]; 2326 currentStyleIndex = currentStyleBlock.styleInformationIndex; 2327 } else { 2328 currentStyle = null; 2329 offsetToNextStyle = int.max; 2330 } 2331 if(oldStyle !is currentStyle) { 2332 if(!endLine) 2333 endSegment = true; 2334 2335 loadNewFont(currentStyle.font); 2336 } 2337 } 2338 2339 if(tryWordWrapOnNext) { 2340 int nextWordwrapPoint = cast(int) idx; 2341 while(nextWordwrapPoint < text.length && !isWordwrapPoint(text[nextWordwrapPoint])) { 2342 if(text[nextWordwrapPoint] == '\n') 2343 break; 2344 nextWordwrapPoint++; 2345 } 2346 2347 if(currentCorner.x + font.stringWidth(text[idx .. nextWordwrapPoint]) >= wordWrapWidth_) 2348 endLine = true; 2349 2350 tryWordWrapOnNext = false; 2351 } 2352 2353 if(endSegment && !endLine) { 2354 finishSegment(idx); 2355 endSegment = false; 2356 } 2357 2358 bool justChangedLine; 2359 if(endLine) { 2360 auto flr = finishLine(idx, oldFont); 2361 if(flr) 2362 return finishEarly(); 2363 endLine = false; 2364 endSegment = false; 2365 justChangedLine = true; 2366 } 2367 2368 if(font !is oldFont) { 2369 // FIXME: adjust height 2370 if(justChangedLine || font.height > lineHeight) 2371 lineHeight = font.height; 2372 if(justChangedLine || font.descent > biggestDescent) 2373 biggestDescent = font.descent; 2374 } 2375 2376 2377 2378 int thisWidth = 0; 2379 2380 // FIXME: delegate private-use area to their own segments 2381 // FIXME: line separator, paragraph separator, form feed 2382 2383 switch(ch) { 2384 case 0: 2385 goto advance; 2386 case '\r': 2387 goto advance; 2388 case '\n': 2389 /+ 2390 finishSegment(idx); 2391 segment.textBeginOffset = cast(int) idx; 2392 2393 thisWidth = 0; 2394 +/ 2395 2396 endLine = true; 2397 goto advance; 2398 2399 // FIXME: a tab at the end of a line causes the next line to indent 2400 case '\t': 2401 finishSegment(idx); 2402 2403 // a tab should be its own segment with no text 2404 // per se 2405 2406 enum tabStop = 48; 2407 thisWidth = 16 + tabStop - currentCorner.x % tabStop; 2408 2409 segment.width += thisWidth; 2410 currentCorner.x += thisWidth; 2411 2412 endSegment = true; 2413 goto advance; 2414 2415 //goto advance; 2416 default: 2417 // FIXME: i don't think the draw thing uses kerning but if it does this is wrong. 2418 2419 // figure out this length (it uses previous idx to get some kerning info used) 2420 version(try_kerning_hack) { 2421 if(lastWidthDistance == 1) { 2422 auto width = font.stringWidth(text[previousIndex .. idx + stride(text[idx])]); 2423 thisWidth = width - lastWidth; 2424 // writeln(text[previousIndex .. idx + stride(text[idx])], " ", width, "-", lastWidth); 2425 } else { 2426 auto width = font.stringWidth(text[idx .. idx + stride(text[idx])]); 2427 thisWidth = width; 2428 } 2429 } else { 2430 if(glyphCacheValid && text[idx] < 128) 2431 thisWidth = glyphWidths[text[idx]]; 2432 else 2433 thisWidth = font.stringWidth(text[idx .. idx + stride(text[idx])]); 2434 } 2435 2436 segment.width += thisWidth; 2437 currentCorner.x += thisWidth; 2438 2439 version(try_kerning_hack) { 2440 lastWidth = thisWidth; 2441 previousIndex = idx; 2442 lastWidthDistance = 0; 2443 } 2444 } 2445 2446 if(wordWrapWidth_ > 0 && isWordwrapPoint(ch)) 2447 tryWordWrapOnNext = true; 2448 2449 // if im iterating and hit something that would change the line height, will have to go back and change everything perhaps. or at least work with offsets from the baseline throughout... 2450 2451 // might also just want a special string sequence that can inject things in the middle of text like inline images. it'd have to tell the height and advance. 2452 2453 // this would be to test if the kerning adjustments do anything. seems like the fonts 2454 // don't care tbh but still. 2455 // thisWidth = font.stringWidth(text[idx .. idx + stride(text[idx])]); 2456 2457 advance: 2458 if(segment.textBeginOffset != -1) { 2459 widths ~= cast(short) thisWidth; 2460 } 2461 } 2462 2463 auto finished = finishLine(text.length, font); 2464 /+ 2465 if(!finished) 2466 currentCorner.y += lineHeight; 2467 import arsd.core; writeln(finished); 2468 +/ 2469 2470 _height = currentCorner.y; 2471 2472 // import arsd.core;writeln(_height); 2473 2474 assert(segments.length); 2475 2476 //return widths; 2477 2478 // writefln("%(%s\n%)", segments[0 .. 10]); 2479 } 2480 2481 private { 2482 int stride(char c) { 2483 if(c < 0x80) { 2484 return 1; 2485 } else if(c == 0xff) { 2486 return 1; 2487 } else { 2488 import core.bitop : bsr; 2489 return 7 - bsr((~uint(c)) & 0xFF); 2490 } 2491 } 2492 } 2493 } 2494 2495 class StyledTextLayouter(StyleClass) : TextLayouter { 2496 2497 }