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