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