forked from adamdruppe/arsd
-
Notifications
You must be signed in to change notification settings - Fork 0
/
textlayouter.d
2327 lines (1823 loc) · 74.4 KB
/
textlayouter.d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/++
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.
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.
See the docs on each of those objects for more details.
Bugs:
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.
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.
Also doesn't handle shaped text, which breaks click point detection on Windows for certain script families.
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.
Relaying out and saving state is only partially incremental at this time.
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.
History:
Written in December 2022. Released in arsd 11.0.
+/
module arsd.textlayouter;
import arsd.simpledisplay;
/+
FIXME: caret style might need to be separate from anything drawn.
FIXME: when adding things, inform new sizes for scrollbar updates in real time
FIXME: scroll when selecting and dragging oob. generally capture on mouse down and release on mouse up.
FIXME: page up, page down.
FIXME: there is a spot right between some glyphs when changing fonts where it selected none.
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)
The style interface might actually want like toHtml and toRtf. at least on the minigui side, not strictly necessary here.
+/
/+
subclass w/ style
lazy layout queuing
style info could possibly be a linked list but it prolly don't have to be anything too special
track changes
+/
/+
Word wrap needs to maintain indentation in some cases
The left and right margins of exclusion area
Exclusion are in the center?
line-spacing
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.
FIXME: in sdpy, font is not reset upon returning from a child painter
FIXME: in minigui the scrollbars can steal focus from the thing the are controlling
FIXME: scw needs a per-button-click scroll amount since 1 may not be sufficient every time (tho 1 should be a possibility somehow)
+/
/+
REPLACED CONTENT
magic char followed by a dchar
the dchar represents the replaced content array index
replaced content needs to tell the layouter: ascent, descent, width.
all replaced content gets its own special segment.
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.
it has a style but it only cares about the alignment from it.
+/
/+
HTML
generally take all the text nodes and make them have unique text style instances
the text style can then refer back to the dom for click handling, css forwarding etc.
but html has blocks...
BLOCK ELEMENTS
margin+padding behavior
bounding box of nested things for background images and borders
an inline-block gets this stuff but does not go on its own line.
INLINE TABLES
+/
// FIXME: add center, left, right, justify and valign top, bottom, middle, baseline
// valign top = ascent = 0 of line. bottom = descent = bottom of line. middle = ascent+descent/2 = middle of line. baseline = matched baselines
// 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
// drawble textm ight give the offsets into the slice after all, and/or give non-trabable character things
// 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.
/++
Represents the style of a span of text.
You should never mutate one of these, instead construct a new one.
Please note that methods may be added to this interface without being a full breaking change.
+/
interface TextStyle {
/++
Must never return `null`.
+/
MeasurableFont font();
// FIXME: I might also want a duplicate function for saving state.
// verticalAlign?
// i should keep a refcount here, then i can do a COW if i wanted to.
// you might use different style things to represent different html elements or something too for click responses.
/++
You can mix this in to your implementation class to get default implementations of new methods I add.
You will almost certainly want to override the things anyway, but this can help you keep things compiling.
Please note that there is no default for font.
+/
static mixin template Defaults() {
/++
The default returns a [TerminalFontRepresentation]. This is almost certainly NOT what you want,
so implement your own `font()` member anyway.
+/
MeasurableFont font() {
return TerminalFontRepresentation.instance;
}
}
}
/++
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.
+/
class TerminalFontRepresentation : MeasurableFont {
static TerminalFontRepresentation instance() {
static TerminalFontRepresentation i;
if(i is null)
i = new TerminalFontRepresentation();
return i;
}
bool isMonospace() { return true; }
int averageWidth() { return 1; }
int height() { return 1; }
/// since it is a grid this is a bit bizarre to translate.
int ascent() { return 1; }
int descent() { return 0; }
int stringWidth(scope const(char)[] s, SimpleWindow window = null) {
int count;
foreach(dchar ch; s)
count++;
return count;
}
}
/++
A selection has four pieces:
1) A position
2) An anchor
3) A focus
4) A user coordinate
The user coordinate should only ever be changed in direct response to actual user action and indicates
where they ideally want the focus to be.
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).
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).
The position, anchor, and focus are stored in opaque units. The user coordinate is EITHER grid coordinates (line, glyph) or screen coordinates (pixels).
Most methods on the selection move the position. This is not visible to the user, it is just an internal marker.
setAnchor() sets the anchor to the current position.
setFocus() sets the focus to the current position.
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.)
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.
The code can look something like this:
---
selection
.moveHome
.setAnchor
.moveDown(5)
.moveEnd
.moveForward(&isEol)
.setFocus
.deleteContent
.moveToUserCoordinate
.setAnchor;
---
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.
void selectBetween(Selection other);
Please note that this is just a handle to another object. Treat it as a reference type.
+/
public struct Selection {
/++
You cannot construct these yourself. Instead, use [TextLayouter.selection] to get it.
+/
@disable this();
private this(TextLayouter layouter, int selectionId) {
this.layouter = layouter;
this.selectionId = selectionId;
}
private TextLayouter layouter;
private int selectionId;
private ref SelectionImpl impl() {
return layouter._selections[selectionId];
}
/+ Inspection +/
/++
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.
See_Also:
[getContent], [getContentString]
+/
bool isEmpty() {
return impl.focus == impl.anchor;
}
/++
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.
Please note that some text blocks may be empty, indicating only style has changed.
See_Also:
[getContentString], [isEmpty]
+/
void getContent(scope void delegate(scope const(char)[] text, TextStyle style) dg) {
dg(layouter.text[impl.start .. impl.end], null); // FIXME: style
}
/++
Convenience function to get the content of the selection as a simple string.
See_Also:
[getContent], [isEmpty]
+/
string getContentString() {
string s;
getContent((txt, style) {
s ~= txt;
});
return s;
}
// need this so you can scroll found text into view and similar
Rectangle focusBoundingBox() {
return layouter.boundingBoxOfGlyph(layouter.findContainingSegment(impl.focus), impl.focus);
}
/+ Setting the explicit positions to the current internal position +/
/++
These functions set the actual selection from the current internal position.
A selection has two major pieces, the anchor and the focus, and a third bookkeeping coordinate, called the user coordinate.
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
you clicked is the anchor position. As you drag, it moves the focus position. The selection is all the text between the anchor and
focus. The cursor (also known as the caret) is drawn at the focus point.
Meanwhile, the user coordinate is the point where the user last explicitly moved the focus. Try clicking near the end of a long line,
then moving up past a short line, to another long line. Your cursor should remain near the column of the original click, even though
the focus moved left while passing through the short line. The user coordinate is how this is achieved - explicit user action on the
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.
Before you set one of these values, you move the internal position with the `move` family of functions ([moveTo], [moveLeft], etc.).
Setting the anchor also always sets the focus.
For example, to select the whole document:
---
with(selection) {
moveToStartOfDocument(); // changes internal position without affecting the actual selection
setAnchor(); // set the anchor, actually changing the selection.
// Note that setting the anchor also always sets the focus, so the selection is empty at this time.
moveToEndOfDocument(); // move the internal position to the end
setFocus(); // and now set the focus, which extends the selection from the anchor, meaning the whole document is selected now
}
---
I didn't set the user coordinate there since the user's action didn't specify a row or column.
+/
Selection setAnchor() {
impl.anchor = impl.position;
impl.focus = impl.position;
// layouter.notifySelectionChanged();
return this;
}
/// ditto
Selection setFocus() {
impl.focus = impl.position;
// layouter.notifySelectionChanged();
return this;
}
/// ditto
Selection setUserCoordinate(Point p) {
impl.virtualFocusPosition = p;
return this;
}
/// ditto
Selection setUserXCoordinate() {
impl.virtualFocusPosition.x = layouter.boundingBoxOfGlyph(layouter.findContainingSegment(impl.position), impl.position).left;
return this;
}
/// ditto
Selection setUserYCoordinate() {
impl.virtualFocusPosition.y = layouter.boundingBoxOfGlyph(layouter.findContainingSegment(impl.position), impl.position).top;
return this;
}
/+ Moving the internal position +/
/++
+/
Selection moveTo(Point p, bool setUserCoordinate = true) {
impl.position = layouter.offsetOfClick(p);
if(setUserCoordinate)
impl.virtualFocusPosition = p;
return this;
}
/++
+/
Selection moveToStartOfDocument() {
impl.position = 0;
return this;
}
/// ditto
Selection moveToEndOfDocument() {
impl.position = cast(int) layouter.text.length - 1; // never include the 0 terminator
return this;
}
/++
+/
Selection moveToStartOfLine(bool byRender = true, bool includeLeadingWhitespace = true) {
// FIXME: chekc for word wrap by checking segment.displayLineNumber
// FIXME: includeLeadingWhitespace
while(impl.position > 0 && layouter.text[impl.position - 1] != '\n')
impl.position--;
return this;
}
/// ditto
Selection moveToEndOfLine(bool byRender = true) {
// FIXME: chekc for word wrap by checking segment.displayLineNumber
while(impl.position + 1 < layouter.text.length && layouter.text[impl.position] != '\n') // never include the 0 terminator
impl.position++;
return this;
}
/++
If the position is abutting an end of line marker, it moves past it, to include it.
If not, it does nothing.
The intention is so you can delete a whole line by doing:
---
with(selection) {
moveToStartOfLine();
setAnchor();
// this moves to the end of the visible line, but if you stopped here, you'd be left with an empty line
moveToEndOfLine();
// this moves past the line marker, meaning you don't just delete the line's content, it deletes the entire line
moveToIncludeAdjacentEndOfLineMarker();
setFocus();
replaceContent("");
}
---
+/
Selection moveToIncludeAdjacentEndOfLineMarker() {
// FIXME: i need to decide what i want to do about \r too. Prolly should remove it at the boundaries.
if(impl.position + 1 < layouter.text.length && layouter.text[impl.position] == '\n') { // never include the 0 terminator
impl.position++;
}
return this;
}
// note there's move up / down / left / right
// in addition to move forward / backward glyph/line
// the directions always match what's on screen.
// the others always match the logical order in the string.
/++
+/
Selection moveUp(int count = 1, bool byRender = true) {
verticalMoveImpl(-1, count, byRender);
return this;
}
/// ditto
Selection moveDown(int count = 1, bool byRender = true) {
verticalMoveImpl(1, count, byRender);
return this;
}
/// ditto
Selection moveLeft(int count = 1, bool byRender = true) {
horizontalMoveImpl(-1, count, byRender);
return this;
}
/// ditto
Selection moveRight(int count = 1, bool byRender = true) {
horizontalMoveImpl(1, count, byRender);
return this;
}
/+
enum PlacementOfFind {
beginningOfHit,
endOfHit
}
enum IfNotFound {
changeNothing,
moveToEnd,
callDelegate
}
enum CaseSensitive {
yes,
no
}
void find(scope const(char)[] text, PlacementOfFind placeAt = PlacementOfFind.beginningOfHit, IfNotFound ifNotFound = IfNotFound.changeNothing) {
}
+/
/++
Does a custom search through the text.
Params:
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.
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.
windowBuffer = a buffer to temporarily hold text for comparison. You should size this for the text you're trying to find
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.
Returns:
an object representing the search results and letting you manipulate the selection based upon it
+/
FindResult find(
scope const(char)[] delegate(scope return const(char)[] buffer) predicate,
int windowBufferSize,
bool searchBackward,
) {
assert(windowBufferSize != 0, "you must pass a buffer of some size");
char[] windowBuffer = new char[](windowBufferSize); // FIXME i don't need to actually copy in the current impl
int currentSpot = impl.position;
const finalSpot = searchBackward ? currentSpot : cast(int) layouter.text.length;
if(searchBackward) {
currentSpot -= windowBuffer.length;
if(currentSpot < 0)
currentSpot = 0;
}
auto endingSpot = currentSpot + windowBuffer.length;
if(endingSpot > finalSpot)
endingSpot = finalSpot;
keep_searching:
windowBuffer[0 .. endingSpot - currentSpot] = layouter.text[currentSpot .. endingSpot];
auto result = predicate(windowBuffer[0 .. endingSpot - currentSpot]);
if(result !is null) {
// we're done, it was found
auto offsetStart = result is null ? currentSpot : cast(int) (result.ptr - windowBuffer.ptr);
assert(offsetStart >= 0 && offsetStart < windowBuffer.length);
return FindResult(this, currentSpot + offsetStart, result !is null, currentSpot + cast(int) (offsetStart + result.length));
} else if((searchBackward && currentSpot > 0) || (!searchBackward && endingSpot < finalSpot)) {
// not found, keep searching
if(searchBackward) {
currentSpot--;
endingSpot--;
} else {
currentSpot++;
endingSpot++;
}
goto keep_searching;
} else {
// not found, at end of search
return FindResult(this, currentSpot, false, currentSpot /* zero length result */);
}
assert(0);
}
/// ditto
static struct FindResult {
private Selection selection;
private int position;
private bool found;
private int endPosition;
///
bool wasFound() {
return found;
}
///
Selection moveTo() {
selection.impl.position = position;
return selection;
}
///
Selection moveToEnd() {
selection.impl.position = endPosition;
return selection;
}
///
void selectHit() {
selection.impl.position = position;
selection.setAnchor();
selection.impl.position = endPosition;
selection.setFocus();
}
}
/+
/+ +
Searches by regex.
This is a template because the regex engine can be a heavy dependency, so it is only
included if you need it. The RegEx object is expected to match the Phobos std.regex.RegEx
api, so while you could, in theory, replace it, it is probably easier to just use the Phobos one.
+/
void find(RegEx)(RegEx re) {
}
+/
/+ Manipulating the data in the selection +/
/++
Replaces the content of the selection. If you replace it with an empty `newText`, it will delete the content.
If newText == "\b", it will delete the selection if it is non-empty, and otherwise delete the thing before the cursor.
If you want to do normal editor backspace key, you might want to check `if(!selection.isEmpty()) selection.moveLeft();`
before calling `selection.deleteContent()`. Similarly, for the delete key, you might use `moveRight` instead, since this
function will do nothing for an empty selection by itself.
FIXME: what if i want to replace it with some multiply styled text? Could probably call it in sequence actually.
+/
Selection replaceContent(scope const(char)[] newText, TextLayouter.StyleHandle style = TextLayouter.StyleHandle.init) {
layouter.wasMutated_ = true;
if(style == TextLayouter.StyleHandle.init)
style = layouter.getInsertionStyleAt(impl.focus);
int removeBegin, removeEnd;
if(this.isEmpty()) {
if(newText.length == 1 && newText[0] == '\b') {
auto place = impl.focus;
if(place > 0) {
int amount = 1;
while((layouter.text[place - amount] & 0b11000000) == 0b10000000) // all non-start bytes of a utf-8 sequence have this convenient property
amount++; // assumes this will never go over the edge cuz of it being valid utf 8 internally
removeBegin = place - amount;
removeEnd = place;
if(removeBegin < 0)
removeBegin = 0;
if(removeEnd < 0)
removeEnd = 0;
}
newText = null;
} else {
removeBegin = impl.terminus;
removeEnd = impl.terminus;
}
} else {
removeBegin = impl.start;
removeEnd = impl.end;
if(newText.length == 1 && newText[0] == '\b') {
newText = null;
}
}
auto place = impl.terminus;
auto changeInLength = cast(int) newText.length - (removeEnd - removeBegin);
// FIXME: the horror
auto trash = layouter.text[0 .. removeBegin];
trash ~= newText;
trash ~= layouter.text[removeEnd .. $];
layouter.text = trash;
impl.position = removeBegin + cast(int) newText.length;
this.setAnchor();
/+
For styles:
if one part resides in the deleted zone, it should be truncated to the edge of the deleted zone
if they are entirely in the deleted zone - their new length is zero - they should simply be deleted
if they are entirely before the deleted zone, it can stay the same
if they are entirely after the deleted zone, they should get += changeInLength
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
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.
+/
//writeln(removeBegin, " ", removeEnd);
//foreach(st; layouter.styles) writeln("B: ", st.offset, "..", st.offset + st.length, " ", st.styleInformationIndex);
// first I'm going to update all of them so it is in a consistent state
foreach(ref st; layouter.styles) {
auto begin = st.offset;
auto end = st.offset + st.length;
void adjust(ref int what) {
if(what < removeBegin) {
// no change needed
} else if(what >= removeBegin && what < removeEnd) {
what = removeBegin;
} else if(what) {
what += changeInLength;
}
}
adjust(begin);
adjust(end);
assert(end >= begin); // empty styles are not permitted by the implementation
st.offset = begin;
st.length = end - begin;
}
// then go back and inject the new style, if needed
if(changeInLength > 0) {
changeStyle(removeBegin, removeBegin + cast(int) newText.length, style);
}
removeEmptyStyles();
// or do i want to use init to just keep using the same style as is already there?
// FIXME
//if(style !is StyleHandle.init) {
// styles ~= StyleBlock(cast(int) before.length, cast(int) changeInLength, style.index);
//}
auto endInvalidate = removeBegin + newText.length;
if(removeEnd > endInvalidate)
endInvalidate = removeEnd;
layouter.invalidateLayout(removeBegin, endInvalidate, changeInLength);
// there's a new style from removeBegin to removeBegin + newText.length
// FIXME other selections in the zone need to be adjusted too
// if they are in the deleted zone, it should be moved to the end of the new zone (removeBegin + newText.length)
// if they are before the deleted zone, they can stay the same
// if they are after the deleted zone, they should be adjusted by changeInLength
foreach(idx, ref selection; layouter._selections[0 .. layouter.selectionsInUse]) {
// don't adjust ourselves here, we already did it above
// and besides don't want mutation in here
if(idx == selectionId)
continue;
void adjust(ref int what) {
if(what < removeBegin) {
// no change needed
} else if(what >= removeBegin && what < removeEnd) {
what = removeBegin;
} else if(what) {
what += changeInLength;
}
}
adjust(selection.anchor);
adjust(selection.terminus);
}
// you might need to set the user coordinate after this!
return this;
}
private void removeEmptyStyles() {
/+ the code doesn't like empty style blocks, so gonna go back and remove those +/
for(int i = 0; i < cast(int) layouter.styles.length; i++) {
if(layouter.styles[i].length == 0) {
for(auto i2 = i; i2 + 1 < layouter.styles.length; i2++)
layouter.styles[i2] = layouter.styles[i2 + 1];
layouter.styles = layouter.styles[0 .. $-1];
layouter.styles.assumeSafeAppend();
i--;
}
}
}
/++
Changes the style of the given selection. Gives existing styles in the selection to your delegate
and you return a new style to assign to that block.
+/
public void changeStyle(TextLayouter.StyleHandle delegate(TextStyle existing) newStyle) {
// FIXME there might be different sub-styles so we should actually look them up and send each one
auto ns = newStyle(null);
changeStyle(impl.start, impl.end, ns);
removeEmptyStyles();
layouter.invalidateLayout(impl.start, impl.end, 0);
}
/+ Impl helpers +/
private void changeStyle(int newStyleBegin, int newStyleEnd, TextLayouter.StyleHandle style) {
// FIXME: binary search
for(size_t i = 0; i < layouter.styles.length; i++) {
auto s = &layouter.styles[i];
const oldStyleBegin = s.offset;
const oldStyleEnd = s.offset + s.length;
if(newStyleBegin >= oldStyleBegin && newStyleBegin < oldStyleEnd) {
// the cases:
// it is an exact match in size, we can simply overwrite it
if(newStyleBegin == oldStyleBegin && newStyleEnd == oldStyleEnd) {
s.styleInformationIndex = style.index;
break; // all done
}
// we're the same as the existing style, so it is just a matter of extending it to include us
else if(s.styleInformationIndex == style.index) {
if(newStyleEnd > oldStyleEnd) {
s.length = newStyleEnd - oldStyleBegin;
// then need to fix up all the subsequent blocks, adding the offset, reducing the length
int remainingFixes = newStyleEnd - oldStyleEnd;
foreach(st; layouter.styles[i + 1 .. $]) {
auto thisFixup = remainingFixes;
if(st.length < thisFixup)
thisFixup = st.length;
// this can result in 0 length, the loop after this will delete that.
st.offset += thisFixup;
st.length -= thisFixup;
remainingFixes -= thisFixup;
assert(remainingFixes >= 0);
if(remainingFixes == 0)
break;
}
}
// otherwise it is all already there and nothing need be done at all
break;
}
// for the rest of the cases, the style does not match and is not a size match,
// so a new block is going to have to be inserted
// ///////////
// we're entirely contained inside, so keep the left, insert ourselves, and re-create right.
else if(newStyleEnd > oldStyleBegin && newStyleEnd < oldStyleEnd) {
// keep the old style on the left...
s.length = newStyleBegin - oldStyleBegin;
auto toInsert1 = TextLayouter.StyleBlock(newStyleBegin, newStyleEnd - newStyleBegin, style.index);
auto toInsert2 = TextLayouter.StyleBlock(newStyleEnd, oldStyleEnd - newStyleEnd, s.styleInformationIndex);
layouter.styles = layouter.styles[0 .. i + 1] ~ toInsert1 ~ toInsert2 ~ layouter.styles[i + 1 .. $];
// writeln(*s); writeln(toInsert1); writeln(toInsert2);
break; // no need to continue processing as the other offsets are unaffected
}
// we need to keep the left end of the original thing, but then insert ourselves on afterward
else if(newStyleBegin >= oldStyleBegin) {
// want to just shorten the original thing, then adjust the values
// so next time through the loop can work on that existing block
s.length = newStyleBegin - oldStyleBegin;
// extend the following style to start here, so there's no gap in the next loop iteration
if(i + i < layouter.styles.length) {
auto originalOffset = layouter.styles[i+1].offset;
assert(originalOffset >= newStyleBegin);
layouter.styles[i+1].offset = newStyleBegin;
layouter.styles[i+1].length += originalOffset - newStyleBegin;
// i will NOT change the style info index yet, since the next iteration will do that
continue;
} else {
// at the end of the loop we can just append the new thing and break out of here
layouter.styles ~= TextLayouter.StyleBlock(newStyleBegin, newStyleEnd - newStyleBegin, style.index);
break;
}
}
else {
// this should be impossible as i think i covered all the cases above
// as we iterate through
// writeln(oldStyleBegin, "..", oldStyleEnd, " -- ", newStyleBegin, "..", newStyleEnd);
assert(0);
}
}
}
// foreach(st; layouter.styles) writeln("A: ", st.offset, "..", st.offset + st.length, " ", st.styleInformationIndex);
}
// returns the edge of the new cursor position
private void horizontalMoveImpl(int direction, int count, bool byRender) {
assert(direction != 0);
auto place = impl.focus + direction;
foreach(c; 0 .. count) {
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
place += direction;
}
// 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
if(place < 0)
place = 0;
if(place >= layouter.text.length)
place = cast(int) layouter.text.length - 1;
impl.position = place;
}
// returns the baseline of the new cursor
private void verticalMoveImpl(int direction, int count, bool byRender) {
assert(direction != 0);
// this needs to find the closest glyph on the virtual x on the previous (rendered) line
int segmentIndex = layouter.findContainingSegment(impl.terminus);
// we know this is going to lead to a different segment since
// the layout breaks up that way, so we can just go straight backward
auto segment = layouter.segments[segmentIndex];
auto idealX = impl.virtualFocusPosition.x;
auto targetLineNumber = segment.displayLineNumber + (direction * count);
if(targetLineNumber < 0)
targetLineNumber = 0;
// FIXME if(!byRender)
// FIXME: when you are going down, a line that begins with tab doesn't highlight the right char.
int bestHit = -1;
int bestHitDistance = int.max;
// writeln(targetLineNumber, " ", segmentIndex, " ", layouter.segments.length);
segmentLoop: while(segmentIndex >= 0 && segmentIndex < layouter.segments.length) {
segment = layouter.segments[segmentIndex];
if(segment.displayLineNumber == targetLineNumber) {
// we're in the right line... but not necessarily the right segment
// writeln("line found");
if(idealX >= segment.boundingBox.left && idealX < segment.boundingBox.right) {
// need to find the exact thing in here
auto hit = segment.textBeginOffset;
auto ul = segment.upperLeft;
bool found;
auto txt = layouter.text[segment.textBeginOffset .. segment.textEndOffset];
auto codepoint = 0;
foreach(idx, dchar d; txt) {
auto width = layouter.segmentsWidths[segmentIndex][codepoint];
hit = segment.textBeginOffset + cast(int) idx;
auto distanceToLeft = ul.x - idealX;
if(distanceToLeft < 0) distanceToLeft = -distanceToLeft;
if(distanceToLeft < bestHitDistance) {
bestHit = hit;
bestHitDistance = distanceToLeft;
} else {
// getting further away = no help
break;
}
/*
// FIXME: I probably want something slightly different
if(ul.x >= idealX) {
found = true;
break;
}
*/
ul.x += width;
codepoint++;
}
/*
if(!found)
hit = segment.textEndOffset - 1;
impl.position = hit;
bestHit = -1;
*/
impl.position = bestHit;
bestHit = -1;
// selections[selectionId].virtualFocusPosition = Point(selections[selectionId].virtualFocusPosition.x, segment.boundingBox.bottom);
break segmentLoop;
} else {
// FIXME: assuming ltr here
auto distance = idealX - segment.boundingBox.right;
if(distance < 0)
distance = -distance;
if(bestHit == -1 || distance < bestHitDistance) {
bestHit = segment.textEndOffset - 1;
bestHitDistance = distance;
}
}
} else if(bestHit != -1) {
impl.position = bestHit;
bestHit = -1;
break segmentLoop;
}
segmentIndex += direction;
}
if(bestHit != -1)
impl.position = bestHit;
if(impl.position == layouter.text.length)
impl.position -- ; // never select the eof marker
}
}
unittest {
auto l = new TextLayouter(new class TextStyle {
mixin Defaults;
});
l.appendText("this is a test string again");
auto s = l.selection();
auto result = s.find(b => (b == "a") ? b : null, 1, false);
assert(result.wasFound);
assert(result.position == 8);
assert(result.endPosition == 9);
result.selectHit();
assert(s.getContentString() == "a");
result.moveToEnd();