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