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